diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index fb57502e..b7b32d99 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -48,6 +48,18 @@ jobs: - name: Run Test run: cargo test --workspace + # The EEST sweep runs nightly, but the cache guards it rests on are shell, and a change that + # breaks them would otherwise surface as a green sweep over a fraction of the corpus. The suite + # drives `run.sh` against a synthetic archive and a stub binary, so it needs no corpus and no + # build. + corpus-cache: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - name: Run corpus cache integrity suite + run: tools/eest-sweep/tests/cache_integrity.sh + no-std: runs-on: ubuntu-24.04 timeout-minutes: 10 diff --git a/.github/workflows/eest-nightly.yml b/.github/workflows/eest-nightly.yml new file mode 100644 index 00000000..43240354 --- /dev/null +++ b/.github/workflows/eest-nightly.yml @@ -0,0 +1,117 @@ +name: EEST Nightly Sweep + +# Runs the whole Ethereum execution-spec-tests state-test corpus through the mega-evm runner, +# nightly, under the unstable spec. +# +# Two questions, one pass. Does anything break — a fixture that trips a debug assertion or an +# internal invariant? And does the unstable spec still differ from the frozen spec it inherits +# from only where its own precision invariant permits? The second is what an unstable spec cannot +# get from fixtures alone: nobody has computed expected results for it, so the frozen spec is the +# only oracle available, and the invariant is what says when the two are allowed to disagree. +# +# Both are hard gates. Everything else — fixtures the runner declines before execution, +# differences the classifier accounts for — is reported and compared against a committed +# baseline, and drift there is a warning in the summary rather than a red run. +# +# Needs no secrets: it builds the repo and runs it against a public, hash-pinned corpus. + +on: + schedule: + # Nightly at 04:00 UTC, after the mutation sweep's 03:00 slot. + - cron: "0 4 * * *" + # No spec inputs. The comparison is decided by Rex7's precision invariant, which relates Rex7 to + # Rex6 and states nothing about any other pair, so there is nothing here to choose: the runner + # refuses every other pair. When a later spec becomes the unstable one, its own invariant has to + # be written into the classifier, and the pair below changes with it. + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + sweep: + name: EEST differential sweep + runs-on: ubuntu-24.04 + # The sweep itself is minutes; the headroom is for a cold dependency build. + timeout-minutes: 90 + env: + TARGET_SPEC: "Rex7" + BASE_SPEC: "Rex6" + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + + - name: Read pinned corpus + id: corpus + run: | + # shellcheck disable=SC1091 + . tools/eest-sweep/corpus.env + echo "release=$EEST_RELEASE" >> "$GITHUB_OUTPUT" + echo "sha256=$EEST_SHA256" >> "$GITHUB_OUTPUT" + + # Keyed on the corpus hash, not on the release name, so an entry restored here always + # belongs to the release the sweep is pinned to. + # + # The key is not what makes the entry trustworthy. The cache holds both the archive and the + # tree unpacked from it; the archive is hash-verified on every run, and the tree — which is + # what the sweep actually reads — is restored from wherever a previous run left it, whole or + # not. What rules out sweeping a fraction of the corpus is on the other side: `run.sh` + # unpacks via a scratch directory and one rename, and records a manifest of every file it + # extracted with that file's hash. Before each run the tree is re-derived from its bytes and + # compared against that manifest; anything missing, added or edited discards the tree and + # unpacks it again. + - name: Cache corpus + uses: actions/cache@v4 + with: + path: .eest-cache + key: eest-corpus-${{ steps.corpus.outputs.sha256 }} + + # `hivetests` is optimized *and* keeps debug assertions live, which is the point: the Rex7 + # gas-conservation cross-checks are `debug_assert!`s, and a release build would run the + # corpus without ever evaluating them. + - name: Build state-test + run: cargo build --profile hivetests -p state-test + + - name: Run sweep + id: sweep + run: | + tools/eest-sweep/run.sh \ + --no-build \ + --target-spec "$TARGET_SPEC" \ + --base-spec "$BASE_SPEC" \ + --cache-dir .eest-cache \ + --report-dir .eest-report + + - name: Write job summary + if: always() + run: | + if [ -f .eest-report/diff-report.json ]; then + python3 tools/eest-sweep/summarize.py \ + .eest-report/diff-report.json \ + --baseline tools/eest-sweep/baseline.json \ + >> "$GITHUB_STEP_SUMMARY" + else + echo "The sweep produced no report; see the job log." >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Upload report + if: always() + uses: actions/upload-artifact@v4 + with: + name: eest-sweep-report + path: | + .eest-report/diff-report.json + .eest-report/sweep.log + if-no-files-found: warn + retention-days: 30 diff --git a/.gitignore b/.gitignore index de945a46..b30e0076 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,10 @@ # Python bytecode cache (scripts/) __pycache__/ + +# EEST sweep working directories (tools/eest-sweep/run.sh): the hash-pinned corpus +# archive and the reports a run produces. Both are regenerable. +/.eest-cache +/.eest-report +# `--report-dir` names the directory, so a run that uses a different one leaves its own. +/.eest-report-* diff --git a/AGENTS.md b/AGENTS.md index 4024f7e0..1179900c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,6 +51,8 @@ Git submodules are required — clone with `--recursive` or run `git submodule u | `mega-evme` | `bin/mega-evme` | CLI tool for EVM execution (`run`, `tx`, `replay`) | | `mega-t8n` | `bin/mega-t8n` | Standalone state transition (t8n) tool | +The EEST corpus sweep that exercises the unstable spec against the whole Ethereum state-test suite lives in `tools/eest-sweep/` and runs nightly (`.github/workflows/eest-nightly.yml`). + ## Architecture ### Spec System (`MegaSpecId`) @@ -114,7 +116,35 @@ Consequently: MegaETH separates EVM gas into two independent dimensions tracked during execution: - **Compute gas**: Measures pure computational cost. - Every opcode's gas consumption is recorded via wrapped instructions in `evm/instructions.rs` — `compute_gas_ext::*` for plain opcodes and `storage_gas_ext::*` for storage-affecting opcodes (SSTORE, LOG, CALL-family, CREATE/CREATE2, SELFDESTRUCT) — both invoking the shared `record_storage_compute_gas!` primitive after the opcode body completes. + Through REX6 every opcode's gas consumption is recorded via wrapped instructions in `evm/instructions.rs` — `compute_gas_ext::*` for plain opcodes and `storage_gas_ext::*` for storage-affecting opcodes (SSTORE, LOG, CALL-family, CREATE/CREATE2, SELFDESTRUCT) — both invoking the shared `record_storage_compute_gas!` primitive after the opcode body completes. + REX7 settles compute gas at checkpoints (storage-gas opcodes, CALL/CREATE family, volatile opcodes, `GAS`, frame entry/resume/exit) rather than after every plain opcode, and enforces limits inside plain segments with a gas clamp. + A REX7 frame that ends in an exceptional halt splits its remaining budget: the work it performed before failing settles through the ordinary enforcing path, while the remainder it destroyed goes into a lane of `ComputeGasTracker` that the reported total and block accounting include but no limit comparison sees — destroyed gas is not work performed, and enforcing it would turn an EVM halt into a resource-limit failure with the gas rescued. + The destroyed half is read from the frame's final result at `AdditionalLimit::finalize_frame`, so revm's create-return rejects and any rewrite an inspector's last callback made are both covered; storage gas a checkpoint body charged before aborting belongs to neither half. + A precompile that fails never becomes a child EVM frame, so the same split is taken at `AdditionalLimit::finalize_frame` from the classification the call returns to its caller: executed work (the KZG fixed fee when verification ran; zero when the input was rejected before any work) enforces, and the unused caller-supplied envelope — including any REX5 forwarded-gas cap gap — is destroyed. + The recording site stages the two numbers only it knows (the uncapped forwarded envelope, and `MegaETH`'s price for the work performed, which a halting precompile's `Gas` does not carry) and the settlement point takes the difference, so a classification an inspector rewrote after the dispatch is the one the split follows. + A frame init that refuses to build a frame at all is settled at the same point, driven by the same classification: a halting refusal (a CREATE onto an occupied address) has its whole child budget destroyed, and a returning or reverting one books nothing because the caller gets the budget back. + The split crosses the transaction boundary: `MegaTransactionOutcome` carries the destroyed part and the enforced part alongside the reported total, and `BlockLimiter` keeps `block_compute_gas_used` (reported) separate from `block_compute_gas_enforced` (the counter block admission compares). + The destroyed part a transaction _reports_ is not the sum of those per-site bookings: `MegaHandler::last_frame_result` derives it once, from a conservation law over the envelope — `spent = C + S + D − K − I`, stated once as `ConservationTerms` (`limit/conservation.rs`) and read from `AdditionalLimit::conservation_terms()` by every site that derives, re-settles or checks it — and the per-site bookings stay as the enforcement split and as the `debug_assert` cross-check that the two agree. + The derived number is reported and nothing else: the block's enforced counter accumulates `MegaTransactionOutcome::compute_gas_enforced`, read from `AdditionalLimit::enforced_compute_gas` (the per-site lane), rather than subtracting the reported destroyed total, so a missing term in the law misreports a statistic instead of repacking blocks. + `minted_call_stipend` is the correction the law needs because revm mints `CALL_STIPEND` into a value-transferring call's child frame without debiting the caller, so recorded work exceeds the envelope by one stipend per such call; it is booked per mint event — at the CALL-family settlement, before frame init — so a value call turned away at frame entry (insufficient balance, call depth) books one too, because its refund returns the mint into the caller's envelope. + `inspector_conjured_gas` is the same kind of correction for a producer outside the EVM: `MegaEvm` wraps every inspector it is handed in `MeasuredInspector`, which snapshots the interpreter's gas counter and a frame input's `gas_limit` across each callback and books the difference into `AdditionalLimit::inspector_ledger` — the EVM does not execute inside a callback, so anything that moves across one is the inspector's. + Gas an inspector writes in was never debited from the transaction's envelope, so without the term the derivation reads such a transaction as having spent less than it did and can go negative; the term is zero for every uninspected transaction and every observation-only inspector. + The same booking site shifts the checkpoint baseline and re-derives the gas clamp, so an inspector's edit never enters the compute measurement and never buys compute headroom. + An edit to a frame _result_'s gas is booked instead from `finalize_frame`, because whether it moves the envelope at all depends on how the frame ends: a returning or reverting frame's remainder goes back to its caller and the edit is booked, a halting one's does not and the destroyed remainder is taken on the EVM's own number. + The shim also counts, on the same ledger, the rewrites it sees that move no gas at all — a frame result's classification or output coming back changed, a finished outcome's metadata (a call's `memory_offset`, a creation's `address`) rewritten around the result inside it, a frame's inputs edited on any of the fields that say what the frame will do (which is all of them but the gas limit, and but the two `OnceCell` memos a creation's inputs carry — filling one is a derived value being computed, which is what a tracer asking where a deployment landed does), a frame the inspector answered itself with a synthetic outcome, and every constant-time reading it can take off a live interpreter — because a rewrite that costs nothing still produces different state and a different receipt. + That last group is stated as a rule rather than as a list: every `O(1)` reading of the interpreter's working set enters the boundary snapshot, which is what makes a program counter stepped past an instruction, a memory grown together with its memo, or a return buffer conjured in front of a frame that made no call all visible on the same lane. + Every gas lane carries a gross alongside its net, and it is the gross that `is_zero` — the guard's question — reads: two edits to one lane that cancel are two edits, whether they cancel inside one frame or across a surviving frame and a rolled-back one, and a net-only reading calls that pair untouched while the execution saw a number the EVM would never have produced. + The interpreter's pending action is measured on the same ledger: a frame holds its gas counter, plus a pending `NewFrame` action's `gas_limit`, or — once a terminating instruction has run — only the `Return` action's own copy, so the shim reads both objects at every live-interpreter callback and books the difference to the lane the action it was left holding names (the result lane for a `Return` action, settled at the frame's settlement point on the final classification; the envelope lane for a `NewFrame` one; the counter lane when the callback removed the action). + A frame the inspector answers itself is the one place a difference across the callback is not the measurement, because no frame is built and the whole result is the inspector's: the shim stages the envelope the answering callback was handed, and `inspect_frame_init` settles the gas the result finally carries against it on the result lane — which also covers whatever of an edit to the inputs survives into a guard's replacement result, and which is zero for the echo convention every tool that intercepts follows. + What no callback boundary can see stays invisible (the _contents_ of the interpreter's stack, memory, return buffer, calldata and code at unchanged identities, direct journal writes), so an all-zero ledger says the shim saw no gas move and nothing it was handed or could read in constant time come back changed, not that the transaction is the one the EVM would have produced alone. + The receipt's other two numbers have lanes of their own, measured at two different points because `MegaETH` produces one of the two quantities and none of the other: a refund is booked nominally across the callback boundary, since only a difference there separates an inspector's share from the EVM's own refunds, while the EIP-8037 state-gas dimension (`reservoir` and `state_gas_spent`, on a `Gas` or on a call's inputs) is settled once from the figures the transaction ends with — revm propagates it by replacement rather than accumulation, so a boundary difference would book edits the EVM goes on to erase. + The reservoir is a term of the conservation law because it lowers the envelope the receipt reports; the refund and the spend counter are not, and are refused by the block guard rather than accounted for. + `crates/mega-evm/src/evm/AGENTS.md` carries the closed per-field enumeration, pinned by `tests/rex7/gas_surface.rs`, which also fails on any row left saying a surface reaches the receipt and no lane books it. + The whole ledger travels on `MegaTransactionOutcome::inspector_ledger`, but it is not what a block is admitted on: an inspector can edit the interpreter's stack or memory contents, or write the journal directly, and change the transaction while leaving every lane at zero. + Admission rests on a `TrustedObserver` declaration instead — a line written in source about one concrete type — and the canonical block path (`run_transaction_with_sizes`, `run_tx_env_with_sizes`, and the `commit_tx_result` funnel every commit entry routes through) refuses a transaction from an EVM running an undeclared inspector with `MegaBlockExecutionError::UndeclaredInspector`, before running it, in release builds as well as debug; `MegaTransactionOutcome::undeclared_inspector` is what carries the answer to the commit funnel. + The ledger is the backstop behind that, read at the same entries as `MegaBlockExecutionError::InspectorAdjustedAccounting`, for a declaration that did not hold and for a result reaching the funnel from a producer this executor never saw. + A tracer keeps working by being declared — `MegaBlockExecutorFactory::create_executor_with_trusted_inspector` is the entry, and a tracer this crate cannot implement the trait for is wrapped in `DeclaredObserver`, which carries the declaration and forwards every callback, with `bin/mega-evme`'s replay command as the worked example; an embedder that wants a rewriting inspector drives `MegaEvm::execute_transaction` directly, which supports it in full and is not covered by the guard — that is what leaves an off-band simulation EVM free to rewrite. + Pre- and post-block system calls and the keyless-deploy sandbox are not entries the guard has to cover: neither produces a `MegaTransactionOutcome`, the ledger is reset at the start of every transaction, and both run uninspected anyway (`Handler::run_system_call` takes the plain frame loop; the sandbox builds its own EVM with no inspector). Subject to a per-spec compute gas limit and further restricted by gas detention (see below). - **Storage gas**: Charges for persistent state modifications (SSTORE, account creation, contract deployment). These costs scale dynamically with SALT bucket capacity (see External Environment Dependencies below). @@ -123,6 +153,19 @@ MegaETH separates EVM gas into two independent dimensions tracked during executi Both dimensions are enforced independently. A transaction can be halted by exceeding either limit. +#### Frame Lifecycle and the Single Settlement Point + +revm assembles a frame's result, decides its journal checkpoint and — for a contract creation — runs the deposit predicates and writes the code all inside `EthFrame::process_next_action`, and runs the inspector's last mutating callback after that function returns. +`evm/frame.rs` splits it: `classify_frame_action` decides what the frame's result is and records the journal decision it reached as a `FrameJournalVerdict`; `commit_frame_journal` carries that decision out. +Between the two run the frozen post-action charge, the inspector's `frame_end`, and `AdditionalLimit::finalize_frame` — the single point a frame's outcome is settled (final classification, executed/destroyed split, frame-init refusal booking, gas rescue, and the REX7 frame-local absorb). +Under REX7 the journal decision is taken later still — the frame loops park it on `MegaEvm::deferred_journal` and `frame_return_result` carries it out, after `AdditionalLimit::before_frame_return_result` (the last thing that can rewrite a frame's result) and before the caller resumes — so a frame's state agrees with the result its caller is handed and a creation's `set_code` still lands with no observation window. +Frozen specs take it where revm does, right after the classification, because what they replay includes the state a frame leaves behind when a later rewrite fails it. +The rewrite that made the extra station necessary is the late frame-local exceed: a per-frame budget is the frame's usage weighed against its _caller's_ budget after the merge, so a frame can overrun one with nothing having latched it. +REX7 asks that question before the pop, through `AdditionalLimit::peek_check_limit_after_pop` over `FrameLimitTracker::view_after_pop`, and rewrites the frame to a revert first; the pop then discards the frame's usage the way it discards any reverting frame's, and the caller carries on. +The pre-pop reading and the post-pop `check_limit()` are cross-checked against each other on every frame return in debug builds, on every spec — that assertion is what stands between the early decision and a drift in what counts as a frame-local exceed. +Both frame loops (`frame_run` / `inspect_frame_run`) and both frame-init paths (`frame_init` / `inspect_frame_init`) run the same bodies; the inspected copies add exactly one thing, the callback that can rewrite a frame's classification. +`classify_frame_action` and `commit_frame_journal` together are a re-ordering of upstream code with no type-level tie to it, so a revm bump has to re-audit them; the debug assertion in `classify_create_return` catches only the one drift class it names. + #### Multidimensional Resource Limits Beyond the dual gas model, mega-evm enforces **four independent per-transaction resource limits** via `AdditionalLimit` (`limit/limit.rs`): @@ -146,6 +189,8 @@ MegaETH's parallel EVM needs to minimize conflicts between concurrent transactio - Different volatile data categories (block env/beneficiary, oracle) have different cap levels defined in `constants.rs`. - The **most restrictive cap wins** when multiple volatile sources are accessed. - Caps are applied via host hooks (`evm/host.rs`) that mark access in a `VolatileDataAccessTracker` (`access/tracker.rs`), then enforced after each volatile opcode via `wrap_op_detain_gas!` in `evm/instructions.rs`. +- REX7 charges the opcode's static fee even when `disableVolatileDataAccess` rejects (charge-on-reject); frozen specs still reject for free. +- REX7 specifies that a detention mark is produced when the target account is loaded, so a frame that cannot afford the pre-load CALL / EXTCODECOPY fees produces no mark (the frozen-window tripwire is `!REX7`-gated). This forces transactions that touch volatile data to terminate quickly, reducing parallel execution conflicts without banning the access outright. Detained gas is effectively refunded — users only pay for actual computation performed. @@ -207,6 +252,7 @@ The following paths are common sources of leakage: Any per-frame gas adjustment applied in `before_frame_init` is skipped on this path. The `push_empty_frame()` call maintains stack alignment but does not apply adjustments. Synthetic results must not assume any per-frame gas mechanism was applied. + Such a result does reach `finalize_frame`, as `FrameExit::RefusedSynthetically`: under REX7 its envelope is settled like any other refusal's, while frozen specs leave it alone. 2. **Gas rescue on TX-level limit exceed** (`limit/limit.rs`): When a transaction-level resource limit is exceeded, `rescue_gas` captures remaining gas for sender refund. If a frame's gas was inflated by a per-frame mechanism, the rescued amount must exclude the inflated portion — otherwise the sender recovers system-granted gas that should have been burned. 3. **Frame return** (`limit/limit.rs`): `before_frame_return_result` is the final hook before gas is returned to the parent. @@ -222,7 +268,7 @@ Correctness of the other three dimensions (data size, KV updates, state growth) 1. **Every non-compute mutation site must latch.** Any code that records data-size/KV/state-growth usage during execution (`on_sstore`, `on_log`, `record_oracle_hint_bytes`, the frame-lifecycle hooks) must run `check_limit()` itself, latching any exceed into `has_exceeded_limit`. - The latch is surfaced by the leading short-circuit of the next `record_compute_gas` call, so the halt lands on the same opcode as the pre-protocol fan-out did. + The latch is surfaced by the leading short-circuit of the next `record_compute_gas` call (through REX6, that is the next metered opcode; under REX7 checkpoint accounting it is the next checkpoint), so the halt lands on the same site as the pre-protocol fan-out did. 2. **Pre-inner recorders must NOT latch.** A site that records usage _before_ its inner instruction executes (currently SELFDESTRUCT's two beneficiary recorders: empty-beneficiary creation and the REX6+ existing-beneficiary credit) must record without latching: the inner instruction can still fail, the frame then discards the usage, and an early latch would stick and rewrite the frame's real result. Such opcodes use a trailing all-dimension check (`record_compute_gas_all_dims`) that runs only after the inner instruction succeeds. @@ -232,8 +278,10 @@ Correctness of the other three dimensions (data size, KV updates, state growth) The protocol governs mutation sites that run during execution; the REX6+ post-execution fee-reward accounting is deliberately outside it. That accounting merges usage into the transaction's reported totals and the block-level cumulative counters after the execution result is final, without latching, and never retroactively fails the transaction. -Rule 1 is backed by a `debug_assert!` in `record_compute_gas`: if a non-compute dimension is over its limit but not yet latched, the assert trips at the exact opcode whose mutation site forgot to call `check_limit()`. +Rule 1 is backed by a `debug_assert!` inside `record_compute_gas_impl`, reached through the guarded entry `record_compute_gas` (`GUARD_LATCH_PROTOCOL = true`): if a non-compute dimension is over its limit but not yet latched, the assert trips at the exact opcode whose mutation site forgot to call `check_limit()`. The sub-tracker checks are non-mutating, so the guard compiles out of release builds. +The same impl is also reached through `record_compute_gas_unguarded` (`GUARD_LATCH_PROTOCOL = false`), which skips the assert. +REX7 frame-exit tail settlement (`after_frame_run_instructions`) uses the unguarded entry: that settlement can observe SELFDESTRUCT pre-inner recorder usage that rule 2 deliberately left unlatched, because the frame is about to pop and discard it, and the guarded entry would trip the assert on that path. When adding an opcode or mutation site that touches a non-compute dimension, decide whether it records after or before its inner instruction, follow the matching case above, and add a test asserting the exceed halts at that opcode. @@ -302,6 +350,11 @@ When the agent is requested to implement a new feature or bug fix, it should con New or modified benchmarks must be executed locally (`cargo bench -p mega-evm --bench `) to verify they pass before committing. Benchmarks may compile but panic at runtime due to missing setup (e.g., required block fields), so compilation alone is not sufficient. For instruction-count deltas across a PR, use the CodSpeed report posted on the PR rather than local wall-clock numbers. +- **Re-run the destroyed-gas conservation scan after a revm / alloy-evm upgrade.** + The REX7 destroyed total is derived from the envelope, so any upstream change that moves gas without a MegaETH site recording it — a new minted subsidy like `CALL_STIPEND`, a changed refund or floor ordering, a new component of `total_gas_spent` — becomes a missing term in the law rather than a compile error. + The frame-lifecycle mirror in `evm/frame.rs` is the same kind of exposure in the other direction: it is a re-ordering of `EthFrame::process_next_action` and `return_create`, so an upstream change to either becomes a silent divergence rather than a compile error. + Diff `InstructionResult` and the early-fail arms of `make_call_frame` / `make_create_frame` / `return_create` against `destroyed_disposition` (`limit/destroyed.rs`) and the arm list in `tests/rex7/result_space_tripwire.rs`: a new variant is a compile error until it is classified swallow / return / unreachable; a new early-fail arm has no type-level tie and must be assigned by hand, which is the CreateCollision-shaped gap the tripwire exists to catch. + After bumping revm or alloy-evm, diff those two upstream functions against `evm/frame.rs`, then run `cargo test -p mega-evm` and `cargo test -p mega-state-test -p state-test` (the `debug_assert` cross-check is live in debug builds) plus the replay fixtures under the latest spec (`cargo run -p state-test -- --bench --bench-spec bench/replay/fixtures`), whose own `post` expectations pin an older spec and would otherwise give the derivation no coverage. - **Use `test_` prefix for Rust test function names.** New `#[test]` functions should be named with a `test_` prefix for consistency with this repository and upstream revm style. If editing nearby tests in the same module, align names to the same `test_` style when reasonable. @@ -312,6 +365,12 @@ When the agent is requested to implement a new feature or bug fix, it should con Never change what an existing stable spec does. - **System contract changes require a new spec.** Do not modify system contract Solidity sources or their Rust integration without also introducing a new spec for backward compatibility. +- **The gas schedule belongs to the spec, not to `CfgEnv`.** + revm 40 made every operation's price a `CfgEnv.gas_params` table an embedder can rewrite, but several `MegaETH` accounting sites carry the schedule's values as constants (the `CALL_STIPEND` a value-transferring call mints, the pre-`REX7` per-byte code-deposit rate, the mainnet table the keyless-deploy preflight estimates intrinsic gas from). + A configuration whose `gas_params` is not exactly `GasParams::new_spec(SpecId::from(cfg.spec))` is therefore rejected with a panic rather than executed, at both `with_cfg` entry points, at the deprecated `new_with_context`, and again at the point of use before every transaction — so a configuration mutated in place after the context was built is caught too. + The check is unconditional across specs: it governs the configuration domain, which no historical block covers. + Build configurations with `CfgEnv::new_with_spec(spec)` or `cfg.set_spec_and_mainnet_gas_params(spec)`; do not add a way to opt out, and do not add a tool-only bypass. + New code that needs one of the schedule's values may read it from `cfg().gas_params()` or restate the constant — under the pin the two are equal, and reading the table is the preferred form for unfrozen specs. - **Override `HardforkParams::validate()` for every new params type.** The default implementation accepts any value silently. Override it with field-level invariant checks (e.g., non-zero addresses) so that `with_params()` panics loudly at chain-config load time rather than allowing the error to surface at the first block where the fork activates. @@ -328,6 +387,12 @@ When the agent is requested to implement a new feature or bug fix, it should con Do not expect these schemes to trigger system contract interception. - **System contract interceptor tests must cover boundary behaviors.** Include tests for normal intercepted path, non-zero value behavior, unknown selector fallback, and CALL vs DELEGATECALL/CALLCODE interception boundaries. +- **A new precompile that can halt after doing work must record that work explicitly.** + Under REX7+ the generic precompile-halt arm books zero executed compute gas and destroys the whole forwarded envelope, because every wired precompile halts only on a pre-work input rejection. + A precompile with a do-work-then-halt path (a future verification precompile that halts after running its check) would leave that work unenforced, so a caller could repeat the failure without the transaction- or block-level compute limits ever accounting for it. + Express failure-after-work as a `revert` instead of a halt (the revert arm records actual spend), or give the precompile its own recording arm the way KZG does. + Only code that compiles into the node can register a precompile, so this is a rule for future authors, not an on-chain attack surface. + Do not override the KZG address — or any precompile that has its own recording arm — without updating that arm to match the replacement; a substitute registered under `PrecompileId::KzgPointEvaluation` is a new precompile and must ship its own arm. - **Respect `no_std` in `mega-evm` crate.** Do not use `std::` directly. Follow the existing pattern: `#[cfg(not(feature = "std"))] use alloc as std;` then `use std::{vec::Vec, ...};`. diff --git a/bin/mega-evme/src/common/trace.rs b/bin/mega-evme/src/common/trace.rs index 1f8c27b2..8d7909dc 100644 --- a/bin/mega-evme/src/common/trace.rs +++ b/bin/mega-evme/src/common/trace.rs @@ -17,7 +17,7 @@ use mega_evm::{ state::EvmState, ExecuteEvm, InspectEvm, }, - MegaContext, MegaEvm, MegaHaltReason, MegaTransaction, + DeclaredObserver, MegaContext, MegaEvm, MegaHaltReason, MegaTransaction, }; use revm_inspectors::tracing::{TracingInspector, TracingInspectorConfig}; use tracing::{debug, info, trace}; @@ -103,6 +103,17 @@ impl TraceArgs { TracingInspector::new(config) } + /// The same tracer, wrapped in the declaration the canonical block-execution path admits an + /// inspected transaction on. + /// + /// [`TracingInspector`] writes nothing back to the EVM, but the declaration cannot be made + /// about it here — both it and the trait are foreign to this crate — so it is made at the + /// point of use, about this one value, by wrapping it. That is the whole of what a node + /// keeping tracing on block production has to write. + pub fn create_trusted_inspector(&self) -> DeclaredObserver { + DeclaredObserver(self.create_inspector()) + } + /// Creates [`GethDefaultTracingOptions`] from CLI arguments pub fn create_geth_options(&self) -> GethDefaultTracingOptions { GethDefaultTracingOptions { @@ -235,7 +246,7 @@ impl TraceArgs { trace!(result_and_state = ?result_and_state, "Evm execution result and state"); // Generate trace string based on tracer type - let trace_str = self.generate_trace(evm.inspector, &result_and_state, evm.db_ref()); + let trace_str = self.generate_trace(&evm.inspector, &result_and_state, evm.db_ref()); trace!(trace_str = ?trace_str, "Generated trace"); Ok((result_and_state.result, result_and_state.state, Some(trace_str))) diff --git a/bin/mega-evme/src/replay/cmd.rs b/bin/mega-evme/src/replay/cmd.rs index 571d7714..77e3ce8a 100644 --- a/bin/mega-evme/src/replay/cmd.rs +++ b/bin/mega-evme/src/replay/cmd.rs @@ -470,10 +470,12 @@ impl Cmd { ); let start = Instant::now(); - let mut inspector = self.trace_args.create_inspector(); + // The tracer reaches the canonical block path through its read-only declaration: the + // executor refuses a transaction from an EVM running an undeclared inspector. + let mut inspector = self.trace_args.create_trusted_inspector(); let mut state = StateBuilder::new().with_database(&mut database).with_bundle_update().build(); - let mut block_executor = block_executor_factory.create_executor_with_inspector( + let mut block_executor = block_executor_factory.create_executor_with_trusted_inspector( &mut state, block_ctx, evm_env, @@ -520,7 +522,7 @@ impl Cmd { .map(|acc| acc.nonce) .unwrap_or(0); - block_executor.inspector_mut().fuse(); + block_executor.inspector_mut().0.fuse(); let outcome = block_executor .run_transaction(wrapped_tx) .map_err(|e| ReplayError::Other(format!("Block execution error: {e}")))?; @@ -547,7 +549,7 @@ impl Cmd { let trace_data = self.trace_args.is_tracing_enabled().then(|| { self.trace_args.generate_trace( - block_executor.inspector(), + &block_executor.inspector().0, &result_and_state, block_executor.evm().db_ref(), ) diff --git a/bin/mega-evme/src/replay/fixture.rs b/bin/mega-evme/src/replay/fixture.rs index ce203422..b8e4df40 100644 --- a/bin/mega-evme/src/replay/fixture.rs +++ b/bin/mega-evme/src/replay/fixture.rs @@ -34,7 +34,10 @@ use op_alloy_consensus::OpTxEnvelope; use op_alloy_rpc_types::Transaction; use state_test::{ runner::{execute_unit_collect, execution_status, halt_reason}, - types::{AccountInfo, Env, MegaEnv, SpecName, Test, TestSuite, TestUnit, TransactionParts}, + types::{ + AccountInfo, Env, MegaEnv, SpecName, Test, TestSuite, TestUnit, TransactionParts, + TxPartIndices, + }, }; use super::{ReplayError, Result}; @@ -225,7 +228,10 @@ where /// Re-execute the isolated unit through `state-test`, cross-check it against the /// observed replay outcome, fill the `post` expectation, and write the fixture. pub(crate) fn finalize_and_write(draft: FixtureDraft, path: &std::path::Path) -> Result<()> { - let executed = execute_unit_collect(&draft.unit, &draft.spec) + // A replayed on-chain transaction is one transaction, so the fixture it produces has exactly + // one vector and it is index zero. + let indexes = TxPartIndices { data: 0, gas: 0, value: 0 }; + let executed = execute_unit_collect(&draft.unit, indexes, &draft.spec) .map_err(|e| ReplayError::Other(format!("fixture self-execution failed: {e}")))?; // Cross-check the isolated execution against the full replay. These values @@ -272,8 +278,13 @@ pub(crate) fn finalize_and_write(draft: FixtureDraft, path: &std::path::Path) -> let mut unit = draft.unit; unit.out = executed.output.clone(); - let test = - Test::for_dump(executed.state_root, executed.logs_root, executed.gas_used, executed.status); + let test = Test::for_dump( + indexes, + executed.state_root, + executed.logs_root, + executed.gas_used, + executed.status, + ); unit.post = BTreeMap::from([(draft.spec, vec![test])]); let suite = TestSuite(BTreeMap::from([(draft.name, unit)])); diff --git a/bin/mega-evme/tests/trace_declaration.rs b/bin/mega-evme/tests/trace_declaration.rs new file mode 100644 index 00000000..7b40cd91 --- /dev/null +++ b/bin/mega-evme/tests/trace_declaration.rs @@ -0,0 +1,125 @@ +//! The tracer `replay --trace` declares read-only has to survive a contract creation. +//! +//! `replay` is the one command that hands the EVM a declared observer, because it is the one whose +//! transaction the canonical block path would admit. A declaration is checked in debug builds: the +//! shim measures the tracer anyway and asserts it booked nothing, so a tracer that writes anything +//! back panics at the callback that did it. +//! +//! `TracingInspector` asks each creation for the address it will occupy, which fills a memo on the +//! inputs, and the shim used to read a filled memo as a rewritten input — so this panicked at the +//! first `CREATE` in a replayed transaction. The offline replay fixtures deploy nothing, so the +//! shape reached no gate at all. This is that gate, over the object the command actually builds: +//! an RPC capture of a deploying transaction would be the other way to write it, and the tracer's +//! declaration is what both would be checking. + +use clap::Parser; +use mega_evm::{ + revm::{ + bytecode::opcode::{CREATE, CREATE2, MSTORE, POP, STOP}, + context::tx::TxEnv, + primitives::{address, Address, Bytes, TxKind, U256}, + }, + test_utils::{BytecodeBuilder, MemoryDatabase}, + MegaContext, MegaEvm, MegaSpecId, MegaTransaction, MegaTransactionNew, +}; +use mega_evme::TraceArgs; + +/// The account the transaction is sent from. +const CALLER: Address = address!("0000000000000000000000000000000000300000"); + +/// The account holding the deploying code. +const CONTRACT: Address = address!("0000000000000000000000000000000000300001"); + +/// `PUSH1 0 PUSH1 0 RETURN` — init code that deploys an empty contract. +const RETURN_EMPTY: [u8; 5] = [0x60, 0x00, 0x60, 0x00, 0xf3]; + +/// Writes `code` into memory from offset zero, one 32-byte word at a time. +fn write_to_memory(builder: BytecodeBuilder, code: &[u8]) -> BytecodeBuilder { + let mut builder = builder; + for (index, chunk) in code.chunks(32).enumerate() { + let mut word = [0u8; 32]; + word[..chunk.len()].copy_from_slice(chunk); + builder = builder.push_bytes(word).push_number((index * 32) as u64).append(MSTORE); + } + builder +} + +/// Init code that creates a contract of its own before returning. +fn nested_init_code() -> Vec { + write_to_memory(BytecodeBuilder::default(), &RETURN_EMPTY) + .push_number(RETURN_EMPTY.len() as u64) + .push_number(0u64) + .push_number(0u64) + .append(CREATE) + .append(POP) + .append_many(RETURN_EMPTY) + .build_vec() +} + +/// A `CREATE` and a `CREATE2` of init code that creates once more: four creations, both schemes, +/// two depths. +fn deploying_code() -> Bytes { + let init = nested_init_code(); + let size = init.len() as u64; + write_to_memory(BytecodeBuilder::default(), &init) + .push_number(size) + .push_number(0u64) + .push_number(0u64) + .append(CREATE) + .append(POP) + .push_number(0x5A17u64) + .push_number(size) + .push_number(0u64) + .push_number(0u64) + .append(CREATE2) + .append(POP) + .append(STOP) + .build() +} + +/// ★ The declared tracer runs a deploying transaction without writing anything back. +/// +/// In a debug build — which is how this suite runs — the assertion inside the shim is what fails +/// if the declaration stops holding, and it names the callback. In a release build the run simply +/// has to succeed. +#[test] +fn test_the_declared_tracer_survives_a_transaction_that_deploys() { + let mut db = MemoryDatabase::default() + .account_code(CONTRACT, deploying_code()) + .account_balance(CALLER, U256::from(1u64) << 64); + + let mut context = MegaContext::new(&mut db, MegaSpecId::REX7); + context.modify_chain(|chain| { + chain.operator_fee_scalar = Some(U256::ZERO); + chain.operator_fee_constant = Some(U256::ZERO); + }); + + let mut inspector = TraceArgs::parse_from(["mega-evme", "--trace"]).create_trusted_inspector(); + let mut evm = MegaEvm::new(context).with_trusted_inspector(&mut inspector); + + let mut tx = MegaTransaction::new(TxEnv { + caller: CALLER, + kind: TxKind::Call(CONTRACT), + gas_limit: 10_000_000, + gas_price: 0, + ..Default::default() + }); + tx.enveloped_tx = Some(Bytes::new()); + + let outcome = evm.execute_transaction(tx).expect("the replayed transaction must execute"); + assert!( + outcome.result_and_state.result.is_success(), + "the fixture must deploy, got {:?}", + outcome.result_and_state.result, + ); + assert_eq!( + outcome.result_and_state.state.values().filter(|account| account.is_created()).count(), + 4, + "the fixture must create four contracts, or it is not exercising the shape", + ); + assert!( + outcome.inspector_ledger.is_zero(), + "the tracer the command declares read-only must book nothing: {:?}", + outcome.inspector_ledger, + ); +} diff --git a/crates/mega-evm/benches/common/mod.rs b/crates/mega-evm/benches/common/mod.rs index c51eec9c..0e14ca5f 100644 --- a/crates/mega-evm/benches/common/mod.rs +++ b/crates/mega-evm/benches/common/mod.rs @@ -20,7 +20,7 @@ use core::convert::Infallible; use mega_evm::{MegaSpecId, TestExternalEnvs}; pub use subject::MegaWithEnv; -use subject::{Mega, OpRevmPinned, RevmPinned, Subject}; +use subject::{InspectKind, Mega, MegaInspected, OpRevmPinned, RevmPinned, Subject}; pub use workload::{Account, TxSpec, Workload}; /// Mega specs registered by [`register_all`] and [`register_mega`]. Shared so @@ -89,6 +89,45 @@ pub fn register_mega_suffixed(group: &mut Group<'_>, variant: &str, w: &Workload run_subjects(group, variant, w, &mega_subjects(SPEC_IDS)); } +/// Specs the inspected-path rows cover: frozen control (REX6) and the unstable +/// target (REX7). The plain rows for these specs already come from +/// [`register_all`]; [`register_inspected`] only adds the inspect variants. +const INSPECTED_SPEC_IDS: &[(&str, MegaSpecId)] = + &[("rex6", MegaSpecId::REX6), ("rex7", MegaSpecId::REX7)]; + +/// Register inspected-path rows for REX6 and REX7 on the current group. +/// +/// Each spec gets four extra rows: +/// - `/inspect_noop` — inspect loop + measurement shim around a +/// [`NoOpInspector`](revm::inspector::NoOpInspector) +/// - `/inspect_tracer` — the same loop with `revm-inspectors`' geth default +/// (`debug_traceTransaction`) +/// - `/inspect_noop_trusted`, `/inspect_tracer_trusted` — the same two inspectors +/// declared read-only, so the shim delegates without measuring. Each trusted row against its +/// untrusted twin is the measurement's cost; against the pre-shim baseline it is what the +/// declaration buys back. +/// +/// Pair with [`register_all`] (or [`register_mega`]) so the unsuffixed +/// `` row remains the plain baseline. Does not re-register that row. +pub fn register_inspected(group: &mut Group<'_>, w: &Workload) { + run_subjects(group, "inspect_noop", w, &inspected_subjects(InspectKind::NoOp)); + run_subjects(group, "inspect_tracer", w, &inspected_subjects(InspectKind::GethTracer)); + run_subjects(group, "inspect_noop_trusted", w, &inspected_subjects(InspectKind::NoOpTrusted)); + run_subjects( + group, + "inspect_tracer_trusted", + w, + &inspected_subjects(InspectKind::GethTracerTrusted), + ); +} + +fn inspected_subjects(kind: InspectKind) -> Vec> { + INSPECTED_SPEC_IDS + .iter() + .map(|&(name, spec)| Box::new(MegaInspected { name, spec, kind }) as Box) + .collect() +} + /// Register mega rows for a caller-supplied spec list (e.g. a single spec, or /// the SELFDESTRUCT-relevant specs). pub fn register_mega_specs( diff --git a/crates/mega-evm/benches/common/subject.rs b/crates/mega-evm/benches/common/subject.rs index 84d38aed..5abe80c1 100644 --- a/crates/mega-evm/benches/common/subject.rs +++ b/crates/mega-evm/benches/common/subject.rs @@ -13,8 +13,8 @@ use alloy_primitives::{Bytes, U256}; use core::convert::Infallible; use criterion::black_box; use mega_evm::{ - revm::inspector::NoOpInspector, test_utils::MemoryDatabase, EmptyExternalEnv, MegaContext, - MegaEvm, MegaSpecId, MegaTransaction, TestExternalEnvs, + revm::inspector::NoOpInspector, test_utils::MemoryDatabase, DeclaredObserver, EmptyExternalEnv, + MegaContext, MegaEvm, MegaSpecId, MegaTransaction, TestExternalEnvs, TrustedObserver, }; use op_revm::{ DefaultOp as _, OpBuilder as _, OpContext as OpContextPinned, OpSpecId as OpSpecIdPinned, @@ -24,8 +24,10 @@ use revm::{ context::{tx::TxEnvBuilder, TxEnv}, database::EmptyDB as EmptyDBPinned, primitives::hardfork::SpecId as SpecIdPinned, - Context as ContextPinned, ExecuteEvm, MainBuilder as _, MainContext as _, + Context as ContextPinned, ExecuteEvm, InspectEvm, Inspector, MainBuilder as _, + MainContext as _, }; +use revm_inspectors::tracing::{TracingInspector, TracingInspectorConfig}; use std::{cell::RefCell, rc::Rc}; use super::workload::{Account, TxSpec, Workload}; @@ -180,6 +182,130 @@ impl Subject for Mega { } } +// +// ============================================================================ +// Mega inspected-path subject. +// ============================================================================ +// + +/// Which inspector an inspected-path row attaches. +/// +/// `NoOp` is the floor: the inspect loop and the measurement shim run, but the +/// inner inspector is empty, so the row is the cost of snapshotting. +/// `GethTracer` is `revm-inspectors`' `debug_traceTransaction` default — the +/// production tracer this crate already admits on the block path. `all()` is +/// not used: it clones memory on every opcode and is not an RPC default. +/// +/// The two `*Trusted` kinds attach the same two inspectors through +/// [`MegaEvm::with_trusted_inspector`], so each pair differs only in whether +/// the shim measures. The gap between a pair is the measurement's whole cost, +/// and the trusted row is what the inspected path costs without it. +#[derive(Clone, Copy)] +pub enum InspectKind { + NoOp, + NoOpTrusted, + GethTracer, + GethTracerTrusted, +} + +/// `MegaEvm` on the inspected frame loop, with the measurement shim live. +/// +/// The plain [`Mega`] row calls `ExecuteEvm::transact`, which never enters an +/// inspector callback. This subject calls [`InspectEvm::inspect_tx`] after +/// [`MegaEvm::with_inspector`], which is the path RPC tracers take and the only +/// path the shim runs on. +pub struct MegaInspected { + pub name: &'static str, + pub spec: MegaSpecId, + pub kind: InspectKind, +} + +impl Subject for MegaInspected { + fn name(&self) -> &str { + self.name + } + + fn run(&self, workload: &Workload) { + match self.kind { + InspectKind::NoOp => { + run_inspected(self.name, self.spec, workload, || NoOpInspector); + } + InspectKind::NoOpTrusted => { + run_inspected_trusted(self.name, self.spec, workload, || NoOpInspector); + } + InspectKind::GethTracer => { + run_inspected(self.name, self.spec, workload, || { + TracingInspector::new(TracingInspectorConfig::default_geth()) + }); + } + InspectKind::GethTracerTrusted => { + run_inspected_trusted(self.name, self.spec, workload, || { + DeclaredObserver(TracingInspector::new(TracingInspectorConfig::default_geth())) + }); + } + } + } +} + +fn run_inspected(name: &str, spec: MegaSpecId, workload: &Workload, make_inspector: Make) +where + I: Inspector>, + Make: FnOnce() -> I, +{ + run_workload( + name, + workload, + || MegaEvm::new(inspected_context(spec, workload)).with_inspector(make_inspector()), + inspect_one_tx, + ); +} + +/// [`run_inspected`] through [`MegaEvm::with_trusted_inspector`]. +/// +/// Everything else is identical, which is the point: the pair of rows differs +/// only in whether the shim measures. +fn run_inspected_trusted( + name: &str, + spec: MegaSpecId, + workload: &Workload, + make_inspector: Make, +) where + I: Inspector> + TrustedObserver, + Make: FnOnce() -> I, +{ + run_workload( + name, + workload, + || MegaEvm::new(inspected_context(spec, workload)).with_trusted_inspector(make_inspector()), + inspect_one_tx, + ); +} + +/// The context both inspected variants build their EVM over. +fn inspected_context( + spec: MegaSpecId, + workload: &Workload, +) -> MegaContext { + let mut context = MegaContext::new(build_pinned_db(&workload.accounts), spec); + context.modify_chain(|chain| zero_operator_fee!(chain)); + context +} + +/// Runs one transaction on the inspected loop, for either variant. +fn inspect_one_tx(evm: &mut MegaEvm, tx: &TxSpec) -> bool +where + I: Inspector>, +{ + let mut mega_tx = MegaTransaction(OpTransactionPinned::new(pinned_tx_env(tx))); + mega_tx.enveloped_tx = Some(Bytes::new()); + // `ExecuteEvm::transact` ignores `inspect` and stays on the plain + // loop; `inspect_tx` is the inspected loop the shim actually sits on. + let r = InspectEvm::inspect_tx(evm, mega_tx).expect("mega inspect"); + let success = r.result.is_success(); + black_box(r); + success +} + // // ============================================================================ // MegaWithEnv subject. diff --git a/crates/mega-evm/benches/transact.rs b/crates/mega-evm/benches/transact.rs index b32abddb..c4db6c18 100644 --- a/crates/mega-evm/benches/transact.rs +++ b/crates/mega-evm/benches/transact.rs @@ -1,8 +1,13 @@ //! Benchmarks for the `ExecuteEvm::transact()` interface. //! //! Each workload runs against two vanilla baselines (`revm_pinned`, -//! `op_revm_pinned`) and four mega specs (`EQUIVALENCE`, `MINI_REX`, `REX4`, -//! `REX5`) so a single bench produces a cross-row gap table. +//! `op_revm_pinned`) and the mega specs (`EQUIVALENCE` … `REX7`) on the plain +//! `transact` path, so a single bench produces a cross-row gap table. +//! +//! Every workload also registers inspected-path rows for REX6 and REX7: +//! `/inspect_noop` (measurement shim around a `NoOpInspector`) and +//! `/inspect_tracer` (`revm-inspectors` geth default). Those rows call +//! `InspectEvm::inspect_tx`, which is the path RPC tracers take. #![allow(missing_docs)] use alloy_primitives::{address, bytes, Address, Bytes, U256}; @@ -10,7 +15,7 @@ use criterion::{criterion_group, criterion_main, Criterion}; use revm::primitives::{keccak256, B256}; mod common; -use common::{register_all, Account, TxSpec, Workload}; +use common::{register_all, register_inspected, Account, TxSpec, Workload}; const CALLER: Address = address!("0000000000000000000000000000000000100000"); const CALLEE: Address = address!("0000000000000000000000000000000000100001"); @@ -37,6 +42,7 @@ fn bench_empty_transaction(c: &mut Criterion) { // the caller needs no balance), matching the original workload. let workload = Workload::single(vec![], TxSpec::call(CALLER, CALLEE)); register_all(&mut group, &workload); + register_inspected(&mut group, &workload); group.finish(); } @@ -51,6 +57,7 @@ fn bench_simple_ether_transfer(c: &mut Criterion) { TxSpec::call(CALLER, CALLEE), ); register_all(&mut group, &workload); + register_inspected(&mut group, &workload); group.finish(); } @@ -83,6 +90,53 @@ fn bench_weth9_transfer(c: &mut Criterion) { TxSpec::call(CALLER, WETH9_ADDRESS).data(calldata), ); register_all(&mut group, &workload); + register_inspected(&mut group, &workload); + group.finish(); +} + +/// Builds a tight countdown loop of cheap opcodes: +/// +/// ```text +/// PUSH3 iterations +/// loop: JUMPDEST; PUSH1 1; SWAP1; SUB; DUP1; PUSH1 loop; JUMPI +/// STOP +/// ``` +/// +/// Each iteration executes 7 opcodes for 26 gas (JUMPDEST 1 + PUSH1 3 + SWAP1 3 + +/// SUB 3 + DUP1 3 + PUSH1 3 + JUMPI 10), all from the cheap-opcode family that +/// dominates real interpreter workloads. +fn hotloop_code(iterations: u32) -> Bytes { + let mut code = Vec::with_capacity(14); + // PUSH3 + code.push(0x62); + code.extend_from_slice(&iterations.to_be_bytes()[1..4]); + // loop target is the JUMPDEST right after the initial PUSH3 (offset 4). + let loop_target = code.len() as u8; + code.push(0x5b); // JUMPDEST + code.push(0x60); // PUSH1 + code.push(0x01); + code.push(0x90); // SWAP1 + code.push(0x03); // SUB + code.push(0x80); // DUP1 + code.push(0x60); // PUSH1 + code.push(loop_target); + code.push(0x57); // JUMPI + code.push(0x00); // STOP + Bytes::from(code) +} + +/// Benchmark a cheap-opcode-dense interpreter hot loop (~700k executed opcodes, +/// ~2.6M gas), the workload shape where per-opcode gas-accounting overhead is +/// the dominant tax. +fn bench_interpreter_hotloop(c: &mut Criterion) { + let mut group = c.benchmark_group("interpreter_hotloop"); + // Gas price is zero, so the caller needs no balance. Callee holds the loop body. + let workload = Workload::single( + vec![Account::new(CALLEE).code(hotloop_code(100_000))], + TxSpec::call(CALLER, CALLEE), + ); + register_all(&mut group, &workload); + register_inspected(&mut group, &workload); group.finish(); } @@ -90,6 +144,7 @@ criterion_group!( benches, bench_empty_transaction, bench_simple_ether_transfer, - bench_weth9_transfer + bench_weth9_transfer, + bench_interpreter_hotloop ); criterion_main!(benches); diff --git a/crates/mega-evm/src/block/executor.rs b/crates/mega-evm/src/block/executor.rs index ed89c762..846ea3aa 100644 --- a/crates/mega-evm/src/block/executor.rs +++ b/crates/mega-evm/src/block/executor.rs @@ -78,6 +78,65 @@ impl core::fmt::Debug for MegaBlockExecutor } } +/// Refuses a transaction whose inspector was never declared read-only, on the canonical path. +/// +/// Block production and block validation are the two places where what the executor reports has to +/// be what the EVM did, reproducibly, on every node. An inspector lives in one node's +/// configuration and its edits reach the receipt, the block's counters and the transaction's +/// state, so the canonical path runs one only on the strength of a +/// [`TrustedObserver`](crate::TrustedObserver) declaration — see +/// [`MegaBlockExecutionError::UndeclaredInspector`]. +/// +/// The criterion is the declaration and not the measurement, because the measurement cannot answer +/// the question. The shim compares what it is handed across a callback boundary; an inspector that +/// edits the interpreter's stack or memory contents, or writes the journal directly, changes the +/// transaction and leaves every lane at zero. A declaration is what someone asserts in source about +/// a concrete type, which is the only thing that reaches inside a callback. +/// +/// Enforced in release builds, deliberately. This is a boundary the canonical path holds against +/// its embedder rather than an invariant `MegaETH` maintains internally, so it has to hold in the +/// binaries that build and validate blocks, and it fails the block rather than the process. +#[inline] +fn reject_undeclared_inspector( + tx_hash: B256, + undeclared_inspector: bool, +) -> Result<(), BlockExecutionError> { + if undeclared_inspector { + return Err(BlockExecutionError::other( + crate::MegaBlockExecutionError::UndeclaredInspector { tx_hash }, + )); + } + Ok(()) +} + +/// Refuses a result whose gas accounting an inspector is measured to have moved. +/// +/// The backstop behind [`reject_undeclared_inspector`], for what a declaration does not cover: a +/// declared type that did not keep its promise, and a result that reaches the commit funnel from +/// somewhere this executor cannot see — another executor instance, an embedder driving +/// [`crate::MegaEvm::execute_transaction`] itself, or a value built by hand. The result's own +/// ledger is the only thing at that funnel that knows anything about how it was produced. +/// +/// The criterion is the whole ledger, not its gas lanes. A rewrite of a frame's classification or +/// output, or a frame the inspector answered itself, moves no gas anywhere and would pass a +/// gas-only check while producing different state and a different receipt. +/// +/// The check is free on every path that passes it: the ledger is a `Copy` struct already on the +/// outcome, and this reads its fields once per transaction. +#[inline] +fn reject_inspector_adjusted_accounting( + tx_hash: B256, + ledger: crate::InspectorLedger, +) -> Result<(), BlockExecutionError> { + if ledger.is_zero() { + return Ok(()); + } + Err(BlockExecutionError::other(crate::MegaBlockExecutionError::InspectorAdjustedAccounting { + tx_hash, + ledger: std::boxed::Box::new(ledger), + })) +} + impl MegaBlockExecutor, R> where DB: StateDB, @@ -470,6 +529,18 @@ where /// (e.g. the `alloy_evm` `ExecutableTx`-constrained path): they resolve the sizes themselves /// and pass them in. Prefer [`MegaBlockExecutor::run_transaction`] otherwise, which resolves /// the sizes for you and cross-checks any cached values. + /// + /// # Contract + /// + /// An EVM running an inspector whose type carries no + /// [`TrustedObserver`](crate::TrustedObserver) declaration refuses the transaction with + /// [`MegaBlockExecutionError::UndeclaredInspector`]( + /// crate::MegaBlockExecutionError::UndeclaredInspector) before executing it, and a result + /// whose gas accounting an inspector is measured to have moved is refused with + /// [`MegaBlockExecutionError::InspectorAdjustedAccounting`]( + /// crate::MegaBlockExecutionError::InspectorAdjustedAccounting) after. A declared tracer is + /// unaffected; an embedder that wants a rewriting inspector drives + /// [`crate::MegaEvm::execute_transaction`] directly. pub fn run_transaction_with_sizes( &mut self, tx: Tx, @@ -479,6 +550,11 @@ where where Tx: IntoTxEnv + RecoveredTx + Copy, { + // Before anything else, including execution: an undeclared inspector does not run on this + // path at all. Refusing after the fact would leave its callbacks a window in which to + // reach the executor's own state cache through `db_mut()`. + reject_undeclared_inspector(tx.tx().tx_hash(), self.evm.has_undeclared_inspector())?; + let is_deposit = tx.tx().ty() == DEPOSIT_TRANSACTION_TYPE; // Check transaction-level and block-level limits before transaction execution @@ -507,6 +583,7 @@ where .evm .execute_transaction(tx.into_tx_env()) .map_err(move |err| BlockExecutionError::evm(alloy_op_evm::map_op_err(err), hash))?; + reject_inspector_adjusted_accounting(tx.tx().tx_hash(), outcome.inspector_ledger)?; Ok(BlockMegaTransactionOutcome { tx, tx_size, da_size, depositor, inner: outcome }) } @@ -527,6 +604,10 @@ where where Rec: RecoveredTx, { + // Same order as `run_transaction_with_sizes`: the inspector's declaration is settled + // before the transaction runs. + reject_undeclared_inspector(recovered.tx().tx_hash(), self.evm.has_undeclared_inspector())?; + let is_deposit = recovered.tx().ty() == DEPOSIT_TRANSACTION_TYPE; self.block_limiter.pre_execution_check( @@ -547,6 +628,7 @@ where .evm .execute_transaction(tx_env) .map_err(move |err| BlockExecutionError::evm(alloy_op_evm::map_op_err(err), hash))?; + reject_inspector_adjusted_accounting(recovered.tx().tx_hash(), outcome.inspector_ledger)?; Ok((depositor, outcome)) } @@ -578,6 +660,16 @@ where /// /// Rejection is not a block-level failure — a block that ends without the rejected /// transaction is perfectly valid — which is why the error is returned rather than latched. + /// + /// This is also where a result an inspector took part in is refused, ahead of admission and + /// of any other reading: the producers guard their own outputs, but a result reaching this + /// funnel may have been produced by another executor instance, by an embedder driving + /// [`crate::MegaEvm::execute_transaction`] itself, or built by hand. What the outcome carries + /// is the only thing here that knows how it was produced — the inspector's declaration, and + /// then the ledger. See [`MegaBlockExecutionError::UndeclaredInspector`]( + /// crate::MegaBlockExecutionError::UndeclaredInspector) and + /// [`MegaBlockExecutionError::InspectorAdjustedAccounting`]( + /// crate::MegaBlockExecutionError::InspectorAdjustedAccounting). pub fn commit_tx_result( &mut self, result: crate::MegaBlockTxResult<::TxType>, @@ -598,10 +690,25 @@ where data_size, kv_updates, compute_gas_used, + // The transaction's derived destroyed total is a reported number; the block + // reports it through `compute_gas_used`, which already carries it, and reaches + // its own enforced counter through `compute_gas_enforced` instead of + // subtracting this one back out. + compute_gas_destroyed: _, + compute_gas_enforced, state_growth_used, + inspector_ledger, + undeclared_inspector, }, } = result; + // Before anything else, including admission: a result an inspector took part in is not + // one this block may contain at all, whether or not it would still fit. The declaration + // is asked first, because it is the admission rule and the ledger is the backstop behind + // it — an undeclared inspector is refused whether or not anything it did was measurable. + reject_undeclared_inspector(tx_hash, undeclared_inspector)?; + reject_inspector_adjusted_accounting(tx_hash, inspector_ledger)?; + // Re-validate limits at commit time to handle parallel execution race conditions. // Between execution and commit, other transactions may have been committed, potentially // exhausting the block's remaining capacity. @@ -616,6 +723,11 @@ where // Accumulate post-execution resource usage into block-level counters. This does not // validate limits; over-limit enforcement happens in `pre_execution_check` before the // next transaction. The deposit-nonce record doubles as the deposit signal here. + // + // Compute gas crosses this boundary as the pair execution produced it — the full reported + // total and the part of it the transaction enforced its own limits against — so the + // limiter can report one and enforce the other. Collapsing them here would hand the block + // a single number that is right for reporting and wrong for admission. self.block_limiter.post_execution_update_raw( result.tx_gas_used(), tx_size, @@ -623,6 +735,7 @@ where data_size, kv_updates, compute_gas_used, + compute_gas_enforced, state_growth_used, depositor.is_some(), ); diff --git a/crates/mega-evm/src/block/factory.rs b/crates/mega-evm/src/block/factory.rs index ad9dd4b0..517e2ec8 100644 --- a/crates/mega-evm/src/block/factory.rs +++ b/crates/mega-evm/src/block/factory.rs @@ -100,19 +100,27 @@ where MegaBlockExecutor::new(evm, block_ctx, self.hardforks.clone(), self.receipt_builder.clone()) } - /// Create a new block executor with an inspector. + /// Create a new block executor with a read-only inspector its type's author has declared + /// [`TrustedObserver`](crate::TrustedObserver). + /// + /// The declaration is what the canonical block-execution path admits an inspected transaction + /// on, so this is the entry a node tracing block production or validation takes. There is no + /// undeclared counterpart: an inspector without a declaration reaches an executor only through + /// the [`BlockExecutorFactory`](alloy_evm::block::BlockExecutorFactory) trait entry, which + /// takes an EVM the caller built and whose transactions are then refused one by one. + /// + /// A `revm-inspectors` tracer cannot be declared where both it and the trait are foreign, so a + /// node wraps it in [`DeclaredObserver`](crate::DeclaredObserver), which is local here, carries + /// the declaration and forwards every callback; `bin/mega-evme`'s replay command is the shape + /// to copy. /// /// # Parameters /// /// - `db`: The database to use for EVM state. /// - `evm_env`: The EVM environment, including block and config environments. /// - `block_ctx`: The block execution context for tracking access patterns. - /// - `inspector`: The inspector to use for debugging and monitoring. - /// - /// # Returns - /// - /// A new `BlockExecutor` instance configured with the provided parameters. - pub fn create_executor_with_inspector<'a, DB, I>( + /// - `inspector`: The declared read-only inspector to observe execution with. + pub fn create_executor_with_trusted_inspector<'a, DB, I>( &self, db: &'a mut State, block_ctx: MegaBlockExecutionCtx, @@ -125,12 +133,15 @@ where > where DB: Database + 'a, - I: Inspector, ExtEnvFactory::EnvTypes>> + 'a, + I: Inspector, ExtEnvFactory::EnvTypes>> + + crate::TrustedObserver + + 'a, { let runtime_limits = block_ctx.block_limits.to_evm_tx_runtime_limits(); let evm = self .evm_factory - .create_evm_with_inspector(db, evm_env, inspector) + .create_evm(db, evm_env) + .with_trusted_inspector(inspector) .with_tx_runtime_limits(runtime_limits); MegaBlockExecutor::new(evm, block_ctx, self.hardforks.clone(), self.receipt_builder.clone()) } @@ -177,6 +188,11 @@ where DB: StateDB, I: Inspector<::Context>, { + // Nothing is checked about the inspector here. This entry takes an EVM the caller built, + // so its inspector may or may not carry a declaration, and the answer is a runtime one + // the executor's own entries ask per transaction — as an error that fails the block, not + // an assertion that stops the process. See `MegaBlockExecutionError::UndeclaredInspector`. + // Synchronize EVM tx runtime limits with the block context's BlockLimits. // This mirrors the inherent factory paths above which apply this // unconditionally on every spec since introduction. Without this, the diff --git a/crates/mega-evm/src/block/limit.rs b/crates/mega-evm/src/block/limit.rs index 58b6b23d..95ad9ea2 100644 --- a/crates/mega-evm/src/block/limit.rs +++ b/crates/mega-evm/src/block/limit.rs @@ -128,6 +128,8 @@ //! - Accumulates resource usage from the executed transaction into block-level counters //! - Does not validate post-execution limits; over-limit enforcement happens before admitting //! the next transaction in [`BlockLimiter::pre_execution_check`] +//! - Compute gas accumulates into two counters, one reported and one enforced; see +//! [`BlockLimiter::block_compute_gas_used`] //! //! 4. **Commit transaction** - [`crate::MegaBlockExecutor::commit_execution_outcome`] //! - Include in block (with success or failed receipt) @@ -604,6 +606,7 @@ impl BlockLimits { block_tx_size_used: 0, block_da_size_used: 0, block_compute_gas_used: 0, + block_compute_gas_enforced: 0, block_state_growth_used: 0, } } @@ -653,7 +656,9 @@ impl BlockLimits { /// let outcome = execute_transaction(tx); /// /// // Post-execution update (the executor commit path drives this internally) -/// limiter.post_execution_update_raw(gas, tx_size, da_size, data, kv, compute, growth, is_deposit); +/// limiter.post_execution_update_raw( +/// gas, tx_size, da_size, data, kv, compute, enforced_compute, growth, is_deposit, +/// ); /// } /// ``` #[derive(Debug, Clone)] @@ -681,9 +686,29 @@ pub struct BlockLimiter { /// This tracks the total number of SSTORE operations across all transactions. pub block_kv_updates_used: u64, - /// Cumulative compute gas consumed by all transactions in the block. + /// Cumulative compute gas consumed by all transactions in the block, as reported. + /// + /// This is the block's public compute-gas statistic: every transaction's full reported total, + /// including the remainders Rex7+ exceptionally halted frames destroyed rather than performed. + /// Admission does not read it — + /// [`block_compute_gas_enforced`](Self::block_compute_gas_enforced) is the counter the + /// block compute-gas limit is evaluated against. pub block_compute_gas_used: u64, + /// The part of [`block_compute_gas_used`](Self::block_compute_gas_used) the block enforces: + /// the sum of what each transaction performed and evaluated its own compute limit against. + /// + /// Destroyed gas is not work the network performed, and no resource limit is evaluated against + /// it at any level. A transaction whose reported total dwarfs its executed work would + /// otherwise close the block's compute capacity for everyone behind it while having computed + /// almost nothing. Before Rex7 nothing is ever destroyed, so this counter and the reported one + /// advance in lockstep. + /// + /// Each transaction contributes the number its own enforcement ran on, not that transaction's + /// reported total less its reported destroyed remainder — the two are equal, but only one of + /// them is a measurement of work performed. + pub block_compute_gas_enforced: u64, + /// Cumulative state growth consumed by all transactions in the block. pub block_state_growth_used: u64, } @@ -709,6 +734,7 @@ impl BlockLimiter { block_tx_size_used: 0, block_da_size_used: 0, block_compute_gas_used: 0, + block_compute_gas_enforced: 0, block_state_growth_used: 0, } } @@ -866,12 +892,14 @@ impl BlockLimiter { })); } - // Check block-level compute gas limit - if self.block_compute_gas_used >= self.limits.block_compute_gas_limit { + // Check block-level compute gas limit. The enforced counter is the one compared, and so + // the one the error reports: destroyed remainders are reported in + // `block_compute_gas_used` but never close the block's compute capacity. + if self.block_compute_gas_enforced >= self.limits.block_compute_gas_limit { return Err(BlockExecutionError::Validation(BlockValidationError::InvalidTx { hash: tx_hash, error: Box::new(MegaBlockLimitExceededError::ComputeGasLimit { - block_used: self.block_compute_gas_used, + block_used: self.block_compute_gas_enforced, limit: self.limits.block_compute_gas_limit, }), })); @@ -900,6 +928,19 @@ impl BlockLimiter { /// the transaction may push the block over a limit, which is intentional to maximize block /// utilization. `is_deposit` gates only the DA-size counter: deposits are exempt from DA /// accounting. + /// + /// Compute gas arrives as two numbers, not one: `compute_gas_used` is the transaction's full + /// reported total, and `compute_gas_enforced` is the part of it the transaction performed and + /// evaluated its own compute limit against (equal to the total before Rex7, which destroys + /// nothing). The reported total lands in the public statistic and the enforced part in the + /// counter the block compute-gas limit is evaluated against. + /// + /// The enforced number is taken, not computed here from the transaction's reported destroyed + /// total. Block admission is enforcement, and it therefore reads the same per-opcode and + /// checkpoint recordings the transaction enforced on, rather than a quantity derived for + /// reporting: subtracting the reported destroyed total would put a reporting derivation on the + /// admission path, where an error in it would repack blocks instead of misreporting a + /// statistic. #[allow(clippy::too_many_arguments)] pub fn post_execution_update_raw( &mut self, @@ -909,6 +950,7 @@ impl BlockLimiter { tx_data: u64, kv_updates: u64, compute_gas_used: u64, + compute_gas_enforced: u64, state_growth_used: u64, is_deposit: bool, ) { @@ -934,8 +976,10 @@ impl BlockLimiter { self.block_kv_updates_used = self.block_kv_updates_used.saturating_add(kv_updates); // Block compute gas limit, no need to check here since we allow the last transaction to - // exceed the limit. + // exceed the limit. Only the executed part advances the enforced counter. self.block_compute_gas_used = self.block_compute_gas_used.saturating_add(compute_gas_used); + self.block_compute_gas_enforced = + self.block_compute_gas_enforced.saturating_add(compute_gas_enforced); // Block state growth limit, no need to check here since we allow the last transaction to // exceed the limit. @@ -944,13 +988,16 @@ impl BlockLimiter { } /// Returns true if any block-level limit has been reached or exceeded. + /// + /// Compute gas answers on the enforced counter, matching what + /// [`pre_execution_check`](Self::pre_execution_check) would reject the next transaction on. pub fn is_block_limit_reached(&self) -> bool { self.block_gas_used >= self.limits.block_gas_limit || self.block_tx_size_used >= self.limits.block_txs_encode_size_limit || self.block_da_size_used >= self.limits.block_da_size_limit || self.block_data_used >= self.limits.block_txs_data_limit || self.block_kv_updates_used >= self.limits.block_kv_update_limit || - self.block_compute_gas_used >= self.limits.block_compute_gas_limit || + self.block_compute_gas_enforced >= self.limits.block_compute_gas_limit || self.block_state_growth_used >= self.limits.block_state_growth_limit } } @@ -1014,6 +1061,7 @@ mod tests { limiter.block_data_used = u64::MAX - 1; limiter.block_kv_updates_used = u64::MAX - 1; limiter.block_compute_gas_used = u64::MAX - 1; + limiter.block_compute_gas_enforced = u64::MAX - 1; limiter.block_state_growth_used = u64::MAX - 1; limiter.post_execution_update_raw( @@ -1024,6 +1072,7 @@ mod tests { u64::MAX, u64::MAX, u64::MAX, + u64::MAX, false, ); @@ -1033,6 +1082,7 @@ mod tests { assert_eq!(limiter.block_data_used, u64::MAX); assert_eq!(limiter.block_kv_updates_used, u64::MAX); assert_eq!(limiter.block_compute_gas_used, u64::MAX); + assert_eq!(limiter.block_compute_gas_enforced, u64::MAX); assert_eq!(limiter.block_state_growth_used, u64::MAX); } @@ -1043,8 +1093,80 @@ mod tests { let mut limiter = BlockLimiter::new(BlockLimits::no_limits()); limiter.block_da_size_used = 100; - limiter.post_execution_update_raw(0, 0, u64::MAX, 0, 0, 0, 0, true); + limiter.post_execution_update_raw(0, 0, u64::MAX, 0, 0, 0, 0, 0, true); assert_eq!(limiter.block_da_size_used, 100); } + + /// The two compute-gas counters accumulate different things: the reported one takes the + /// transaction's whole total, the enforced one only the part the transaction performed. A + /// destroyed remainder that leaked into the enforced counter would close the block's compute + /// capacity for work that never happened. + #[test] + fn test_post_execution_update_raw_splits_the_compute_gas_lanes() { + let mut limiter = BlockLimiter::new(BlockLimits::no_limits()); + + // A transaction reporting 1,000,000 having performed 100,000 of it. + limiter.post_execution_update_raw(0, 0, 0, 0, 0, 1_000_000, 100_000, 0, false); + assert_eq!(limiter.block_compute_gas_used, 1_000_000, "the report takes the whole total"); + assert_eq!(limiter.block_compute_gas_enforced, 100_000, "enforcement takes only the work"); + + // A second transaction that destroyed nothing advances both counters by the same amount. + limiter.post_execution_update_raw(0, 0, 0, 0, 0, 50_000, 50_000, 0, false); + assert_eq!(limiter.block_compute_gas_used, 1_050_000); + assert_eq!(limiter.block_compute_gas_enforced, 150_000); + } + + /// Nothing is ever destroyed before Rex7, so a block of transactions whose enforced part is + /// their whole total leaves the two counters equal at every step — the pre-Rex7 behaviour, + /// which the split must reproduce byte for byte. + #[test] + fn test_compute_gas_lanes_coincide_without_a_destroyed_part() { + let mut limiter = BlockLimiter::new(BlockLimits::no_limits()); + + for compute in [21_000, 500, 1_234_567, 0] { + limiter.post_execution_update_raw(0, 0, 0, 0, 0, compute, compute, 0, false); + assert_eq!( + limiter.block_compute_gas_used, limiter.block_compute_gas_enforced, + "with nothing destroyed the reported and enforced counters must not diverge" + ); + } + } + + /// Admission compares the enforced counter, and the error it raises must state that same + /// number: a rejected transaction's operator reads `block_used` to understand what filled the + /// block, and the reported total would name a budget the block never spent. + #[test] + fn test_block_compute_gas_admission_reads_the_enforced_counter() { + let mut limits = BlockLimits::no_limits(); + limits.block_compute_gas_limit = 1_000_000; + let mut limiter = BlockLimiter::new(limits); + + // One transaction reporting far past the block limit, having performed almost none of it. + limiter.post_execution_update_raw(0, 0, 0, 0, 0, 5_000_000, 50_000, 0, false); + assert!( + limiter.block_compute_gas_used > limits.block_compute_gas_limit, + "the reported total must carry the destroyed remainder past the limit" + ); + assert!( + !limiter.is_block_limit_reached(), + "a destroyed remainder must not fill the block's compute capacity" + ); + assert!( + limiter.pre_execution_check(B256::ZERO, 0, 0, 0, false).is_ok(), + "the next transaction must still be admitted" + ); + + // Executed work fills it, and the error names the enforced counter. + limiter.post_execution_update_raw(0, 0, 0, 0, 0, 950_000, 950_000, 0, false); + assert!(limiter.is_block_limit_reached(), "executed work does fill the block"); + let error = limiter + .pre_execution_check(B256::ZERO, 0, 0, 0, false) + .expect_err("a full block must reject the next transaction"); + let message = format!("{error:?}"); + assert!( + message.contains("ComputeGasLimit") && message.contains("block_used: 1000000"), + "the error must report the counter that was compared, got {message}" + ); + } } diff --git a/crates/mega-evm/src/block/result.rs b/crates/mega-evm/src/block/result.rs index 8fcf9743..8760c92e 100644 --- a/crates/mega-evm/src/block/result.rs +++ b/crates/mega-evm/src/block/result.rs @@ -1,3 +1,7 @@ +#[cfg(not(feature = "std"))] +use alloc as std; +use std::boxed::Box; + use alloy_evm::{block::TxResult, InvalidTxError}; use alloy_primitives::TxHash; use revm::{ @@ -170,7 +174,11 @@ pub enum MegaBlockLimitExceededError { /// Block compute gas limit reached. #[error("Block compute gas limit reached: block_used={block_used} >= limit={limit}")] ComputeGasLimit { - /// Compute gas used by block so far + /// Compute gas used by block so far, as the limit measures it. + /// + /// This is the enforced reading — the counter that was actually compared — so it excludes + /// the remainders Rex7+ exceptionally halted frames destroyed. The block's full reported + /// compute statistic, which includes them, can be higher. block_used: u64, /// Block compute gas limit limit: u64, @@ -245,6 +253,97 @@ impl InvalidTxError for MegaBlockLimitExceededError { } } +/// A `MegaETH` block executor's own refusals — the ones that are neither a resource limit nor a +/// transaction the EVM rejected. +/// +/// These say the executor was asked to do something it must not do, so they are reported as +/// [`BlockExecutionError::Internal`](alloy_evm::block::BlockExecutionError::Internal) rather than +/// as a verdict about the transaction: the block is not built, and there is nothing about the +/// transaction itself for a caller to fix. +#[derive(Debug, Clone, thiserror::Error)] +pub enum MegaBlockExecutionError { + /// A transaction reached the canonical block-execution path from an EVM running an inspector + /// whose type carries no [`TrustedObserver`](crate::TrustedObserver) declaration. + /// + /// This is the admission rule, and it is a rule about the *configuration* rather than about + /// what one run was observed to do. The measurement shim books what an inspector writes across + /// a callback boundary, but an inspector can reach past that boundary — editing the contents + /// of the interpreter's stack or memory, or writing the journal directly — and change what the + /// transaction produces while leaving every lane of + /// [`InspectorLedger`](crate::InspectorLedger) at zero. An empty ledger therefore cannot be + /// what a block is admitted on. + /// + /// What can is a declaration: a line written in source, about one concrete type, by someone + /// who had read it. So the canonical path takes an EVM running no inspector, or one whose + /// inspector was built through + /// [`MegaEvm::with_trusted_inspector`](crate::MegaEvm::with_trusted_inspector), and refuses + /// everything else — including an inspector that only observes, because the criterion is what + /// the type's author declared and not what this run happened to do. + /// + /// A tracer keeps working by being declared. `revm-inspectors`' tracers are foreign types and + /// the trait is local to this crate, so a node wraps one in + /// [`DeclaredObserver`](crate::DeclaredObserver), which carries the declaration and forwards + /// every callback; `bin/mega-evme`'s replay command does exactly this. An embedder that wants + /// a rewriting inspector still has one — [`MegaEvm::execute_transaction`]( + /// crate::MegaEvm::execute_transaction) supports it in full — it just does not get to call the + /// result a block. + #[error( + "transaction {tx_hash} reached the canonical block-execution path from an EVM running an \ + inspector whose type carries no `TrustedObserver` declaration" + )] + UndeclaredInspector { + /// The transaction that was refused. + tx_hash: TxHash, + }, + + /// A transaction an inspector took part in reached the canonical block-execution path. + /// + /// Block production and block validation must produce the same numbers for the same block, on + /// every node, so what the executor reports has to be what the EVM did — and only that. An + /// inspector is present on one node's configuration and not on another's, and it can break + /// that in two ways: + /// + /// - by writing gas into an interpreter's counter, into a frame's envelope, or into a + /// returning frame's result, which reaches the receipt, the transaction's reported compute + /// total, and through it the block's cumulative counters; + /// - by rewriting what a frame *did* — its classification, its output, or the frame itself + /// through a synthetic outcome — which moves no gas at all and reaches the transaction's + /// state and its receipt directly. + /// + /// Both are refused. The second is why the criterion is the whole ledger rather than its gas + /// lanes: a rewrite that costs nothing is not a rewrite that changes nothing. + /// + /// This is the backstop behind [`UndeclaredInspector`](Self::UndeclaredInspector), not the + /// admission rule. An undeclared inspector is refused before it runs, so what is left for this + /// to catch is a declaration that did not hold — which debug builds measure and assert — and a + /// result that arrives at the commit funnel already carrying a non-zero ledger, produced by + /// another executor instance, by an embedder driving the EVM itself, or built by hand. The + /// commit funnel cannot see the EVM that produced such a result; the result's own ledger is + /// the only thing there that knows anything. + /// + /// An embedder that genuinely wants a rewriting inspector still has one — + /// [`MegaEvm::execute_transaction`](crate::MegaEvm::execute_transaction) supports it in full, + /// with the ledger reported on the outcome — it just does not get to call the result a block. + /// That is also what leaves a simulation EVM an embedder drives off the canonical path alone, + /// however much its inspector rewrites: this guard sits on the block executor's entries, not + /// on the EVM. + #[error( + "transaction {tx_hash} reached the canonical block-execution path after an inspector took \ + part in it: {ledger:?}" + )] + InspectorAdjustedAccounting { + /// The transaction the adjusted accounting belongs to. + tx_hash: TxHash, + /// What the measurement shim booked for that transaction. + /// + /// Boxed: the ledger is six signed lanes and two counters, which is most of this enum's + /// size, and every value of this type is boxed again into + /// [`BlockExecutionError::Internal`](alloy_evm::block::BlockExecutionError::Internal) the + /// moment it is built. + ledger: Box, + }, +} + #[cfg(test)] mod tests { use super::*; @@ -280,7 +379,11 @@ mod tests { data_size: 1, kv_updates: 2, compute_gas_used: 3, + compute_gas_destroyed: 1, + compute_gas_enforced: 2, state_growth_used: 4, + inspector_ledger: crate::InspectorLedger::default(), + undeclared_inspector: false, }; // One hop: MegaTransactionOutcome -> ResultAndState. @@ -301,6 +404,7 @@ mod tests { // One hop for the resource dimensions (`Copy` scalars may leave through a deref). let kv: u64 = outcome.kv_updates; assert_eq!((kv, outcome.compute_gas_used, outcome.state_growth_used), (2, 3, 4)); + assert_eq!((outcome.compute_gas_destroyed, outcome.compute_gas_enforced), (1, 2)); } #[test] diff --git a/crates/mega-evm/src/evm/AGENTS.md b/crates/mega-evm/src/evm/AGENTS.md index 0245c104..d8d09964 100644 --- a/crates/mega-evm/src/evm/AGENTS.md +++ b/crates/mega-evm/src/evm/AGENTS.md @@ -6,7 +6,8 @@ MegaEVM execution core that wraps revm/op-revm with MegaETH instruction tables, ## STRUCTURE - `mod.rs`: `MegaEvm` wrapper, inspector toggling, execution convenience APIs. - `context.rs`: execution context composition and state wiring. -- `execution.rs`: transaction execution flow and result shaping. +- `execution.rs`: transaction execution flow, the two frame loops and the two frame-init paths, and result shaping. +- `frame.rs`: revm's frame-action processing, split so the journal decision can be withheld until the frame's result is final — `classify_frame_action` decides the result, `commit_frame_journal` carries the decision out. - `factory.rs`: `MegaEvmFactory` builder for context and external env wiring. - `instructions.rs`: spec-layered opcode table and extension wrappers. - `host.rs`: host overrides for volatile tracking, oracle reads, SALT gas hooks. @@ -21,9 +22,234 @@ MegaEVM execution core that wraps revm/op-revm with MegaETH instruction tables, - Oracle `sload` handling forces cold semantics for deterministic replay. - `MegaEvm` methods read aggregate resource usage from `additional_limit` after execution. - Keep inspector and non-inspector paths behaviorally aligned. + Observational inspectors (`NoOpInspector`, tracers that only read) are bit-identical to no inspector at all, and must stay so. +- Rewriting inspectors are supported in full, and what they do to gas is measured rather than assumed — see `## INSPECTOR CONTRACT` below for the per-shape table and the two shapes that are refused. +- Under REX7 a frame's journal decision travels: the frame loops park it on `MegaEvm::deferred_journal` and `frame_return_result` carries it out, after `AdditionalLimit::before_frame_return_result` — the last thing that can rewrite a frame's result — and before the caller resumes. + There is never more than one decision outstanding and it never survives the step it was parked for; `hold_deferred_journal` asserts that. + Anything new that can rewrite a frame's result has to land inside that window, or it reopens the split the deferral closed. +- Both frame loops and both frame-init paths run the same bodies; the inspected copies add exactly one thing, the callback that can rewrite a frame's classification. + Add to the shared body, not to one copy: `tests/rex7/frame_loop_parity.rs` compares the two on every frame outcome, state included, and is what a one-sided edit fails. + +## INSPECTOR CONTRACT + +Every inspector `MegaEvm` is handed is wrapped in `MeasuredInspector` (`inspector.rs`) before it reaches the inner EVM. +The public accessors hand the unwrapped inspector back, so the type a caller names is unchanged and the wrapper is not something a caller opts into or can opt out of. + +The shim's soundness rests on one fact: the EVM does not execute inside an inspector callback. +Anything that changes between the moment the shim delegates to the user's inspector and the moment control comes back is therefore the inspector's doing by construction, not by attribution — which is what makes the callback boundary a place a measurement can be taken at all. +The shim snapshots what it cares about on the way in, compares on the way out, and books the difference on `InspectorLedger` (`../limit/inspector_ledger.rs`), which travels out on `MegaTransactionOutcome::inspector_ledger`. + +Where a lane is booked also decides which specs book it. +A lane booked at a callback boundary is booked on every spec, because the shim itself is not spec-gated. +A lane booked at the frame's settlement point is booked from `MINI_REX` onwards, because `AdditionalLimit`'s frame settlement does not run before it — so under `EQUIVALENCE` a rewrite of a finished frame result's gas reaches the receipt with no lane recording it, and the block guard does not see it. +`tests/equivalence/pre_mini_rex_gates.rs` pins both halves. + +Every gas lane is two numbers, because the ledger's two consumers ask different questions. +The conservation law needs the **net**, since gas written into one object and taken back out of another really did leave the envelope where it was. +The block guard needs the **gross**, since two edits that cancel are two edits: a `+1` before a frame reads its own remaining gas and a `−1` after it has read it net to nothing and leave the frame holding a number the EVM would never have produced, and the same pair split across a surviving frame and a rolled-back one moves what the sender pays. +`InspectorLedger::is_zero` — the guard's question — is defined over the gross halves; `conjured_gas` — the law's term — over the nets. +`Lane::book` moves both, which is what makes it impossible to move a lane without the guard seeing it. + +### What each rewrite shape costs + +Read the table by the *argument the rewrite reaches through*, not by the tool that makes it: two tools editing the same argument are one row. + +| Rewrite shape | Support | Where it is booked | What enforcement sees | +| --- | --- | --- | --- | +| Read-only observation | Supported, free — and, when the type is declared, not even measured (see **The declared observer** below) | nothing | an empty ledger, and numbers identical to an uninspected run | +| Gas written into a live interpreter's counter (`initialize_interp`, `step`, `step_end`, `log_full`) | Supported | `InspectorLedger::gas`, at the callback boundary | nothing: the checkpoint baseline shifts by the same amount, and the gas clamp is re-derived on the spot so injected gas buys no compute headroom | +| A frame input's `gas_limit`, raised or lowered (`frame_start`, `call`, `create`) | Supported | `InspectorLedger::env`, at the callback boundary | nothing: a frame's compute budget comes from the tracker, not from its gas limit | +| A frame input's semantic fields — target, caller, value, scheme, calldata, static flag (`frame_start`, `call`, `create`) | Supported | `InspectorLedger::interventions`, at the callback boundary | nothing: it changes what the frame does, not what it costs | +| A synthetic outcome that skips the frame entirely (`frame_start`, `call`, `create`) | Supported | `InspectorLedger::interventions`; nothing on the `env` lane — the edited inputs never reach a frame — and the gas the outcome carries on the `result` lane, measured against the envelope the answering callback was handed rather than as a difference across it | the frame's envelope is settled at `finalize_frame` as `FrameExit::RefusedSynthetically` | +| A finished frame result's remaining gas (`call_end`, `create_end`, `frame_end`) | Supported | `InspectorLedger::result`, at the frame's settlement point rather than at the callback boundary | nothing | +| A finished frame result's returned output (`call_end`, `create_end`, `frame_end`) | Supported | `InspectorLedger::interventions`, at the callback boundary | nothing | +| A finished outcome's metadata — a call's `memory_offset`, a creation's `address`, the two flags beside them (`call_end`, `create_end`, `frame_end`) | Supported | `InspectorLedger::interventions`, at the callback boundary | nothing: it changes what the caller reads next, not what the frame cost | +| A refund written into any `Gas` a callback holds | Supported | `InspectorLedger::refund`, at the callback boundary, nominally | nothing: a refund moves `tx_gas_used`, not the `limit - remaining` the conservation law is stated over | +| The EIP-8037 state-gas pool or spend counter, on any `Gas` or on a call's inputs | Supported | `InspectorLedger::reservoir` / `::state_gas`, settled once from the figures the transaction ends with | the pool lowers the envelope the receipt reports, so it joins the law's `I` term; the spend counter moves the receipt's state-gas figure and nothing else | +| A successful frame result rewritten into a revert or a halt | Supported | `InspectorLedger::interventions` — no gas moves | the journal decision follows the final result, so the frame's state is rolled back with it | +| A failed **call** frame rewritten into a success | Supported | `InspectorLedger::interventions` | the journal commits, so the frame's state follows the result its caller was handed | +| The classification of a result **frame init** produced, moved across the success / revert / halt boundary | **Refused** | `InspectorLedger::rejected_rewrites`, alongside `interventions` | `reject_forbidden_frame_init_rewrite` restores the original classification and fails the transaction with `EVMError::Custom` | +| A failed **contract creation** rewritten into a success | **Refused** | `InspectorLedger::rejected_rewrites`, alongside `interventions` | `reject_forbidden_create_rewrite` restores the original classification and fails the transaction with `EVMError::Custom`; debug builds assert | +| Any constant-time reading of a live interpreter's working set — its program counter, its code's identity, revm's `continue_execution` flag, the stack's length, the return buffer's identity, the memory's size and window offset, the memo of how far that memory has been paid for (`Gas::memory`), the frame's four identifying fields and its calldata's identity, the static flag, the spec id | Supported | `InspectorLedger::interventions`, at the callback boundary, off `inspector.rs::WorkingSet` | nothing directly — but a stepped program counter deletes an instruction from the frame, and growing the memory and the memo together skips the next expanding opcode's charge, which is why these need a booking at all | +| The *contents* of the interpreter's stack, memory, return buffer, calldata or code, at unchanged identities | Supported, unmeasured | nothing — telling whether they came back changed needs a snapshot of unbounded state | the EVM executes on the edited state and meters it as its own work, because it is | +| A direct journal write (`tstore`, `log`, …) | Supported, unmetered | nothing — no argument the shim holds describes it | `MegaETH`'s data-size / KV / state-growth lanes do not see it; it moves no gas, so the conservation law is unaffected | +| The gas a pending `InterpreterAction` carries, reached through `LoopControl` (`step_end`) | Supported | the lane the action the callback left behind names: `InspectorLedger::result` for a `Return` action, settled at the frame's settlement point because that action *is* the frame's result a moment later; `InspectorLedger::env` for a `NewFrame` one, booked at the child's `frame_start`; `InspectorLedger::gas` when the callback removed the action, because the frame then carries on spending its counter | nothing | +| A pending action's classification or output, or an action installed, removed or swapped for the other variant | Supported | `InspectorLedger::interventions`, alongside whatever gas the change moved on the lane above | it changes what the EVM does next, not what the frame has spent | +| `CfgEnv` or the active gas schedule | **Refused** | — | the gas-schedule pin panics; the schedule belongs to the spec, and a rewritten one has no accounting lane that could rescue it | + +Two independent stops back the creation refusal: the shim restores the classification, and `frame.rs`'s `FrameJournalVerdict::CreateRejected` carries no code and no commit branch, so even with the refusal removed such a rewrite deposits nothing. + +### Why an init-produced result's classification is refused + +The rest of the table rests on the REX7 deferral: a frame's journal decision is parked until after the last callback, so a rewrite of its classification is followed by the state it leaves behind. +A result that comes out of frame init has no such window, and cannot be given one from here. +revm decides the journal inside `make_call_frame`, statements before it returns — a value-transferring call into an empty-code account commits the transfer and returns `Stop`, a precompile that fails reverts it and returns its own failure — and `MegaETH`'s system contract interceptors decide theirs before they return, the `KeylessDeploy` one by merging a whole sandbox's state. +All of it has happened by the time a callback sees the result, so honouring a rewrite hands the caller an answer the state behind it contradicts: a transfer the recipient keeps and the sender is told failed, or a deployment the caller is told reverted and that stands anyway. + +The refused set is the results `MegaEvm::init_frame_unsettled` returns, taken as a whole rather than arm by arm. +Some of those arms carry no state a rewrite could contradict — a depth rejection, a limit refusal `MegaETH` took before revm opened a checkpoint — and are covered anyway, because the arms are revm's early-fail returns plus `MegaETH`'s interceptors and guards, a set with no type-level tie to anything here and one a revm bump grows without a compile error. +A result an inspector answered the frame with itself is deliberately outside the set: nothing in the EVM decided anything for it, no checkpoint was opened and no state written, so its classification is the inspector's to state and rewriting it contradicts nothing. +`execution.rs::frame_end_on_frame_init_result` is the one place the window is opened, which is what keeps the boundary a call site rather than a judgement repeated per arm. + +Gas and output are untouched by the refusal, on both sides of that boundary: they are measured on the lanes above either way. + +Unlike the creation refusal this one does not assert. +The shape it catches is the most ordinary rewrite a tool makes — failing a call — landing on the one kind of frame it cannot be applied to, so a corpus that produces it has to be able to report it rather than die on it. +`mega-state-test`'s chaos pool draws it on purpose (`ChaosShape::MoveInitResultClass`) and counts the refusals as `ChaosClass::Refused`. + +**Reopening condition.** A rewriting inspector could be given the same window a running frame has, by mirroring `make_call_frame` inside `MegaETH` so that the journal decision behind an init-produced result is parked on `deferred_journal` like every other. +That is roughly a hundred lines of upstream frame-init logic duplicated, with no type-level tie to the original — the same exposure `evm/frame.rs` already carries once — and it buys one rewrite shape. +Take it only if the shape turns out to be needed. + +Booking is a *reported* quantity throughout. No resource limit is ever compared against the ledger, and `MegaTransactionOutcome::compute_gas_enforced` comes off the enforcement lane rather than out of the reported total — so an inspector cannot buy a transaction headroom on any dimension. + +### The declared observer + +Measuring costs about a nanosecond per reading per opcode, and there are sixteen readings taken twice per opcode, which adds between a third and two thirds to a production tracer's run. +An inspector type whose author has implemented `TrustedObserver` for it is delegated to without any of that: `MeasuredInspector::new_trusted`, reached through `MegaEvm::with_trusted_inspector`, builds a shim that forwards every callback and takes no reading. +The declaration is also what the canonical block path admits an inspected transaction on, so the fast path and the block path are reached by the same statement about the type. + +**What the declaration promises.** Every callback of the type leaves the EVM exactly as it found it: nothing written to an interpreter's gas counter or its pending action, nothing to a frame's inputs, nothing to a frame result's classification, gas, output or metadata, nothing to a refund, and no frame answered with a synthetic outcome. +It may read whatever it likes and write to its own state. +That is exactly the table's first row, and a type that keeps it is measured to zero on every lane. + +**Why a declaration and not a detection.** The shim measures at a callback boundary precisely because it cannot see inside the callback, so "does this type write anything back" is not a question it can ask ahead of time — only one it can answer afterwards, at the cost the declaration exists to avoid. + +**Why a trait and not a wrapper.** A `Trusted` wrapper cannot be seen by the shim at all. +`MeasuredInspector::new` is generic over the inspector, `MegaEvm` names the shim as `MeasuredInspector` for whatever the caller chose, and `EvmFactory` hands inspectors in under an `I: Inspector` bound — so asking "is this `I` a `Trusted<_>`" would need either specialisation or a bound on every inspector `MegaETH` can be handed, including the foreign ones it can implement nothing for. +The question is therefore answered where it can be type-checked, at the one constructor whose bound is the declaration, and carried on the shim as a flag. +The wrapper's other weakness is worse than its unimplementability: `Trusted::new(inspector)` written once inside a generic function declares whatever that function is handed, which is how an RPC-supplied tracer would arrive on the fast path. +An implementation names a concrete type and no value can carry one. + +**Trust, and verify.** Under `debug_assertions` a declared type takes the measuring path anyway, and the shim asserts the ledger came back empty after every callback it measures. +`MegaEvm::execute_transaction` asks the same question once more at the end of the transaction, which is the backstop for a callback added later whose own verification was never written. +The per-callback assert names the site for every edit booked at the boundary that measured it, which since the traffic/movement split is every lane but one: an edit to a *finished frame result*'s gas is booked at the frame's settlement point, after the last `*_end` callback has returned, so a declaration broken only there is caught by the transaction-level backstop rather than named at the callback. +Neither costs anything in release, where a declared type reaches no measuring body at all. +There is no behavioural fork between the two builds for a declaration that holds: the measurement of a type that writes nothing back is a sequence of reads that books nothing, so debug and release execute the same transaction and only a false declaration tells them apart — by panicking. +`tests/rex7/trusted_observer.rs` holds both halves, and `mega-state-test`'s `RunMode::ObserveTrusted` holds the three-way comparison against a plain and a measured run of the same observer. + +**What may be declared.** Read-only tracers: the `revm-inspectors` `TracingInspector` family (`debug_traceTransaction`, `trace_*`, the call and prestate tracers) and anything else that only records what it is shown. +`NoOpInspector` is declared here, being the only inspector this crate can reach. +The rest cannot be declared from here or from `mega-reth`, because the orphan rule wants one of the two to be local and neither the trait nor `TracingInspector` is: a node wraps the tracer in `DeclaredObserver`, which is local here, carries the declaration and forwards every callback, so the whole of what an embedder writes is `DeclaredObserver(tracer)`. +The declaration is still an assertion made in source about one concrete inspector — the wrapper moves where it is written, from a newtype's definition to the line that wraps the value, and is not a way around the rules above. +`benches/common/subject.rs` uses it for the `inspect_tracer_trusted` rows and is the shape to copy. +A wrapper that does nothing but forward may lift a declaration — `&mut T` does — but only from a concrete declared type; a wrapper that adds behaviour of its own is a type in its own right and has to be read on its own terms. + +**What may not be declared.** Anything that intercepts or rewrites, however little. +In `mega-reth` that is `OracleSetSlotInspector` (`crates/megaeth/engine/src/oracle/executor.rs`), which answers a call to the oracle contract with a synthetic `CallOutcome` — the "synthetic outcome that skips the frame entirely" row of the table above, and the clearest thing a declaration may not cover. +`ToggleInspector` (`crates/megaeth/rpc/src/toggle_inspector.rs`) forwards or does nothing, so it may be declared for a concrete declared inner type and never generically over its parameter. +The firewall `Tracer` (`crates/megaeth/payload/src/tx_firewall_trace/tracer.rs`) writes nothing back to the EVM and is a candidate, but it reads through `db_mut()` during `step`, so declaring it needs someone to have read what that does to the state cache — the marking is `mega-reth`'s to make, and this list is the input to it, not the decision. +Anything supplied by a request — a JavaScript tracer, an RPC-selected tracer config — cannot be declared at all, because a declaration is about a type and a request carries a value. + +**How a node reaches it.** `EvmFactory::create_evm_with_inspector` cannot: its bound is `I: Inspector` and its return type is fixed, so it has no way to select the constructor. +The route is `factory.create_evm(db, env).with_trusted_inspector(tracer)`, which keeps the factory's own dynamic precompiles and differs from the two-step untrusted form only in the method name. +`MegaBlockExecutorFactory::create_executor_with_trusted_inspector` is that route packaged for the block path, and it is the entry a node tracing block production or validation takes. +There is no undeclared counterpart on the factory: an inspector without a declaration reaches an executor only by building the EVM and passing it to the `BlockExecutorFactory` trait entry below, which is the shape a node already uses and which refuses the transactions rather than the construction. +`create_executor` (the `BlockExecutorFactory` trait method) takes an EVM the caller already built and checks nothing about its inspector, because the question is a runtime one the executor's own entries ask per transaction — an error that fails the block rather than an assertion that stops the process. +`bin/mega-evme`'s replay command is the worked example of the whole shape: `DeclaredObserver(TracingInspector::new(..))`, handed to `create_executor_with_trusted_inspector`. + +**Which shim an EVM ends up with.** `MegaEvm::new` and `without_inspector` build the declared shim over `NoOpInspector`, because `Evm::set_inspector_enabled` is a public trait method: an EVM built with no inspector can have its shim switched on without any constructor being reached, and everything that then runs is `NoOpInspector`. +`with_trusted_inspector` is the only other route to the declared shim; `with_inspector` and `InspectEvm::set_inspector` build the measured one, the latter even for a type that carries a declaration, since its bound is plain `Inspector`. +So a swap drops the declaration, which is the safe direction and is what keeps the default shim's declaration from spreading to whatever replaces it. + +**Which question each execution entry asks.** `execute_transaction` selects its frame loop on the runtime flag and reports `has_undeclared_inspector`, which reads the same flag — with the flag off the inspector does not run, and reporting no inspector is right. +The deprecated `inspect_transaction` runs the inspecting loop whatever the flag says, so it reports the declaration off the inspector's type alone. +Reading the flag there would report a transaction an undeclared inspector took part in as one that had none, and the commit funnel would admit it. + +### The window a counter edit reaches nothing through + +A gas-counter edit made while the interpreter is already holding a `Return` action is written into an object nobody reads again: revm's inspected loop runs `step_end` after the instruction that set the action, and the action carries its own snapshot of the gas, which is what becomes the frame's result. +The shim books nothing for such an edit and still shifts the settlement baseline for it — `MegaETH`'s tail settlement reads the counter after the action is set, so without the shift the edit would read as work the frame performed. +The predicate is the pending action's variant, not "the loop is ending": a `NewFrame` action ends the loop too, and that frame resumes on exactly this counter. + +What the counter no longer speaks for, the action does, and the shim measures both against the same reading. +A frame holds `counter` with no action pending, `counter + f.gas_limit` with a `NewFrame` action, and the action's own copy with a `Return` one (`inspector.rs::held`); the counter lane books the counter's movement exactly when the EVM will read it again, and the action lane books the rest. +The two together account for every unit of gas the frame holds, whatever the callback did to the action's shape. + +### Every gas an inspector can reach + +The shape table above is written over rewrites this repository has thought of. +This one is written over the `Inspector` trait's own signatures, and is closed: `tests/rex7/gas_surface.rs` pins it against what upstream's derived `Debug` renders, field by field, and fails on one that has no verdict here. + +Read it by the object the gas sits in. +The first six rows are the lanes measured across a callback boundary; the three after them are the numbers that share those objects and are measured somewhere else; the rest are the ones that need no lane, each with the reason. + +| Gas carrier | Reachable at | Verdict | +| --- | --- | --- | +| `Interpreter::gas` → `remaining` | `initialize_interp`, `step`, `step_end`, `log_full` | `InspectorLedger::gas`, at the callback boundary, when the action the callback left behind leaves the counter live | +| A pending `InterpreterAction::Return(_)`'s `gas` → `remaining` | `step_end` | `InspectorLedger::result`, staged and settled at the frame's settlement point | +| A pending `InterpreterAction::NewFrame(_)`'s `gas_limit` | `step_end` | `InspectorLedger::env`, staged and booked at the child's `frame_start` | +| `FrameInput` / `CallInputs` / `CreateInputs` → `gas_limit` | `frame_start`, `call`, `create` | `InspectorLedger::env`, at the callback boundary — unless the same callback answers the frame, in which case this value is the baseline the row below is measured against | +| The `Option` / `Option` / `Option` a callback **returns** → `gas` → `remaining` | `frame_start`, `call`, `create` | `InspectorLedger::result`, settled at `finalize_frame` against that baseline | +| `FrameResult` / `CallOutcome` / `CreateOutcome` → `result.gas` → `remaining` | `frame_end`, `call_end`, `create_end` | `InspectorLedger::result`, at the frame's settlement point | +| Every `Gas` above → `refunded` | every callback that holds one | `InspectorLedger::refund`, at the callback boundary. Nominal: neither the EIP-3529 cap nor the chain of successful frame returns an edit must survive is attributable to one callback, and the lane feeds no identity — so over-stating it costs nothing, while under-stating it would let a rewritten receipt into a block. | +| Every `Gas` above → `reservoir`, and `CallInputs` / `CreateInputs` → `reservoir` | every callback that holds one | `InspectorLedger::reservoir`, settled once from the figure the transaction ends with, and a term of the law because the receipt reports the pool as unspent. `MegaETH` produces none of it, so there is no difference to take; revm propagates it between frames by replacement, so a boundary difference would book edits the EVM goes on to erase. | +| Every `Gas` above → `gas_limit` | every callback that holds one | Inert. op-revm normalises the top-level gas object to the transaction's own limit before the settlement point, and no REX7 lane reads a frame's limit; the two that do are the REX4 legacy stipend's burn and rescue caps, which REX5 mode does not take. | +| Every `Gas` above → `state_gas_spent` | every callback that holds one | `InspectorLedger::state_gas`, settled at the same point. Not a term of the law: it moves the receipt's state-gas figure, not the envelope. Its *other* effect — a failing frame folds it into its caller's pool — arrives inside the reservoir lane, which is read after the fold. | +| Every `Gas` above → `memory` (`MemoryGas`: `words_num`, `expansion_cost`) | every callback that holds one | Not a budget but a memo of how far the frame's memory has been paid for — and the number the next expanding opcode compares its requirement against, so moving it *together with the memory* skips that opcode's charge while leaving every interpreter invariant intact. Booked on `InspectorLedger::interventions`, off `WorkingSet`. | +| `CallInputs` / `CreateInputs` semantic fields, including `charged_new_account_state_gas`; `InterpreterResult::result` and `::output`; `CallOutcome::memory_offset` / `::was_precompile_called` / `::precompile_call_logs` / `::charged_new_account_state_gas`; `CreateOutcome::address` | `frame_start`, `call`, `create`, `frame_end`, `call_end`, `create_end` | Not gas. Booked on `InspectorLedger::interventions` by the rewrite comparison. | +| `CreateInputs` → `cached_address` / `cached_init_code_hash` | `frame_start`, `create` | Not gas, and not compared. Two `OnceCell` memos of the semantic fields above, filled on demand through a shared reference, so a callback that asks a creation where it will land returns the object structurally changed having edited nothing — which is what `created_address` is for and what every tracer that records a deployment does. `CREATE_INPUTS_COMPARISON` in `tests/rex7/gas_surface.rs` is where the exclusion is written down. What the exclusion costs: filling the address memo with a nonce other than the caller's redirects the creation, because `make_create_frame` reads the memo, and telling that apart needs the pre-bump nonce and — under `CREATE2` — the keccak the memo exists to avoid. That one is content-class, like the interpreter's stack and memory contents, and rests on the declaration. | +| Every constant-time reading of `Interpreter`'s own fields — `bytecode` (program counter, code identity, `continue_execution`), `stack` (length), `return_data` (buffer identity), `memory` (size, window offset), `gas` → `memory` (the memo), `input` (target, code address, caller, value, calldata identity), `runtime_flag` (static flag, spec id) | the four live-interpreter callbacks | Not gas, and the whole of what a boundary can read off a live interpreter in constant time. Booked on `InspectorLedger::interventions`, off `WorkingSet`. | +| `Interpreter::extend` | the four live-interpreter callbacks | Not gas, and not readable: `InterpreterTypes::Extend` carries no trait bound, so a shim generic over the interpreter has nothing it can call on it. `MegaETH` configures it as `()`. | +| The **contents** of the interpreter's `stack`, `memory`, `return_data` buffer, calldata and code, at unchanged identities | the four live-interpreter callbacks | Not gas. The EVM executes on whatever it finds and meters that as its own work, because it is. | +| `&mut CTX` — the journal, and through `MegaContext`'s `DerefMut` the transaction, the block, the configuration and `MegaETH`'s own trackers | every callback but `selfdestruct` | Not gas the EVM handed over. Unmeasured for the reason the journal is: telling whether any of it came back changed needs a snapshot of unbounded state that no callback boundary can take at a cost the inspected path can carry. The gas schedule is the exception — the schedule pin rejects a rewritten one, at the next transaction rather than within this one. | +| Everything passed by value (`Log`; `selfdestruct`'s three arguments) and the inputs the `*_end` callbacks take by shared reference | — | No mutable reach at all. | + +**There are no open rows, and the table cannot be left with one.** +`Coverage::NotClosed` still exists, so a surface that reaches what `MegaETH` reports and that no lane books is nameable — but `tests/rex7/gas_surface.rs::test_the_table_carries_no_open_gap` fails on any row that carries it. +Writing a gap down is how it gets closed; leaving it written down is how a table stops being a statement about the code. +What no test can catch is a gap _mis_-classified as `Inert` or `NotGas`: those verdicts are claims about what the EVM does with a number, and only the measurement each was written from backs them. +There are two cautionary cases, and they failed differently. +`state_gas_spent` sat under "EIP-8037 is off, so nothing reads it", which is true of every instruction and not of the receipt — a wrong verdict. +`MemoryGas` had the right verdict and the wrong reason: "editing this desynchronises it from the memory and the EVM reads out of bounds" is true of each field alone and false of the pair moved together with the memory, which is exactly the rewrite the row was excusing. +A reason that only covers half its own input space is the harder of the two to see, because the row reads as considered. + +**What the closure pin does and does not reach.** +A field upstream adds to any of these structs shows up in its `Debug` rendering, matches no row, and fails the test by name. +`Interpreter` itself is one of those structs, which is the row that had been missing: its fields were named in prose, the prose did not say `bytecode`, and an inspector could step the program counter past an instruction with every lane and every counter reading zero. +A variant upstream adds to `InterpreterAction`, `FrameInput` or `FrameResult` fails the build, because the module matches all three exhaustively with no catch-all. +A *callback* upstream adds to the `Inspector` trait does neither — the trait gives every method a default body, so an unimplemented one silently does nothing — which is why the obligation below is written out. + +### Rules for changing this + +- **Add the shim's counterpart when adding an `Inspector` callback.** + An unwrapped callback is an unmeasured hole, not a compile error. + `tests/rex7/inspector_cheat_matrix.rs` enumerates every callback × shape pair and fails on one that is neither covered nor excused, which is what turns a new callback into a red test. + The counterpart is three things, not one: the measurement, the `if !self.measures()` delegation that skips it for a declared observer, and the `verify_trusted` call that checks the declaration held. + Leaving out the second costs a declared observer its fast path at that callback and nothing else; leaving out the third is the one that loses something, and it is what the transaction-level backstop in `MegaEvm::execute_transaction` exists to catch. +- **On a revm bump, re-read the trait's method list against `tests/rex7/gas_surface.rs`'s `CALLBACKS`, and give any new callback a row in the shape table and a column in the cheat matrix.** + This is the one direction no pin reaches, and it is the direction that adds reach. + The field-level and variant-level pins in the same file cover everything else, and both fail loudly on their own. +- **Compare a frame's inputs on the fields that say what the frame does, and classify every new one.** + A call's inputs are compared by the derived equality with the gas limit normalised out, which picks up a field upstream adds by itself; a creation's are compared field by field, because two of theirs are memos an observation-only tracer fills. + The trade is that the second list is one somebody has to keep complete, and `tests/rex7/gas_surface.rs::CREATE_INPUTS_COMPARISON` is where it is closed: every field of both structs is classified semantic, envelope or memo against upstream's own `Debug` rendering, a semantic one needs a case in `tests/rex7/shim_input_comparison.rs` that proves an edit to it is booked, and a memo appearing on a *call's* inputs fails the test that licenses the derived equality. + What a comparison must never do is read a derived value being computed as an input being changed: that is the shape that made every `revm-inspectors` tracer report an intervention at every `CREATE`, and made a declared one fail the debug verification at the first contract a transaction deployed. +- **Book a result rewrite from the frame's settlement point, not from the callback boundary.** + Whether such an edit moves the transaction's envelope depends on how the frame ends: a returning or reverting frame's remaining gas goes back to its caller, a halting one's does not. + The gas an intercepting callback puts into a synthetic outcome travels through that same lane. +- **Take every constant-time reading of the interpreter, not a chosen list of them.** + `WorkingSet` is the snapshot the four live-interpreter callbacks are compared across, and the rule it is built on is stated over the *cost* of a reading rather than over a list of interesting ones: if it is `O(1)` off a field of `Interpreter`, it is in the snapshot. + A list is only as complete as whoever wrote it, and the four-reading list that preceded this rule left `bytecode` out entirely. + Two tests hold the rule: `tests/rex7/gas_surface.rs` pins `Interpreter`'s field set against upstream's `Debug`, and `inspector.rs`'s own unit tests move each reading in turn and fail if it moves nothing, or if a reading exists that no case moves. + The snapshot is stated twice — once as `WorkingSet::of`, which records the readings, and once as `WorkingSet::unchanged`, which compares them against a live interpreter without building a second snapshot — and the same unit tests hold the two lists together: each case asserts both that the reading it moved is named and that `unchanged` returns `false`, so a reading in one list and not the other is a reading the shim takes and never compares. + On a revm bump, re-read the trait methods the snapshot reads through — `Jumps`, `LoopControl`, `LegacyBytecode`, `StackTr`, `ReturnData`, `MemoryTr`, `InputsTr`, `RuntimeFlag` — for a new constant-time accessor, which is a new reading and not a compile error anywhere. + A reading that would need unbounded work is the one thing the rule does not ask for; it belongs in the contents row, which has no lane. +- **Compare every reading at every callback, not once per frame.** + The four fields a frame's identity is made of — its target, the address of the code it runs, its caller and its value — together with its calldata identity, its static flag and its spec id, cannot change while it runs, which makes them the readings a cheaper shim would compare once per frame instead of twice per opcode. + They can change — an inspector writes them — and the shape that exploits a per-frame comparison is an edit made in `step` and undone in `step_end`, which leaves the frame's identity equal to the EVM's at every point outside those two callbacks while the instruction in between reads something else. + `tests/rex7/shim_blind_spots.rs::test_a_frame_invariant_moved_and_moved_back_is_booked` is that shape, and it costs the transaction nothing, so no gas lane can stand in for the comparison. + Making a reading cheaper is free to do; taking it less often needs an argument that this test survives. +- **Book a lane through `Lane::book`, never by writing its net.** + The gross half is what `is_zero` reads, so a booking that moves only the net is a rewrite the guard admits — and one that cancels against a later booking is exactly the shape that is invisible from the net alone. +- **Keep every rewrite out of a block.** + Supporting a rewrite is not the same as admitting one: the canonical block-execution path refuses a transaction from an EVM running an inspector its type never declared `TrustedObserver`, before running it, in release builds as well as debug — because an inspector is one node's configuration and its edits reach the receipt. + The criterion is the declaration rather than the ledger because the ledger cannot answer the question: an inspector that edits the interpreter's stack or memory contents, or writes the journal directly, changes the transaction and leaves every lane at zero. + The ledger stays as the backstop behind it, read at the same entries, for a declaration that did not hold and for a result reaching the commit funnel from a producer this executor never saw — which is why a rewrite that moves no gas still has to be booked, on `InspectorLedger::interventions`. + An EVM an embedder drives itself is deliberately not covered: it produces no block, so there is nothing for two nodes to disagree about. + See `tests/block_executor/inspector_guard.rs`. ## WHERE TO LOOK -- New spec opcode delta: `instructions.rs` (`mini_rex`, `rex`, `rex2`, `rex3`, `rex4`, `rex5`, `rex6`, `rex7` tables; `rex6` and `rex7` currently alias their predecessor, expressing their deltas as `is_enabled` dispatch inside the shared handlers). +- New spec opcode delta: `instructions.rs` (`mini_rex`, `rex`, `rex2`, `rex3`, `rex4`, `rex5`, `rex6`, `rex7` tables; `rex6` still aliases `rex5` and expresses its deltas as `is_enabled` dispatch inside the shared handlers; `rex7` is a standalone checkpoint table built from revm's base table, with the 17 storage / CALL / CREATE / SELFDESTRUCT / not-yet-activated slots inherited from `rex6`, and with 15 volatile `*_checkpoint` handlers plus `gas_checkpoint` registered as rex7-only). - Volatile access detention trigger changes: `host.rs` and volatile wrappers in `instructions.rs`. - Call forwarding and stipend interplay: `instructions.rs` + `../limit/storage_call_stipend.rs`. - New external gas pricing path: `host.rs` gas helper methods. diff --git a/crates/mega-evm/src/evm/context.rs b/crates/mega-evm/src/evm/context.rs index a81e6f33..792522a6 100644 --- a/crates/mega-evm/src/evm/context.rs +++ b/crates/mega-evm/src/evm/context.rs @@ -37,8 +37,12 @@ pub type MegaInnerContext = revm::Context< >; use revm::{ context::{BlockEnv, CfgEnv, ContextSetters, ContextTr, LocalContext}, - context_interface::context::ContextError, + context_interface::{ + cfg::{GasId, GasParams}, + context::ContextError, + }, database::EmptyDB, + primitives::hardfork::SpecId as EthSpecId, Journal, }; @@ -237,6 +241,13 @@ impl MegaContext { /// # Returns /// /// Returns a new `Context` instance wrapping the provided context. + /// + /// # Panics + /// + /// If the provided context's `gas_params` is not the schedule its own `cfg.spec` defines — + /// the gas schedule belongs to the spec, see [`assert_spec_owned_gas_schedule`]. A + /// configuration still on its builder's default spec is fine: the spec relabel below + /// re-derives the schedule for `spec`. #[deprecated(note = "Use `MegaContext::new` instead")] pub fn new_with_context( context: MegaInnerContext, @@ -245,6 +256,12 @@ impl MegaContext { ) -> Self { let mut inner = context; + // Checked against the spec the caller's configuration itself carries, before the relabel + // below re-derives the schedule: a configuration still on its builder's default op-spec + // (`revm::Context::op()` starts at `BEDROCK`) is a supported input here, but its schedule + // must be that spec's, not one the caller rewrote. + assert_spec_owned_gas_schedule(&inner.cfg); + // Spec in context must keep the same with parameter `spec`. // revm 40 keeps per-spec `GasParams` in `CfgEnv`, so update both together — // bare `cfg.spec = ...` would leave the caller's (e.g. BEDROCK) params in place. @@ -367,6 +384,10 @@ impl MegaContext { /// specification, it automatically applies appropriate contract size limits /// if they are not already set in the configuration. /// + /// A spec change rebuilds the additional-limit trackers from the new spec and the + /// already-configured runtime limits so spec-latched state stays aligned. An unchanged + /// spec leaves the existing tracker in place. + /// /// # `tx_chain_id_check` is pinned off /// /// revm 40 flipped the `CfgEnv::tx_chain_id_check` default from `false` to `true` — a gate @@ -377,6 +398,19 @@ impl MegaContext { /// configuration that explicitly set it. An embedder that wants the gate opts in through /// [`with_cfg_unpinned`](Self::with_cfg_unpinned), where the field is taken as provided. /// + /// # The gas schedule is defined by the spec + /// + /// `cfg.gas_params` must be exactly the schedule `cfg.spec` defines — the table + /// `CfgEnv::new_with_spec(spec)` and `cfg.set_spec_and_mainnet_gas_params(spec)` install. + /// `MegaETH`'s gas schedule is a property of the spec rather than of the configuration, so + /// there is no supported way to override it, and a configuration that deviates is rejected + /// here with a panic rather than run. See [`assert_spec_owned_gas_schedule`] for why the + /// deviation cannot be tolerated and where else the same check runs. + /// + /// # Panics + /// + /// If `cfg.gas_params` is not the schedule `cfg.spec` defines. + /// /// # Arguments /// /// * `cfg` - The configuration environment @@ -397,11 +431,18 @@ impl MegaContext { /// /// Skipping that pin does not skip `MegaETH`'s own consensus pins: /// + /// - The gas schedule is defined by the spec: a `gas_params` that deviates from what `cfg.spec` + /// defines panics here exactly as it does in [`with_cfg`](Self::with_cfg) — see + /// [`assert_spec_owned_gas_schedule`]. /// - EIP-8037 (Amsterdam state gas) is forced off before every transaction runs, wherever the /// configuration came from — see [`force_amsterdam_eip8037_off`]. /// - Under `MINI_REX` and later, the contract size and initcode size limits fill in when the /// configuration leaves them unset. /// + /// # Panics + /// + /// If `cfg.gas_params` is not the schedule `cfg.spec` defines. + /// /// # Arguments /// /// * `cfg` - The configuration environment @@ -417,8 +458,22 @@ impl MegaContext { /// [`with_cfg_unpinned`](Self::with_cfg_unpinned): both adopt the caller's configuration the /// same way, and differ only in whether `tx_chain_id_check` is pinned to the revm-27 `false` /// or taken as the caller provided it. + /// + /// A spec change rebuilds [`AdditionalLimit`] from the new spec and the already-configured + /// runtime limits, so spec-latched tracker state stays aligned with [`Self::spec`]. Limits + /// already set by [`with_tx_runtime_limits`](Self::with_tx_runtime_limits) are kept; they are + /// not replaced by the new spec's defaults. An unchanged spec leaves the existing tracker in + /// place. + /// + /// # Panics + /// + /// If `cfg.gas_params` is not the schedule `cfg.spec` defines — the gas schedule belongs to + /// the spec, see [`assert_spec_owned_gas_schedule`]. fn apply_cfg(mut self, cfg: CfgEnv, intent: CfgIntent) -> Self { - self.spec = cfg.spec; + assert_spec_owned_gas_schedule(&cfg); + let new_spec = cfg.spec; + let spec_changed = new_spec != self.spec; + self.spec = new_spec; self.inner = self.inner.with_cfg(cfg.into_op_cfg()); if intent == CfgIntent::Pinned { self.inner.cfg.tx_chain_id_check = false; @@ -433,6 +488,10 @@ impl MegaContext { Some(constants::mini_rex::MAX_INITCODE_SIZE); } } + if spec_changed { + let limits = self.additional_limit.borrow().limits; + self.additional_limit = Rc::new(RefCell::new(AdditionalLimit::new(self.spec, limits))); + } self } @@ -695,6 +754,8 @@ impl MegaContext { /// /// DB-dependent pre-frame usage may still be recorded later during pre-execution. pub(crate) fn on_new_tx(&mut self) { + assert_cfg_spec_matches_context_spec(self.spec, self.inner.cfg.spec); + assert_spec_owned_gas_schedule(&self.inner.cfg); force_amsterdam_eip8037_off(&mut self.inner.cfg); self.reset_volatile_data_access(); @@ -845,6 +906,129 @@ pub(crate) fn force_amsterdam_eip8037_off(cfg: &mut CfgEnv) { cfg.enable_amsterdam_eip8037 = false; } +/// Panics unless `cfg` carries exactly the gas schedule its own `spec` defines. +/// +/// `MegaETH`'s gas schedule belongs to the spec, not to the configuration. revm 40 turned the +/// price of every operation into a `CfgEnv::gas_params` table an embedder can rewrite, and +/// `MegaETH`'s own accounting does not read that table everywhere revm does: several recording +/// sites carry the schedule's value as a constant (the `CALL_STIPEND` a value-transferring call +/// mints, the pre-`REX7` per-byte code-deposit rate, the mainnet table the keyless-deploy +/// preflight estimates intrinsic gas from). Under a rewritten table those sites would book +/// something other than what revm charged, which silently breaks the conservation law the +/// reported compute total is derived from — and, for a table that prices the call stipend below +/// revm's, takes the 98/100 forwarding cap's subtraction below zero. +/// +/// Rather than teach every such site to read the table, the schedule is pinned: a configuration +/// whose `gas_params` deviates from its spec's is rejected outright, at the loudest available +/// signal, so no transaction ever runs on one. This mirrors [`crate::HardforkParams::validate`], +/// which panics at chain-config load time instead of letting a bad value surface at the first +/// block that uses it. The check is unconditional across specs: it governs the configuration +/// domain, which no historical block covers, so gating it on a spec would only narrow the +/// guarantee without preserving anything. +/// +/// Deviation is the only thing rejected — the rest of `CfgEnv` stays the embedder's, including +/// switches that change what a transaction costs by other means (`disable_eip7623`, +/// `limit_contract_code_size`, the blob caps). +/// +/// Called from the three points a configuration can reach the EVM through: both `with_cfg` +/// entry points (via [`MegaContext::apply_cfg`]), the deprecated +/// [`MegaContext::new_with_context`], and [`MegaContext::on_new_tx`] — the last one being the +/// point of use, which also covers a configuration mutated in place after the context was built +/// (reachable through the mutable deref, e.g. `ctx.modify_cfg`). The comparison is a pointer +/// compare in the common case: both tables come from the same per-spec `OnceLock` inside revm. +pub(crate) fn assert_spec_owned_gas_schedule + Clone + core::fmt::Debug>( + cfg: &CfgEnv, +) { + let expected = GasParams::new_spec(cfg.spec.clone().into()); + if cfg.gas_params != expected { + panic_gas_schedule_mismatch(&cfg.spec, &cfg.gas_params, &expected); + } +} + +/// Reports the first entry on which a configuration's gas schedule deviates from its spec's, and +/// panics. +/// +/// Split out of [`assert_spec_owned_gas_schedule`] and taking the spec as `&dyn Debug` so the +/// formatting and the table walk stay out of the caller's inlined fast path, and are emitted once +/// rather than per instantiation. +#[cold] +#[inline(never)] +fn panic_gas_schedule_mismatch( + spec: &dyn core::fmt::Debug, + actual: &GasParams, + expected: &GasParams, +) -> ! { + let mismatch = actual + .table() + .iter() + .zip(expected.table().iter()) + .enumerate() + .find(|(_, (got, want))| got != want); + let (id, got, want) = match mismatch { + Some((index, (got, want))) => (GasId::new(index as u8), *got, *want), + // `!=` on `GasParams` compares exactly these tables, so a mismatch always has an entry. + None => unreachable!("gas params differ but no table entry does"), + }; + panic!( + "gas params differ from the spec-defined schedule for {spec:?}: `{}` is {got}, the \ + schedule defines {want}. MegaETH's gas schedule is defined by the spec and cannot be \ + overridden through `CfgEnv::gas_params`; build the configuration with \ + `CfgEnv::new_with_spec(spec)` or `cfg.set_spec_and_mainnet_gas_params(spec)` and leave \ + the schedule alone.", + id.name(), + ) +} + +/// Panics unless `cfg_spec` is the op-spec `spec` maps to. +/// +/// A [`MegaContext`] carries its spec twice: as the [`MegaSpecId`] on the context, and as the +/// [`OpSpecId`] that spec maps to inside `CfgEnv`. Execution reads both, from different halves. +/// The `MegaSpecId` selects the instruction table, the precompile set and the +/// [`AdditionalLimit`](crate::AdditionalLimit) trackers, all baked when the EVM is built from the +/// context; `CfgEnv::spec` is what revm's own spec gating reads while a transaction runs. The two +/// must name the same fork, and every supported way of setting a spec writes both from a single +/// value — the constructors derive the configuration from the `MegaSpecId` they are given, and +/// [`MegaContext::apply_cfg`] takes the context's `MegaSpecId` from the configuration it adopts. +/// +/// They come apart only through the mutable deref to the inner context (`ctx.modify_cfg`, or a +/// `&mut CfgEnv` taken directly), which reaches the configuration without passing through either. +/// Rewriting `cfg.spec` there leaves the baked halves on the context's `MegaSpecId` while revm +/// prices the transaction under the written one — one transaction executing under two forks at +/// once, with `MegaETH`'s wrappers, precompiles and resource limits taken from a fork revm is not +/// pricing. Writing the schedule along with the spec (`set_spec_and_mainnet_gas_params`) leaves +/// [`assert_spec_owned_gas_schedule`] satisfied, because the schedule then does match the spec +/// that was written, so that check alone does not catch this. +/// +/// Called from [`MegaContext::on_new_tx`], and checked there rather than at each entry point for +/// the same reason [`force_amsterdam_eip8037_off`] is applied there: the point of use covers a +/// configuration mutated in place after the context was built, which a per-entry-point check +/// cannot. It runs ahead of [`assert_spec_owned_gas_schedule`] so that a bare `cfg.spec` write — +/// which both checks reject — is reported as the desync it is, rather than as a schedule that +/// could be repaired by installing the written spec's table. +pub(crate) fn assert_cfg_spec_matches_context_spec(spec: MegaSpecId, cfg_spec: OpSpecId) { + if cfg_spec != spec.into_op_spec() { + panic_context_spec_mismatch(spec, cfg_spec); + } +} + +/// Reports a context whose two spec fields name different forks, and panics. +/// +/// Split out of [`assert_cfg_spec_matches_context_spec`] and marked cold for the same reason +/// [`panic_gas_schedule_mismatch`] is: the formatting stays out of the caller's inlined fast path. +#[cold] +#[inline(never)] +fn panic_context_spec_mismatch(spec: MegaSpecId, cfg_spec: OpSpecId) -> ! { + panic!( + "the configuration's spec is {cfg_spec:?}, but this context executes {spec:?}, whose \ + op-spec is {:?}. MegaETH's spec is not a `CfgEnv` field a caller can rewrite on a live \ + context: it also selects the instruction table, the precompiles and the resource-limit \ + trackers, which are baked when the EVM is built, so a rewritten `CfgEnv::spec` would \ + run one transaction under two specs at once. Change the spec by adopting a whole \ + configuration through `MegaContext::with_cfg` (or `with_cfg_unpinned`), which sets both.", + spec.into_op_spec(), + ) +} + /// A convenient trait to convert a `CfgEnv` into a `CfgEnv`. /// /// This trait provides a conversion method for `OpStack` configuration environments @@ -875,16 +1059,17 @@ impl IntoOpCfgEnv for CfgEnv { /// This method relabels the specification type and carries every other field of the /// caller's configuration — the gas schedule included — into the `OpStack` shape. It is a /// relabel and nothing more: the fields `MegaETH` does not let a caller choose are settled - /// where they are read, not here (EIP-8037 by [`force_amsterdam_eip8037_off`]). + /// where they are read, not here (EIP-8037 by [`force_amsterdam_eip8037_off`], the gas + /// schedule by [`assert_spec_owned_gas_schedule`]). /// /// # Returns /// /// Returns a new `CfgEnv` with all fields moved from `self`. fn into_op_cfg(self) -> CfgEnv { let op_spec = OpSpecId::from(self.spec); - // Keep the caller's gas schedule instead of re-deriving it from the spec: an embedder - // may have installed its own, and a spec-derived one is unaffected either way because - // every `MegaSpecId` and its op-spec map to the same eth hardfork. + // Carry the schedule rather than re-deriving it: the relabel must not be the thing that + // silently repairs a deviating table, and re-deriving would be a no-op on a conforming + // one anyway — every `MegaSpecId` and its op-spec map to the same eth hardfork. let gas_params = self.gas_params.clone(); // `with_spec_and_gas_params` is revm's own whole-struct carrier: it moves every field // (including the ones behind revm cargo features) into the new spec type, so fields @@ -923,19 +1108,20 @@ impl IntoMegaethCfgEnv for CfgEnv { mod tests { use super::*; - use alloy_primitives::address; + use alloy_primitives::{address, Address, Bytes, U256}; use revm::{ - context::CfgEnv, + context::{tx::TxEnvBuilder, CfgEnv}, context_interface::cfg::{GasId, GasParams}, database::EmptyDB, primitives::hardfork::SpecId, }; - use crate::TestExternalEnvs; + use crate::{test_utils::MemoryDatabase, MegaTransactionNew as _, TestExternalEnvs}; - /// A gas schedule an embedder could install: the spec table with one entry moved off its - /// mainnet value. Distinct from every `GasParams::new_spec(..)` table, so a conversion that - /// re-derives the schedule from the spec instead of carrying it shows up as a diff. + /// A gas schedule an embedder could try to install: the spec table with one entry moved off + /// its mainnet value. Distinct from every `GasParams::new_spec(..)` table, so a conversion + /// that re-derives the schedule from the spec instead of carrying it shows up as a diff — and + /// so an entry point that admits it instead of rejecting it does too. fn custom_gas_params() -> GasParams { let mut gas_params = GasParams::new_spec(SpecId::PRAGUE); gas_params.override_gas([(GasId::tx_token_cost(), 40)]); @@ -943,6 +1129,31 @@ mod tests { gas_params } + /// The schedule `spec` defines — the one and only schedule an entry point admits. + fn spec_gas_params(spec: MegaSpecId) -> GasParams { + GasParams::new_spec(SpecId::from(spec)) + } + + /// An untouched configuration for `spec`, schedule included. + fn spec_cfg(spec: MegaSpecId) -> CfgEnv { + CfgEnv::new_with_spec(spec) + } + + /// Every `MegaSpecId`, so the schedule pin is asserted across the whole progression rather + /// than on whichever spec a test happened to pick. + const ALL_SPECS: [MegaSpecId; 10] = [ + MegaSpecId::EQUIVALENCE, + MegaSpecId::MINI_REX, + MegaSpecId::REX, + MegaSpecId::REX1, + MegaSpecId::REX2, + MegaSpecId::REX3, + MegaSpecId::REX4, + MegaSpecId::REX5, + MegaSpecId::REX6, + MegaSpecId::REX7, + ]; + /// A [`CfgEnv`] with every field moved off its revm default, so any field the conversion /// drops instead of carrying collapses back to a default and fails an equality assert. fn fully_customized_cfg(spec: MegaSpecId) -> CfgEnv { @@ -969,6 +1180,16 @@ mod tests { cfg } + /// [`fully_customized_cfg`] with the one field a caller does not own put back to the schedule + /// its spec defines, so the configuration reaches an entry point instead of being rejected by + /// it. Everything else is still off its revm default, which is what the carry-every-field + /// asserts need. + fn admissible_customized_cfg(spec: MegaSpecId) -> CfgEnv { + let mut cfg = fully_customized_cfg(spec); + cfg.gas_params = spec_gas_params(spec); + cfg + } + /// The `MegaSpecId` <-> `OpSpecId` config conversions relabel the spec type and nothing else: /// every other field — the gas schedule and the revm 40 switches included — belongs to the /// caller and must survive both legs, EIP-8037 included. What `MegaETH` does not let a caller @@ -998,20 +1219,223 @@ mod tests { /// `with_cfg` is where an embedder's `CfgEnv` lands. It must reach the inner revm config /// intact — only the `MegaETH` pins (spec, `MINI_REX` size limits) may differ. #[test] - fn test_with_cfg_carries_embedder_gas_params_and_switches() { + fn test_with_cfg_carries_embedder_switches_and_the_spec_schedule() { let mut cfg = CfgEnv::new_with_spec(MegaSpecId::REX6); cfg.chain_id = 6342; - cfg.gas_params = custom_gas_params(); cfg.disable_eip7623 = true; - let context = - MegaContext::new(EmptyDB::default(), MegaSpecId::EQUIVALENCE).with_cfg(cfg.clone()); + let context = MegaContext::new(EmptyDB::default(), MegaSpecId::EQUIVALENCE).with_cfg(cfg); - assert_eq!(context.inner.cfg.gas_params, cfg.gas_params); + assert_eq!(context.inner.cfg.gas_params, spec_gas_params(MegaSpecId::REX6)); assert!(context.inner.cfg.disable_eip7623); assert_eq!(context.inner.cfg.chain_id, 6342); } + /// The gas schedule is not an embedder's to set. A configuration carrying anything other than + /// the schedule its spec defines is rejected at the entry point rather than run, because + /// `MegaETH` records several of the schedule's values from constants rather than from the + /// table and would otherwise book charges revm never made. + #[test] + #[should_panic(expected = "gas params differ from the spec-defined schedule")] + fn test_with_cfg_rejects_a_schedule_off_the_spec_table() { + let mut cfg = CfgEnv::new_with_spec(MegaSpecId::REX6); + cfg.gas_params = custom_gas_params(); + + let _ = MegaContext::new(EmptyDB::default(), MegaSpecId::EQUIVALENCE).with_cfg(cfg); + } + + /// The `call_stipend` entry specifically: `MegaETH` books the stipend revm mints into a + /// value-transferring call's child frame from `gas::CALL_STIPEND`, and the 98/100 forwarding + /// cap subtracts the same constant back out of the child's budget. A schedule that priced the + /// stipend differently would desync both. + #[test] + #[should_panic(expected = "gas params differ from the spec-defined schedule")] + fn test_with_cfg_rejects_an_overridden_call_stipend() { + let mut cfg = CfgEnv::new_with_spec(MegaSpecId::REX7); + cfg.gas_params.override_gas([(GasId::call_stipend(), 0)]); + + let _ = MegaContext::new(EmptyDB::default(), MegaSpecId::EQUIVALENCE).with_cfg(cfg); + } + + /// The `code_deposit_cost` entry specifically: revm debits a successful `CREATE` the + /// schedule's per-byte rate, and pre-`REX7` specs record that charge from the constant. + #[test] + #[should_panic(expected = "gas params differ from the spec-defined schedule")] + fn test_with_cfg_rejects_an_overridden_code_deposit_cost() { + let mut cfg = CfgEnv::new_with_spec(MegaSpecId::REX7); + cfg.gas_params.override_gas([(GasId::code_deposit_cost(), 201)]); + + let _ = MegaContext::new(EmptyDB::default(), MegaSpecId::EQUIVALENCE).with_cfg(cfg); + } + + /// The rejection reports the spec whose schedule was expected, which entry deviated and both + /// values, so an embedder that hits it can act on the message rather than bisect its + /// configuration. + #[test] + #[should_panic( + expected = "schedule for REX7: `code_deposit_cost` is 201, the schedule defines 200" + )] + fn test_schedule_rejection_names_the_spec_the_entry_and_both_values() { + let mut cfg = CfgEnv::new_with_spec(MegaSpecId::REX7); + cfg.gas_params.override_gas([(GasId::code_deposit_cost(), 201)]); + + let _ = MegaContext::new(EmptyDB::default(), MegaSpecId::EQUIVALENCE).with_cfg(cfg); + } + + /// Every spec's own default schedule is admissible on every entry point, and reaches the EVM + /// as written. The pin is a rejection of deviation, not a narrowing of which specs run. + #[test] + fn test_every_spec_default_schedule_is_admissible() { + for spec in ALL_SPECS { + let expected = spec_gas_params(spec); + + let pinned = MegaContext::new(EmptyDB::default(), MegaSpecId::EQUIVALENCE) + .with_cfg(spec_cfg(spec)); + assert_eq!(pinned.inner.cfg.gas_params, expected, "with_cfg on {spec:?}"); + + let unpinned = MegaContext::new(EmptyDB::default(), MegaSpecId::EQUIVALENCE) + .with_cfg_unpinned(spec_cfg(spec)); + assert_eq!(unpinned.inner.cfg.gas_params, expected, "with_cfg_unpinned on {spec:?}"); + } + } + + /// The constructors that build their own configuration install the spec's schedule, so the + /// contexts that never see a caller's `CfgEnv` satisfy the pin by construction. This is the + /// path the keyless-deploy sandbox takes: it builds its inner context from + /// [`MegaContext::new_with_shared_ext_envs`] rather than inheriting the outer configuration, + /// so no override could reach it even if one had been admitted outside. + #[test] + fn test_constructors_install_the_spec_schedule_on_every_spec() { + for spec in ALL_SPECS { + let expected = spec_gas_params(spec); + + let plain = MegaContext::new(EmptyDB::default(), spec); + assert_eq!(plain.inner.cfg.gas_params, expected, "MegaContext::new on {spec:?}"); + + let sandbox_shaped = MegaContext::<_, EmptyExternalEnv>::new_with_shared_ext_envs( + EmptyDB::default(), + spec, + Rc::new(EmptyExternalEnv), + Rc::new(RefCell::new(EmptyExternalEnv)), + ); + assert_eq!( + sandbox_shaped.inner.cfg.gas_params, expected, + "the sandbox's constructor on {spec:?}", + ); + } + } + + /// Migrating a live context between specs leaves it on the schedule the new spec defines, in + /// both directions — the entry point adopts the whole configuration, so the schedule cannot + /// be left behind from the spec the context previously ran. + #[test] + fn test_spec_migration_keeps_the_schedule_on_the_active_spec() { + let mut context = MegaContext::new(EmptyDB::default(), MegaSpecId::EQUIVALENCE); + + for spec in [MegaSpecId::REX5, MegaSpecId::REX7, MegaSpecId::REX5, MegaSpecId::MINI_REX] { + context = context.with_cfg(spec_cfg(spec)); + + assert_eq!(context.mega_spec(), spec); + assert_eq!(context.inner.cfg.gas_params, spec_gas_params(spec), "after {spec:?}"); + // And the migrated context is one a transaction can run on: `on_new_tx` re-checks the + // schedule at the point of use. + context.on_new_tx(); + } + } + + /// The entry points are not the only place the schedule is checked: it is re-checked at the + /// point of use, so a configuration mutated in place after the context was built — reachable + /// through the mutable deref, e.g. `ctx.modify_cfg` — cannot execute either. + #[test] + #[should_panic(expected = "gas params differ from the spec-defined schedule")] + fn test_on_new_tx_rejects_a_schedule_mutated_after_construction() { + let mut context = MegaContext::new(EmptyDB::default(), MegaSpecId::REX7); + context.modify_cfg(|cfg| cfg.gas_params = custom_gas_params()); + + context.on_new_tx(); + } + + /// The spec is pinned the same way the schedule is, and for a shape the schedule pin cannot + /// see: `cfg.spec` and `cfg.gas_params` rewritten together on a live context are + /// self-consistent, so the schedule matches the spec that was written. What no longer matches + /// is the context — the instruction table, the precompiles and the resource-limit trackers + /// stay on the `MegaSpecId` the EVM was built from, so the transaction would run under two + /// specs at once. + #[test] + #[should_panic(expected = "the configuration's spec is BEDROCK, but this context executes")] + fn test_on_new_tx_rejects_a_spec_and_schedule_mutated_together() { + let mut context = MegaContext::new(EmptyDB::default(), MegaSpecId::REX7); + context.modify_cfg(|cfg| cfg.set_spec_and_mainnet_gas_params(OpSpecId::BEDROCK)); + // The pair is self-consistent: the schedule pin, on its own, admits this configuration. + assert_eq!( + context.inner.cfg.gas_params, + GasParams::new_spec(SpecId::from(OpSpecId::BEDROCK)), + ); + + context.on_new_tx(); + } + + /// A bare `cfg.spec` write desyncs the context the same way, and is rejected by the spec pin + /// rather than by the schedule pin it also trips: the schedule is a consequence here, and its + /// message would send an embedder to `set_spec_and_mainnet_gas_params`, which repairs the + /// schedule and leaves the desync — the shape the test above covers. + #[test] + #[should_panic(expected = "the configuration's spec is BEDROCK, but this context executes")] + fn test_on_new_tx_rejects_a_bare_spec_write() { + let mut context = MegaContext::new(EmptyDB::default(), MegaSpecId::REX7); + context.modify_cfg(|cfg| cfg.spec = OpSpecId::BEDROCK); + + context.on_new_tx(); + } + + /// The rejection names both specs and the entry point that sets them together, so an embedder + /// that hits it can act on the message. + #[test] + #[should_panic( + expected = "this context executes REX7, whose op-spec is ISTHMUS. MegaETH's spec is not" + )] + fn test_spec_rejection_names_both_specs_and_the_supported_entry_point() { + let mut context = MegaContext::new(EmptyDB::default(), MegaSpecId::REX7); + context.modify_cfg(|cfg| cfg.set_spec_and_mainnet_gas_params(OpSpecId::BEDROCK)); + + context.on_new_tx(); + } + + /// The spec pin rejects desync, not any particular spec: every spec, reached by every + /// supported path — the constructors, both `with_cfg` entry points, and the deprecated + /// `new_with_context` — passes it at the point of use. + #[allow(deprecated)] + #[test] + fn test_every_spec_passes_the_spec_pin_on_every_construction_path() { + for spec in ALL_SPECS { + MegaContext::new(EmptyDB::default(), spec).on_new_tx(); + + MegaContext::<_, EmptyExternalEnv>::new_with_shared_ext_envs( + EmptyDB::default(), + spec, + Rc::new(EmptyExternalEnv), + Rc::new(RefCell::new(EmptyExternalEnv)), + ) + .on_new_tx(); + + MegaContext::new(EmptyDB::default(), MegaSpecId::EQUIVALENCE) + .with_cfg(spec_cfg(spec)) + .on_new_tx(); + + MegaContext::new(EmptyDB::default(), MegaSpecId::EQUIVALENCE) + .with_cfg_unpinned(spec_cfg(spec)) + .on_new_tx(); + + // The deprecated constructor's input is a configuration on revm's default op-spec, + // which it relabels — so this also covers the relabel leaving the two in sync. + let inner: MegaInnerContext = revm::Context::op() + .with_tx(crate::MegaTransaction::default()) + .with_db(EmptyDB::default()); + MegaContext::new_with_context(inner, spec, ExternalEnvs::::default()) + .on_new_tx(); + } + } + /// The pin is unconditional: a configured cfg — here a blob schedule plus an explicitly /// enabled check — still comes out with the gate off. `with_cfg` is the compatibility entry /// point; enabling the gate requires `with_cfg_unpinned`. @@ -1029,19 +1453,6 @@ mod tests { ); } - /// The pin does not depend on the rest of the configuration: a custom gas schedule rides - /// through while the gate still comes out pinned off. - #[test] - fn test_with_cfg_pins_chain_id_check_off_with_custom_gas_params() { - let mut cfg = CfgEnv::new_with_spec(MegaSpecId::REX5); - cfg.gas_params = custom_gas_params(); - - let context = MegaContext::new(EmptyDB::default(), MegaSpecId::EQUIVALENCE).with_cfg(cfg); - - assert!(!context.inner.cfg.tx_chain_id_check); - assert_eq!(context.inner.cfg.gas_params, custom_gas_params()); - } - /// An untouched `CfgEnv::new_with_spec` config carries revm 40's flipped default, which the /// caller never asked for: `with_cfg` re-pins revm 27's `false`. #[test] @@ -1078,7 +1489,7 @@ mod tests { /// inner revm config as written. #[test] fn test_with_cfg_unpinned_carries_every_field() { - let mut cfg = fully_customized_cfg(MegaSpecId::REX6); + let mut cfg = admissible_customized_cfg(MegaSpecId::REX6); cfg.tx_chain_id_check = true; let context = MegaContext::new(EmptyDB::default(), MegaSpecId::EQUIVALENCE) @@ -1088,6 +1499,17 @@ mod tests { assert_eq!(context.inner.cfg, cfg.into_op_cfg()); } + /// Skipping the chain-id pin does not skip the schedule check: the escape hatch is an opt-in + /// to revm 40's chain-id gate, not to owning the gas schedule. + #[test] + #[should_panic(expected = "gas params differ from the spec-defined schedule")] + fn test_with_cfg_unpinned_rejects_a_schedule_off_the_spec_table() { + let cfg = fully_customized_cfg(MegaSpecId::REX6); + + let _ = + MegaContext::new(EmptyDB::default(), MegaSpecId::EQUIVALENCE).with_cfg_unpinned(cfg); + } + /// The opt-in skips the chain-id pin, not `MegaETH`'s other normalization: the spec is /// adopted and the `MINI_REX` size limits still fill in when unset. #[test] @@ -1158,24 +1580,25 @@ mod tests { assert!(!alloy_evm::Evm::cfg_env(&evm).enable_amsterdam_eip8037); } - /// The deprecated constructor re-derives the gas schedule only when it applies a different - /// op-spec. A caller already sitting on the `MegaETH` op-spec keeps its own schedule. + /// The deprecated constructor rejects a rewritten schedule like every other entry point. Its + /// input is checked against the spec the caller's own configuration carries — the relabel + /// below re-derives the schedule for a configuration on a different spec, and must not be + /// what quietly repairs a rewritten one. #[allow(deprecated)] #[test] - fn test_new_with_context_keeps_gas_params_when_spec_already_matches() { + #[should_panic(expected = "gas params differ from the spec-defined schedule")] + fn test_new_with_context_rejects_a_schedule_off_the_spec_table() { let mut inner: MegaInnerContext = revm::Context::op() .with_tx(crate::MegaTransaction::default()) .with_db(EmptyDB::default()); inner.cfg.set_spec_and_mainnet_gas_params(MegaSpecId::EQUIVALENCE.into_op_spec()); inner.cfg.gas_params = custom_gas_params(); - let context = MegaContext::new_with_context( + let _ = MegaContext::new_with_context( inner, MegaSpecId::EQUIVALENCE, ExternalEnvs::::default(), ); - - assert_eq!(context.inner.cfg.gas_params, custom_gas_params()); } /// Same unconditional pin at the deprecated constructor: a configured context that enabled @@ -1269,6 +1692,200 @@ mod tests { } } + /// Compute limit tight enough that a leftover REX7 gas clamp is visible in receipt gas. + const CFG_MIGRATION_COMPUTE_LIMIT: u64 = 50_000; + /// Transaction gas limit used by the `PUSH0 STOP` migration probe. + const CFG_MIGRATION_TX_GAS_LIMIT: u64 = 1_000_000; + const CFG_MIGRATION_CALLER: Address = address!("0000000000000000000000000000000000300000"); + const CFG_MIGRATION_CONTRACT: Address = address!("0000000000000000000000000000000000300001"); + /// `PUSH0 STOP` — a compute-only body, so a leftover gas clamp shows up as receipt gas. + const CFG_MIGRATION_CODE: [u8; 2] = [0x5f, 0x00]; + + #[derive(Debug, PartialEq, Eq)] + struct CfgMigrationOutcome { + success: bool, + gas_used: u64, + compute_gas: u64, + } + + fn cfg_migration_limits() -> EvmTxRuntimeLimits { + EvmTxRuntimeLimits::from_spec(MegaSpecId::REX7) + .with_tx_compute_gas_limit(CFG_MIGRATION_COMPUTE_LIMIT) + } + + fn cfg_migration_db() -> MemoryDatabase { + MemoryDatabase::default() + .account_balance(CFG_MIGRATION_CALLER, U256::from(10).pow(U256::from(18))) + .account_code(CFG_MIGRATION_CONTRACT, Bytes::from_static(&CFG_MIGRATION_CODE)) + } + + fn run_cfg_migration_tx( + mut context: MegaContext, + ) -> CfgMigrationOutcome { + context.modify_chain(|chain| { + chain.operator_fee_scalar = Some(U256::ZERO); + chain.operator_fee_constant = Some(U256::ZERO); + }); + + let tx = TxEnvBuilder::default() + .caller(CFG_MIGRATION_CALLER) + .call(CFG_MIGRATION_CONTRACT) + .gas_limit(CFG_MIGRATION_TX_GAS_LIMIT) + .build_fill(); + let mut tx = crate::MegaTransaction::new(tx); + tx.enveloped_tx = Some(Bytes::new()); + + let mut evm = crate::MegaEvm::new(context); + let result = + alloy_evm::Evm::transact_raw(&mut evm, tx).expect("cfg-migration probe must execute"); + let compute_gas = evm.ctx.additional_limit.borrow().get_usage().compute_gas; + CfgMigrationOutcome { + success: result.result.is_success(), + gas_used: result.result.tx_gas_used(), + compute_gas, + } + } + + /// Spec-latched tracker bits must follow `with_cfg`, using the already-configured limits. + #[test] + fn test_with_cfg_rebuilds_latched_limit_state_when_spec_changes() { + let limits = cfg_migration_limits(); + + let rex7_to_rex6 = MegaContext::new(EmptyDB::default(), MegaSpecId::REX7) + .with_tx_runtime_limits(limits) + .with_cfg(CfgEnv::new_with_spec(MegaSpecId::REX6)); + let rex6_direct = + MegaContext::new(EmptyDB::default(), MegaSpecId::REX6).with_tx_runtime_limits(limits); + + assert_eq!(rex7_to_rex6.mega_spec(), MegaSpecId::REX6); + assert_eq!( + rex7_to_rex6.additional_limit.borrow().rex7_enabled(), + rex6_direct.additional_limit.borrow().rex7_enabled(), + ); + assert!( + !rex7_to_rex6.additional_limit.borrow().rex7_enabled(), + "REX6 must not latch checkpoint accounting" + ); + assert_eq!(rex7_to_rex6.additional_limit.borrow().limits, limits); + + let rex6_to_rex7 = MegaContext::new(EmptyDB::default(), MegaSpecId::REX6) + .with_tx_runtime_limits(limits) + .with_cfg(CfgEnv::new_with_spec(MegaSpecId::REX7)); + let rex7_direct = + MegaContext::new(EmptyDB::default(), MegaSpecId::REX7).with_tx_runtime_limits(limits); + + assert_eq!(rex6_to_rex7.mega_spec(), MegaSpecId::REX7); + assert_eq!( + rex6_to_rex7.additional_limit.borrow().rex7_enabled(), + rex7_direct.additional_limit.borrow().rex7_enabled(), + ); + assert!( + rex6_to_rex7.additional_limit.borrow().rex7_enabled(), + "REX7 must latch checkpoint accounting" + ); + assert_eq!(rex6_to_rex7.additional_limit.borrow().limits, limits); + + let via_unpinned = MegaContext::new(EmptyDB::default(), MegaSpecId::REX7) + .with_tx_runtime_limits(limits) + .with_cfg_unpinned(CfgEnv::new_with_spec(MegaSpecId::REX6)); + assert!( + !via_unpinned.additional_limit.borrow().rex7_enabled(), + "with_cfg_unpinned must rebuild latched limit state on a spec change" + ); + assert_eq!(via_unpinned.additional_limit.borrow().limits, limits); + } + + /// Same-spec `with_cfg` must not replace the additional-limit `Rc`. + #[test] + fn test_with_cfg_same_spec_keeps_additional_limit_identity() { + let limits = cfg_migration_limits(); + let context = + MegaContext::new(EmptyDB::default(), MegaSpecId::REX6).with_tx_runtime_limits(limits); + let before = Rc::clone(&context.additional_limit); + + let context = context.with_cfg(CfgEnv::new_with_spec(MegaSpecId::REX6)); + + assert!(Rc::ptr_eq(&before, &context.additional_limit)); + assert_eq!(context.additional_limit.borrow().limits, limits); + assert!(!context.additional_limit.borrow().rex7_enabled()); + } + + #[test] + fn test_with_cfg_rex7_to_rex6_matches_direct_rex6_when_limits_applied_first() { + let limits = cfg_migration_limits(); + + let mut migrated_db = cfg_migration_db(); + let migrated = run_cfg_migration_tx( + MegaContext::new(&mut migrated_db, MegaSpecId::REX7) + .with_tx_runtime_limits(limits) + .with_cfg(CfgEnv::new_with_spec(MegaSpecId::REX6)), + ); + + let mut direct_db = cfg_migration_db(); + let direct = run_cfg_migration_tx( + MegaContext::new(&mut direct_db, MegaSpecId::REX6).with_tx_runtime_limits(limits), + ); + + assert_eq!(migrated, direct); + } + + #[test] + fn test_with_cfg_rex6_to_rex7_matches_direct_rex7_when_limits_applied_first() { + let limits = cfg_migration_limits(); + + let mut migrated_db = cfg_migration_db(); + let migrated = run_cfg_migration_tx( + MegaContext::new(&mut migrated_db, MegaSpecId::REX6) + .with_tx_runtime_limits(limits) + .with_cfg(CfgEnv::new_with_spec(MegaSpecId::REX7)), + ); + + let mut direct_db = cfg_migration_db(); + let direct = run_cfg_migration_tx( + MegaContext::new(&mut direct_db, MegaSpecId::REX7).with_tx_runtime_limits(limits), + ); + + assert_eq!(migrated, direct); + } + + #[test] + fn test_with_cfg_rex7_to_rex6_matches_direct_rex6_when_limits_applied_after() { + let limits = cfg_migration_limits(); + + let mut migrated_db = cfg_migration_db(); + let migrated = run_cfg_migration_tx( + MegaContext::new(&mut migrated_db, MegaSpecId::REX7) + .with_cfg(CfgEnv::new_with_spec(MegaSpecId::REX6)) + .with_tx_runtime_limits(limits), + ); + + let mut direct_db = cfg_migration_db(); + let direct = run_cfg_migration_tx( + MegaContext::new(&mut direct_db, MegaSpecId::REX6).with_tx_runtime_limits(limits), + ); + + assert_eq!(migrated, direct); + } + + #[test] + fn test_with_cfg_rex6_to_rex7_matches_direct_rex7_when_limits_applied_after() { + let limits = cfg_migration_limits(); + + let mut migrated_db = cfg_migration_db(); + let migrated = run_cfg_migration_tx( + MegaContext::new(&mut migrated_db, MegaSpecId::REX6) + .with_cfg(CfgEnv::new_with_spec(MegaSpecId::REX7)) + .with_tx_runtime_limits(limits), + ); + + let mut direct_db = cfg_migration_db(); + let direct = run_cfg_migration_tx( + MegaContext::new(&mut direct_db, MegaSpecId::REX7).with_tx_runtime_limits(limits), + ); + + assert_eq!(migrated, direct); + } + /// Sharing SALT env handles between parent and sandbox must not merge their bucket caches. #[test] fn test_shared_salt_env_keeps_dynamic_gas_cache_isolated() { diff --git a/crates/mega-evm/src/evm/execution.rs b/crates/mega-evm/src/evm/execution.rs index a10c3d97..79de3d07 100644 --- a/crates/mega-evm/src/evm/execution.rs +++ b/crates/mega-evm/src/evm/execution.rs @@ -40,11 +40,13 @@ use revm::{ Inspector, Journal, }; +use super::frame::{classify_frame_action, commit_frame_journal, PendingJournal}; use crate::{ constants, dispatch_system_contract_interceptors, is_deposit_like_transaction, is_mega_system_transaction_with, limit::ACCOUNT_INFO_WRITE_SIZE, sent_from_system_address, - ExternalEnvTypes, HostExt, JournalInspectTr, MegaContext, MegaEvm, MegaHaltReason, - MegaInstructions, MegaSpecId, MegaTransactionError, MEGA_SYSTEM_TRANSACTION_SOURCE_HASH, + AdditionalLimit, ExternalEnvTypes, FrameExit, HostExt, JournalInspectTr, MeasuredInspector, + MegaContext, MegaEvm, MegaHaltReason, MegaInstructions, MegaSpecId, MegaTransactionError, + MEGA_SYSTEM_TRANSACTION_SOURCE_HASH, }; /// Revm handler for `MegaETH`. It internally wraps the [`op_revm::handler::OpHandler`] and inherits @@ -167,9 +169,37 @@ where // Check if the initial gas exceeds the tx gas limit, if so, we halt with out of gas let ctx = evm.ctx(); let tx = ctx.tx(); - if tx.gas_limit() < init_and_floor_gas.initial_regular_gas { + let gas_limit = tx.gas_limit(); + let tx_kind = tx.kind(); + if gas_limit < init_and_floor_gas.initial_regular_gas { + // REX7+: this halt burns the whole transaction envelope without running a single + // opcode, and it produces its result before any frame exists — so the frame-exit + // settlement that splits an ordinary exceptional halt can never see it. Take the same + // split here: the intrinsic compute gas `validate` already recorded is the only work + // performed and stays on the enforcing lane, and the rest of the envelope is + // destroyed, which makes the reported total cover what the receipt burns. + // + // REX5+ rejects this transaction in `validate` instead — the final initial-gas check + // there compares the same pair after every MegaETH storage-gas contribution has been + // folded in, and returns `CallGasCostMoreThanGasLimit` before `pre_execution` debits + // the sender. So no transaction on a spec that has the destroyed lane reaches this + // branch today; the recording is what keeps the lane correct if a later spec grows an + // intrinsic component that is only resolved after validation. + // + // That reachability is what the booking here relies on: this path returns its result + // without running the frame loop, so it never reaches the settlement in + // `last_frame_result` that derives the reported destroyed total. A spec that made the + // branch reachable would produce a receipt reporting nothing destroyed while this + // booking says otherwise, and would have to settle the derivation here as well. + if ctx.spec.is_enabled(MegaSpecId::REX7) { + let mut additional_limit = ctx.additional_limit.borrow_mut(); + // Nothing can have been destroyed before the first frame, so the recorded total + // is entirely enforcing usage. + let performed = additional_limit.get_usage().compute_gas; + additional_limit.record_burned_gas(gas_limit.saturating_sub(performed)); + } // If not sufficient gas, we halt with out of gas - let oog_frame_result = gen_oog_frame_result(tx.kind(), tx.gas_limit()); + let oog_frame_result = gen_oog_frame_result(tx_kind, gas_limit); return Ok(Some(oog_frame_result)); } Ok(None) @@ -419,7 +449,7 @@ impl MegaEvm { #[inline] fn before_frame_run( ctx: &MegaContext, - frame: &EthFrame, + frame: &mut EthFrame, ) -> Result, ContextDbError>> { // Check if the additional limit is already exceeded, if so, we should immediately stop // and synthesize an interpreter action. @@ -454,32 +484,41 @@ impl MegaEvm { return Ok(()); } let is_rex5 = ctx.spec.is_enabled(MegaSpecId::REX5); + let is_rex7 = ctx.spec.is_enabled(MegaSpecId::REX7); if let InterpreterAction::Return(interpreter_result) = action { + // REX7: hand any clamp-hidden gas back to the result and latch a clamp-induced + // out-of-gas as the compute exceed it stands for, before the code-deposit charge below + // observes the result's gas. + ctx.additional_limit.borrow_mut().settle_frame_final_result(interpreter_result); + // Charge storage gas cost for the number of bytes if frame.data.is_create() && interpreter_result.is_ok() { let code_deposit_storage_gas = constants::mini_rex::CODEDEPOSIT_STORAGE_GAS * interpreter_result.output.len() as u64; - if !interpreter_result.gas.record_regular_cost(code_deposit_storage_gas) { + if interpreter_result.gas.record_regular_cost(code_deposit_storage_gas) { + // Storage gas, not compute work — and it is charged after the frame's tail + // segment has been measured, so nothing else can classify it. + ctx.additional_limit + .borrow_mut() + .record_non_compute_gas(i128::from(code_deposit_storage_gas)); + } else { + // Nothing was debited, so there is nothing to classify; the frame now halts + // out of gas and the remainder it keeps is settled as destroyed below. interpreter_result.result = InstructionResult::OutOfGas; } } - // REX5+: pre-charge canonical code-deposit compute gas before + // REX5/REX6: pre-charge canonical code-deposit compute gas before // process_next_action commits the CREATE checkpoint. Skip when // revm's return_create would not charge it; the existing // limit-side hook below owns the result-marking on exceed. - if is_rex5 && frame.data.is_create() { - let cfg = ctx.cfg(); - if will_return_create_charge_code_deposit( + if is_rex5 && !is_rex7 && frame.data.is_create() { + if let Some(canonical_code_deposit_gas) = canonical_code_deposit_gas( + ctx, interpreter_result, - cfg.max_code_size(), - cfg.spec().into_eth_spec(), - cfg.is_eip3541_disabled(), + frozen_code_deposit_gas(interpreter_result.output.len()), ) { - let code_len = interpreter_result.output.len() as u64; - let canonical_code_deposit_gas = - code_len.saturating_mul(revm::interpreter::gas::CODEDEPOSIT); let _ = ctx .additional_limit .borrow_mut() @@ -491,73 +530,192 @@ impl MegaEvm { // Update additional limits. MiniRex is guaranteed to be enabled here. ctx.additional_limit.borrow_mut().after_frame_run_instructions(frame, action); + // REX7: settle the canonical code-deposit charge — read off the active gas schedule, so + // the amount weighed and recorded is the amount revm will debit even under a schedule an + // embedder installed — after the hook above has closed the frame's tail segment, merged + // this frame's non-compute usage and marked the result if any of that put the frame over a + // limit. Two things follow from settling here rather than + // ahead of the hook. The charge is weighed against the frame's complete usage instead of a + // total still missing its tail. And a frame the hook already failed is skipped, because the + // marked result fails the deposit predicate — which is the point: revm only charges the + // deposit on a result that is still successful when the action is processed, so a charge + // recorded for a frame that ends any other way is compute gas nothing ever spent. + if is_rex7 { + if let InterpreterAction::Return(interpreter_result) = action { + if frame.data.is_create() { + if let Some(canonical_code_deposit_gas) = canonical_code_deposit_gas( + ctx, + interpreter_result, + active_code_deposit_gas(ctx, interpreter_result.output.len()), + ) { + let rewrite = ctx + .additional_limit + .borrow_mut() + .settle_create_code_deposit_compute_gas(canonical_code_deposit_gas); + if let Some((result, output)) = rewrite { + interpreter_result.result = result; + interpreter_result.output = output; + } + } + } + } + } + Ok(()) } - /// Apply `MiniRex` additional limits after frame action processing. + /// Charges the compute gas revm's own frame-action processing spent, on the specs that read + /// it from the result rather than weighing it beforehand. /// - /// Under REX5+ for CREATE results, the code-deposit compute gas was - /// already pre-charged in [`after_frame_run_instructions`]; pass - /// `None` here so the post-action hook does not double-record. + /// The only charge this ever sees is a contract creation's code deposit, and only through + /// REX4: REX5 onwards weigh that charge at the frame's exit — against a predicate, before it + /// is taken — and pass `None` here so the same gas is not recorded twice. A call frame's + /// classification spends nothing, so its delta is structurally zero. + /// + /// This runs *before* the last mutating callback, unlike the rest of the frame's settlement. + /// The delta it measures is a difference between two readings of the result's gas, and a + /// callback that edits that gas would land inside the difference and be recorded as compute + /// work the EVM performed. The specs that reach this arm are frozen, so the reading stays + /// where their behaviour was fixed. #[inline] - fn after_frame_run( + fn settle_post_action_charge( ctx: &MegaContext, - frame_output: &mut ItemOrResult, - gas_remaining_before_process_action: Option, - ) -> Result<(), ContextDbError>> { + frame_result: &mut FrameResult, + gas_remaining_before_classification: Option, + ) { if !ctx.spec.is_enabled(MegaSpecId::MINI_REX) { - return Ok(()); + return; } - let is_rex5 = ctx.spec.is_enabled(MegaSpecId::REX5); + let pass_through = if ctx.spec.is_enabled(MegaSpecId::REX5) && + matches!(frame_result, FrameResult::Create(_)) + { + None + } else { + gas_remaining_before_classification + }; + ctx.additional_limit.borrow_mut().settle_post_action_charge(frame_result, pass_through); + } - if let ItemOrResult::Result(frame_result) = frame_output { - // REX5+: code-deposit compute gas for CREATE results was already - // pre-charged. Skip post-action recording so we don't double-count. - let pass_through = if is_rex5 && matches!(frame_result, FrameResult::Create(_)) { - None - } else { - gas_remaining_before_process_action - }; - ctx.additional_limit.borrow_mut().after_frame_run(frame_result, pass_through); + /// The single point a frame's outcome is settled: after the last callback that can rewrite it, + /// and before the journal is told what to do with it. + /// + /// `inspector_gas_delta` is what that callback did to the result's gas, and is zero on the + /// uninspected path, where no callback runs at all. An edit an earlier callback made to the + /// terminating action this result was built from is staged on the limit tracker and joins it + /// there. + #[inline] + fn finalize_frame( + ctx: &MegaContext, + result: &mut FrameResult, + exit: FrameExit, + inspector_gas_delta: i128, + ) { + if !ctx.spec.is_enabled(MegaSpecId::MINI_REX) { + return; } + ctx.additional_limit.borrow_mut().finalize_frame(result, exit, inspector_gas_delta); + } - Ok(()) + /// The whole of a frame's exit, from its final action to the journal decision — the body both + /// frame loops run. + /// + /// The loops differ in exactly one thing, which is what `last_callback` carries: the inspected + /// loop passes the inspector's `frame_end` and the plain loop passes nothing. Everything else + /// — the gas reading the frozen post-action charge is measured against, the settlement point, + /// the journal decision — is this function, so the two loops cannot drift apart in it. + fn settle_and_commit_frame( + ctx: &mut MegaContext, + frame: &mut EthFrame, + action: InterpreterAction, + last_callback: impl FnOnce(&mut MegaContext, &FrameInput, &mut FrameResult), + ) -> (FrameInitOrResult>, Option) { + let gas_remaining_before = match (&action, ctx.spec.is_enabled(MegaSpecId::MINI_REX)) { + (InterpreterAction::Return(interpreter_result), true) => { + Some(interpreter_result.gas.remaining()) + } + _ => None, + }; + + let pending = match classify_frame_action(ctx, frame, action) { + ItemOrResult::Item(frame_init) => return (ItemOrResult::Item(frame_init), None), + ItemOrResult::Result(pending) => pending, + }; + frame.set_finished(true); + let (mut result, journal) = pending.split(); + + // Where the journal decision goes is the one thing about a frame's exit that is not the + // same on every spec. Frozen specs take it here, the moment the classification is done, + // because that is where revm takes it and because what they replay includes the state a + // frame leaves behind when a later rewrite fails it: a contract creation that commits and + // is then rewritten into a halt keeps its deployed code. + // + // REX7 withholds the decision, so that the state a frame leaves behind agrees with the + // result its caller is handed. It is withheld past the end of this function, because the + // last thing that can rewrite the result is not here: a frame-local resource exceed + // detected only once the frame's usage has been weighed against its caller's budget lands + // in `before_frame_return_result`, one step further on. The caller hands the decision back + // to `commit_frame_journal` there, still ahead of the caller resuming. + let mut deferred_journal = None; + if ctx.spec.is_enabled(MegaSpecId::REX7) { + deferred_journal = Some(journal); + } else { + commit_frame_journal(ctx, journal, &result); + } + + Self::settle_post_action_charge(ctx, &mut result, gas_remaining_before); + + let gas_before_callback = result.gas().remaining(); + last_callback(ctx, &frame.input, &mut result); + let inspector_gas_delta = + i128::from(result.gas().remaining()) - i128::from(gas_before_callback); + + Self::finalize_frame(ctx, &mut result, FrameExit::Ran, inspector_gas_delta); + + (ItemOrResult::Result(result), deferred_journal) } } -/// Mirrors `revm_handler::frame::return_create`'s pre-commit predicate. -/// Returns `true` iff `return_create` would charge `code_len * CODEDEPOSIT` -/// from the interpreter gas and commit the checkpoint. +/// The code-deposit compute gas a CREATE returning `output_len` bytes is charged under the +/// configuration's active gas schedule — the same reading `return_create` takes. +/// +/// The active schedule is required to be the one the spec defines, so this reads the same +/// per-byte rate as revm's built-in constant. Reading it off the schedule rather than restating +/// the constant keeps the amount `MegaETH` weighs and records tied to the amount revm debits at +/// the source, so the two cannot drift apart independently. +#[inline] +fn active_code_deposit_gas( + ctx: &MegaContext, + output_len: usize, +) -> u64 { + ctx.cfg().gas_params().code_deposit_cost(output_len) +} + +/// The frozen REX5/REX6 reading of the same charge: revm's built-in per-byte rate as a constant. /// -/// REVIEW ON UPSTREAM BUMP: keep in lockstep with -/// `revm-handler::frame::return_create`. Any revm bump that touches the -/// predicate inputs (`is_ok`, EIP-3541 gate, EIP-170 gate, code-deposit -/// gas availability) requires re-auditing this helper. -fn will_return_create_charge_code_deposit( +/// Those specs record the charge without a conservation law behind it, and their behavior is +/// frozen, which makes the constant the definition rather than an approximation of one. +#[inline] +fn frozen_code_deposit_gas(output_len: usize) -> u64 { + (output_len as u64).saturating_mul(revm::interpreter::gas::CODEDEPOSIT) +} + +/// `code_deposit_gas` if revm will actually charge it to this CREATE frame, `None` when +/// `return_create` would not charge it at all. +#[inline] +fn canonical_code_deposit_gas( + ctx: &MegaContext, interpreter_result: &InterpreterResult, - max_code_size: usize, - runtime_spec_id: revm::primitives::hardfork::SpecId, - is_eip3541_disabled: bool, -) -> bool { - use revm::primitives::hardfork::SpecId; - - if !interpreter_result.result.is_ok() { - return false; - } - if !is_eip3541_disabled && - runtime_spec_id.is_enabled_in(SpecId::LONDON) && - interpreter_result.output.first() == Some(&0xEF) - { - return false; - } - if runtime_spec_id.is_enabled_in(SpecId::SPURIOUS_DRAGON) && - interpreter_result.output.len() > max_code_size - { - return false; - } - let code_deposit_gas = (interpreter_result.output.len() as u64) - .saturating_mul(revm::interpreter::gas::CODEDEPOSIT); - interpreter_result.gas.remaining() >= code_deposit_gas + code_deposit_gas: u64, +) -> Option { + let cfg = ctx.cfg(); + super::frame::will_return_create_charge_code_deposit( + interpreter_result, + cfg.max_code_size(), + cfg.spec().into_eth_spec(), + cfg.is_eip3541_disabled(), + code_deposit_gas, + ) + .then_some(code_deposit_gas) } impl Handler @@ -717,6 +875,11 @@ where ctx.additional_limit() .borrow_mut() .record_compute_gas(initial_and_floor_gas.initial_regular_gas); + // Everything this block adds to `initial_regular_gas` from here on is MegaETH storage + // gas: it is charged to the transaction's envelope but is not compute work, and only + // the base intrinsic just recorded is. Snapshot the base so the difference can be + // booked as non-compute gas once every addition is in. + let base_intrinsic_gas = initial_and_floor_gas.initial_regular_gas; // MegaETH MiniRex modification: calldata storage gas costs (10x the standard EVM rates) // - Standard tokens: 40 gas per token (vs 4) @@ -907,6 +1070,20 @@ where .into()); } } + + // Book the MegaETH share of intrinsic gas — calldata storage gas, the flat REX + // intrinsic storage gas, and the callee-side / deposit-caller account-creation gas — + // as non-compute gas. Deliberately last: every contribution above is inside it. + // + // The paths that return early from here are validation rejects, which leave the lanes + // holding nothing but the base intrinsic already recorded as compute. For an ordinary + // transaction that is the end of it: there is no receipt, so no settlement ever reads + // the lanes. A deposit is the exception — it is not allowed to fail, so its receipt is + // rebuilt to report the whole gas limit, and the boundary that rebuilds it settles the + // difference against exactly this pair of lanes. + ctx.additional_limit().borrow_mut().record_non_compute_gas(i128::from( + initial_and_floor_gas.initial_regular_gas.saturating_sub(base_intrinsic_gas), + )); } Ok(initial_and_floor_gas) @@ -1001,9 +1178,35 @@ where if is_mini_rex { let ctx = evm.ctx_mut(); - let additional_limit = ctx.additional_limit.borrow(); + let mut additional_limit = ctx.additional_limit.borrow_mut(); let gas = frame_result.gas_mut(); gas.erase_cost(additional_limit.rescued_gas); + + // REX7 settlement point. The transaction's envelope is final exactly here: op-revm + // has normalised the gas object to `tx.gas_limit()`, the rescue has been handed back, + // and every frame's burn settlement and every precompile recording already ran. + // Nothing after this point burns gas — `post_execution`'s EIP-3529 refund and EIP-7623 + // floor only move the number the receipt reports — so this is the one place the + // envelope can be read to derive what the transaction destroyed. Reading it any later + // would fold a refund into the destroyed total; reading it any earlier would miss the + // rescue. The derived value is what the transaction reports, so this read point is + // load-bearing for the reported number, not just for the cross-check it also runs. + // + // `total_gas_spent` rather than the deprecated `spent`: the two are the same + // subtraction today, and EIP-8037's state-gas split — which is what deprecated the + // latter — is pinned off for every `MegaEVM` transaction. + // + // The reservoir is therefore structurally zero and the subtraction below is a no-op on + // every path — but it is the receipt's own arithmetic (`limit - remaining - + // reservoir`), and stating it here is what makes the settlement's envelope the one the + // receipt reports rather than one that happens to coincide with it. An inspector is + // the one thing that can fill a reservoir, and the lane booked a line earlier is what + // the law adds back so the two sides still meet. + additional_limit + .record_inspector_state_gas_dimension(gas.reservoir(), gas.state_gas_spent()); + additional_limit.settle_destroyed_compute_gas( + gas.total_gas_spent().saturating_sub(gas.reservoir()), + ); } Ok(()) @@ -1084,6 +1287,24 @@ where error: Self::Error, ) -> Result, Self::Error> { let result = self.op.catch_error(evm, error)?; + + // Reaching an `Ok` here means one thing: op-revm rewrote a failed deposit into a receipt + // that reports the transaction's whole gas limit. Its `output` starts as the incoming + // error and is replaced only on that branch, so every other error still propagates as + // `Err` and produces no receipt at all. The rewrite is the last thing that happens to the + // transaction's envelope, after the journal has been rolled back and after any settlement + // the transaction reached — so it is the only place the rewritten envelope can be booked. + // + // Skipped inside a keyless-deploy sandbox. The conservation law is stated over an outer + // transaction's final envelope; a sandbox transaction's gas is a charge inside its + // parent's envelope, and the parent settles once, later, over its own. A sandbox tx that + // is rewritten here is a validation reject, whose whole reservation the interceptor hands + // back and whose usage never crosses the boundary. + if !evm.ctx().is_inside_sandbox() { + let envelope_gas_spent = result.gas().total_gas_spent(); + evm.ctx().additional_limit().borrow_mut().settle_rewritten_envelope(envelope_gas_spent); + } + // Belt-and-braces: op-revm already reverts the journal to the default checkpoint before // building FailedDeposit, so logs are already empty. Clearing is idempotent. Ok(strip_logs_if_not_success(result.map_haltreason(MegaHaltReason::Base))) @@ -1192,70 +1413,46 @@ where } } -impl revm::handler::EvmTr for MegaEvm +/// What [`MegaEvm::init_frame_unsettled`] hands back: revm's own frame-init outcome, plus how the +/// frame's settlement should read a refusal. +type UnsettledFrameInit<'a, DB, ExtEnvs> = Result< + (FrameInitResult<'a, EthFrame>, FrameExit), + ContextDbError>, +>; + +impl MegaEvm where DB: Database, { - type Context = MegaContext; - - type Instructions = MegaInstructions; - - type Precompiles = PrecompilesMap; - - type Frame = EthFrame; - - #[inline] - fn all( - &self, - ) -> (&Self::Context, &Self::Instructions, &Self::Precompiles, &FrameStack) { - (&self.inner.ctx, &self.inner.instruction, &self.inner.precompiles, &self.inner.frame_stack) - } - - #[inline] - fn all_mut( - &mut self, - ) -> ( - &mut Self::Context, - &mut Self::Instructions, - &mut Self::Precompiles, - &mut FrameStack, - ) { - ( - &mut self.inner.ctx, - &mut self.inner.instruction, - &mut self.inner.precompiles, - &mut self.inner.frame_stack, - ) - } - - #[inline] - fn ctx(&mut self) -> &mut Self::Context { - &mut self.inner.ctx - } - - #[inline] - fn ctx_ref(&self) -> &Self::Context { - &self.inner.ctx - } - - #[inline] - fn ctx_instructions(&mut self) -> (&mut Self::Context, &mut Self::Instructions) { - (&mut self.inner.ctx, &mut self.inner.instruction) - } - + /// Parks a frame's journal decision until `frame_return_result`, one step of revm's execution + /// loop later. + /// + /// That step is the only thing that runs in between, and it is where the last rewrite of the + /// frame's result happens — a frame-local resource exceed the frame's own budget could not see, + /// because it is defined against the caller's budget after the merge. Holding the decision + /// across it is what lets a frame that reports a revert have reverted. + /// + /// A frame that suspended into a child frame parks nothing and clears whatever a previous + /// frame left, which is the invariant the assertion states: there is never more than one + /// decision outstanding, and it never survives the step it was parked for. #[inline] - fn ctx_precompiles(&mut self) -> (&mut Self::Context, &mut Self::Precompiles) { - (&mut self.inner.ctx, &mut self.inner.precompiles) - } - - fn frame_stack(&mut self) -> &mut FrameStack { - &mut self.inner.frame_stack + fn hold_deferred_journal(&mut self, pending: Option) { + debug_assert!( + self.deferred_journal.is_none(), + "a frame's journal decision outlived the step it was parked for" + ); + self.deferred_journal = pending; } - fn frame_init( + /// Everything `frame_init` decides — whether a frame is built at all, and what result stands + /// in for it when it is not — with the refusal left unsettled. + /// + /// Both frame-init paths run this. The inspected one has a callback to insert between the + /// refusal and its settlement, so the settlement cannot live in here. + fn init_frame_unsettled( &mut self, - mut frame_init: ::FrameInit, - ) -> Result, ContextDbError> { + mut frame_init: FrameInit, + ) -> UnsettledFrameInit<'_, DB, ExtEnvs> { let is_mini_rex_enabled = self.ctx().spec.is_enabled(MegaSpecId::MINI_REX); let is_rex_enabled = self.ctx().spec.is_enabled(MegaSpecId::REX); let is_rex3_enabled = self.ctx().spec.is_enabled(MegaSpecId::REX3); @@ -1300,40 +1497,14 @@ where } } - // REX4+: If a TX-level limit is already exceeded (e.g., intrinsic DataSize/KVUpdate - // overflow from before_tx_start), abort before interceptor dispatch. Interceptors - // return synthetic results that skip before_frame_init(), which would otherwise - // catch the exceeded limit. - // - // Gated to REX4 only: pre-REX4 specs use TX-global check_limit() which catches - // intrinsic overflow during execution. Changing pre-REX4 behavior would break replay. - if is_rex4_enabled { - // Separate borrow scope: the RefMut must be dropped before push_empty_frame - // borrows again. - let exceeded = additional_limit - .borrow_mut() - .frame_result_if_exceeding_limit(&frame_init.frame_input); - if let Some(frame_result) = exceeded { - additional_limit.borrow_mut().push_empty_frame(); - return Ok(FrameInitResult::Result(frame_result)); - } - } - - // REX5+: enforce `CALL_STACK_LIMIT` before interceptor dispatch. Interceptors - // short-circuit before revm's `make_call_frame` runs its own depth check, so - // without this guard a system contract could be invoked at unbounded depth. - // Scope mirrors interceptor dispatch (Call/StaticCall only); other schemes still - // flow into revm where its own depth check applies. - if is_rex5_enabled { - if let FrameInput::Call(call_inputs) = &frame_init.frame_input { - if matches!(call_inputs.scheme, CallScheme::Call | CallScheme::StaticCall) && - frame_init.depth > CALL_STACK_LIMIT as usize - { - let frame_result = gen_call_too_deep_result(call_inputs); - additional_limit.borrow_mut().push_empty_frame(); - return Ok(FrameInitResult::Result(frame_result)); - } - } + if let Some((frame_result, exit)) = Self::refuse_frame_before_dispatch( + &additional_limit, + &frame_init, + is_rex4_enabled, + is_rex5_enabled, + ) { + additional_limit.borrow_mut().push_empty_frame(); + return Ok((FrameInitResult::Result(frame_result), exit)); } // System contract interception dispatch. @@ -1359,7 +1530,7 @@ where if is_mini_rex_enabled { additional_limit.borrow_mut().push_empty_frame(); } - return Ok(FrameInitResult::Result(result)); + return Ok((FrameInitResult::Result(result), FrameExit::RefusedSynthetically)); } } } @@ -1369,7 +1540,7 @@ where .borrow_mut() .before_frame_init(&mut frame_init, self.ctx().journal_mut())? { - return Ok(FrameInitResult::Result(frame_result)); + return Ok((FrameInitResult::Result(frame_result), FrameExit::Refused)); } } @@ -1381,11 +1552,146 @@ where additional_limit.borrow_mut().after_frame_init(&init_result); } + Ok((init_result, FrameExit::Refused)) + } + + /// The two guards that stand in front of system contract interceptor dispatch, and the + /// refusal each of them produces. + /// + /// The order is load-bearing: a transaction that is already over a resource limit has to + /// report that, rather than have it shadowed by a depth rejection that happens to also apply. + /// Both frame-init paths run this, so an inspector's synthetic outcome cannot be delivered + /// under conditions the plain path refuses. + fn refuse_frame_before_dispatch( + additional_limit: &core::cell::RefCell, + frame_init: &FrameInit, + is_rex4_enabled: bool, + is_rex5_enabled: bool, + ) -> Option<(FrameResult, FrameExit)> { + // REX4+: If a TX-level limit is already exceeded (e.g., intrinsic DataSize/KVUpdate + // overflow from before_tx_start), abort before interceptor dispatch. Interceptors + // return synthetic results that skip before_frame_init(), which would otherwise + // catch the exceeded limit. + // + // Gated to REX4 only: pre-REX4 specs use TX-global check_limit() which catches + // intrinsic overflow during execution. Changing pre-REX4 behavior would break replay. + if is_rex4_enabled { + let exceeded = additional_limit + .borrow_mut() + .frame_result_if_exceeding_limit(&frame_init.frame_input); + if let Some(frame_result) = exceeded { + return Some((frame_result, FrameExit::Refused)); + } + } + + // REX5+: enforce `CALL_STACK_LIMIT` before interceptor dispatch. Interceptors + // short-circuit before revm's `make_call_frame` runs its own depth check, so + // without this guard a system contract could be invoked at unbounded depth. + // Scope mirrors interceptor dispatch (Call/StaticCall only); other schemes still + // flow into revm where its own depth check applies. + if is_rex5_enabled { + if let FrameInput::Call(call_inputs) = &frame_init.frame_input { + if matches!(call_inputs.scheme, CallScheme::Call | CallScheme::StaticCall) && + frame_init.depth > CALL_STACK_LIMIT as usize + { + return Some(( + gen_call_too_deep_result(call_inputs), + FrameExit::RefusedSynthetically, + )); + } + } + } + + None + } +} + +impl revm::handler::EvmTr for MegaEvm +where + DB: Database, +{ + type Context = MegaContext; + + type Instructions = MegaInstructions; + + type Precompiles = PrecompilesMap; + + type Frame = EthFrame; + + #[inline] + fn all( + &self, + ) -> (&Self::Context, &Self::Instructions, &Self::Precompiles, &FrameStack) { + (&self.inner.ctx, &self.inner.instruction, &self.inner.precompiles, &self.inner.frame_stack) + } + + #[inline] + fn all_mut( + &mut self, + ) -> ( + &mut Self::Context, + &mut Self::Instructions, + &mut Self::Precompiles, + &mut FrameStack, + ) { + ( + &mut self.inner.ctx, + &mut self.inner.instruction, + &mut self.inner.precompiles, + &mut self.inner.frame_stack, + ) + } + + #[inline] + fn ctx(&mut self) -> &mut Self::Context { + &mut self.inner.ctx + } + + #[inline] + fn ctx_ref(&self) -> &Self::Context { + &self.inner.ctx + } + + #[inline] + fn ctx_instructions(&mut self) -> (&mut Self::Context, &mut Self::Instructions) { + (&mut self.inner.ctx, &mut self.inner.instruction) + } + + #[inline] + fn ctx_precompiles(&mut self) -> (&mut Self::Context, &mut Self::Precompiles) { + (&mut self.inner.ctx, &mut self.inner.precompiles) + } + + fn frame_stack(&mut self) -> &mut FrameStack { + &mut self.inner.frame_stack + } + + fn frame_init( + &mut self, + frame_init: ::FrameInit, + ) -> Result, ContextDbError> { + let is_mini_rex_enabled = self.ctx().spec.is_enabled(MegaSpecId::MINI_REX); + let additional_limit = self.ctx().additional_limit.clone(); + + let (mut init_result, exit) = self.init_frame_unsettled(frame_init)?; + + // There is no inspector on this path, so the callback slot the settlement leaves open is + // empty and the frame's refusal is settled with nothing having rewritten it. + if is_mini_rex_enabled { + if let ItemOrResult::Result(result) = &mut init_result { + additional_limit.borrow_mut().finalize_frame(result, exit, 0); + } + } Ok(init_result) } - /// This method copies the logic from `revm::handler::EvmTr::frame_run` to and add additional - /// logic before `process_next_action` to handle the additional limit. + /// Runs one frame, settles it and tells the journal what to do with it. + /// + /// This is `revm::handler::EvmTr::frame_run` with `MegaETH`'s frame hooks and with the journal + /// decision withheld until the frame's settlement has run — see + /// [`settle_and_commit_frame`](MegaEvm::settle_and_commit_frame), which is the whole of the + /// body this shares with the inspected loop. There is no inspector on this path, so the + /// callback slot the settlement leaves for one is empty. #[inline] fn frame_run( &mut self, @@ -1408,28 +1714,10 @@ where // After frame_run instructions Hook Self::after_frame_run_instructions(context, frame, &mut action)?; - // Record gas remaining before frame action processing - let gas_remaining_before = match (&action, context.spec.is_enabled(MegaSpecId::MINI_REX)) { - (InterpreterAction::Return(interpreter_result), true) => { - Some(interpreter_result.gas.remaining()) - } - _ => None, - }; - - // Process the frame action, it may need to create a new frame or return the current frame - // result. - let mut frame_output = frame - .process_next_action::<_, ContextDbError>(context, action) - .inspect(|i| { - if i.is_result() { - frame.set_finished(true); - } - })?; - - // After frame_run Hook - Self::after_frame_run(context, &mut frame_output, gas_remaining_before)?; - - Ok(frame_output) + let (outcome, deferred_journal) = + Self::settle_and_commit_frame(context, frame, action, |_, _, _| {}); + self.hold_deferred_journal(deferred_journal); + Ok(outcome) } fn frame_return_result( @@ -1448,6 +1736,15 @@ where ctx.additional_limit.borrow_mut().before_frame_return_result::(&mut result); } + // REX7: the journal decision the frame's classification reached, carried out now — after + // the last thing that can rewrite the result, and before the caller resumes. A creation's + // `set_code` therefore still lands inside this window, with no point at which the caller + // could observe a deployed contract the frame's result denies. Frozen specs carry nothing + // here; they told the journal at classification time. + if let Some(pending) = self.deferred_journal.take() { + commit_frame_journal(&mut self.inner.ctx, pending, &result); + } + // Call the inner frame_return_result function to return the frame result. let ret = self.inner.frame_return_result(result)?; @@ -1471,7 +1768,12 @@ where DB: Database, INSP: Inspector>, { - type Inspector = INSP; + /// The inspector revm's inspected loops drive is the measurement shim, not the caller's own + /// inspector — every callback revm makes has to cross the shim's boundary for the shim to be + /// able to measure it. The caller's type is still what + /// [`alloy_evm::Evm::Inspector`](alloy_evm::Evm) and + /// [`InspectEvm::Inspector`](revm::InspectEvm) name. + type Inspector = MeasuredInspector; #[inline] fn all_inspector( @@ -1553,63 +1855,86 @@ where let is_mini_rex_enabled = ctx.spec.is_enabled(MegaSpecId::MINI_REX); let is_rex4_enabled = ctx.spec.is_enabled(MegaSpecId::REX4); let is_rex5_enabled = ctx.spec.is_enabled(MegaSpecId::REX5); + let additional_limit = ctx.additional_limit.clone(); // Check if inspector wants to skip this call/create - if let Some(mut output) = frame_start(ctx, inspector, &mut frame_init.frame_input) { + if let Some(output) = frame_start(ctx, inspector, &mut frame_init.frame_input) { + // What the transaction funded this frame with, staged by the measurement shim at the + // callback that answered it. Taken here rather than at the settlement below so that + // it cannot outlive this frame init on a spec that settles nothing. + let envelope = additional_limit.borrow_mut().take_inspector_interception_envelope(); + debug_assert!( + envelope.is_some() || matches!(frame_init.frame_input, FrameInput::Empty), + "every inspector is wrapped in the measurement shim, which stages the envelope \ + of any frame a callback answers itself", + ); + // Inspector intercepted — `frame_init()` is skipped entirely, so neither - // `frame_result_if_exceeding_limit` nor `before_frame_init` would run. - // - // The priority order below mirrors `frame_init`'s exact order so that a - // TX-level additional-limit exceed is reported instead of being shadowed by - // a CallTooDeep guard: - // 1. TX-level limit exceed (REX4+) - // 2. CALL_STACK_LIMIT depth guard (REX5+) - // 3. Deliver the inspector's synthetic output - // Each early-return path calls `frame_end` to keep inspector callbacks paired. - - // (1) REX4+: if a TX-level limit is already exceeded (e.g., intrinsic - // overflow), abort to ensure correct gas rescue before inspector callbacks. - // Gated to REX4 to avoid changing stable spec behavior. - if is_rex4_enabled { - let exceeded = ctx - .additional_limit - .borrow_mut() - .frame_result_if_exceeding_limit(&frame_init.frame_input); - if let Some(mut frame_result) = exceeded { - ctx.additional_limit.borrow_mut().push_empty_frame(); - frame_end(ctx, inspector, &frame_init.frame_input, &mut frame_result); - return Ok(ItemOrResult::Result(frame_result)); - } - } - // (2) REX5+: enforce CALL_STACK_LIMIT for Call/StaticCall so an inspector - // cannot deliver a synthetic call result at unbounded depth, mirroring the - // protection added to `frame_init` before interceptor dispatch. - if is_rex5_enabled { - if let FrameInput::Call(call_inputs) = &frame_init.frame_input { - if matches!(call_inputs.scheme, CallScheme::Call | CallScheme::StaticCall) && - frame_init.depth > CALL_STACK_LIMIT as usize - { - let mut frame_result = gen_call_too_deep_result(call_inputs); - ctx.additional_limit.borrow_mut().push_empty_frame(); - frame_end(ctx, inspector, &frame_init.frame_input, &mut frame_result); - return Ok(ItemOrResult::Result(frame_result)); - } - } - } - // (3) MINI_REX+: push empty frame to keep the limit tracker stack balanced + // `frame_result_if_exceeding_limit` nor `before_frame_init` would run. The two + // guards that stand in front of interceptor dispatch are the same ones the plain + // path runs, in the same order, so that a TX-level additional-limit exceed is + // reported instead of being shadowed by a depth rejection. + let (mut output, exit) = Self::refuse_frame_before_dispatch( + &additional_limit, + &frame_init, + is_rex4_enabled, + is_rex5_enabled, + ) + .unwrap_or((output, FrameExit::RefusedSynthetically)); + + // MINI_REX+: push empty frame to keep the limit tracker stack balanced // (`before_frame_return_result` will pop). if is_mini_rex_enabled { - ctx.additional_limit.borrow_mut().push_empty_frame(); + additional_limit.borrow_mut().push_empty_frame(); } + + // Deliberately *not* the marked window below: this result is the inspector's own, + // produced by a callback that answered the frame before anything in the EVM decided + // anything for it. There is no journal decision behind it for a later rewrite to + // contradict — no checkpoint was opened, no state was written — so moving its + // classification is supported like any other result rewrite. frame_end(ctx, inspector, &frame_init.frame_input, &mut output); + + if is_mini_rex_enabled { + // No frame ran, so the whole of what this result carries is the inspector's + // doing, measured against the envelope rather than across one callback: the + // synthetic outcome's own gas, whatever `frame_end` then did to it, and whatever + // of an edit to the inputs survived into a guard's replacement result. The + // settlement point splits it on the classification the caller ends up seeing, + // exactly as it does for a frame that really ran. + let inspector_gas_delta = envelope.map_or(0, |envelope| { + i128::from(output.gas().remaining()) - i128::from(envelope) + }); + additional_limit.borrow_mut().finalize_frame( + &mut output, + exit, + inspector_gas_delta, + ); + } return Ok(ItemOrResult::Result(output)); } - // Normal path - delegate to frame_init (which pushes a real frame) + // Normal path - delegate to the shared frame-init body (which pushes a real frame). let frame_input = frame_init.frame_input.clone(); - if let ItemOrResult::Result(mut output) = self.frame_init(frame_init)? { + let logs_before_init = ctx.journal().logs().len(); + let (init_result, exit) = self.init_frame_unsettled(frame_init)?; + if let ItemOrResult::Result(mut output) = init_result { let (ctx, inspector) = self.ctx_inspector(); - frame_end(ctx, inspector, &frame_input, &mut output); + + forward_precompile_logs(ctx, inspector, logs_before_init, &output); + + let gas_before_callback = output.gas().remaining(); + frame_end_on_frame_init_result(ctx, inspector, &frame_input, &mut output); + let inspector_gas_delta = + i128::from(output.gas().remaining()) - i128::from(gas_before_callback); + + if is_mini_rex_enabled { + additional_limit.borrow_mut().finalize_frame( + &mut output, + exit, + inspector_gas_delta, + ); + } return Ok(ItemOrResult::Result(output)); } @@ -1619,9 +1944,17 @@ where Ok(ItemOrResult::Item(frame)) } - /// This method copies the logic from `MegaEvm::frame_run` with inspector support. - /// It adds the same additional limit checks while using `inspect_instructions` instead of - /// `run_plain`. + /// The inspected twin of [`frame_run`](revm::handler::EvmTr::frame_run). + /// + /// It differs from the plain loop in exactly two places: the instruction loop is the inspected + /// one, and the callback slot the shared settlement leaves open is filled with the inspector's + /// `frame_end`. Everything between the frame's final action and the journal decision is the + /// same function for both loops. + /// + /// `frame_end` runs *before* the journal decision, which is where it differs from revm's own + /// inspected loop. It is the last callback that can rewrite a frame's classification, and both + /// `MegaETH`'s settlement and the state the frame leaves behind follow the classification it + /// hands back. #[inline] fn inspect_frame_run( &mut self, @@ -1634,7 +1967,7 @@ where inspect_instructions( ctx, &mut frame.interpreter, - inspector, + &mut *inspector, instructions.instruction_table(), instructions.gas_table(), ) @@ -1643,34 +1976,80 @@ where // Apply additional limits and storage gas cost Self::after_frame_run_instructions(ctx, frame, &mut action)?; - // Record gas remaining before frame action processing - let gas_remaining_before = match (&action, ctx.spec.is_enabled(MegaSpecId::MINI_REX)) { - (InterpreterAction::Return(interpreter_result), true) => { - Some(interpreter_result.gas.remaining()) - } - _ => None, - }; - - // Process the frame action, it may need to create a new frame or return the current frame - // result. - let mut frame_output = frame - .process_next_action::<_, ContextDbError>(ctx, action) - .inspect(|i| { - if i.is_result() { - frame.set_finished(true); - } - })?; - - // After frame_run Hook - Self::after_frame_run(ctx, &mut frame_output, gas_remaining_before)?; + let (outcome, deferred_journal) = + Self::settle_and_commit_frame(ctx, frame, action, |ctx, frame_input, frame_result| { + frame_end(ctx, inspector, frame_input, frame_result); + }); + self.hold_deferred_journal(deferred_journal); + Ok(outcome) + } +} - // Call frame_end for inspector callback - if let ItemOrResult::Result(frame_result) = &mut frame_output { - let (ctx, inspector, frame) = self.ctx_inspector_frame(); - frame_end(ctx, inspector, &frame.input, frame_result); - } +/// Runs the inspector's last callback over a result [`init_frame_unsettled`] produced, inside the +/// window the measurement shim reads to tell such a result apart from one a frame produced. +/// +/// What the shim does inside it is refuse a rewrite that moves the result's classification. The +/// journal decision behind such a result was taken before any callback ran and no callback can +/// reach it: revm's `make_call_frame` commits a value-transferring call into an empty-code account +/// and reverts a failing precompile inside itself, and a system contract interceptor decides its +/// own before it returns — the `KeylessDeploy` one by merging a whole sandbox's state. Gas and +/// output are untouched by the window; they are measured on their own lanes either way. +/// +/// The boundary is the *call site*, not a classification of the arms behind it, and deliberately +/// so: the arms are revm's early-fail returns plus `MegaETH`'s interceptors and guards, a set with +/// no type-level tie to anything here, and one that a revm bump grows without a compile error. +/// Some of them — a depth rejection, a refusal `MegaETH` took before opening a checkpoint — carry +/// no state a rewrite could contradict, and are covered anyway. +/// +/// [`init_frame_unsettled`]: MegaEvm::init_frame_unsettled +#[inline] +fn frame_end_on_frame_init_result( + ctx: &mut MegaContext, + inspector: &mut INSP, + frame_input: &FrameInput, + output: &mut FrameResult, +) where + INSP: Inspector>, +{ + ctx.additional_limit.borrow_mut().set_settling_frame_init_result(true); + frame_end(ctx, inspector, frame_input, output); + ctx.additional_limit.borrow_mut().set_settling_frame_init_result(false); +} - Ok(frame_output) +/// Hands an inspector the logs a precompile emitted, which no other callback would show it. +/// +/// A precompile is dispatched inside the frame init and comes back as a result rather than a +/// frame, so nothing it emits passes through the instruction loop's `log` callback. Its logs reach +/// an inspector through here or not at all — which is how revm's own inspected frame init treats +/// them, and matching that is the point: an inspector's view of a transaction should not depend on +/// whether `MegaETH` or revm assembled the frame init around it. +/// +/// A rejected precompile's logs are the ones the journal has already rolled back, so the outcome +/// carries them separately; a successful one's are still on the journal, past the mark taken +/// before the frame init ran. +/// +/// No precompile `MegaETH` registers emits a log, so this forwards nothing today. It is here for +/// the same reason the log strip in `execution_result` is: one that does would otherwise change +/// what an inspector sees, silently and in the direction of showing less. +#[inline] +fn forward_precompile_logs( + ctx: &mut MegaContext, + inspector: &mut INSP, + logs_before_init: usize, + output: &FrameResult, +) where + INSP: Inspector>, +{ + let FrameResult::Call(CallOutcome { was_precompile_called, precompile_call_logs, .. }) = output + else { + return; + }; + if !*was_precompile_called { + return; + } + let journalled = ctx.journal_mut().logs()[logs_before_init..].to_vec(); + for log in journalled.into_iter().chain(precompile_call_logs.iter().cloned()) { + inspector.log(ctx, log); } } @@ -1706,6 +2085,11 @@ fn gen_call_too_deep_result(call_inputs: &revm::interpreter::CallInputs) -> Fram /// which can happen after `MegaHandler::validate` has added any MegaETH-specific /// intrinsic gas (calldata storage gas, REX intrinsic storage gas, callee-side /// new-account storage gas, or the REX5+ deposit-caller storage gas). +/// +/// Reachable on `MINI_REX`..REX4 only: those specs run their initial-gas check midway through +/// the `MegaETH` additions, so a contribution added after it can still push the total past the +/// gas limit and land here. REX5 moved that check to the end of the additions, which turns the +/// same transaction into a `CallGasCostMoreThanGasLimit` validation error instead. fn gen_oog_frame_result(tx_kind: TxKind, gas_limit: u64) -> FrameResult { match tx_kind { TxKind::Call(_address) => FrameResult::Call(CallOutcome::new( @@ -1731,10 +2115,10 @@ fn gen_oog_frame_result(tx_kind: TxKind, gas_limit: u64) -> FrameResult { mod mutation_tests { use super::*; use crate::{ - test_utils::MemoryDatabase, AdditionalLimit, EmptyExternalEnv, EvmTxRuntimeLimits, + test_utils::MemoryDatabase, AdditionalLimit, EmptyExternalEnv, EvmTxRuntimeLimits, Lane, LimitCheck, LimitKind, }; - use alloy_primitives::Address; + use alloy_primitives::{Address, Log}; use revm::{ context::ContextTr, inspector::InspectorEvmTr, @@ -1801,6 +2185,21 @@ mod mutation_tests { consume_synthetic_limit_frame(evm.ctx_ref(), result); } + /// `EvmTr::frame_stack` is an accessor, not a factory. revm's `Handler::execution_result` and + /// `Handler::catch_error` reach the EVM's own frame stack through it and clear it there, so a + /// fresh stack handed out per call would leave the real one untouched and every caller + /// clearing something nobody else can see. + #[test] + fn test_frame_stack_hands_out_the_evms_own_stack() { + let mut evm = MegaEvm::new(MegaContext::new(MemoryDatabase::default(), MegaSpecId::REX5)); + let own = core::ptr::from_ref(&evm.inner.frame_stack); + assert_eq!( + core::ptr::from_mut(EvmTr::frame_stack(&mut evm)).cast_const(), + own, + "the trait accessor must project the EVM's own frame stack", + ); + } + #[test] fn test_frame_init_depth_short_circuit_pushes_limit_frame() { let mut evm = MegaEvm::new(MegaContext::new(MemoryDatabase::default(), MegaSpecId::REX5)); @@ -1828,6 +2227,98 @@ mod mutation_tests { } } + /// Raises the child's envelope and then answers the frame itself, echoing the raised figure. + /// + /// The shape the two override tests below need: an interception whose gas figure is *not* the + /// envelope the transaction funded, so that a guard replacing the outcome has something to + /// get wrong. + #[derive(Default)] + struct RaisingStopInspector; + + /// How much [`RaisingStopInspector`] adds to the envelope it is handed. + const RAISE: u64 = 4_000; + + impl Inspector for RaisingStopInspector { + fn call(&mut self, _context: &mut CTX, inputs: &mut CallInputs) -> Option { + inputs.gas_limit += RAISE; + Some(CallOutcome::new( + InterpreterResult::new( + InstructionResult::Stop, + Bytes::new(), + Gas::new(inputs.gas_limit), + ), + inputs.return_memory_offset.clone(), + )) + } + } + + /// A guard that replaces an interception's outcome still leaves the inspector's edit reaching + /// the caller, and it has to be booked. + /// + /// The depth rejection is built from the frame input the callback edited, and `CallTooDeep` + /// is in the revert group — so the caller reclaims the raised figure, not the one the + /// transaction funded. Measuring against the envelope the callback was handed catches this + /// without the settlement having to know which of the two results it is looking at. + #[test] + fn test_a_guard_replacing_an_interception_still_books_the_edit_that_reached_the_caller() { + let mut evm = MegaEvm::new(MegaContext::new(MemoryDatabase::default(), MegaSpecId::REX5)) + .with_inspector(RaisingStopInspector); + let ItemOrResult::Result(result) = InspectorEvmTr::inspect_frame_init( + &mut evm, + call_frame_init(CALL_STACK_LIMIT as usize + 1), + ) + .unwrap() else { + panic!("depth guard must override the inspector result"); + }; + assert_eq!( + result.gas().remaining(), + TEST_GAS_LIMIT + RAISE, + "the rejection is built from the inputs the callback left behind", + ); + assert_eq!( + evm.ctx_ref().additional_limit.borrow().inspector_ledger().result, + Lane::once(i128::from(RAISE)), + "the caller reclaims the raise, so the ledger must carry it", + ); + consume_synthetic_limit_frame(evm.ctx_ref(), result); + } + + /// The mirror: a rejection the caller reclaims nothing from moves the envelope by nothing, and + /// the sender's rescue is taken on the envelope the transaction funded rather than on the + /// raised figure. + /// + /// The lane's two halves separate here. Its net is zero, because the halting rejection hands + /// the raise to nobody; its gross is not, because the inspector still made the edit and the + /// block guard's question is whether the transaction was left alone. + #[test] + fn test_a_halting_guard_rescues_the_funded_envelope_and_not_the_raised_one() { + let mut evm = + MegaEvm::new(context_with_latched_limit()).with_inspector(RaisingStopInspector); + let ItemOrResult::Result(result) = + InspectorEvmTr::inspect_frame_init(&mut evm, call_frame_init(1)).unwrap() + else { + panic!("latched limit must override the inspector result"); + }; + let limit = evm.ctx_ref().additional_limit.borrow(); + let result_lane = limit.inspector_ledger().result; + assert_eq!( + result_lane.net(), + 0, + "a halting rejection hands nothing back, so the edit reaches the envelope not at all", + ); + assert_eq!( + result_lane.gross(), + u128::from(RAISE), + "but the inspector still wrote it, and the guard has to see that", + ); + assert_eq!( + limit.rescued_gas, TEST_GAS_LIMIT, + "the sender is refunded what the transaction funded, not what the inspector wrote", + ); + drop(limit); + consume_synthetic_limit_frame(evm.ctx_ref(), result); + } + #[test] fn test_inspect_frame_init_limit_short_circuit_pushes_limit_frame() { let mut evm = MegaEvm::new(context_with_latched_limit()).with_inspector(StopInspector); @@ -1852,4 +2343,159 @@ mod mutation_tests { }; consume_synthetic_limit_frame(evm.ctx_ref(), result); } + + /// Counts every log the inspector is handed, and nothing else. + #[derive(Default)] + struct LogCountingInspector { + logs: Vec, + } + + impl Inspector for LogCountingInspector { + fn log(&mut self, _context: &mut CTX, log: Log) { + self.logs.push(log); + } + } + + fn precompile_outcome(journalled: bool) -> FrameResult { + let log = Log::new_unchecked(Address::ZERO, Vec::new(), Bytes::from_static(b"emitted")); + FrameResult::Call(CallOutcome { + result: InterpreterResult::new( + InstructionResult::Return, + Bytes::new(), + Gas::new(TEST_GAS_LIMIT), + ), + memory_offset: 0..0, + was_precompile_called: true, + precompile_call_logs: if journalled { Vec::new() } else { vec![log] }, + charged_new_account_state_gas: false, + }) + } + + /// A precompile's logs never reach the instruction loop, so unless the frame init forwards + /// them an inspector simply does not see them. Both of the places they can be — still on the + /// journal for a precompile that succeeded, carried on the outcome for one whose frame was + /// rolled back — have to be forwarded, and in that order. + #[test] + fn test_precompile_logs_reach_the_inspector_from_both_places_they_live() { + for journalled in [false, true] { + let mut context = MegaContext::new(MemoryDatabase::default(), MegaSpecId::REX7); + let logs_before = context.journal_mut().logs().len(); + if journalled { + context.journal_mut().log(Log::new_unchecked( + Address::ZERO, + Vec::new(), + Bytes::from_static(b"emitted"), + )); + } + let mut inspector = LogCountingInspector::default(); + + forward_precompile_logs( + &mut context, + &mut inspector, + logs_before, + &precompile_outcome(journalled), + ); + + assert_eq!( + inspector.logs.len(), + 1, + "the precompile's log must reach the inspector (journalled: {journalled})", + ); + assert_eq!(inspector.logs[0].data.data, Bytes::from_static(b"emitted")); + } + } + + /// Nothing else in a frame init is a precompile, and forwarding a call frame's journal tail + /// would replay logs the instruction loop already showed. + #[test] + fn test_a_non_precompile_frame_init_result_forwards_nothing() { + let mut context = MegaContext::new(MemoryDatabase::default(), MegaSpecId::REX7); + context.journal_mut().log(Log::new_unchecked( + Address::ZERO, + Vec::new(), + Bytes::from_static(b"emitted"), + )); + let mut inspector = LogCountingInspector::default(); + + let mut outcome = precompile_outcome(true); + let FrameResult::Call(call) = &mut outcome else { unreachable!() }; + call.was_precompile_called = false; + + forward_precompile_logs(&mut context, &mut inspector, 0, &outcome); + + assert!(inspector.logs.is_empty(), "a plain frame-init result forwards nothing"); + } + + /// Drives [`MegaHandler::before_execution`] straight at its short-circuit: a transaction whose + /// gas limit is one below the initial gas it is handed, with `recorded_intrinsic` already on + /// the compute-gas tracker the way `validate` leaves it. + /// + /// Returns `(halt gas spent, reported compute total, destroyed part)`. + /// + /// Driving the hook directly is the only way to reach the branch from REX5 on: those specs + /// run their initial-gas check after every `MegaETH` storage-gas contribution, so a transaction + /// with this shape is rejected in `validate` instead (pinned by + /// `tests/rex7/pre_execution_intrinsic_reject.rs`). + fn run_before_execution_short_circuit( + spec: MegaSpecId, + recorded_intrinsic: u64, + ) -> (u64, u64, u64) { + let mut context = MegaContext::new(MemoryDatabase::default(), spec); + context.inner.tx.base.gas_limit = TEST_GAS_LIMIT; + context.additional_limit.borrow_mut().record_compute_gas(recorded_intrinsic); + + let mut evm = MegaEvm::new(context); + let handler = MegaHandler::< + _, + revm::context::result::EVMError, + (), + >::new(); + let result = handler + .before_execution(&mut evm, &InitialAndFloorGas::new(TEST_GAS_LIMIT + 1, 0)) + .expect("the short circuit cannot fail") + .expect("initial gas above the gas limit must short-circuit"); + + let additional_limit = evm.ctx_ref().additional_limit.borrow(); + ( + result.gas().total_gas_spent(), + additional_limit.get_usage().compute_gas, + additional_limit.burned_compute_gas(), + ) + } + + /// REX7: the pre-execution intrinsic overrun burns the whole envelope having performed only + /// the intrinsic compute gas `validate` recorded, so that intrinsic stays enforcing and the + /// rest of the envelope is destroyed. The reported total covers the envelope the halt burns. + #[test] + fn test_before_execution_short_circuit_destroys_the_unperformed_envelope() { + const INTRINSIC: u64 = 21_000; + let (spent, reported, destroyed) = + run_before_execution_short_circuit(MegaSpecId::REX7, INTRINSIC); + + assert_eq!(spent, TEST_GAS_LIMIT, "the halt burns the whole transaction envelope"); + assert_eq!(reported, TEST_GAS_LIMIT, "the reported total covers the burnt envelope"); + assert_eq!( + destroyed, + TEST_GAS_LIMIT - INTRINSIC, + "everything the transaction did not perform is destroyed", + ); + assert_eq!( + reported - destroyed, + INTRINSIC, + "only the intrinsic compute gas already recorded enforces", + ); + } + + /// REX6 has no destroyed lane: the same short circuit records nothing beyond the intrinsic + /// `validate` already put on the tracker, and burns the same envelope. + #[test] + fn test_before_execution_short_circuit_records_nothing_before_rex7() { + const INTRINSIC: u64 = 21_000; + let (spent, reported, destroyed) = + run_before_execution_short_circuit(MegaSpecId::REX6, INTRINSIC); + + assert_eq!(spent, TEST_GAS_LIMIT, "the burnt envelope is spec-independent"); + assert_eq!(reported, INTRINSIC, "REX6 reports only what validate recorded"); + assert_eq!(destroyed, 0, "REX6 has no destroyed lane"); + } } diff --git a/crates/mega-evm/src/evm/factory.rs b/crates/mega-evm/src/evm/factory.rs index 6afe5241..5b4c6c4e 100644 --- a/crates/mega-evm/src/evm/factory.rs +++ b/crates/mega-evm/src/evm/factory.rs @@ -197,17 +197,12 @@ mod tests { const CHAIN_ID: u64 = 6342; const SENDER: Address = address!("0000000000000000000000000000000000000f00"); const TARGET: Address = address!("0000000000000000000000000000000000000f01"); - /// revm's mainnet cost per calldata token. - const MAINNET_TX_TOKEN_COST: u64 = 4; - /// The per-token cost an embedder installs in place of the mainnet one. + /// The per-token cost an embedder might try to install in place of the mainnet one. const CUSTOM_TX_TOKEN_COST: u64 = 40; /// revm's mainnet EIP-7623 floor cost per calldata token. const MAINNET_TX_FLOOR_COST_PER_TOKEN: u64 = 10; /// revm's mainnet base cost of a transaction, the constant part of the EIP-7623 floor. const MAINNET_TX_BASE_STIPEND: u64 = 21_000; - /// Zero calldata bytes are one EIP-7623 token each. This many keeps the transaction above - /// every gas floor, so its gas used tracks the per-token cost of the schedule directly. - const UNFLOORED_CALLDATA_TOKENS: u64 = 100; /// Enough calldata tokens that the EIP-7623 floor rises above the transaction's own cost /// (`MegaETH`'s calldata storage gas included), so the floor decides the gas used. const FLOOR_BINDING_CALLDATA_TOKENS: u64 = 2_000; @@ -227,17 +222,18 @@ mod tests { } /// The mainnet `PRAGUE` schedule with the calldata token cost moved off its mainnet value — - /// the kind of override an embedder installs for its own chain. + /// the kind of override an embedder might reach for, and which the factory rejects. fn embedder_gas_params(tx_token_cost: u64) -> GasParams { let mut gas_params = GasParams::new_spec(SpecId::PRAGUE); gas_params.override_gas([(GasId::tx_token_cost(), tx_token_cost)]); gas_params } - fn embedder_cfg(tx_token_cost: u64, disable_eip7623: bool) -> CfgEnv { + /// A `REX6` configuration an embedder hands the factory. Only `disable_eip7623` is moved off + /// its default — the gas schedule is the spec's, which is the only one the factory admits. + fn embedder_cfg(disable_eip7623: bool) -> CfgEnv { let mut cfg = CfgEnv::new_with_spec(MegaSpecId::REX6); cfg.chain_id = CHAIN_ID; - cfg.gas_params = embedder_gas_params(tx_token_cost); cfg.disable_eip7623 = disable_eip7623; cfg } @@ -286,7 +282,7 @@ mod tests { /// neither leg may silently reset a field to its revm default. #[test] fn test_create_evm_round_trips_embedder_cfg() { - let cfg = embedder_cfg(40, true); + let cfg = embedder_cfg(true); let db = MemoryDatabase::default(); let evm = MegaEvmFactory::new().create_evm(db, evm_env(cfg.clone())); @@ -295,7 +291,7 @@ mod tests { let read_back = evm.cfg_env(); assert_eq!(read_back.spec, MegaSpecId::REX6); assert_eq!(read_back.chain_id, CHAIN_ID); - assert_eq!(read_back.gas_params, cfg.gas_params, "custom gas schedule must survive"); + assert_eq!(read_back.gas_params, cfg.gas_params, "the spec's gas schedule must survive"); assert!(read_back.disable_eip7623, "revm 40 switches must survive"); // And through `finish`, which hands the config back to the embedder. @@ -306,6 +302,19 @@ mod tests { assert!(evm_env.cfg_env.disable_eip7623); } + /// The gas schedule is the one `CfgEnv` field the factory does not accept from an embedder. + /// It belongs to the spec, and `create_evm` — the production entry point an embedder's + /// `EvmEnv` arrives through — rejects a rewritten one rather than building an EVM that would + /// charge one schedule and account for another. + #[test] + #[should_panic(expected = "gas params differ from the spec-defined schedule")] + fn test_create_evm_rejects_a_gas_schedule_off_the_spec_table() { + let mut cfg = embedder_cfg(true); + cfg.gas_params = embedder_gas_params(CUSTOM_TX_TOKEN_COST); + + let _ = MegaEvmFactory::new().create_evm(MemoryDatabase::default(), evm_env(cfg)); + } + /// The production path — `create_evm` routing the embedder's `EvmEnv` through `with_cfg` — /// pins the chain-id gate off however the config arrives: revm 40 defaults the flag to /// `true`, and every frozen `MegaETH` spec ran without the gate. @@ -323,34 +332,13 @@ mod tests { ); } - /// Carrying the config is not enough — it must also drive execution. A custom per-token - /// calldata cost moves the same transaction's gas by exactly its per-token delta. - #[test] - fn test_embedder_gas_schedule_takes_effect_on_gas() { - let mainnet_schedule = - run_calldata_tx(embedder_cfg(MAINNET_TX_TOKEN_COST, true), UNFLOORED_CALLDATA_TOKENS); - let custom_schedule = - run_calldata_tx(embedder_cfg(CUSTOM_TX_TOKEN_COST, true), UNFLOORED_CALLDATA_TOKENS); - - assert_eq!( - custom_schedule - mainnet_schedule, - (CUSTOM_TX_TOKEN_COST - MAINNET_TX_TOKEN_COST) * UNFLOORED_CALLDATA_TOKENS, - "the embedder's per-token calldata cost must price the transaction" - ); - } - - /// Same for the `disable_eip7623` switch: with enough calldata for the floor to bind, turning + /// Carrying the config is not enough — it must also drive execution. The `disable_eip7623` + /// switch is one an embedder does own: with enough calldata for the floor to bind, turning /// EIP-7623 off removes exactly revm's floor cost from the transaction's gas. #[test] fn test_embedder_eip7623_switch_takes_effect_on_gas() { - let with_eip7623 = run_calldata_tx( - embedder_cfg(MAINNET_TX_TOKEN_COST, false), - FLOOR_BINDING_CALLDATA_TOKENS, - ); - let without_eip7623 = run_calldata_tx( - embedder_cfg(MAINNET_TX_TOKEN_COST, true), - FLOOR_BINDING_CALLDATA_TOKENS, - ); + let with_eip7623 = run_calldata_tx(embedder_cfg(false), FLOOR_BINDING_CALLDATA_TOKENS); + let without_eip7623 = run_calldata_tx(embedder_cfg(true), FLOOR_BINDING_CALLDATA_TOKENS); assert_eq!( with_eip7623 - without_eip7623, @@ -361,19 +349,29 @@ mod tests { } /// A config whose gas schedule prices state gas: the mainnet `PRAGUE` table with Amsterdam's - /// charge for setting a fresh storage slot dropped in. An embedder can install exactly this, - /// which is what leaves EIP-8037 one flag away from repricing a frozen spec. + /// charge for setting a fresh storage slot dropped in. This is the shape the schedule pin + /// exists to turn away — a schedule under which EIP-8037 would have something to move. fn state_gas_priced_cfg() -> CfgEnv { let amsterdam_sstore_set_state_gas = GasParams::new_spec(SpecId::AMSTERDAM).get(GasId::sstore_set_state_gas()); assert_ne!(amsterdam_sstore_set_state_gas, 0, "state gas must be priced for this probe"); - let mut cfg = embedder_cfg(MAINNET_TX_TOKEN_COST, true); + let mut cfg = embedder_cfg(true); cfg.gas_params .override_gas([(GasId::sstore_set_state_gas(), amsterdam_sstore_set_state_gas)]); cfg } + /// The entries EIP-8037 splits a charge into. All of them are zero on every schedule + /// `MegaSpecId` defines, which is what makes the split inert once the schedule is pinned. + const STATE_GAS_IDS: [fn() -> GasId; 5] = [ + GasId::sstore_set_state_gas, + GasId::new_account_state_gas, + GasId::code_deposit_state_gas, + GasId::create_state_gas, + GasId::tx_eip7702_state_gas_bytecode, + ]; + /// Executes one transaction into [`SSTORE_FRESH_SLOT_CODE`] planted at [`TARGET`] and returns /// its gas used. fn run_sstore_tx(cfg: CfgEnv) -> u64 { @@ -407,19 +405,18 @@ mod tests { result.result.tx_gas_used() } - /// EIP-8037 is the one `CfgEnv` field an embedder does not own. `MegaETH`'s gas accounting + /// EIP-8037 is another `CfgEnv` field an embedder does not own. `MegaETH`'s gas accounting /// assumes no state-gas split exists, so the flag is forced off before the EVM is built and /// again before every transaction, reads back off, and setting it changes nothing about what a /// transaction costs. /// - /// The probe is a fresh-slot `SSTORE` under a schedule that prices state gas — - /// `state_gas_priced_cfg` asserts the Amsterdam charge it installs is non-zero, so a live - /// split would land on this transaction. There is deliberately no "forced past the pin" - /// control any more: the force now happens inside the transaction, after any window a test - /// could write the flag in, which is the property being asserted. + /// The probe is a fresh-slot `SSTORE`, the operation the split would reprice first. It runs + /// on the spec's schedule because that is the only schedule a transaction can run on — see + /// `test_a_state_gas_priced_schedule_is_rejected` for the schedule that would have given the + /// split something to move, and why it never reaches execution. #[test] fn test_embedder_cannot_enable_amsterdam_eip8037() { - let mut cfg = state_gas_priced_cfg(); + let mut cfg = embedder_cfg(true); cfg.enable_amsterdam_eip8037 = true; let evm = MegaEvmFactory::new().create_evm(MemoryDatabase::default(), evm_env(cfg.clone())); @@ -440,20 +437,35 @@ mod tests { ); // And execution never enters state-gas accounting: a fresh-slot `SSTORE` costs the same - // whether or not the embedder asked for EIP-8037, even on a schedule that prices state - // gas. + // whether or not the embedder asked for EIP-8037. assert_eq!( run_sstore_tx(cfg), - run_sstore_tx(state_gas_priced_cfg()), + run_sstore_tx(embedder_cfg(true)), "an embedder's EIP-8037 request must not reprice a fresh-slot SSTORE" ); + } - // And the state-gas price in the schedule is inert on its own: installing it changes - // nothing either, so no part of execution reads the state-gas table. - assert_eq!( - run_sstore_tx(state_gas_priced_cfg()), - run_sstore_tx(embedder_cfg(MAINNET_TX_TOKEN_COST, true)), - "a schedule that prices state gas must not reprice a transaction while the split is off" - ); + /// The second half of the EIP-8037 guarantee, now carried by the schedule pin: the split has + /// nothing to move. Every state-gas entry is zero on every schedule `MegaSpecId` defines, and + /// a schedule that priced one is rejected before it can run a transaction — so the flag being + /// forced off is a second lock on a door the schedule already closed, not the only one. + #[test] + fn test_state_gas_is_unpriced_on_every_spec_schedule() { + for spec in [MegaSpecId::EQUIVALENCE, MegaSpecId::REX5, MegaSpecId::REX6, MegaSpecId::REX7] + { + let schedule = GasParams::new_spec(SpecId::from(spec)); + for id in STATE_GAS_IDS { + assert_eq!(schedule.get(id()), 0, "{:?} on {spec:?}", id().name()); + } + } + } + + /// A schedule that prices state gas is a schedule off the spec table, and is turned away at + /// the factory like any other. + #[test] + #[should_panic(expected = "gas params differ from the spec-defined schedule")] + fn test_a_state_gas_priced_schedule_is_rejected() { + let _ = MegaEvmFactory::new() + .create_evm(MemoryDatabase::default(), evm_env(state_gas_priced_cfg())); } } diff --git a/crates/mega-evm/src/evm/frame.rs b/crates/mega-evm/src/evm/frame.rs new file mode 100644 index 00000000..4bf65afd --- /dev/null +++ b/crates/mega-evm/src/evm/frame.rs @@ -0,0 +1,607 @@ +//! Turning a frame's final action into a frame result, with the journal decision withheld. +//! +//! # Why `MegaETH` owns this +//! +//! revm assembles a frame's result, decides whether the frame's journal checkpoint commits or +//! reverts, and — for a contract creation — runs the deposit predicates and writes the code, all +//! inside one function, and it runs the inspector's last mutating callback *after* that function +//! returns. So the classification an inspector is handed is already carved into state. +//! +//! `MegaETH` needs the opposite order. Its resource accounting settles a frame once, on the +//! frame's final classification, and an inspector is allowed to rewrite that classification; a +//! settlement taken before the rewrite would book a result that never reaches the caller, and a +//! journal committed before the rewrite would leave state behind that the reported result denies. +//! +//! This module therefore splits the upstream function in two. [`classify_frame_action`] does +//! everything that decides *what the frame's result is* — the create-return predicates, the +//! code-deposit charge, assembling the outcome — and records what the journal will have to be told +//! as a [`FrameJournalVerdict`]. [`commit_frame_journal`] carries that verdict out, once the +//! result is final. Between the two sit the inspector's last callback and `MegaETH`'s single +//! frame settlement point. +//! +//! # Upstream lockstep +//! +//! REVIEW ON UPSTREAM BUMP: [`classify_frame_action`] and [`commit_frame_journal`] together must +//! stay a faithful re-ordering of `revm_handler::EthFrame::process_next_action` and +//! `revm_handler::frame::return_create`. A revm bump that changes what those do — a new predicate, +//! a different charge, a changed journal decision — has to be mirrored here, because nothing in +//! the type system ties the two together. The debug assertion in [`classify_create_return`] +//! catches one specific class of drift (the deposit predicate `MegaETH` weighs against) and +//! nothing else. + +use alloy_primitives::{Address, Bytes}; +use revm::{ + context::{Cfg, ContextTr, JournalTr}, + context_interface::journaled_state::JournalCheckpoint, + handler::{EthFrame, FrameData, FrameResult, ItemOrResult}, + interpreter::{ + interpreter::EthInterpreter, interpreter_action::FrameInit, CallOutcome, CreateOutcome, + FrameInput, InstructionResult, InterpreterAction, InterpreterResult, + }, + primitives::hardfork::SpecId, + state::Bytecode, +}; + +/// What the journal has to be told about a frame, once that frame's result is final. +/// +/// The variants carry the decision the *classification* reached, not the decision that will be +/// carried out: a creation whose predicates all passed still reverts if the final result is no +/// longer successful, and the code it would have deposited is dropped. +#[derive(Clone, Debug)] +pub(crate) enum FrameJournalVerdict { + /// A call frame: commit if the final result is successful, revert otherwise. + Call, + /// A contract creation the deposit predicates turned away — an oversized runtime code, an + /// `0xEF` prefix, or a code-deposit charge the frame could not afford. Reverts + /// unconditionally: the classification already failed the frame, and nothing a later + /// rewrite says brings the rejected code back. + CreateRejected, + /// A contract creation that passed every deposit predicate and whose code is ready to be + /// written, if the final result is still successful. + /// + /// Holding the code here rather than re-reading the result's output at commit time is what + /// makes the deposit structurally unable to follow a rewrite: the bytes written are the bytes + /// the predicates approved, and they are written only on the branch this verdict allows. + CreateAccepted { address: Address, code: Bytes }, +} + +/// A frame's result, with the journal not yet told what to do with it. +#[derive(Debug)] +pub(crate) struct PendingFrame { + /// The frame's result as classified. + result: FrameResult, + /// The journal decision the classification reached but did not carry out. + journal: PendingJournal, +} + +impl PendingFrame { + /// Hands out the result and the journal decision still owed on it, so that the caller can put + /// its own work between the two — or not. + pub(crate) fn split(self) -> (FrameResult, PendingJournal) { + (self.result, self.journal) + } +} + +/// A journal decision a frame's classification reached, waiting to be carried out. +#[derive(Debug)] +pub(crate) struct PendingJournal { + verdict: FrameJournalVerdict, + /// The frame's own journal checkpoint, to revert to. + checkpoint: JournalCheckpoint, +} + +/// The classification half of revm's `process_next_action`: everything that decides a frame's +/// result, and nothing that writes state. +/// +/// Returns the child frame to build when the action is a new frame — that path settles nothing, +/// because the frame is suspended rather than finished. +pub(crate) fn classify_frame_action( + ctx: &CTX, + frame: &mut EthFrame, + action: InterpreterAction, +) -> ItemOrResult { + let mut interpreter_result = match action { + InterpreterAction::NewFrame(frame_input) => { + return ItemOrResult::Item(FrameInit { + frame_input, + depth: frame.depth + 1, + memory: frame.interpreter.memory.new_child_context(), + }) + } + InterpreterAction::Return(result) => result, + }; + + let (result, verdict) = match &frame.data { + FrameData::Call(call_frame) => { + // Propagate the EIP-8037 new-account state-gas flag from the frame input so the parent + // can refund the upfront charge if the call ends in revert or halt. + let charged_new_account_state_gas = match &frame.input { + FrameInput::Call(inputs) => inputs.charged_new_account_state_gas, + _ => false, + }; + let mut outcome = + CallOutcome::new(interpreter_result, call_frame.return_memory_range.clone()); + outcome.charged_new_account_state_gas = charged_new_account_state_gas; + (FrameResult::Call(outcome), FrameJournalVerdict::Call) + } + FrameData::Create(create_frame) => { + let address = create_frame.created_address; + let verdict = classify_create_return(ctx, &mut interpreter_result, address); + (FrameResult::Create(CreateOutcome::new(interpreter_result, Some(address))), verdict) + } + }; + + ItemOrResult::Result(PendingFrame { + result, + journal: PendingJournal { verdict, checkpoint: frame.checkpoint }, + }) +} + +/// The state-writing half: the journal decision the classification recorded, carried out against +/// the frame's *final* result. +/// +/// Two rewrites are possible between the two halves, and this is where each of them lands: +/// +/// - a successful frame rewritten into a failure reverts, and a creation deposits no code — the +/// caller is told the frame failed, and the state agrees; +/// - a failed frame rewritten into a success cannot commit a creation, because the verdict a +/// rejected creation carries has no code and no commit branch. (The measurement shim refuses that +/// rewrite outright and restores the original classification, so this is the second of two +/// independent stops rather than the only one.) +pub(crate) fn commit_frame_journal( + ctx: &mut CTX, + pending: PendingJournal, + result: &FrameResult, +) { + let PendingJournal { verdict, checkpoint } = pending; + let is_ok = result.instruction_result().is_ok(); + let journal = ctx.journal_mut(); + match verdict { + FrameJournalVerdict::Call => { + if is_ok { + journal.checkpoint_commit(); + } else { + journal.checkpoint_revert(checkpoint); + } + } + FrameJournalVerdict::CreateRejected => journal.checkpoint_revert(checkpoint), + FrameJournalVerdict::CreateAccepted { address, code } => { + if is_ok { + journal.checkpoint_commit(); + journal.set_code(address, Bytecode::new_legacy(code)); + } else { + journal.checkpoint_revert(checkpoint); + } + } + } +} + +/// The classification half of revm's `return_create`: the deposit predicates and the code-deposit +/// charge, with every journal write deferred to the verdict. +/// +/// The predicates run in upstream's order, because they do not commute: the code-size limit is +/// checked before the deposit is charged so that oversized code is not billed for storage it never +/// gets, and the `0xEF` rejection is checked before that same charge for the same reason. +fn classify_create_return( + ctx: &CTX, + interpreter_result: &mut InterpreterResult, + address: Address, +) -> FrameJournalVerdict { + let cfg = ctx.cfg(); + let max_code_size = cfg.max_code_size(); + let is_eip3541_disabled = cfg.is_eip3541_disabled(); + let spec_id: SpecId = cfg.spec().into(); + let is_amsterdam_eip8037 = cfg.is_amsterdam_eip8037_enabled(); + let gas_params = cfg.gas_params(); + let gas_for_code = gas_params.code_deposit_cost(interpreter_result.output.len()); + + // What `MegaETH`'s own code-deposit accounting predicted this classification would do, weighed + // a moment ago against this same result. The two are the same decision read twice, so a bump + // that changes one and not the other turns into a failing assertion rather than into compute + // gas recorded for a deposit that never happened, or a deposit charged with nothing recorded. + #[cfg(debug_assertions)] + let predicted_charge = will_return_create_charge_code_deposit( + interpreter_result, + max_code_size, + spec_id, + is_eip3541_disabled, + gas_for_code, + ); + + let verdict = 'classify: { + if !interpreter_result.result.is_ok() { + break 'classify FrameJournalVerdict::CreateRejected; + } + + // EIP-170 / EIP-7954: runtime code size limit, checked before any deposit charge. + if spec_id.is_enabled_in(SpecId::SPURIOUS_DRAGON) && + interpreter_result.output.len() > max_code_size + { + interpreter_result.result = InstructionResult::CreateContractSizeLimit; + break 'classify FrameJournalVerdict::CreateRejected; + } + + // EIP-3541: reject new contract code starting with the 0xEF byte. + if !is_eip3541_disabled && + spec_id.is_enabled_in(SpecId::LONDON) && + interpreter_result.output.first() == Some(&0xEF) + { + interpreter_result.result = InstructionResult::CreateContractStartingWithEF; + break 'classify FrameJournalVerdict::CreateRejected; + } + + if !interpreter_result.gas.record_regular_cost(gas_for_code) { + // EIP-2 point 3: a creation that cannot pay for its own code deposit fails out of gas + // rather than leaving an empty contract behind. Before Homestead it left one. + if spec_id.is_enabled_in(SpecId::HOMESTEAD) { + interpreter_result.result = InstructionResult::OutOfGas; + break 'classify FrameJournalVerdict::CreateRejected; + } + interpreter_result.output = Bytes::new(); + } + + // EIP-8037 splits the deposit into a hash charge and a state-gas charge. Every `MegaEVM` + // configuration pins the EIP off, so this is mirrored for lockstep rather than for reach. + if is_amsterdam_eip8037 { + let hash_cost = gas_params.keccak256_cost(interpreter_result.output.len()); + if !interpreter_result.gas.record_regular_cost(hash_cost) { + interpreter_result.result = InstructionResult::OutOfGas; + break 'classify FrameJournalVerdict::CreateRejected; + } + let state_gas_for_code = + gas_params.code_deposit_state_gas(interpreter_result.output.len()); + if state_gas_for_code > 0 && + !interpreter_result.gas.record_state_cost(state_gas_for_code) + { + interpreter_result.result = InstructionResult::OutOfGas; + break 'classify FrameJournalVerdict::CreateRejected; + } + } + + interpreter_result.result = InstructionResult::Return; + FrameJournalVerdict::CreateAccepted { address, code: interpreter_result.output.clone() } + }; + + // EIP-8037 adds two more charges past the predicate's last one, so the two only have to + // agree while it is off — which every `MegaEVM` configuration pins it to be. + #[cfg(debug_assertions)] + debug_assert!( + is_amsterdam_eip8037 || + predicted_charge == matches!(verdict, FrameJournalVerdict::CreateAccepted { .. }), + "the code-deposit predicate and the create classification disagreed" + ); + + verdict +} + +/// Whether [`classify_create_return`] will charge `code_deposit_gas` and accept the deposit. +/// +/// The charge is conditional — the classification only takes it from a creation that clears every +/// deposit predicate — and `MegaETH` has to record the matching compute gas *before* the charge +/// happens, at the frame's exit settlement, while it can still rewrite the result to stop the +/// charge. So the decision is read twice: once here, ahead of time, and once by the classification +/// itself. [`classify_create_return`] asserts in debug builds that the two agreed. +pub(crate) fn will_return_create_charge_code_deposit( + interpreter_result: &InterpreterResult, + max_code_size: usize, + runtime_spec_id: SpecId, + is_eip3541_disabled: bool, + code_deposit_gas: u64, +) -> bool { + if !interpreter_result.result.is_ok() { + return false; + } + if !is_eip3541_disabled && + runtime_spec_id.is_enabled_in(SpecId::LONDON) && + interpreter_result.output.first() == Some(&0xEF) + { + return false; + } + if runtime_spec_id.is_enabled_in(SpecId::SPURIOUS_DRAGON) && + interpreter_result.output.len() > max_code_size + { + return false; + } + interpreter_result.gas.remaining() >= code_deposit_gas +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{test_utils::MemoryDatabase, EmptyExternalEnv, MegaContext, MegaSpecId}; + use alloy_primitives::{address, Address as Addr, U256}; + use revm::{ + context::JournalTr, + context_interface::cfg::{GasId, GasParams}, + handler::{CallFrame, FrameData}, + interpreter::{ + CallInput, CallInputs, CallScheme, CallValue, Gas, InstructionResult, + InterpreterAction, InterpreterResult, + }, + primitives::hardfork::SpecId as RevmSpecId, + }; + use std::{boxed::Box, vec, vec::Vec}; + + const DEPLOYED: Addr = address!("00000000000000000000000000000000000c0de0"); + /// Ample: the deposit charge for the runtime codes below is a few hundred gas. + const FRAME_GAS: u64 = 1_000_000; + + fn context() -> MegaContext { + MegaContext::new(MemoryDatabase::default(), MegaSpecId::REX7) + } + + fn returned(output: Vec, gas: u64) -> InterpreterResult { + InterpreterResult::new(InstructionResult::Return, Bytes::from(output), Gas::new(gas)) + } + + /// Runs the classification and reports what it decided: the rewritten instruction result, and + /// whether the verdict allows a deposit. + fn classify(result: &mut InterpreterResult) -> FrameJournalVerdict { + classify_create_return(&context(), result, DEPLOYED) + } + + fn accepts(verdict: &FrameJournalVerdict) -> bool { + matches!(verdict, FrameJournalVerdict::CreateAccepted { .. }) + } + + /// A creation that clears every deposit predicate is accepted, is charged for its code, and + /// carries the exact bytes the predicates approved. + #[test] + fn test_a_clean_creation_is_accepted_and_charged_for_its_code() { + let mut result = returned(vec![0x00; 32], FRAME_GAS); + let verdict = classify(&mut result); + + let FrameJournalVerdict::CreateAccepted { address, code } = verdict else { + panic!("a clean creation must be accepted, got {verdict:?}") + }; + assert_eq!(address, DEPLOYED); + assert_eq!(code, Bytes::from(vec![0x00; 32]), "the approved bytes travel with the verdict"); + assert_eq!(result.result, InstructionResult::Return); + assert_eq!( + FRAME_GAS - result.gas.remaining(), + 32 * revm::interpreter::gas::CODEDEPOSIT, + "the deposit is charged during classification, not at the journal decision", + ); + } + + /// Each rejecting predicate rejects, names itself on the result, and — this is the part that + /// matters for the deposit — leaves no code on the verdict for anything to write. + #[test] + fn test_every_rejecting_predicate_rejects_without_code() { + // (name, runtime code, gas the frame has left, the classification it must produce) + let cases: Vec<(&str, Vec, u64, InstructionResult)> = vec![ + ( + "0xEF prefix", + vec![0xEF, 0x00], + FRAME_GAS, + InstructionResult::CreateContractStartingWithEF, + ), + ( + "cannot pay the deposit", + vec![0x00; 32], + 32 * revm::interpreter::gas::CODEDEPOSIT - 1, + InstructionResult::OutOfGas, + ), + ]; + + for (name, code, gas, expected) in cases { + let mut result = returned(code, gas); + let verdict = classify(&mut result); + + assert!(!accepts(&verdict), "{name}: must not be accepted, got {verdict:?}"); + assert_eq!(result.result, expected, "{name}: classification"); + } + } + + /// A creation whose frame never succeeded is rejected untouched: the classification does not + /// charge it, does not rename its failure, and hands the journal a verdict with no code. + #[test] + fn test_a_failed_frame_is_rejected_without_being_charged() { + let mut result = InterpreterResult::new( + InstructionResult::Revert, + Bytes::from_static(b"reason"), + Gas::new(FRAME_GAS), + ); + let verdict = classify(&mut result); + + assert!(!accepts(&verdict)); + assert_eq!(result.result, InstructionResult::Revert, "the failure keeps its own name"); + assert_eq!(result.gas.remaining(), FRAME_GAS, "and pays nothing for a deposit"); + } + + /// The journal decision follows the *final* result. A creation the predicates accepted, whose + /// result is then rewritten into a failure, must not leave its code behind. + #[test] + fn test_a_creation_rewritten_into_a_failure_deposits_nothing() { + let mut ctx = context(); + let checkpoint = ctx.journal_mut().checkpoint(); + let mut result = returned(vec![0x60; 32], FRAME_GAS); + let verdict = classify_create_return(&ctx, &mut result, DEPLOYED); + assert!(accepts(&verdict), "the fixture must be a creation the predicates accepted"); + + // What a `create_end` rewrite does, after the classification and before the journal. + result.result = InstructionResult::Revert; + let frame_result = FrameResult::Create(CreateOutcome::new(result, Some(DEPLOYED))); + commit_frame_journal(&mut ctx, PendingJournal { verdict, checkpoint }, &frame_result); + + let account = ctx.journal_mut().load_account(DEPLOYED).unwrap(); + assert!(account.info.is_empty_code_hash(), "no code may be deposited for a failed frame"); + } + + /// And the other direction: a creation the predicates *rejected*, whose result is then + /// rewritten into a success, has no code and no commit branch to reach. The rewrite cannot + /// deposit code that never passed the predicates, whatever the result says. + #[test] + fn test_a_rejected_creation_rewritten_into_a_success_still_deposits_nothing() { + let mut ctx = context(); + let checkpoint = ctx.journal_mut().checkpoint(); + // Runtime code the frame cannot pay to deposit. + let mut result = returned(vec![0x60; 32], 32 * revm::interpreter::gas::CODEDEPOSIT - 1); + let verdict = classify_create_return(&ctx, &mut result, DEPLOYED); + assert!(!accepts(&verdict), "the fixture must be a creation the predicates rejected"); + + result.result = InstructionResult::Return; + let frame_result = FrameResult::Create(CreateOutcome::new(result, Some(DEPLOYED))); + commit_frame_journal(&mut ctx, PendingJournal { verdict, checkpoint }, &frame_result); + + let account = ctx.journal_mut().load_account(DEPLOYED).unwrap(); + assert!( + account.info.is_empty_code_hash(), + "a rejected creation carries no code, so a rewrite has nothing to deposit", + ); + } + + /// A call frame's journal decision reads the final result and nothing else. + #[test] + fn test_a_call_frame_commits_or_reverts_on_its_final_result() { + for (label, instruction_result, expect_committed) in [ + ("success", InstructionResult::Stop, true), + ("revert", InstructionResult::Revert, false), + ("halt", InstructionResult::OutOfGas, false), + ] { + let mut ctx = context(); + ctx.journal_mut().load_account(DEPLOYED).expect("the account must load"); + let checkpoint = ctx.journal_mut().checkpoint(); + ctx.journal_mut() + .sstore(DEPLOYED, U256::from(1), U256::from(7)) + .expect("sstore must reach the in-memory database"); + + let frame_result = FrameResult::Call(CallOutcome::new( + InterpreterResult::new(instruction_result, Bytes::new(), Gas::new(FRAME_GAS)), + 0..0, + )); + commit_frame_journal( + &mut ctx, + PendingJournal { verdict: FrameJournalVerdict::Call, checkpoint }, + &frame_result, + ); + + let stored = ctx.journal_mut().sload(DEPLOYED, U256::from(1)).unwrap().data; + assert_eq!( + stored == U256::from(7), + expect_committed, + "{label}: the frame's write must follow its final result", + ); + } + } + + fn call_inputs(charged_new_account_state_gas: bool) -> CallInputs { + CallInputs { + input: CallInput::Bytes(Bytes::new()), + return_memory_offset: 0..0, + gas_limit: FRAME_GAS, + bytecode_address: DEPLOYED, + target_address: DEPLOYED, + caller: Addr::ZERO, + value: CallValue::Transfer(U256::ZERO), + scheme: CallScheme::Call, + is_static: false, + reservoir: 0, + known_bytecode: Default::default(), + charged_new_account_state_gas, + } + } + + /// A call frame's outcome carries the EIP-8037 new-account flag its *inputs* were built with, + /// so the caller can refund the upfront charge when the call ends in revert or halt. The flag + /// lives only on the inputs, and the outcome is the only thing that reaches the caller — read + /// it off anything else and a reverted call keeps a charge it never owed. + #[test] + fn test_a_call_frames_outcome_carries_its_inputs_new_account_state_gas_flag() { + for charged in [true, false] { + let ctx = context(); + let mut frame = EthFrame::invalid(); + frame.data = FrameData::Call(CallFrame { return_memory_range: 0..0 }); + frame.input = FrameInput::Call(Box::new(call_inputs(charged))); + + let action = InterpreterAction::Return(InterpreterResult::new( + InstructionResult::Stop, + Bytes::new(), + Gas::new(FRAME_GAS), + )); + let ItemOrResult::Result(pending) = classify_frame_action(&ctx, &mut frame, action) + else { + panic!("a returning frame classifies into a result, not into a child frame"); + }; + let (FrameResult::Call(outcome), _) = pending.split() else { + panic!("a call frame classifies into a call outcome"); + }; + + assert_eq!( + outcome.charged_new_account_state_gas, charged, + "the flag must travel from the frame's inputs onto the outcome", + ); + } + } + + /// The configuration the EIP-8037 branch of the classification is written for: the state-gas + /// split enabled, and a schedule that prices the code deposit it splits. + /// + /// No `MegaEVM` transaction can run under it. `force_amsterdam_eip8037_off` pins the flag off + /// wherever a configuration comes from, and the gas-schedule pin rejects a rewritten table, so + /// the branch is carried for lockstep with upstream rather than for reach. Calling the + /// classification directly is what lets the lockstep be checked. + fn eip8037_context() -> (MegaContext, u64) { + let per_byte = + GasParams::new_spec(RevmSpecId::AMSTERDAM).get(GasId::code_deposit_state_gas()); + assert_ne!( + per_byte, 0, + "the probe needs a schedule that prices the code deposit's state gas" + ); + + let mut ctx = context(); + ctx.inner.cfg.enable_amsterdam_eip8037 = true; + ctx.inner.cfg.gas_params.override_gas([(GasId::code_deposit_state_gas(), per_byte)]); + (ctx, per_byte) + } + + /// With EIP-8037 on, a creation pays two charges past the code deposit — a hash charge and a + /// state-gas charge — and is accepted only when it can afford both. + #[test] + fn test_the_eip8037_split_charges_the_hash_and_the_state_gas_on_top_of_the_deposit() { + let (ctx, state_gas_per_byte) = eip8037_context(); + let code = vec![0x00; 32]; + let deposit = 32 * revm::interpreter::gas::CODEDEPOSIT; + let hash = ctx.cfg().gas_params().keccak256_cost(code.len()); + let state_gas = state_gas_per_byte * 32; + assert_ne!(hash, 0, "the probe needs a priced hash charge to tell the two apart"); + + let mut result = returned(code, FRAME_GAS); + let verdict = classify_create_return(&ctx, &mut result, DEPLOYED); + + assert!(accepts(&verdict), "a creation that can afford all three charges is accepted"); + assert_eq!(result.result, InstructionResult::Return); + assert_eq!( + FRAME_GAS - result.gas.remaining(), + deposit + hash + state_gas, + "all three charges are taken, and the state gas spills out of an empty reservoir", + ); + assert_eq!( + result.gas.state_gas_spent(), + i64::try_from(state_gas).expect("the probe's state gas fits an i64"), + "the state-gas charge is recorded on the state dimension, not only on the counter", + ); + } + + /// Each of the two EIP-8037 charges fails the creation on its own when the frame is one gas + /// short of it, and names the failure out of gas. + #[test] + fn test_either_eip8037_charge_can_fail_the_creation_on_its_own() { + let (ctx, state_gas_per_byte) = eip8037_context(); + let code = vec![0x00; 32]; + let deposit = 32 * revm::interpreter::gas::CODEDEPOSIT; + let hash = ctx.cfg().gas_params().keccak256_cost(code.len()); + let state_gas = state_gas_per_byte * 32; + + for (name, gas) in [ + ("the hash charge", deposit + hash - 1), + ("the state-gas charge", deposit + hash + state_gas - 1), + ] { + let mut result = returned(code.clone(), gas); + let verdict = classify_create_return(&ctx, &mut result, DEPLOYED); + + assert!(!accepts(&verdict), "{name}: one gas short must reject the creation"); + assert_eq!(result.result, InstructionResult::OutOfGas, "{name}: classification"); + } + } +} diff --git a/crates/mega-evm/src/evm/inspector.rs b/crates/mega-evm/src/evm/inspector.rs new file mode 100644 index 00000000..3461fc13 --- /dev/null +++ b/crates/mega-evm/src/evm/inspector.rs @@ -0,0 +1,1971 @@ +//! The measurement shim every inspector handed to `MegaETH` is wrapped in. +//! +//! An inspector is not a passive observer. Every callback that receives a live interpreter can +//! write to its gas counter and to the action it is holding, and every callback that receives a +//! frame's inputs can change the gas limit the frame is about to be built with. `MegaETH` meters +//! compute gas by watching those exact counters and derives what a transaction destroyed from the +//! envelope it spent, so an unmeasured edit reads as the EVM having done less work than it did. +//! +//! The EVM does not execute inside a callback, so anything that changes between the moment the +//! shim delegates and the moment control comes back is the inspector's by construction. The shim +//! snapshots on the way in, compares on the way out, and books the difference — which is why it +//! sits at the `Inspector` implementation layer: wrapping the object reaches every boundary, and +//! mirroring `inspect_instructions` would take on a core dispatch loop for no additional reach. +//! +//! Where each measurement goes is [`InspectorLedger`](crate::InspectorLedger)'s own documentation. +//! Two things here are not differences across a boundary. A callback that answers a frame itself +//! stages the envelope it was handed, because no frame is built and there is no other side to +//! compare against. And the EIP-8037 state-gas dimension is settled once by the transaction, +//! because revm propagates it by replacement and a boundary difference would book edits the EVM +//! goes on to erase. +//! +//! An inspector type whose author has declared it read-only, by implementing [`TrustedObserver`] +//! in source, is delegated to without any of this. Debug builds measure it anyway and assert the +//! ledger stayed empty, so a wrong declaration fails where it is exercised rather than where it is +//! deployed. +//! +//! Nothing here changes what an inspector may do to the EVM, and nothing here runs on the +//! uninspected path — revm's plain interpreter loop never calls an inspector at all. +#[cfg(not(feature = "std"))] +use alloc as std; +use std::{string::String, vec::Vec}; + +use alloy_evm::Database; +use alloy_primitives::{Address, Bytes, Log, U256}; +use core::ops::Range; +use revm::{ + context::{ContextError, ContextTr}, + handler::FrameResult, + interpreter::{ + interpreter_types::{ + InputsTr, Jumps, LegacyBytecode, LoopControl, MemoryTr, ReturnData, RuntimeFlag, + StackTr, + }, + CallInput, CallInputs, CallOutcome, CreateInputs, CreateOutcome, FrameInput, + InstructionResult, Interpreter, InterpreterAction, InterpreterResult, InterpreterTypes, + }, + primitives::hardfork::SpecId, + Inspector, +}; + +use crate::{AdditionalLimit, ExternalEnvTypes, MegaContext, MegaSpecId}; + +/// The message a refused `create_end` rewrite surfaces as `EVMError::Custom`. +/// +/// Public because a refusal is a designed outcome and not an execution failure: a harness that +/// drives rewriting inspectors over a corpus has to tell the two apart, and the error's message is +/// what carries the difference. +pub const FORBIDDEN_CREATE_REVIVAL: &str = + "inspector rewrote a failed contract creation into a successful one"; + +/// The message a refused rewrite of a frame-init result surfaces as `EVMError::Custom`. +/// +/// Public for the same reason as [`FORBIDDEN_CREATE_REVIVAL`]. +pub const FORBIDDEN_FRAME_INIT_REWRITE: &str = + "inspector moved the classification of a result frame init produced"; + +/// A promise, made in source about one inspector type, that none of its callbacks writes anything +/// back to the EVM. +/// +/// Every callback of a declared type leaves the EVM exactly as it found it: it writes nothing to +/// an interpreter's gas counter or its pending action, nothing to a frame's inputs, nothing to a +/// frame result's classification, gas, output or metadata, nothing to a refund, and it never +/// answers a frame with a synthetic outcome. It may read whatever it likes and write to its own +/// state. +/// +/// What the declaration buys is the cost of the measurement, never its verdict: a type that keeps +/// the promise measures to zero on every lane anyway. It is a declaration rather than a detection +/// because the shim measures at a boundary precisely because it cannot see inside a callback, so +/// "does this write anything back" is not a question it can ask ahead of time. +/// +/// # The rules this trait is under +/// +/// - **No blanket implementation, ever.** Each implementation names one concrete type, so a +/// declaration is a line someone wrote about a type they had read. +/// - **Not reachable from data.** The only route to the fast path is +/// [`MeasuredInspector::new_trusted`], whose bound is this trait — an RPC-supplied tracer is a +/// value, and no value can carry an implementation. +/// - **Do not implement it for anything that intercepts.** An inspector that answers a frame +/// itself, edits inputs, or rewrites a result is a rewriting inspector however little it +/// rewrites; those are supported, measured, and must stay measured. +/// - **A foreign inspector needs a wrapper.** The orphan rule wants one of the trait and the type +/// to be local, and for a `revm-inspectors` tracer neither is, so a node wraps it in +/// [`DeclaredObserver`], which is local here and carries the declaration. +/// +/// Debug builds measure a declared type anyway and assert the ledger stayed empty after every +/// callback, so a wrong declaration fails at the callback that broke it. +pub trait TrustedObserver {} + +/// The inspector `MegaETH` runs with when none was supplied observes nothing at all. +impl TrustedObserver for revm::inspector::NoOpInspector {} + +/// A declared observer stays declared when it is handed over by reference. +/// +/// revm implements `Inspector` for `&mut I`, which is how a caller keeps an inspector it can read +/// back afterwards. This lifts the declaration to the same shape, and it grants nothing: `&mut T` +/// is declared exactly when `T` is, so no type becomes trusted that was not trusted already. +impl TrustedObserver for &mut T {} + +/// Carries a [`TrustedObserver`] declaration for an inspector whose type cannot carry one. +/// +/// The orphan rule wants one of the trait and the type to be local, and for a `revm-inspectors` +/// tracer used from a node neither is. This is the local half, supplied once here so that every +/// embedder does not write it again: it forwards every callback of the `Inspector` trait to the +/// inspector inside it and adds nothing of its own. +/// +/// The declaration is still an assertion someone makes in source about one concrete inspector — +/// `DeclaredObserver` only moves where it is written, from a newtype's definition to the line that +/// wraps the value. `DeclaredObserver(tracer)` says "I have read this tracer and it writes nothing +/// back to the EVM", exactly as a hand-written forwarding newtype did, and it is subject to the +/// same rules: wrapping something that intercepts or rewrites is a false declaration, and a debug +/// build will fail at the callback that breaks it. It is a way of writing the promise, not a way +/// around it. +/// +/// ```ignore +/// let executor = factory.create_executor_with_trusted_inspector( +/// db, +/// block_ctx, +/// evm_env, +/// DeclaredObserver(TracingInspector::new(TracingInspectorConfig::all())), +/// ); +/// ``` +/// +/// Forwarding by hand is what this replaces, and the reason is that a hand-written forwarder fails +/// quietly: every `Inspector` method has a default body, so a callback revm adds and a forwarder +/// misses is not a compile error but a callback the wrapped inspector stops receiving. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct DeclaredObserver(pub I); + +impl DeclaredObserver { + /// Declares `inner` read-only and wraps it. + pub const fn new(inner: I) -> Self { + Self(inner) + } + + /// The declared inspector. + pub const fn inner(&self) -> &I { + &self.0 + } + + /// The declared inspector, mutably. + pub const fn inner_mut(&mut self) -> &mut I { + &mut self.0 + } + + /// Unwraps the declaration, returning the inspector it was made about. + pub fn into_inner(self) -> I { + self.0 + } +} + +/// The whole of what the wrapper is for. +impl TrustedObserver for DeclaredObserver {} + +/// Every callback of revm 40's `Inspector`, forwarded unchanged. +/// +/// Written out in full rather than left to the trait's default bodies: a default body does not +/// forward, it does nothing, so an unlisted callback would be one the wrapped inspector silently +/// stops receiving. `tests/block_executor/declared_observer.rs` compares the callback sequence a +/// recording inspector sees wrapped against the one it sees bare, which is what turns an upstream +/// callback added here and not forwarded into a failing test. +impl Inspector for DeclaredObserver +where + INTR: InterpreterTypes, + I: Inspector, +{ + fn initialize_interp(&mut self, interp: &mut Interpreter, context: &mut CTX) { + self.0.initialize_interp(interp, context); + } + + fn step(&mut self, interp: &mut Interpreter, context: &mut CTX) { + self.0.step(interp, context); + } + + fn step_end(&mut self, interp: &mut Interpreter, context: &mut CTX) { + self.0.step_end(interp, context); + } + + fn log(&mut self, context: &mut CTX, log: Log) { + self.0.log(context, log); + } + + fn log_full(&mut self, interp: &mut Interpreter, context: &mut CTX, log: Log) { + self.0.log_full(interp, context, log); + } + + fn frame_start(&mut self, context: &mut CTX, frame_input: &mut FI) -> Option { + self.0.frame_start(context, frame_input) + } + + fn frame_end(&mut self, context: &mut CTX, frame_input: &FI, frame_result: &mut FR) { + self.0.frame_end(context, frame_input, frame_result); + } + + fn call(&mut self, context: &mut CTX, inputs: &mut CallInputs) -> Option { + self.0.call(context, inputs) + } + + fn call_end(&mut self, context: &mut CTX, inputs: &CallInputs, outcome: &mut CallOutcome) { + self.0.call_end(context, inputs, outcome); + } + + fn create(&mut self, context: &mut CTX, inputs: &mut CreateInputs) -> Option { + self.0.create(context, inputs) + } + + fn create_end( + &mut self, + context: &mut CTX, + inputs: &CreateInputs, + outcome: &mut CreateOutcome, + ) { + self.0.create_end(context, inputs, outcome); + } + + fn selfdestruct(&mut self, contract: Address, target: Address, value: U256) { + self.0.selfdestruct(contract, target, value); + } +} + +/// Wraps a user inspector so that what it does to gas accounting is measured and booked. +/// +/// `MegaETH` applies this itself, so the wrapper is not something a caller opts into or can opt +/// out of. What a caller *can* opt into is being measured more cheaply, by declaring the +/// inspector's type [`TrustedObserver`] and building the shim with +/// [`new_trusted`](Self::new_trusted). +/// +/// Derefs to the wrapped inspector, so `evm.inspector().whatever()` reaches the user's own type. +#[derive(Clone, Copy, Debug, Default, derive_more::Deref, derive_more::DerefMut)] +pub struct MeasuredInspector { + #[deref] + #[deref_mut] + inner: I, + /// Whether the wrapped type's author declared it [`TrustedObserver`]. + /// + /// A flag rather than a type parameter: answering it in the type system would need either a + /// bound on every inspector `MegaETH` can be handed, including the foreign ones it cannot + /// implement anything for, or a second overlapping impl of `Inspector`. So it is answered at + /// the one constructor whose bound is the declaration and carried here. `Default` leaves it + /// false, which is the safe direction: an unbuilt shim measures. + trusted: bool, +} + +impl MeasuredInspector { + /// Wraps `inner` in the measurement shim. + pub const fn new(inner: I) -> Self { + Self { inner, trusted: false } + } + + /// Whether this callback takes the measuring path. + /// + /// False only for a declared [`TrustedObserver`] in a release build. Under `debug_assertions` + /// every inspector is measured and a declared one is additionally asserted to have booked + /// nothing, which is what makes the declaration a checked claim rather than a comment. + /// + /// This and [`verify_trusted`] read the same flag, so a profile that turns assertions on in an + /// optimised build gets the measured path *and* the check, never one without the other. + #[inline(always)] + const fn measures(&self) -> bool { + !self.trusted || cfg!(debug_assertions) + } + + /// The wrapped inspector. + pub const fn inner(&self) -> &I { + &self.inner + } + + /// The wrapped inspector, mutably. + pub const fn inner_mut(&mut self) -> &mut I { + &mut self.inner + } + + /// Unwraps the shim, returning the inspector it was measuring. + pub fn into_inner(self) -> I { + self.inner + } + + /// Whether the wrapped inspector's type was declared [`TrustedObserver`]. + /// + /// Read by the transaction-level backstop, which catches a declaration broken at a callback + /// whose own verification is missing. + pub const fn is_trusted(&self) -> bool { + self.trusted + } +} + +impl MeasuredInspector { + /// Wraps `inner` in a shim that delegates to it without measuring, on the strength of its + /// type's [`TrustedObserver`] declaration. + /// + /// This is the only constructor that produces the fast path, and its bound is the only way to + /// reach it. Debug builds measure anyway and assert the result is empty. + pub const fn new_trusted(inner: I) -> Self { + Self { inner, trusted: true } + } +} + +/// Asserts, in debug builds, that a declared [`TrustedObserver`] really booked nothing. +/// +/// Called after every measured callback, so the first one to break the promise is the one that +/// fails and names the site. Compiled out of release builds along with the measurement it checks. +#[inline] +fn verify_trusted( + trusted: bool, + context: &MegaContext, + callback: &'static str, +) { + #[cfg(debug_assertions)] + if trusted { + let ledger = context.additional_limit.borrow().inspector_ledger(); + assert!( + ledger.is_zero(), + "an inspector declared `TrustedObserver` wrote something back at `{callback}`: \ + {ledger:?}", + ); + } + #[cfg(not(debug_assertions))] + { + let _ = (trusted, context, callback); + } +} + +/// Where the gas an interpreter is holding will go next, read off the action it is holding. +/// +/// This is the one thing that decides how gas measured at a live-interpreter callback is booked, +/// for both of the objects such a callback can write gas into — the interpreter's own counter and +/// the pending action. The three variants are the three places a frame's budget can be sitting +/// when a callback runs. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ActionLane { + /// No action pending: the frame carries straight on, so what it holds is its counter. + Counter, + /// A `NewFrame` action: the frame suspends, and the action carries the envelope a child is + /// about to be built with. The frame itself resumes on its counter afterwards. + Envelope, + /// A `Return` action: the frame is over, and the action carries what its caller reclaims. + Result, +} + +impl ActionLane { + /// The lane an interpreter holding `action` is on. + #[inline] + const fn of(action: Option<&InterpreterAction>) -> Self { + match action { + None => Self::Counter, + Some(InterpreterAction::NewFrame(_)) => Self::Envelope, + Some(InterpreterAction::Return(_)) => Self::Result, + } + } + + /// Whether an edit to this interpreter's gas counter can still reach the transaction's + /// envelope. + /// + /// It cannot exactly on [`Result`](Self::Result): the terminating instruction has already + /// copied the counter into the action that becomes the frame's result, so a callback writing + /// to the counter afterwards writes into an object nobody reads again. The other two shapes + /// are live — a frame with no action carries straight on, and a suspending one resumes on this + /// very counter — so "the loop is about to break" is not the question, and a rule phrased that + /// way would stop booking edits that really do move a budget. + /// + /// Read *after* the callback returns, because the question is about the counter it left + /// behind. Only the ledger is gated on this: the checkpoint baseline shifts for a dead-window + /// edit exactly as it does for a live one, or gas the inspector wrote in would read as work + /// the frame performed. + #[inline] + const fn counter_reaches_envelope(self) -> bool { + !matches!(self, Self::Result) + } +} + +/// The gas a frame and its pending continuation hold, given the action it is carrying and its own +/// counter. +/// +/// ```text +/// held(None, counter) = counter // will spend its counter +/// held(NewFrame(f), counter) = counter + f.gas_limit // and has handed the child's on +/// held(Return(r), counter) = r.gas.remaining() // the caller reclaims the action's copy +/// ``` +/// +/// Both readings [`LiveReading`] takes use the counter *the EVM left behind*, so the counter +/// cancels out of the difference wherever it appears on both sides. What is left is the part of +/// the movement that is not already on the counter lane, whatever the callback did to the action's +/// shape. +#[inline] +fn held(action: Option<&InterpreterAction>, counter: u64) -> i128 { + match action { + None => i128::from(counter), + Some(InterpreterAction::NewFrame(frame_input)) => { + i128::from(counter) + frame_input_gas_limit(frame_input).map_or(0, i128::from) + } + Some(InterpreterAction::Return(result)) => i128::from(result.gas.remaining()), + } +} + +/// [`held`]'s counterpart on the refund dimension. +/// +/// The middle case differs from [`held`]'s: a `NewFrame` action carries a child's envelope but no +/// refund of its own, so the counter is the live object in two of the three cases and only a +/// terminating action displaces it. +#[inline] +fn held_refund(action: Option<&InterpreterAction>, counter: i64) -> i64 { + match action { + None | Some(InterpreterAction::NewFrame(_)) => counter, + Some(InterpreterAction::Return(result)) => result.gas.refunded(), + } +} + +/// Books what a callback did to a refund counter. +/// +/// Nominal: the figure booked is what the inspector wrote, not what survives the EIP-3529 cap or +/// the chain of frame returns between here and the receipt. Neither of those is a quantity a +/// boundary can measure, and over-stating is the safe direction for the lane's one consumer. +#[inline] +fn book_refund(limit: &mut AdditionalLimit, before: i64, after: i64) { + if before != after { + limit.record_inspector_refund_adjustment(i128::from(after) - i128::from(before)); + } +} + +/// What a live-interpreter callback did to the interpreter's pending action. +#[derive(Clone, Copy, Debug)] +struct ActionChange { + /// Gas the callback moved through the action, over and above anything it did to the counter. + gas: i128, + /// Whether the action came back describing something other than what the EVM decided to do. + rewritten: bool, + /// Where that gas is now sitting, which is what decides how it is booked. + lane: ActionLane, +} + +/// The pending action a callback was handed, in the form the boundary compares it in. +/// +/// Holds only what the rewrite comparison reads — the gas is taken as [`held`] when the reading is +/// made and never needs the action again. That matters because this reading is taken twice per +/// opcode: copying the action itself would carry an output buffer and a frame input's boxed inputs +/// across every one, while the shape a running frame is almost always in costs a discriminant. +/// +/// The output buffer is held rather than reduced to its identity because [`same_buffer`] compares +/// by address, and only an owner keeps that address from being reused underneath it. +#[derive(Clone, Debug)] +enum ActionSnapshot { + /// No action pending: the frame carries straight on. + Empty, + /// A `Return` action — the classification and output the frame's caller will be handed. + Return(InstructionResult, Bytes), + /// A `NewFrame` action — the inputs a child is about to be built from. The one comparison + /// here that needs the object rather than a reading off it. + NewFrame(FrameInput), +} + +impl ActionSnapshot { + /// Takes the way-in reading off the action an interpreter is holding. + #[inline(always)] + fn of(action: Option<&InterpreterAction>) -> Self { + match action { + None => Self::Empty, + Some(InterpreterAction::Return(result)) => { + Self::Return(result.result, result.output.clone()) + } + Some(InterpreterAction::NewFrame(frame_input)) => Self::NewFrame(frame_input.clone()), + } + } + + /// Whether a callback left behind an action describing something other than what the EVM + /// decided. + /// + /// Gas is excluded, as it is at every other boundary: it travels on the lanes + /// [`book_pending_action`] routes it to, and counting it here would report one rewrite twice. + /// Every shape change counts — installing, removing or swapping an action rewrites what the + /// EVM does next as thoroughly as it is possible to. + #[inline(always)] + fn rewritten(self, after: Option<&InterpreterAction>) -> bool { + match (self, after) { + (Self::Empty, None) => false, + (Self::Return(result, output), Some(InterpreterAction::Return(after))) => { + result_rewritten((result, &output), after) + } + (Self::NewFrame(before), Some(InterpreterAction::NewFrame(after))) => { + frame_input_rewritten(before, after) + } + _ => true, + } + } +} + +/// Books what a callback did to the interpreter's pending action. +/// +/// The gas goes to the lane the action the callback *left behind* names, because that is where the +/// number now lives and so what decides when it can still be settled: +/// +/// - [`ActionLane::Result`] is staged for the frame's settlement point, like an edit at the frame's +/// last callback — whether it moves anything depends on the classification the caller ends up +/// seeing, which no callback here knows; +/// - [`ActionLane::Envelope`] is staged for the frame-start callback of the child it will build; +/// - [`ActionLane::Counter`] is booked on the spot: with no action left the frame carries on +/// spending what it holds, which is what a counter edit does. This is the algebra's third case +/// rather than a shape the API offers — `reset_action` leaves the action in place, so emptying +/// the slot means writing `None` and desynchronising the two. +#[inline] +fn book_pending_action(limit: &mut AdditionalLimit, change: ActionChange) { + if change.gas != 0 { + match change.lane { + ActionLane::Result => limit.stage_inspector_action_result_adjustment(change.gas), + ActionLane::Envelope => limit.stage_inspector_action_env_adjustment(change.gas), + ActionLane::Counter => limit.record_inspector_action_counter_adjustment(change.gas), + } + } + book_intervention(limit, change.rewritten); +} + +/// The gas limit a frame input carries, for the two variants that have one. +#[inline] +fn frame_input_gas_limit(frame_input: &FrameInput) -> Option { + match frame_input { + FrameInput::Call(inputs) => Some(inputs.gas_limit), + FrameInput::Create(inputs) => Some(inputs.gas_limit()), + FrameInput::Empty => None, + } +} + +/// Books what a callback did to a frame's envelope, together with whatever an earlier callback +/// staged into the same envelope through the pending `NewFrame` action — and, when the callback +/// answered the frame itself, stages that envelope for the frame's settlement point. +/// +/// `intercepted` is true when the callback returned a synthetic outcome: the frame is skipped and +/// the EVM never reads the inputs it edited, so that edit moves nothing on this lane. The staged +/// amount is booked either way, and the asymmetry is not an oversight — it was written by a +/// *different* callback into the action the caller's `CALL` / `CREATE` opcode had already +/// produced, so the caller's debit is behind it and a later decision to answer the frame cannot +/// un-make that. +/// +/// An interception stages a baseline rather than booking a difference because it has no object on +/// the other side: the frame is never built, and what the caller reclaims from is a result the +/// inspector wrote from nothing. What the transaction funded is the envelope on the way in, and +/// only the frame init that asked can take that difference. +#[inline] +fn book_env_adjustment( + limit: &mut AdditionalLimit, + before: Option, + after: Option, + intercepted: bool, +) { + let staged = limit.take_inspector_action_env_adjustment(); + let callback = match (intercepted, before, after) { + (false, Some(before), Some(after)) => i128::from(after) - i128::from(before), + _ => 0, + }; + if let (true, Some(before)) = (intercepted, before) { + limit.stage_inspector_interception_envelope(before); + } + // Booked separately rather than summed first: the two were written in different callbacks, so + // an envelope raised in one and lowered back in the other is two edits, not none. The staged + // half already counted its own traffic where it was measured, so only its movement lands here. + if staged != 0 { + limit.record_staged_inspector_env_movement(staged); + } + if callback != 0 { + limit.record_inspector_env_adjustment(callback); + } +} + +/// Books one rewrite that changes what the execution *did* rather than what it cost. +/// +/// Every caller answers the same question about the argument it was handed: did it come back +/// describing something other than what the EVM was about to do? Two things are deliberately not +/// part of it. **Gas**, because it is booked on the ledger's own lanes and counting it here would +/// report one rewrite twice. And **anything neither the argument nor a constant-time reading off +/// it describes** — the contents of the interpreter's stack and memory, and the journal — because +/// telling whether those came back changed needs a snapshot of unbounded state that no per-opcode +/// boundary can take. Their sizes are constant-time readings and are covered. +#[inline] +fn book_intervention(limit: &mut AdditionalLimit, changed: bool) { + if changed { + limit.record_inspector_intervention(); + } +} + +/// An `O(1)` identity for a byte buffer: where it starts and how long it is. +/// +/// The same comparison [`same_buffer`] makes, stored. Neither reads a byte — a buffer's contents +/// at an unchanged address and length are the class with no lane — but replacing the buffer is the +/// only way to change an immutable one, and that is what this catches. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct BufferId { + /// Where the buffer starts, as a bare address rather than a live pointer. + addr: usize, + /// How long it is. + len: usize, +} + +impl BufferId { + #[inline] + fn of(bytes: &[u8]) -> Self { + Self { addr: bytes.as_ptr() as usize, len: bytes.len() } + } +} + +/// An `O(1)` identity for a frame's calldata, which is either an owned buffer or a window into the +/// shared one. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum CallInputId { + /// An owned buffer, identified the way every other buffer here is. + Bytes(BufferId), + /// A window into the context's shared memory buffer, identified by its bounds. Where the + /// window points is the reading; what is inside it lives in the shared buffer and is + /// content-class like any other buffer's contents. + SharedBuffer(usize, usize), +} + +impl CallInputId { + #[inline] + fn of(input: &CallInput) -> Self { + match input { + CallInput::Bytes(bytes) => Self::Bytes(BufferId::of(bytes)), + CallInput::SharedBuffer(range) => Self::SharedBuffer(range.start, range.end), + } + } +} + +/// Every constant-time reading a callback boundary can take off a live interpreter. +/// +/// # The rule +/// +/// **Every `O(1)` reading of the interpreter's working set is in this snapshot** — not a list of +/// the readings someone thought of, but the readings themselves, enumerated field by field against +/// revm's `Interpreter` and the traits each field is reachable through. +/// +/// Stated over readings rather than over fields because that is the shape of what a boundary can +/// do: an inspector reaches the whole interpreter, and a snapshot taken twice per opcode can only +/// compare what it can read back in constant time. So the line is drawn at the cost of the +/// reading, and everything on the cheap side of it is taken. An enumeration is only as complete as +/// whoever wrote it, and the four-reading version this replaced left out `bytecode` — which let an +/// inspector step the program counter past an instruction, deleting it from the frame, with every +/// lane reading zero. +/// +/// # What is here, by the field it is read from +/// +/// - `bytecode` — the program counter, the code's identity, and revm's `continue_execution` flag, +/// which the inspected loop breaks on and which is a separate object from the pending action. +/// - `stack` — its length. +/// - `return_data` — the buffer's identity, which `RETURNDATASIZE` and `RETURNDATACOPY` read. +/// - `memory` — its size, and the offset of the frame's window into the shared buffer. +/// - `gas` — the memory memo's two halves. The budget half moves on the gas lanes instead. +/// - `input` — the four addresses and values a frame's identity is made of, and its calldata's +/// identity. `target_address` is what every storage instruction resolves against. +/// - `runtime_flag` — the static flag and the spec id. +/// +/// `extend` is the one field with no reading, by construction: `InterpreterTypes::Extend` carries +/// no trait bound, so a shim generic over the interpreter has nothing to call on it. +/// +/// The *contents* of the stack, memory, return buffer, calldata and code are deliberately absent: +/// walking unbounded state is the one thing a per-opcode boundary cannot do. +/// +/// The pair that made the snapshot necessary is the memory and its memo, moved together. Raising +/// the memo alone desynchronises the two and the EVM reads out of bounds; growing the memory alone +/// is charged twice. Moving both leaves every interpreter invariant intact, having paid nothing, +/// and makes every later expansion inside the new bound free — which no gas lane can see, because +/// what it changes is what the EVM charges afterwards. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct WorkingSet { + /// Where in its code the frame is about to execute. + pc: usize, + /// Which code that is. + code: BufferId, + /// Whether revm's inspected instruction loop will take another turn. + running: bool, + /// How many words the frame has on its stack. + stack_len: usize, + /// The return data the frame's `RETURNDATASIZE` and `RETURNDATACOPY` will read. + return_data: BufferId, + /// How many bytes of memory the frame has. + memory_size: usize, + /// Where the frame's window into the shared memory buffer starts. + memory_offset: usize, + /// How many words of that memory the frame has been charged for. + memory_words: usize, + /// What that charge came to. + memory_expansion_cost: u64, + /// The account the frame's storage instructions resolve against. + target_address: Address, + /// The account whose code the frame is running. + bytecode_address: Option
, + /// Who called it. + caller_address: Address, + /// With what value. + call_value: U256, + /// And with what calldata. + call_input: CallInputId, + /// Whether the frame may write state. + is_static: bool, + /// Which gas schedule and opcode set it runs under. + spec_id: SpecId, +} + +impl WorkingSet { + /// Whether a live interpreter still reads the way this snapshot recorded it. + /// + /// The same question as `*self == Self::of(interp)`, asked without building the second + /// snapshot: written that way it materialises two hundred bytes onto the stack for the length + /// of one `==`, twice per opcode, and the optimiser does not reliably take them away again. + /// + /// Every reading in [`of`](Self::of) is compared here and neither list may be shortened + /// without the other. The unit tests below hold them together: a reading in the snapshot and + /// missing here is one the shim takes and never compares. + /// + /// `inline` rather than `inline(always)` on purpose — forced into all four callbacks it is + /// slower beside the real tracer the inspected path carries. + #[inline] + fn unchanged(&self, interp: &Interpreter) -> bool { + let memory = interp.gas.memory(); + (self.pc == interp.bytecode.pc()) && + (self.code == BufferId::of(interp.bytecode.bytecode_slice())) && + (self.running == interp.bytecode.is_not_end()) && + (self.stack_len == interp.stack.len()) && + (self.return_data == BufferId::of(interp.return_data.buffer())) && + (self.memory_size == interp.memory.size()) && + (self.memory_offset == interp.memory.local_memory_offset()) && + (self.memory_words == memory.words_num) && + (self.memory_expansion_cost == memory.expansion_cost) && + (self.target_address == interp.input.target_address()) && + (self.bytecode_address.as_ref() == interp.input.bytecode_address()) && + (self.caller_address == interp.input.caller_address()) && + (self.call_value == interp.input.call_value()) && + (self.call_input == CallInputId::of(interp.input.input())) && + (self.is_static == interp.runtime_flag.is_static()) && + (self.spec_id == interp.runtime_flag.spec_id()) + } + + /// Takes every reading off a live interpreter. + #[inline] + fn of(interp: &Interpreter) -> Self { + let memory = interp.gas.memory(); + Self { + pc: interp.bytecode.pc(), + code: BufferId::of(interp.bytecode.bytecode_slice()), + running: interp.bytecode.is_not_end(), + stack_len: interp.stack.len(), + return_data: BufferId::of(interp.return_data.buffer()), + memory_size: interp.memory.size(), + memory_offset: interp.memory.local_memory_offset(), + memory_words: memory.words_num, + memory_expansion_cost: memory.expansion_cost, + target_address: interp.input.target_address(), + bytecode_address: interp.input.bytecode_address().copied(), + caller_address: interp.input.caller_address(), + call_value: interp.input.call_value(), + call_input: CallInputId::of(interp.input.input()), + is_static: interp.runtime_flag.is_static(), + spec_id: interp.runtime_flag.spec_id(), + } + } +} + +/// Everything a `CallOutcome` carries besides the `InterpreterResult` inside it. +/// +/// The result is compared on its own, by [`result_rewritten`]; this is the rest, and it is not +/// bookkeeping. `memory_offset` is where the caller copies the callee's output to, so moving it +/// feeds the caller a word the callee never wrote. `charged_new_account_state_gas` tells the +/// caller whether to refund an EIP-8037 upfront charge, and the two precompile fields decide which +/// logs an inspector is shown next. +#[derive(Clone, Debug, PartialEq, Eq)] +struct CallMetadata { + memory_offset: Range, + was_precompile_called: bool, + precompile_call_logs: Vec, + charged_new_account_state_gas: bool, +} + +impl CallMetadata { + #[inline] + fn of(outcome: &CallOutcome) -> Self { + Self { + memory_offset: outcome.memory_offset.clone(), + was_precompile_called: outcome.was_precompile_called, + precompile_call_logs: outcome.precompile_call_logs.clone(), + charged_new_account_state_gas: outcome.charged_new_account_state_gas, + } + } +} + +/// [`CallMetadata`] for the generic callback, which is handed the variant rather than the outcome. +/// +/// A creation's own metadata is one field: the address the caller's stack is about to receive. +/// Rewriting it reports a contract at an address holding no code while the deployed code stays +/// where it was — a split no classification expresses and no gas lane sees. +/// +/// Matched without a catch-all, so a `FrameResult` variant added upstream stops the build here. +#[derive(Clone, Debug, PartialEq, Eq)] +enum OutcomeMetadata { + Call(CallMetadata), + Create(Option
), +} + +impl OutcomeMetadata { + #[inline] + fn of(result: &FrameResult) -> Self { + match result { + FrameResult::Call(outcome) => Self::Call(CallMetadata::of(outcome)), + FrameResult::Create(outcome) => Self::Create(outcome.address), + } + } +} + +/// Whether two output buffers are the same buffer. +/// +/// By address and length, not content. `Bytes` is immutable, so a callback can only change an +/// output by putting a different buffer there, and the caller holds its snapshot across the +/// comparison — which keeps the original alive, so its address cannot be reused underneath. +#[inline] +fn same_buffer(before: &Bytes, after: &Bytes) -> bool { + before.as_ptr() == after.as_ptr() && before.len() == after.len() +} + +/// Whether a callback rewrote what a finished frame *did*: the classification its caller will see, +/// or the output it will read. +/// +/// The classification carries the most — whether the caller sees a success, whether the frame's +/// state is committed, and whether its remainder is handed back or destroyed — and none of it +/// moves gas, so none of it leaves a trace in any gas lane. +#[inline] +fn result_rewritten(before: (InstructionResult, &Bytes), after: &InterpreterResult) -> bool { + before.0 != after.result || !same_buffer(before.1, &after.output) +} + +/// Whether a callback edited a call frame's inputs anywhere but in their gas limit. +/// +/// Everything else a call input carries — who is called, with what value, under which scheme, with +/// what calldata, in a static context or not — describes what the frame will do. +/// +/// Taken as the derived equality rather than field by field, which a call's inputs can afford +/// because every field of `CallInputs` is one of those descriptions: there is no memo among them, +/// so a field upstream adds joins the comparison by itself. That is a claim about upstream's +/// struct and it is pinned as one, in `tests/rex7/gas_surface.rs`, which classifies every field of +/// both input types as semantic or memo and fails if a call's inputs ever grow one of the latter. +#[inline] +fn call_inputs_rewritten(mut before: CallInputs, after: &CallInputs) -> bool { + before.gas_limit = after.gas_limit; + before != *after +} + +/// Whether a callback edited a creation's inputs anywhere but in their gas limit. +/// +/// Compared field by field, which the derived equality cannot stand in for here: `CreateInputs` +/// carries two `OnceCell` memos, of the address the creation will occupy and of the init code's +/// hash, and both are filled on demand through a shared reference. Filling one is a derived value +/// being computed, not an input being changed — the frame the EVM builds afterwards is built from +/// the same six numbers either way — and it is what `created_address` does, which every tracer +/// that records a deployment calls. Comparing them would report the most ordinary thing an +/// observation-only tracer does as a rewrite. +/// +/// What is compared is the whole of what the frame is built from: who creates, under which scheme, +/// with what value, from what init code, and out of what state-gas pool. The gas limit is left out +/// for the reason a call's is — it travels on the envelope lane, and comparing it here would +/// report one edit twice. +/// +/// What the exclusion costs is one shape, and it is content-class rather than free: the address +/// memo is derived from a nonce its caller supplies, and revm reads the memo when it builds the +/// frame, so filling it with a nonce other than the one the EVM would have used redirects the +/// deployment. Telling that apart needs the caller's pre-bump nonce and, under `CREATE2`, the +/// keccak of the init code that the memo exists to avoid computing — neither of which a boundary +/// crossed twice per creation can take. It joins the readings a boundary cannot make at all, and +/// rests on the declaration a block's admission rests on. +#[inline] +fn create_inputs_rewritten(before: &CreateInputs, after: &CreateInputs) -> bool { + before.caller() != after.caller() || + before.scheme() != after.scheme() || + before.value() != after.value() || + before.init_code() != after.init_code() || + before.reservoir() != after.reservoir() +} + +/// [`call_inputs_rewritten`] / [`create_inputs_rewritten`] for the generic callback, which is +/// handed the variant rather than the inputs. A callback that swapped the variant itself has +/// rewritten the frame as thoroughly as it is possible to. +#[inline] +fn frame_input_rewritten(before: FrameInput, after: &FrameInput) -> bool { + match (before, after) { + (FrameInput::Call(before), FrameInput::Call(after)) => { + call_inputs_rewritten(*before, after) + } + (FrameInput::Create(before), FrameInput::Create(after)) => { + create_inputs_rewritten(&before, after) + } + (FrameInput::Empty, FrameInput::Empty) => false, + _ => true, + } +} + +/// What an inspector can ask `MegaETH` about the frame result it is holding. +/// +/// One question: is this a result a frame *ran* to produce, or one frame init produced without +/// ever building a frame? The two arrive at the same callback holding the same type, and the +/// difference decides what a classification rewrite does — a running frame's journal decision is +/// still outstanding and follows the rewrite, an init-produced result's was taken before the +/// callback existed and is refused. +/// +/// A tool that only observes never needs this. One that rewrites classifications does, because the +/// only other way to find out is to have its transaction refused. +pub trait FrameResultOriginTr { + /// Whether the frame result the `*_end` callbacks are being handed came out of frame init. + /// + /// False everywhere else, including at every callback that is not one of those three. + fn is_frame_init_result(&self) -> bool; +} + +impl FrameResultOriginTr for MegaContext { + #[inline] + fn is_frame_init_result(&self) -> bool { + self.additional_limit.borrow().is_settling_frame_init_result() + } +} + +/// Which of the three things a frame's result says — the granularity the refusals are stated over. +/// +/// A result's gas and its returned output move freely; the lanes measure those. What cannot move +/// is which of these three the caller is handed, because that is what the journal decision +/// answers. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ResultClass { + /// The frame returned, and its writes stand. + Success, + /// The frame reverted, and its writes are rolled back with its gas handed back. + Revert, + /// The frame halted exceptionally, and its writes are rolled back with its gas destroyed. + Halt, +} + +impl ResultClass { + #[inline] + const fn of(result: InstructionResult) -> Self { + if result.is_ok() { + Self::Success + } else if result.is_revert() { + Self::Revert + } else { + Self::Halt + } + } +} + +/// One shape of rewrite the shim refuses. +/// +/// Both are detection only: nothing here compensates the journal. The classification is restored, +/// the ledger counts the refusal, and the error slot carries the reason so the transaction fails +/// rather than producing a receipt built on the rewrite. What differs is how loud a debug build +/// is, which is [`Self::note_in_debug`]. +/// +/// Both are gated to REX7+: on a frozen spec a rewrite reaches no accounting lane it can make +/// unsound, and those specs' behaviour is closed. +#[derive(Clone, Copy, Debug)] +enum Forbidden { + /// A result frame init produced, moved across the success / revert / halt boundary. + /// + /// Every other classification rewrite is supported because REX7 withholds the journal decision + /// until the result is final, so a frame rewritten into a revert has its state rolled back + /// with it. A result out of frame *init* has no such window and cannot be given one from + /// here: upstream decides inside `make_call_frame` — an empty-code call commits its + /// transfer and returns `Stop`, a failing precompile reverts and returns its own failure — + /// and `MegaETH`'s interceptors decide before they return, the `KeylessDeploy` one by + /// merging a whole sandbox's state. Honouring a rewrite would hand the caller an answer + /// the state behind it contradicts. + /// + /// A result an inspector answered the frame with itself is deliberately outside the refusal: + /// nothing in the EVM decided anything for it, so its classification is the inspector's to + /// state. What separates the two is which callback site in `inspect_frame_init` ran, which is + /// where the window is opened. + FrameInitRewrite, + /// A non-successful contract creation turned into a successful one. + /// + /// Forbidden rather than supported because there is no state behind it: by the time + /// `create_end` runs, revm has reverted the frame's checkpoint and declined to deposit the + /// code — the size limit, the `0xEF` prefix rule and the code-deposit charge are all evaluated + /// before the callback. Honouring it would report a deployment at an address holding no code. + CreateRevival, +} + +impl Forbidden { + /// The reason the error slot carries, which is also what a test matches on. + const fn message(self) -> &'static str { + match self { + Self::FrameInitRewrite => FORBIDDEN_FRAME_INIT_REWRITE, + Self::CreateRevival => FORBIDDEN_CREATE_REVIVAL, + } + } + + /// Whether this rewrite is the one that happened. + #[inline] + fn applies( + self, + context: &MegaContext, + before: InstructionResult, + after: InstructionResult, + ) -> bool { + match self { + Self::FrameInitRewrite => { + ResultClass::of(before) != ResultClass::of(after) && + context.additional_limit.borrow().is_settling_frame_init_result() + } + Self::CreateRevival => !before.is_ok() && after.is_ok(), + } + } + + /// What a debug build does about it, once the refusal has been carried out. + /// + /// A frame-init rewrite is the most ordinary rewrite a tool makes — failing a call — landing + /// on the one frame kind it cannot be applied to, so a corpus should be able to report it + /// rather than die on it; all that is asserted is that the refusal did restore the + /// classification. A revived creation has no reading behind it at all, so a corpus that + /// produces one should stop. + #[inline] + fn note_in_debug(self, before: InstructionResult, after: InstructionResult) { + match self { + Self::FrameInitRewrite => debug_assert_eq!( + ResultClass::of(after), + ResultClass::of(before), + "{}: the refusal must leave the caller holding the classification the EVM \ + produced", + self.message(), + ), + Self::CreateRevival => debug_assert!( + false, + "{}: {before:?} was rewritten to a success, which no journal entry and no \ + deposited code stands behind", + self.message(), + ), + } + } +} + +/// Restores the classification a forbidden rewrite moved, and fails the transaction over it. +#[inline] +fn reject_forbidden_rewrite( + context: &mut MegaContext, + what: Forbidden, + before: InstructionResult, + result: &mut InterpreterResult, +) { + if !context.spec.is_enabled(MegaSpecId::REX7) || !what.applies(context, before, result.result) { + return; + } + result.result = before; + context.additional_limit.borrow_mut().record_inspector_rejected_rewrite(); + let slot = context.error(); + if slot.is_ok() { + *slot = Err(ContextError::Custom(String::from(what.message()))); + } + what.note_in_debug(before, result.result); +} + +/// What the shim reads off a live interpreter on the way into a callback, and settles on the way +/// out. +/// +/// The four callbacks handed a live interpreter run the same measurement, written once here: +/// [`enter`](Self::enter) takes the way-in readings, the user's inspector runs, and +/// [`leave`](Self::leave) takes them again and books the differences. +/// +/// `IN_OPEN_SEGMENT` on [`leave`](Self::leave) is the one thing that differs between the four: +/// `initialize_interp` runs before the frame's settlement window is opened, so there is no open +/// segment for a counter edit to be moved out of. +struct LiveReading { + /// Every constant-time reading of the interpreter's working set. + working_set: WorkingSet, + /// The pending action, in the form the rewrite comparison reads it in. + action: ActionSnapshot, + /// [`held`], taken at the counter below — the way-in half of the action lane's difference. + held: i128, + /// [`held_refund`], taken at the same moment. + refund: i64, + /// The interpreter's own gas counter, which is also the counter both [`held`] readings are + /// taken at. + gas: u64, +} + +impl LiveReading { + /// Takes every way-in reading off a live interpreter. + #[inline(always)] + fn enter(interp: &mut Interpreter) -> Self { + let working_set = WorkingSet::of(interp); + let gas = interp.gas.remaining(); + let refunded = interp.gas.refunded(); + let action = interp.bytecode.action().as_ref(); + Self { + working_set, + held: held(action, gas), + refund: held_refund(action, refunded), + action: ActionSnapshot::of(action), + gas, + } + } + + /// Takes the readings again and books what the callback moved. + #[inline(always)] + fn leave( + self, + interp: &mut Interpreter, + context: &MegaContext, + ) where + DB: Database, + ExtEnvs: ExternalEnvTypes, + INTR: InterpreterTypes, + { + let moved = !self.working_set.unchanged(interp); + let gas = interp.gas.remaining(); + let refunded = interp.gas.refunded(); + let action = interp.bytecode.action().as_ref(); + let lane = ActionLane::of(action); + let change = ActionChange { + // Both readings are taken at the counter the EVM left behind, so the counter cancels + // out of the difference wherever it appears on both sides. + gas: held(action, self.gas) - self.held, + rewritten: self.action.rewritten(action), + lane, + }; + let refund = held_refund(action, refunded); + + let mut limit = context.additional_limit.borrow_mut(); + book_intervention(&mut limit, moved); + book_pending_action(&mut limit, change); + book_refund(&mut limit, self.refund, refund); + if gas != self.gas { + limit.record_inspector_gas_adjustment::( + &mut interp.gas, + self.gas, + lane.counter_reaches_envelope(), + ); + } + } +} + +/// The measuring bodies of the four live-interpreter callbacks, kept out of line. +/// +/// Each callback is a branch on the declaration and, when taken, the measurement. Only the branch +/// belongs in revm's instruction loop, and `inline(never)` is what puts it there alone: inlined, +/// the measurement's two hundred bytes of readings are laid down inside the loop for a declared +/// observer that never executes them, and the loop pays for them in registers and instruction +/// cache regardless. +/// +/// The trade was measured both ways. Beside the real tracer the inspected path carries in +/// production, outlining makes the undeclared path faster too; beside an empty inspector it is +/// slower, because there the call is the whole of the work — and an empty inspector is an +/// instrument for isolating this shim's cost, not a workload. +/// +/// Written out four times rather than taken as a function pointer, which would cost the +/// undeclared path the inlining of the inner inspector's own callback. +impl MeasuredInspector { + #[inline(never)] + fn initialize_interp_measured( + &mut self, + interp: &mut Interpreter, + context: &mut MegaContext, + ) where + DB: Database, + ExtEnvs: ExternalEnvTypes, + INTR: InterpreterTypes, + I: Inspector, INTR>, + { + let reading = LiveReading::enter(interp); + self.inner.initialize_interp(interp, context); + reading.leave::(interp, context); + verify_trusted(self.trusted, context, "initialize_interp"); + } + + #[inline(never)] + fn step_measured( + &mut self, + interp: &mut Interpreter, + context: &mut MegaContext, + ) where + DB: Database, + ExtEnvs: ExternalEnvTypes, + INTR: InterpreterTypes, + I: Inspector, INTR>, + { + let reading = LiveReading::enter(interp); + self.inner.step(interp, context); + reading.leave::(interp, context); + verify_trusted(self.trusted, context, "step"); + } + + #[inline(never)] + fn step_end_measured( + &mut self, + interp: &mut Interpreter, + context: &mut MegaContext, + ) where + DB: Database, + ExtEnvs: ExternalEnvTypes, + INTR: InterpreterTypes, + I: Inspector, INTR>, + { + let reading = LiveReading::enter(interp); + self.inner.step_end(interp, context); + reading.leave::(interp, context); + verify_trusted(self.trusted, context, "step_end"); + } + + #[inline(never)] + fn log_full_measured( + &mut self, + interp: &mut Interpreter, + context: &mut MegaContext, + log: Log, + ) where + DB: Database, + ExtEnvs: ExternalEnvTypes, + INTR: InterpreterTypes, + I: Inspector, INTR>, + { + let reading = LiveReading::enter(interp); + self.inner.log_full(interp, context, log); + reading.leave::(interp, context); + verify_trusted(self.trusted, context, "log_full"); + } +} + +/// Books what an entry callback did to the frame it was handed. +/// +/// `frame_start`, `call` and `create` are one measurement over three argument types, and this is +/// the body: the envelope moved, whether anything else about the inputs came back changed, and — +/// when the callback answered the frame itself — the refund its synthetic outcome carries. +/// `intercepted_refund` is `Some` exactly when it did. +#[inline] +fn book_frame_entry( + limit: &mut AdditionalLimit, + before: Option, + after: Option, + intercepted_refund: Option, + rewritten: bool, +) { + let intercepted = intercepted_refund.is_some(); + book_env_adjustment(limit, before, after, intercepted); + if let Some(refund) = intercepted_refund { + // A synthetic outcome has no "before" to difference against, so the whole of its refund is + // the inspector's; the baseline is zero because a frame that never ran refunded nothing. + book_refund(limit, 0, refund); + } + book_intervention(limit, intercepted || rewritten); +} + +/// What a finished frame reads as on the way into an `*_end` callback. +/// +/// The three `*_end` callbacks are one measurement over three argument types, the way the three +/// entry callbacks are. `M` is whatever the object carries outside the `InterpreterResult` the +/// three of them share. +struct FrameEnding { + result: InstructionResult, + output: Bytes, + metadata: M, + refund: i64, +} + +impl FrameEnding { + /// Books what the callback did to a finished frame, and refuses the rewrites that are + /// forbidden. + /// + /// `is_create` selects the second refusal. Both read the classification as it stood on the way + /// in, and the frame-init one runs first because it restores whatever it refuses — which + /// leaves the creation refusal nothing to see. + #[inline] + fn book( + self, + context: &mut MegaContext, + result: &mut InterpreterResult, + metadata: M, + is_create: bool, + ) { + { + let mut limit = context.additional_limit.borrow_mut(); + book_refund(&mut limit, self.refund, result.gas.refunded()); + book_intervention(&mut limit, result_rewritten((self.result, &self.output), result)); + book_intervention(&mut limit, metadata != self.metadata); + } + reject_forbidden_rewrite(context, Forbidden::FrameInitRewrite, self.result, result); + if is_create { + reject_forbidden_rewrite(context, Forbidden::CreateRevival, self.result, result); + } + } +} + +impl Inspector, INTR> for MeasuredInspector +where + DB: Database, + ExtEnvs: ExternalEnvTypes, + INTR: InterpreterTypes, + I: Inspector, INTR>, +{ + /// Measured, but without settling a segment: this runs after the frame is built and before its + /// settlement window is opened, so there is nothing open to close. The frame's own entry hook + /// opens the window on whatever counter this callback leaves behind. + #[inline(always)] + fn initialize_interp( + &mut self, + interp: &mut Interpreter, + context: &mut MegaContext, + ) { + if !self.measures() { + return self.inner.initialize_interp(interp, context); + } + self.initialize_interp_measured(interp, context); + } + + #[inline(always)] + fn step(&mut self, interp: &mut Interpreter, context: &mut MegaContext) { + if !self.measures() { + return self.inner.step(interp, context); + } + self.step_measured(interp, context); + } + + /// The one callback that runs with an action already pending: revm runs it after the + /// instruction that set the frame's action, so on a terminating opcode the counter it hands + /// out has already been copied into the result the caller will be given — see + /// [`ActionLane::counter_reaches_envelope`] — and the action holding that copy is reachable + /// through `LoopControl`. Both objects are measured, on the lanes + /// [`book_pending_action`] routes them to. + #[inline(always)] + fn step_end(&mut self, interp: &mut Interpreter, context: &mut MegaContext) { + if !self.measures() { + return self.inner.step_end(interp, context); + } + self.step_end_measured(interp, context); + } + + /// No interpreter and no frame inputs are reachable here, so there is nothing to measure — + /// this callback can only touch the context, which the shim does not police. + #[inline] + fn log(&mut self, context: &mut MegaContext, log: Log) { + self.inner.log(context, log); + } + + #[inline(always)] + fn log_full( + &mut self, + interpreter: &mut Interpreter, + context: &mut MegaContext, + log: Log, + ) { + if !self.measures() { + return self.inner.log_full(interpreter, context, log); + } + self.log_full_measured(interpreter, context, log); + } + + #[inline] + fn frame_start( + &mut self, + context: &mut MegaContext, + frame_input: &mut FrameInput, + ) -> Option { + if !self.measures() { + return self.inner.frame_start(context, frame_input); + } + let before = frame_input.clone(); + let outcome = self.inner.frame_start(context, frame_input); + book_frame_entry( + &mut context.additional_limit.borrow_mut(), + frame_input_gas_limit(&before), + frame_input_gas_limit(frame_input), + outcome.as_ref().map(|outcome| outcome.gas().refunded()), + frame_input_rewritten(before, frame_input), + ); + verify_trusted(self.trusted, context, "frame_start"); + outcome + } + + #[inline] + fn frame_end( + &mut self, + context: &mut MegaContext, + frame_input: &FrameInput, + frame_result: &mut FrameResult, + ) { + if !self.measures() { + return self.inner.frame_end(context, frame_input, frame_result); + } + let entry = FrameEnding { + result: frame_result.instruction_result(), + output: frame_result.interpreter_result().output.clone(), + metadata: OutcomeMetadata::of(frame_result), + refund: frame_result.gas().refunded(), + }; + self.inner.frame_end(context, frame_input, frame_result); + let metadata = OutcomeMetadata::of(frame_result); + let is_create = matches!(frame_result, FrameResult::Create(_)); + entry.book(context, frame_result.interpreter_result_mut(), metadata, is_create); + verify_trusted(self.trusted, context, "frame_end"); + } + + #[inline] + fn call( + &mut self, + context: &mut MegaContext, + inputs: &mut CallInputs, + ) -> Option { + if !self.measures() { + return self.inner.call(context, inputs); + } + let before = inputs.clone(); + let outcome = self.inner.call(context, inputs); + book_frame_entry( + &mut context.additional_limit.borrow_mut(), + Some(before.gas_limit), + Some(inputs.gas_limit), + outcome.as_ref().map(|outcome| outcome.result.gas.refunded()), + call_inputs_rewritten(before, inputs), + ); + verify_trusted(self.trusted, context, "call"); + outcome + } + + /// `CallInputs` is immutable here and the frame's result gas is deliberately not booked at this + /// boundary — see [`InspectorLedger::env`](crate::InspectorLedger::env) — so the only thing to + /// measure is what the callback did to the result's classification and output. + #[inline] + fn call_end( + &mut self, + context: &mut MegaContext, + inputs: &CallInputs, + outcome: &mut CallOutcome, + ) { + if !self.measures() { + return self.inner.call_end(context, inputs, outcome); + } + let entry = FrameEnding { + result: outcome.result.result, + output: outcome.result.output.clone(), + metadata: CallMetadata::of(outcome), + refund: outcome.result.gas.refunded(), + }; + self.inner.call_end(context, inputs, outcome); + let metadata = CallMetadata::of(outcome); + entry.book(context, &mut outcome.result, metadata, false); + verify_trusted(self.trusted, context, "call_end"); + } + + #[inline] + fn create( + &mut self, + context: &mut MegaContext, + inputs: &mut CreateInputs, + ) -> Option { + if !self.measures() { + return self.inner.create(context, inputs); + } + let before = inputs.clone(); + let outcome = self.inner.create(context, inputs); + book_frame_entry( + &mut context.additional_limit.borrow_mut(), + Some(before.gas_limit()), + Some(inputs.gas_limit()), + outcome.as_ref().map(|outcome| outcome.result.gas.refunded()), + create_inputs_rewritten(&before, inputs), + ); + verify_trusted(self.trusted, context, "create"); + outcome + } + + /// Forwards, then refuses a rewrite that turned a failed contract creation into a successful + /// one: by this point the journal is already reverted and no code was deposited, so the + /// original classification is restored and the transaction is failed with an error rather than + /// allowed to report a deployment that did not happen. + #[inline] + fn create_end( + &mut self, + context: &mut MegaContext, + inputs: &CreateInputs, + outcome: &mut CreateOutcome, + ) { + if !self.measures() { + return self.inner.create_end(context, inputs, outcome); + } + let entry = FrameEnding { + result: outcome.result.result, + output: outcome.result.output.clone(), + metadata: outcome.address, + refund: outcome.result.gas.refunded(), + }; + self.inner.create_end(context, inputs, outcome); + let address = outcome.address; + entry.book(context, &mut outcome.result, address, true); + verify_trusted(self.trusted, context, "create_end"); + } + + /// Everything this callback receives is passed by value, so it cannot change execution state. + #[inline] + fn selfdestruct(&mut self, contract: Address, target: Address, value: U256) { + self.inner.selfdestruct(contract, target, value); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{test_utils::MemoryDatabase, EmptyExternalEnv}; + use revm::{ + bytecode::Bytecode, + interpreter::{ + interpreter::{EthInterpreter, ExtBytecode}, + CreateScheme, InputsImpl, InterpreterAction, SharedMemory, + }, + }; + + /// A second address, for the cases that move one of the frame's identifying addresses. + const OTHER: Address = Address::repeat_byte(0x0B); + + /// How many bytes of memory the probe starts with. + /// + /// Non-zero so that the window-offset case can move the frame's checkpoint into the shared + /// buffer without the size moving with it. + const PROBE_MEMORY: usize = 32; + + /// The interpreter every case moves a reading on. + fn probe() -> Interpreter { + let mut interp = Interpreter::new( + SharedMemory::new(), + ExtBytecode::new(Bytecode::new_raw(Bytes::from_static(&[0x5B, 0x5B, 0x00]))), + InputsImpl::default(), + false, + SpecId::default(), + 1_000_000, + ); + interp.memory.resize(PROBE_MEMORY); + interp + } + + /// The readings that differ between two snapshots, by name. + /// + /// Destructured exhaustively rather than compared with `PartialEq`, and that is the whole + /// point: a reading added to [`WorkingSet`] is a compile error here until it is named, and one + /// removed is a compile error too. Rust cannot enumerate a struct's fields at run time, and + /// this is the substitute — the same role the derived `Debug` rendering plays for the foreign + /// structs in `tests/rex7/gas_surface.rs`. + fn moved(before: &WorkingSet, after: &WorkingSet) -> Vec<&'static str> { + let WorkingSet { + pc, + code, + running, + stack_len, + return_data, + memory_size, + memory_offset, + memory_words, + memory_expansion_cost, + target_address, + bytecode_address, + caller_address, + call_value, + call_input, + is_static, + spec_id, + } = *before; + [ + ("pc", pc == after.pc), + ("code", code == after.code), + ("running", running == after.running), + ("stack_len", stack_len == after.stack_len), + ("return_data", return_data == after.return_data), + ("memory_size", memory_size == after.memory_size), + ("memory_offset", memory_offset == after.memory_offset), + ("memory_words", memory_words == after.memory_words), + ("memory_expansion_cost", memory_expansion_cost == after.memory_expansion_cost), + ("target_address", target_address == after.target_address), + ("bytecode_address", bytecode_address == after.bytecode_address), + ("caller_address", caller_address == after.caller_address), + ("call_value", call_value == after.call_value), + ("call_input", call_input == after.call_input), + ("is_static", is_static == after.is_static), + ("spec_id", spec_id == after.spec_id), + ] + .into_iter() + .filter_map(|(name, same)| (!same).then_some(name)) + .collect() + } + + /// One case: the name of a reading, and a rewrite that moves it. + type Case = (&'static str, fn(&mut Interpreter)); + + /// One case: the name of a field, and a rewrite that moves it. + type OutcomeCase = (&'static str, fn(&mut CallOutcome)); + + /// One rewrite per reading, each moving the reading it is named for and nothing else. + /// + /// Every one is something an inspector can do to a live interpreter through the traits the + /// shim itself reads through, and several are rewrites with teeth: stepping the program + /// counter deletes an instruction from the frame, clearing the static flag lets a + /// `STATICCALL` write state, and moving the target address redirects every storage + /// instruction to another account. + const CASES: [Case; 16] = [ + ("pc", |interp| interp.bytecode.relative_jump(1)), + ("code", |interp| { + interp.bytecode = ExtBytecode::new(Bytecode::new_raw(Bytes::from_static(&[0x00]))); + }), + ("running", |interp| { + let gas = interp.gas; + interp.bytecode.set_action(InterpreterAction::new_halt(InstructionResult::Stop, gas)); + }), + ("stack_len", |interp| assert!(interp.stack.push(U256::ZERO))), + ("return_data", |interp| interp.return_data.set_buffer(Bytes::from_static(&[0x01]))), + ("memory_size", |interp| interp.memory.resize(PROBE_MEMORY * 2)), + ("memory_offset", |interp| { + // A child window over the same shared buffer, resized to the size the parent had, so + // that the frame's memory looks the same and starts somewhere else. + let mut child = interp.memory.new_child_context(); + child.resize(PROBE_MEMORY); + interp.memory = child; + }), + ("memory_words", |interp| interp.gas.memory_mut().words_num += 1), + ("memory_expansion_cost", |interp| interp.gas.memory_mut().expansion_cost += 1), + ("target_address", |interp| interp.input.target_address = OTHER), + ("bytecode_address", |interp| interp.input.bytecode_address = Some(OTHER)), + ("caller_address", |interp| interp.input.caller_address = OTHER), + ("call_value", |interp| interp.input.call_value = U256::from(1)), + ("call_input", |interp| { + interp.input.input = CallInput::Bytes(Bytes::from_static(&[0x01])); + }), + ("is_static", |interp| interp.runtime_flag.is_static = true), + ("spec_id", |interp| interp.runtime_flag.spec_id = SpecId::FRONTIER), + ]; + + /// ★ Every reading the snapshot holds is one the shim really takes off the interpreter. + /// + /// The rule the snapshot is built on is "every `O(1)` reading of the interpreter's working + /// set", and a rule of that shape fails in two ways: a reading that is declared and never + /// read, and a reading that is read and never declared. Each case moves exactly one reading + /// and asserts that exactly that one name comes back — so a field dropped from + /// [`WorkingSet::of`] leaves its case detecting nothing, and a field left out of the snapshot + /// entirely never compiles past [`moved`]. + /// + /// Each case also asserts that [`WorkingSet::unchanged`] sees the same movement, which is the + /// third way the rule can fail. That comparison is written out rather than derived from the + /// snapshot, so a reading missing from it is a reading the shim takes, stores, and never + /// compares — [`moved`] would still name it and the shim would still book nothing. + #[test] + fn test_every_reading_moves_exactly_the_reading_it_is_named_for() { + let unchanged = probe(); + assert!( + moved(&WorkingSet::of(&unchanged), &WorkingSet::of(&unchanged)).is_empty(), + "a snapshot compared against itself must report nothing moved", + ); + assert!( + WorkingSet::of(&unchanged).unchanged(&unchanged), + "and an interpreter nothing touched must still read the way it was recorded", + ); + assert_ne!(SpecId::default(), SpecId::FRONTIER, "the spec-id case must move something"); + + for (name, rewrite) in CASES { + let mut interp = probe(); + let before = WorkingSet::of(&interp); + rewrite(&mut interp); + let after = WorkingSet::of(&interp); + assert_eq!( + moved(&before, &after), + [name], + "the rewrite for {name} must move that reading and no other", + ); + assert!( + !before.unchanged(&interp), + "and the comparison the shim makes must see {name} move", + ); + } + } + + /// One rewrite per field a finished call carries besides its `InterpreterResult`. + /// + /// `CallMetadata` is the same kind of snapshot as [`WorkingSet`] over a different object, and + /// it fails the same way: a field held and never compared is a rewrite the shim is handed and + /// books nothing for. Three of these move nothing `MegaETH` produces today — it runs with + /// EIP-8037 off and no wired precompile emits a log — so no fixture can show their effect, and + /// a per-field check is the only thing that holds the claim that they are seen at all. + const OUTCOME_CASES: [OutcomeCase; 4] = [ + ("memory_offset", |outcome| outcome.memory_offset = 1..2), + ("was_precompile_called", |outcome| outcome.was_precompile_called = true), + ("precompile_call_logs", |outcome| { + outcome.precompile_call_logs.push(Log::new_unchecked(OTHER, Vec::new(), Bytes::new())); + }), + ("charged_new_account_state_gas", |outcome| { + outcome.charged_new_account_state_gas = true; + }), + ]; + + fn call_outcome() -> CallOutcome { + CallOutcome::new( + InterpreterResult::new( + InstructionResult::Stop, + Bytes::new(), + revm::interpreter::Gas::new(0), + ), + 0..0, + ) + } + + /// Every field of a finished call outside its result is compared, and each one on its own. + /// + /// The derived `PartialEq` is what makes a new upstream field visible: it joins the struct, + /// joins the equality, and the identical pair below still agrees until someone gives it a + /// case. What the cases add is that each existing field is compared *individually* — one of + /// them moving is enough on its own. + #[test] + fn test_every_finished_call_field_outside_the_result_is_compared() { + let base = call_outcome(); + assert_eq!( + CallMetadata::of(&base), + CallMetadata::of(&call_outcome()), + "two identical outcomes must compare equal", + ); + for (name, rewrite) in OUTCOME_CASES { + let mut moved = call_outcome(); + rewrite(&mut moved); + assert_ne!( + CallMetadata::of(&base), + CallMetadata::of(&moved), + "a rewritten {name} must be visible to the shim", + ); + } + } + + /// The case list covers the snapshot, and covers each reading once. + /// + /// [`moved`]'s destructuring is what keeps [`WorkingSet`] and this module in step at compile + /// time; this is the run-time half of the same closure. The snapshot below is written out + /// field by field with every reading different, so what [`moved`] reports on it is the set of + /// readings [`moved`] can see at all — and that set has to be exactly the set the cases above + /// exercise. A reading added to the snapshot is a compile error in two places before it gets + /// here, and an unexercised one fails this. + #[test] + fn test_the_case_list_covers_every_reading_exactly_once() { + let mut names: Vec<&str> = CASES.iter().map(|(name, _)| *name).collect(); + let declared = names.len(); + names.sort_unstable(); + names.dedup(); + assert_eq!(names.len(), declared, "no reading may be listed twice"); + + let before = WorkingSet::of(&probe()); + let everything = WorkingSet { + pc: before.pc + 1, + code: BufferId { addr: before.code.addr + 1, len: before.code.len + 1 }, + running: !before.running, + stack_len: before.stack_len + 1, + return_data: BufferId { + addr: before.return_data.addr + 1, + len: before.return_data.len + 1, + }, + memory_size: before.memory_size + 1, + memory_offset: before.memory_offset + 1, + memory_words: before.memory_words + 1, + memory_expansion_cost: before.memory_expansion_cost + 1, + target_address: OTHER, + bytecode_address: Some(OTHER), + caller_address: OTHER, + call_value: before.call_value + U256::from(1), + call_input: CallInputId::SharedBuffer(0, 1), + is_static: !before.is_static, + spec_id: SpecId::FRONTIER, + }; + let mut all = moved(&before, &everything); + all.sort_unstable(); + assert_eq!(all, names, "every reading the snapshot holds must have a case, and vice versa"); + } + + /// One case: the name of a field a creation's inputs are built from, and a rewrite that moves + /// it. + type CreateCase = (&'static str, fn(&mut CreateInputs)); + + /// The creation every case below rewrites one field of. + fn create_inputs() -> CreateInputs { + CreateInputs::new( + Address::ZERO, + CreateScheme::Create, + U256::ZERO, + Bytes::from_static(&[0x60, 0x00]), + 1_000_000, + 0, + ) + } + + /// One rewrite per field the comparison is written over, each moving the field it is named + /// for. + /// + /// Every one is something an inspector can do to a creation through the setters upstream + /// gives it, and each changes what the frame does: who is recorded as the creator, which + /// address the contract lands at, what it is funded with, what code runs, and what state-gas + /// pool it draws from. + const CREATE_CASES: [CreateCase; 5] = [ + ("caller", |inputs| inputs.set_call(OTHER)), + ("scheme", |inputs| { + inputs.set_scheme(CreateScheme::Create2 { salt: U256::from(0x5A17) }); + }), + ("value", |inputs| inputs.set_value(U256::from(1))), + ("init_code", |inputs| inputs.set_init_code(Bytes::from_static(&[0x00]))), + ("reservoir", |inputs| inputs.set_reservoir(1)), + ]; + + /// ★ Every field a creation's frame is built from is one an edit to is booked. + /// + /// `CreateInputs` is compared field by field rather than by the derived equality a call's + /// inputs use, which trades a comparison that grows by itself for one someone has to keep + /// complete — so each field gets a case that moves it and asserts the shim sees it move. The + /// other half of the trade, that the list is still upstream's whole field set, is not a + /// question this module can ask; `tests/rex7/gas_surface.rs` asks it against the struct's own + /// `Debug` rendering. + #[test] + fn test_every_semantic_field_of_a_creation_is_compared() { + assert!( + !create_inputs_rewritten(&create_inputs(), &create_inputs()), + "two identical creations must not read as a rewrite", + ); + + let mut names: Vec<&str> = CREATE_CASES.iter().map(|(name, _)| *name).collect(); + let declared = names.len(); + names.sort_unstable(); + names.dedup(); + assert_eq!(names.len(), declared, "no field may be listed twice"); + + for (name, rewrite) in CREATE_CASES { + let before = create_inputs(); + let mut after = create_inputs(); + rewrite(&mut after); + assert!( + create_inputs_rewritten(&before, &after), + "a rewritten {name} must be visible to the shim", + ); + } + } + + /// ★ Filling a creation's memo cells is not a rewrite, in either direction. + /// + /// This is the whole reason the comparison is written out: `created_address` and + /// `init_code_hash` fill an `OnceCell` through a shared reference, so the object a callback + /// was handed comes back structurally different having had a derived value computed off it. + /// Every tracer that records a deployment calls the first of those, so the derived equality + /// booked an intervention for the most ordinary thing an observation-only inspector does. + /// + /// The other direction is the setters', which clear the cells: an inspector that writes back + /// the init code a creation already had has changed nothing and emptied both memos. + #[test] + fn test_filling_or_clearing_a_creations_memo_is_not_a_rewrite() { + let before = create_inputs(); + let after = create_inputs(); + after.created_address(0); + after.init_code_hash(); + assert!( + !create_inputs_rewritten(&before, &after), + "computing the created address and the init code hash changes no input", + ); + assert!( + !create_inputs_rewritten(&after, &before), + "and neither does a setter clearing the memos back down", + ); + + let mut moved = create_inputs(); + moved.set_value(U256::from(1)); + moved.created_address(0); + assert!( + create_inputs_rewritten(&before, &moved), + "a memo filled beside a real edit must not hide the edit", + ); + } + + /// ★ Two frame inputs of the same empty variant are not a rewrite. + /// + /// The variant a frame's inputs carry is itself part of what the shim compares — a callback + /// that swapped it has rewritten the frame as thoroughly as it is possible to — so the pair + /// that did not move needs an arm of its own. `FrameInput::Empty` is revm's placeholder rather + /// than a frame it builds, and without the arm the placeholder compared against itself would + /// book an intervention nobody made. + #[test] + fn test_two_empty_frame_inputs_are_not_a_rewrite() { + assert!( + !frame_input_rewritten(FrameInput::Empty, &FrameInput::Empty), + "the placeholder compared against itself moved nothing", + ); + assert!( + frame_input_rewritten( + FrameInput::Empty, + &FrameInput::Create(Box::new(create_inputs())), + ), + "a variant swapped out of the placeholder is a rewrite", + ); + assert!( + frame_input_rewritten( + FrameInput::Create(Box::new(create_inputs())), + &FrameInput::Empty, + ), + "and so is one swapped into it", + ); + } + + /// ★ The frame-init origin question answers the settlement window, in both directions. + /// + /// It decides what a classification rewrite does — a running frame's journal decision is still + /// outstanding and follows the rewrite, an init-produced result's was taken before the + /// callback existed and is refused — so an answer stuck at either constant refuses every + /// rewrite or follows every one. + #[test] + fn test_the_frame_init_origin_tracks_the_settlement_window() { + let context: MegaContext = + MegaContext::new(MemoryDatabase::default(), MegaSpecId::REX7); + assert!( + !context.is_frame_init_result(), + "outside the window, a result is one a frame ran to produce", + ); + + context.additional_limit.borrow_mut().set_settling_frame_init_result(true); + assert!( + context.is_frame_init_result(), + "inside the window, it is one frame init produced with no frame ever built", + ); + + context.additional_limit.borrow_mut().set_settling_frame_init_result(false); + assert!(!context.is_frame_init_result(), "and the window closes again"); + } + + /// Counts the logs it is handed, and nothing else. + #[derive(Default)] + struct LogCollectingInspector { + logs: Vec, + } + + impl Inspector for LogCollectingInspector { + fn log(&mut self, _context: &mut CTX, log: Log) { + self.logs.push(log); + } + } + + /// ★ The shim forwards the log callback to the inspector it wraps. + /// + /// This is the one callback with no interpreter and no frame inputs to compare, so there is + /// nothing for the shim to measure and its whole job is delegation. `MegaETH` reaches it from + /// `forward_precompile_logs`, which is the only way a precompile's logs are ever shown to an + /// inspector — a shim that swallowed them would hide them entirely. + #[test] + fn test_the_shim_forwards_the_log_callback_it_has_nothing_to_measure_on() { + let mut context: MegaContext = + MegaContext::new(MemoryDatabase::default(), MegaSpecId::REX7); + let mut shim = MeasuredInspector::new(LogCollectingInspector::default()); + let log = Log::new_unchecked(Address::ZERO, Vec::new(), Bytes::from_static(b"emitted")); + + Inspector::<_, EthInterpreter>::log(&mut shim, &mut context, log.clone()); + + assert_eq!(shim.inner().logs, vec![log], "the wrapped inspector must see the log"); + assert!( + context.additional_limit.borrow().inspector_ledger().is_zero(), + "and a callback with nothing to measure must book nothing", + ); + } + + /// ★ A creation's gas limit is not part of the comparison. + /// + /// It travels on the envelope lane, which books the amount rather than the fact, so counting + /// it here would report one edit twice. A call's inputs are excluded from their own comparison + /// the same way. + #[test] + fn test_a_creations_gas_limit_is_left_to_the_envelope_lane() { + let before = create_inputs(); + let mut after = create_inputs(); + after.set_gas_limit(before.gas_limit() + 1); + assert!( + !create_inputs_rewritten(&before, &after), + "the gas limit is booked as an amount, not as an intervention", + ); + } +} diff --git a/crates/mega-evm/src/evm/instructions.rs b/crates/mega-evm/src/evm/instructions.rs index db6b23ff..9afc6a65 100644 --- a/crates/mega-evm/src/evm/instructions.rs +++ b/crates/mega-evm/src/evm/instructions.rs @@ -165,6 +165,20 @@ use revm::{ /// usage. CREATE2 is the one real behavior change: REX6+ short-circuits to `create_rex6`, which /// folds the memory-expansion gas into the single post-body recording instead of recording it as /// a separate eager entry as REX5 did. +/// - **REX7** (extends REX6): switches to **checkpoint compute-gas settlement**. The plain opcodes +/// are revm's own instructions with no recording wrapper at all; compute gas settles as an +/// interpreter-gas delta at each checkpoint — the storage-gas opcodes, the CALL / CREATE family, +/// the volatile opcodes, and frame entry / resume / exit. Per-transaction totals are unchanged +/// for a transaction that stays inside every limit and never halts exceptionally; a frame that +/// does halt exceptionally additionally reports the budget it destroyed, which is enforced +/// against nothing. Enforcement inside a plain segment is the gas clamp, which stops the crossing +/// opcode before it executes; an exceed detected by a settlement instead surfaces at the +/// checkpoint that settled it rather than at the opcode that crossed the limit. +/// - Volatile opcodes: `volatile_data_ext::*_checkpoint` (raw instruction + segment settlement + +/// detention cap) in place of the `compute_gas_ext` delegation +/// - Storage-gas, CALL-family, CREATE and SELFDESTRUCT: the REX6 handler chains, settling from +/// the checkpoint baseline internally +/// - Every other opcode: revm's raw instruction /// /// Note: chains terminating at `storage_gas_ext` (rather than `compute_gas_ext`) reflect the /// canonical metering order above — `storage_gas_ext::*` records compute gas internally via @@ -577,22 +591,115 @@ macro_rules! set_halt_action { mod rex7 { use super::*; - /// Returns the instruction table for the `REX7` spec. - /// - /// Changes from Rex6: none yet. + /// Returns the instruction table for the `REX7` spec — **checkpoint compute-gas accounting**. + /// + /// Unlike every earlier custom table, the plain opcodes are revm's own instructions with no + /// per-opcode gas recording at all: the interpreter's gas counter is the accounting source, + /// and compute gas settles as a segment delta at each checkpoint. The checkpoints are exactly + /// the positions that have to stay wrapped anyway: + /// + /// - the storage-gas opcodes (SSTORE, LOG0–LOG4, SELFDESTRUCT) and the CALL / CREATE family — + /// the same handler chains as Rex6, whose [`record_storage_compute_gas!`] settles from the + /// checkpoint baseline instead of a per-opcode capture; + /// - the volatile / detention opcodes — `*_checkpoint` variants that run the raw instruction, + /// settle the segment, then apply the detention cap; + /// - frame entry / resume and frame exit — `AdditionalLimit::before_frame_run` opens the window + /// and `after_frame_run_instructions` settles the tail segment. + /// + /// Per-transaction totals telescope to the same sums as per-opcode recording. What differs is + /// where a limit-exceeding transaction halts: the exceed surfaces at the next checkpoint + /// rather than at the opcode that crossed the limit. + /// + /// Of those, only the volatile / detention handlers (plus `GAS`, a checkpoint so that the + /// clamp is restored before the counter is observed) are declared here. The storage-gas and + /// frame-spawning slots are copied out of the Rex6 table one opcode at a time, which is what + /// makes "the same handler chains as Rex6" a property of the construction rather than of two + /// declarations kept in sync. The same copy covers the four opcodes revm wires ahead of the + /// fork that activates them (`DUPN`, `SWAPN`, `EXCHANGE`, `SLOTNUM`): Rex6 leaves + /// `control::unknown` in those slots, so inheriting them keeps the two opcode sets identical + /// instead of letting revm's base table decide what Rex7 exposes. + /// + /// The Rex6 behavior differences (canonical metering order, `create_rex6` dispatch, + /// SELFDESTRUCT existing-target accounting, CALL-family EIP-7702 delegate resolution on the + /// disabled path) live as internal `spec.is_enabled(MegaSpecId::REX6)` dispatch inside the + /// shared handlers reused here, so they carry over unchanged. + /// + /// `H` is `Sized` here — unlike the earlier tables — because the base table comes from revm's + /// own [`instructions::instruction_table`], whose bound it is. The only caller instantiates it + /// with [`MegaContext`], so nothing is lost. pub(super) const fn instruction_table< WIRE: InterpreterTypes, - H: HostExt + ContextTr + JournalInspectTr + ?Sized, + H: HostExt + ContextTr + JournalInspectTr, >() -> [Instruction; 256] where WIRE::Stack: StackInspectTr, { - rex6::instruction_table::() + use revm::bytecode::opcode::*; + let mut table = instructions::instruction_table::(); + let rex6 = rex6::instruction_table::(); + + /// The opcodes Rex7 takes from the Rex6 table verbatim. + const INHERITED_FROM_REX6: &[u8] = &[ + // Storage-gas and frame-spawning checkpoints: the Rex6 handler chains, which under + // Rex7 open with a checkpoint prologue and close with an epilogue. + SSTORE, + LOG0, + LOG1, + LOG2, + LOG3, + LOG4, + CREATE, + CREATE2, + CALL, + CALLCODE, + DELEGATECALL, + STATICCALL, + SELFDESTRUCT, + // Opcodes revm's table wires ahead of the fork that activates them: every + // `MegaSpecId` maps to a pre-activation Ethereum spec and no `MegaETH` table has ever + // dispatched them, so Rex6 holds `control::unknown` here. + DUPN, + SWAPN, + EXCHANGE, + SLOTNUM, + ]; + + let mut i = 0; + while i < INHERITED_FROM_REX6.len() { + let opcode = INHERITED_FROM_REX6[i] as usize; + table[opcode] = rex6[opcode]; + i += 1; + } + + // Volatile / detention checkpoints: raw instruction, segment settlement, detention cap. + table[BALANCE as usize] = Instruction::new(volatile_data_ext::balance_checkpoint); + table[EXTCODESIZE as usize] = Instruction::new(volatile_data_ext::extcodesize_checkpoint); + table[EXTCODECOPY as usize] = Instruction::new(volatile_data_ext::extcodecopy_checkpoint); + table[EXTCODEHASH as usize] = Instruction::new(volatile_data_ext::extcodehash_checkpoint); + table[BLOCKHASH as usize] = Instruction::new(volatile_data_ext::blockhash_checkpoint); + table[COINBASE as usize] = Instruction::new(volatile_data_ext::coinbase_checkpoint); + table[TIMESTAMP as usize] = Instruction::new(volatile_data_ext::timestamp_checkpoint); + table[NUMBER as usize] = Instruction::new(volatile_data_ext::block_number_checkpoint); + table[DIFFICULTY as usize] = Instruction::new(volatile_data_ext::difficulty_checkpoint); + table[GASLIMIT as usize] = Instruction::new(volatile_data_ext::gas_limit_opcode_checkpoint); + table[BASEFEE as usize] = Instruction::new(volatile_data_ext::basefee_checkpoint); + table[BLOBBASEFEE as usize] = Instruction::new(volatile_data_ext::blobbasefee_checkpoint); + table[BLOBHASH as usize] = Instruction::new(volatile_data_ext::blobhash_checkpoint); + table[SELFBALANCE as usize] = Instruction::new(volatile_data_ext::selfbalance_checkpoint); + table[SLOAD as usize] = Instruction::new(volatile_data_ext::sload_checkpoint); + + // Gas-clamp enforcement: `GAS` has to be a checkpoint so the clamp is restored before + // the counter is observed. + table[GAS as usize] = Instruction::new(compute_gas_ext::gas_checkpoint); + + table } /// Returns the static gas table for the `REX7` spec. /// - /// The instruction table is unchanged from Rex6, so the zeroed set is too. + /// The volatile-guarded set is unchanged from Rex6 — the checkpoint handlers guard and charge + /// exactly the opcodes their per-opcode counterparts did — so the zeroed set is too. The plain + /// opcodes keep revm's entries: their pre-charge is what the segment delta measures. pub(super) const fn gas_table(table: GasTable) -> GasTable { rex6::gas_table(table) } @@ -736,6 +843,135 @@ macro_rules! run_inner_instruction_or_abort { }; } +/// REX7 checkpoint prologue. Runs at the top of every checkpoint handler, before any gas capture or +/// gas-consuming work: +/// +/// 1. Settles the open plain-opcode segment — `baseline − remaining`, both readings on the clamped +/// counter, telescoping over exactly the unwrapped opcodes since the last checkpoint. +/// 2. Restores the clamp-hidden gas, so the checkpoint's body runs on the **true** counter: the +/// CALL-family forwarding math, the `GAS` opcode's pushed value and the storage-gas charges all +/// observe real gas, which is what keeps the clamp unobservable to a transaction that never +/// exceeds a limit. +/// 3. Re-opens the settlement window at the restored counter. +/// +/// Halts — returning from the enclosing handler — when the settlement surfaces a limit exceed, +/// including one latched earlier by a non-compute mutation site. The restore has already happened +/// on that path, so the frame result carries true gas. No-op before REX7. +macro_rules! checkpoint_prologue { + ($context:expr) => { + if $context.host.spec_id().is_enabled(MegaSpecId::REX7) { + let exceeding_result = { + let mut additional_limit = $context.host.additional_limit().borrow_mut(); + let remaining = $context.interpreter.gas.remaining(); + let segment = additional_limit.checkpoint_baseline().saturating_sub(remaining); + let hidden = additional_limit.checkpoint_restore_hidden(); + $context.interpreter.gas.erase_cost(hidden); + additional_limit.sync_checkpoint_baseline($context.interpreter.gas.remaining()); + if additional_limit.record_compute_gas(segment) { + None + } else { + Some(additional_limit.exceeding_instruction_result()) + } + }; + if let Some(result) = exceeding_result { + set_halt_action!($context.interpreter, result); + return Err(result); + } + } + }; +} + +/// REX7 checkpoint epilogue: re-applies the gas clamp from the freshly settled usage — including +/// any detention cap the checkpoint just installed — and re-opens the settlement window on the +/// clamped counter. +/// +/// Only applies when the frame keeps executing. A checkpoint that published an action has either +/// suspended into a child frame (the resume clamps in `AdditionalLimit::before_frame_run`) or ended +/// the frame (the frame's final result restores instead), and clamping either would strand hidden +/// gas across the boundary. No-op before REX7. +macro_rules! checkpoint_epilogue { + ($context:expr) => { + if $context.host.spec_id().is_enabled(MegaSpecId::REX7) && + $context.interpreter.bytecode.action().is_none() + { + let mut additional_limit = $context.host.additional_limit().borrow_mut(); + let hide = + additional_limit.checkpoint_clamp_amount($context.interpreter.gas.remaining()); + if hide > 0 { + let clamped = $context.interpreter.gas.record_regular_cost(hide); + debug_assert!(clamped, "clamp amount exceeds remaining gas"); + } + additional_limit.sync_checkpoint_baseline($context.interpreter.gas.remaining()); + } + }; +} + +/// Records a checkpoint opcode's own body gas (`$gas_before − remaining`) and re-opens the +/// settlement window, enforcing the compute-gas limit exactly as the per-opcode wrappers do. +/// +/// Used by the REX7 checkpoint handlers whose bodies can never spawn a child frame (the volatile +/// opcodes, `SLOAD`, `SELFBALANCE`, `GAS`). The CALL / CREATE and storage-gas bodies use +/// [`record_storage_compute_gas!`] instead, which additionally excludes storage charges and +/// forwarded child gas. +macro_rules! record_checkpoint_body_compute_gas { + ($context:expr, $gas_before:expr) => { + let gas_after = $context.interpreter.gas.remaining(); + let gas_used = $gas_before.saturating_sub(gas_after); + { + let mut additional_limit = $context.host.additional_limit().borrow_mut(); + additional_limit.sync_checkpoint_baseline(gas_after); + compute_gas!($context.interpreter, additional_limit, gas_used); + } + }; + // Variant for the volatile checkpoints, whose tail installs the detention cap. A frame-local + // exceed reports as a revert, which the per-opcode layering carries past the cap application + // rather than returning on, so the cap is applied here before returning. A TX-level exceed + // reports as an out-of-gas halt, which that layering does short-circuit — no cap on that path. + ($context:expr, $gas_before:expr, detention_tail) => { + let gas_after = $context.interpreter.gas.remaining(); + let gas_used = $gas_before.saturating_sub(gas_after); + let exceeding_result = { + let mut additional_limit = $context.host.additional_limit().borrow_mut(); + additional_limit.sync_checkpoint_baseline(gas_after); + if additional_limit.record_compute_gas(gas_used) { + None + } else { + Some(additional_limit.exceeding_instruction_result()) + } + }; + if let Some(result) = exceeding_result { + set_halt_action!($context.interpreter, result); + if !result.is_halt() { + apply_compute_gas_limit!($context); + } + return Err(result); + } + }; +} + +/// Charges `$amount` of `MegaETH` storage gas to the interpreter's counter and keeps it out of the +/// REX7 settlement segment that is currently open, returning the amount charged. +/// +/// Storage gas is never compute gas. A checkpoint body normally subtracts its own charge when +/// [`record_storage_compute_gas!`] closes the body's measurement window — but a body that halts +/// before reaching that macro (a static-context `LOG`, an inner instruction that runs out of gas) +/// leaves the frame-exit settlement measuring a segment the charge is still inside, which would +/// report storage gas as compute gas. Excluding it from the segment as it is charged makes the +/// exclusion hold on both paths; on the normal path the body's own window re-syncs the segment +/// afterwards, so this is invisible there. +/// +/// Returns `Err(OutOfGas)` from the enclosing handler when the frame cannot afford the charge, +/// exactly as a bare `gas!` would — with nothing debited and so nothing to exclude. No-op before +/// REX7, where nothing measures against a segment. +macro_rules! charge_storage_gas { + ($context:expr, $amount:expr) => {{ + let amount: u64 = $amount; + gas!($context.interpreter, amount); + $context.host.additional_limit().borrow_mut().exclude_storage_gas_from_segment(amount); + amount + }}; +} + /// Records an opcode's compute gas in a single measurement window and enforces the compute-gas /// limit. The REX6 storage-affecting handlers invoke it directly with the storage gas they /// charged; plain opcodes use the leaner inline recording in @@ -758,6 +994,9 @@ macro_rules! run_inner_instruction_or_abort { /// CREATE2 differs only by folding its memory-expansion gas into this single window instead of /// recording it separately. /// +/// Under REX7 checkpoint accounting the window is the same one — [`checkpoint_prologue!`] runs +/// ahead of the `$gas_before` capture — but the static gas is not added back: see the macro body. +/// /// On exceeding the compute-gas limit, halts the interpreter and returns from the enclosing /// instruction handler. The early return mirrors [`compute_gas!`] so a trailing statement after /// this macro (e.g. the pre-REX5 `resize_gas` late-record in `storage_gas_ext::create`) is only @@ -765,25 +1004,51 @@ macro_rules! run_inner_instruction_or_abort { /// add gas to the tracker after the OOG was already set. macro_rules! record_storage_compute_gas { ($context:expr, $gas_before:expr, $storage_charged:expr, $opcode:expr) => {{ + let spec = $context.host.spec_id(); + let is_rex6 = spec.is_enabled(MegaSpecId::REX6); + let is_rex7 = spec.is_enabled(MegaSpecId::REX7); let gas_after = $context.interpreter.gas.remaining(); - let mut gas_used = (const { static_gas($opcode) } + $gas_before.saturating_sub(gas_after)) - .saturating_sub($storage_charged); - // Exclude gas forwarded to a child frame. REX5+ excludes the revm-side `CALL_STIPEND` - // (added by value-transferring CALL/CALLCODE without deducting from the parent) so the - // parent's compute gas is not under-counted; pre-REX5 subtracts the full child gas limit - // for replay parity. `forwarded_child_gas` records that deducted amount so the abort path - // below can return it to the parent. + // The per-opcode `$gas_before` window applies on every spec: under checkpoint accounting + // the plain segment ahead of this opcode was already settled by + // [`checkpoint_prologue!`], which also restored the gas clamp, so `$gas_before` + // (captured after the prologue) lives on the true counter and measures the same + // span it measures everywhere else. + // + // What the two differ on is the opcode's static gas. Whoever charges it — the interpreter + // before dispatch, or an outer volatile wrapper — does so ahead of the prologue, so under + // checkpoint accounting it is already inside the settled segment and adding it back here + // would bill it twice. + let mut gas_used = if is_rex7 { + $gas_before.saturating_sub(gas_after).saturating_sub($storage_charged) + } else { + (const { static_gas($opcode) } + $gas_before.saturating_sub(gas_after)) + .saturating_sub($storage_charged) + }; + // Exclude gas forwarded to a child frame. REX5+ keeps the revm-side `CALL_STIPEND` out of + // the subtraction — a value-transferring CALL/CALLCODE mints it into the child's budget + // instead of deducting it from the parent — so the parent's compute gas is not + // under-counted; pre-REX5 subtracts the full child gas limit for replay parity. + // `forwarded_child_gas` records that deducted amount so the abort path below can return it + // to the parent. let mut forwarded_child_gas: u64 = 0; + // The stipend revm mints into the child's budget without debiting the caller, booked once + // this opcode hands the child invocation on — whether or not a child frame then runs. The + // one path that mints nothing is the compute-limit abort below, which discards the pending + // child before the EVM ever sees it. + let mut minted_call_stipend: u64 = 0; match $context.interpreter.bytecode.action() { Some(InterpreterAction::NewFrame(FrameInput::Call(call_inputs))) => { - let stipend_from_revm = if $context.host.spec_id().is_enabled(MegaSpecId::REX5) && + let stipend_from_revm = if spec.is_enabled(MegaSpecId::REX5) && matches!(call_inputs.scheme, CallScheme::Call | CallScheme::CallCode) && call_inputs.transfers_value() { + // The constant is what revm minted: the active gas schedule is required to be + // the one the spec defines, so its `call_stipend` entry is this value. gas::CALL_STIPEND } else { 0 }; + minted_call_stipend = stipend_from_revm; let parent_contributed = call_inputs.gas_limit.saturating_sub(stipend_from_revm); forwarded_child_gas = parent_contributed; gas_used = gas_used.saturating_sub(parent_contributed); @@ -797,10 +1062,22 @@ macro_rules! record_storage_compute_gas { // On a compute-limit halt the pending child `NewFrame` is discarded (the child never runs), // but revm already deducted the forwarded gas and the outer `forward_gas_ext` erase is // skipped on this abort path. REX6+: return that gas to the parent before halting. - let is_rex6 = $context.host.spec_id().is_enabled(MegaSpecId::REX6); let exceeding_result = { let mut additional_limit = $context.host.additional_limit().borrow_mut(); + // Re-open the settlement window at this opcode's exit before recording, so neither a + // halt here nor the frame-final settlement can bill this segment twice. + if is_rex7 { + additional_limit.sync_checkpoint_baseline(gas_after); + } if additional_limit.record_compute_gas(gas_used) { + // The invocation survives this opcode, so the stipend revm minted into its budget + // is now live, whatever becomes of the child: the callee spends it as work no + // envelope funded, or hands it back and shrinks the envelope, or never runs at all + // — a frame init that fails on balance or depth refunds the whole child budget, + // mint included, into the caller's envelope, which shrinks it by the same amount. + // Book it where the destroyed-remainder derivation can reconcile the recorded work + // against what the transaction spent. + additional_limit.record_minted_call_stipend(minted_call_stipend); None } else { Some(additional_limit.exceeding_instruction_result()) @@ -1111,8 +1388,19 @@ pub mod forward_gas_ext { /// - `$wrapped_fn`: Path to the wrapped instruction implementation /// - `$has_transfer_logic`: Expression to determine if value is being transferred (e.g., /// `has_transfer` or `false`) + /// + /// The `@checkpoint_tail` variant additionally re-applies the REX7 gas clamp on the way out. It + /// is used by `CREATE` / `CREATE2`, whose table entries dispatch straight here; the CALL family + /// is wrapped once more by `volatile_data_ext::wrap_call_volatile_check`, which owns the + /// epilogue so that it lands after the detention cap that wrapper installs. macro_rules! wrap_gas_cap { ($fn_name:ident, $opcode_name:expr, $wrapped_fn:path, $has_transfer_logic:expr) => { + wrap_gas_cap!(@inner $fn_name, $opcode_name, $wrapped_fn, $has_transfer_logic, false); + }; + (@checkpoint_tail $fn_name:ident, $opcode_name:expr, $wrapped_fn:path, $has_transfer_logic:expr) => { + wrap_gas_cap!(@inner $fn_name, $opcode_name, $wrapped_fn, $has_transfer_logic, true); + }; + (@inner $fn_name:ident, $opcode_name:expr, $wrapped_fn:path, $has_transfer_logic:expr, $checkpoint_tail:literal) => { #[doc = concat!("`", $opcode_name, "` opcode with 98/100 gas forwarding rule.")] #[inline] pub fn $fn_name< @@ -1138,7 +1426,10 @@ pub mod forward_gas_ext { // We recover the forwarded gas to the child call from the parent call. let child_gas = call_inputs.gas_limit as u128; - // There may be a call stipend if there is value to be transferred. + // There may be a call stipend if there is value to be transferred. The + // constant is what revm added to `gas_limit`: the active gas schedule is + // required to be the one the spec defines, so its `call_stipend` entry is + // this value and the subtraction below cannot go negative. let transfer_gas_stipend = if has_transfer { gas::CALL_STIPEND as u128 } else { 0 }; let forwarded_gas = child_gas - transfer_gas_stipend; // Safe from underflow @@ -1196,6 +1487,9 @@ pub mod forward_gas_ext { } _ => {} } + if $checkpoint_tail { + checkpoint_epilogue!(context); + } inner_outcome } }; @@ -1228,8 +1522,12 @@ pub mod forward_gas_ext { wrap_gas_cap!(call_code, "CALLCODE", storage_gas_ext::call_code, check_call_has_transfer); wrap_gas_cap!(delegate_call, "DELEGATECALL", storage_gas_ext::delegate_call, no_transfer); wrap_gas_cap!(static_call, "STATICCALL", storage_gas_ext::static_call, no_transfer); - wrap_gas_cap!(create, "CREATE", storage_gas_ext::create::, no_transfer); - wrap_gas_cap!(create2, "CREATE2", storage_gas_ext::create::, no_transfer); + wrap_gas_cap!( + @checkpoint_tail create, "CREATE", storage_gas_ext::create::, no_transfer + ); + wrap_gas_cap!( + @checkpoint_tail create2, "CREATE2", storage_gas_ext::create::, no_transfer + ); } /** Volatile data access opcode handlers with compute gas limit enforcement. @@ -1258,12 +1556,18 @@ opcode and revert immediately if the access would be volatile. This ensures that disabled volatile accesses do not pollute the tracker's `volatile_data_accessed` bitmap or lower the `compute_gas_limit`. -The check runs before *anything* is charged for the opcode, including its static gas: every spec's -static gas table zeroes the entries of the opcodes handled here, and the entry is charged via -[`charge_static_gas`] only after the check has declined to fire. +Through REX6 the check runs before *anything* is charged for the opcode, including its static gas: +every spec's static gas table zeroes the entries of the opcodes handled here, and the entry is +charged via [`charge_static_gas`] only after the check has declined to fire. That is what lets the rejection be a revert that keeps the frame's whole remaining gas even when that gas would not have covered the opcode's static cost. +REX7 charges the static entry on a rejection, before producing the revert: the payload is +unchanged, but the static fee stays charged and is settled into compute gas with the open +segment at frame exit. A passing guard still charges at the success-path position (after the +checkpoint prologue has restored the true counter). A frame that cannot afford the static fee +runs out of gas instead of reaching the revert. Frozen specs are unchanged. + # Where the Static Gas Lands Once the Guard Declines `MegaETH`'s gas schedule is the one revm charged from inside each opcode's body, before a static gas @@ -1336,6 +1640,10 @@ pub mod volatile_data_ext { /// transaction is found, so the wrapper backfill archived for that case gets implemented /// instead of the divergence going unnoticed. /// + /// REX7 specifies that same charge-before-load order, so a window miss is not a replay + /// divergence there and this check must not fire. The gate is `!is_enabled(REX7)` rather + /// than a table split because the CALL-family wrapper is shared with the frozen specs. + /// /// The check over-approximates on purpose — it does not reconstruct how far revm 27 would /// have gotten — with one exception: a `MemoryOOG` halt is never routed here, because memory /// expansion was charged before the load under revm 27 as well, so that halt shape cannot @@ -1347,6 +1655,9 @@ pub mod volatile_data_ext { opcode: u8, raw_target: Option
, ) { + if host.spec_id().is_enabled(MegaSpecId::REX7) { + return; + } let Some(target) = raw_target else { return }; if host.volatile_data_tracker().borrow().has_accessed_beneficiary_balance() { return; @@ -1365,15 +1676,15 @@ pub mod volatile_data_ext { } /// Rejects the guarded opcode with the `disableVolatileDataAccess` revert data and returns from - /// the enclosing handler, leaving the frame's gas exactly as it was before the opcode. - /// - /// Every guard using this macro rejects its opcode *before* the opcode executes, so the opcode - /// must cost the frame nothing: the gas snapshot taken into the revert action is what the - /// parent frame gets back. Nothing has been debited at this point — the guarded opcodes are - /// excluded from the interpreter's static-gas pre-charge and are charged by - /// [`charge_static_gas`] only once the guard has declined to fire — so the snapshot is taken - /// as-is. Debiting and refunding around the guard instead would be observably wrong for a - /// frame holding less gas than the pre-charge: it would never reach the guard at all. + /// the enclosing handler, snapshotting the frame's gas as it stands. + /// + /// Through REX6 nothing has been debited at this point — the guarded opcodes are excluded from + /// the interpreter's static-gas pre-charge and are charged by [`charge_static_gas`] only once + /// the guard has declined to fire — so the snapshot is the gas the frame held on entry and the + /// parent gets all of it back. REX7 charges the static entry first, so the snapshot already + /// reflects that debit and the revert does not refund it. Debiting and refunding around the + /// guard instead would be observably wrong for a frozen-spec frame holding less gas than the + /// pre-charge: it would never reach the guard at all. macro_rules! revert_volatile_access_disabled { ($context:expr, $opcode:ident, $access_type:expr) => {{ $context.interpreter.bytecode.set_action(InterpreterAction::new_return( @@ -1576,20 +1887,20 @@ pub mod volatile_data_ext { // The guards apply only once the opcode actually acts on a target. if let Some(addr_word) = context.interpreter.stack.inspect::<0>() { let target: Address = addr_word.into_address(); + let spec = context.host.spec_id(); // REX6: the executing contract (source) reading and zeroing its own balance is // itself a beneficiary observation. Frozen off pre-REX6, where only the stack // target below was guarded. - if context.host.spec_id().is_enabled(MegaSpecId::REX6) && - context.interpreter.input.target_address() == beneficiary - { - revert_volatile_access_disabled!( - context, - SELFDESTRUCT, - VolatileDataAccessType::Beneficiary - ); - } + let hits_source = spec.is_enabled(MegaSpecId::REX6) && + context.interpreter.input.target_address() == beneficiary; // All specs: the stack target (the value-transfer destination). - if target == beneficiary { + let hits_target = target == beneficiary; + if hits_source || hits_target { + // REX7 charge-on-reject: the static entry is paid even though the body never + // runs. Frozen specs keep the historical zero-charge reject. + if spec.is_enabled(MegaSpecId::REX7) { + charge_static_gas!(context, SELFDESTRUCT); + } revert_volatile_access_disabled!( context, SELFDESTRUCT, @@ -1697,11 +2008,18 @@ pub mod volatile_data_ext { /// for a frame that can afford them. /// /// A frame that *cannot* afford the charge is the case where that order shows: the body never - /// runs, so it never loads the target and never marks beneficiary access. That divergence is - /// unreachable from a wrapper. What is reachable is the tail: the detention cap below is - /// applied on every path out of this handler, including the out-of-gas one, so an access marked - /// by an already-returned inner frame is still propagated into the transaction's compute - /// budget. + /// runs, so it never loads the target and never marks beneficiary access. Through REX6 that + /// window is a frozen replay hazard (wontfix #20, tripwire below). REX7 specifies it: the mark + /// is produced when the target account is loaded, so a frame that cannot pay the pre-load fees + /// produces none. + /// + /// REX7 also charges the static entry on a disable rejection (charge-on-reject); frozen specs + /// still reject for free. The charge sits in the open segment and the frame-exit settlement + /// records it as compute. + /// + /// What is reachable on every spec is the tail: the detention cap below is applied on every + /// path out of this handler, including the out-of-gas one, so an access marked by an + /// already-returned inner frame is still propagated into the transaction's compute budget. macro_rules! wrap_call_volatile_check { ($fn_name:ident, $opcode:ident, $inner_fn:path) => { #[doc = concat!("`", stringify!($opcode), "` opcode with volatile data access disabled check for beneficiary.")] @@ -1713,19 +2031,21 @@ pub mod volatile_data_ext { context: InstructionContext<'_, H, WIRE>, ) -> InstructionExecResult { let inner_outcome: InstructionExecResult; + let spec = context.host.spec_id(); + let is_rex7 = spec.is_enabled(MegaSpecId::REX7); // Rex4+: If targeting the beneficiary while volatile access is disabled, revert before // executing the opcode to avoid polluting the tracker. Only this disabled path can // revert and only it needs the EIP-7702 delegate resolved, so the resolve (a DB read) // is gated behind the disabled check to keep it off the common (enabled) hot path — // enabled-access detention is marked by the host as the CALL loads the resolved // delegate. + let mut reject_disabled = false; if context.host.volatile_access_disabled() { // Peek the target address from the stack (position 1 for CALL-like opcodes: // stack layout is [gas_limit, to, ...]). if let Some(addr_word) = context.interpreter.stack.inspect::<1>() { let target: Address = addr_word.into_address(); let beneficiary = context.host.beneficiary_address(); - let spec = context.host.spec_id(); // The raw target already being the beneficiary observes beneficiary state // regardless of where it itself delegates, so check it first — `||` short-circuits // so no EIP-7702 delegate is resolved (and no DB read happens) in that case. @@ -1742,11 +2062,7 @@ pub mod volatile_data_ext { context.host.best_effort_resolve_eip7702_delegate_address(target) == beneficiary) { - revert_volatile_access_disabled!( - context, - $opcode, - VolatileDataAccessType::Beneficiary - ); + reject_disabled = true; } } } @@ -1757,14 +2073,31 @@ pub mod volatile_data_ext { let tripwire_target: Option
= context.interpreter.stack.inspect::<1>().map(|w| w.into_address()); - // Charged here rather than after the body — see the macro's doc comment. The charge - // does not return early: the detention tail below has to run on this path too. - const STATIC_GAS: u64 = static_gas(opcode::$opcode); - if !context.interpreter.gas.record_regular_cost(STATIC_GAS) { - #[cfg(debug_assertions)] - debug_check_frozen_detention_window(context.host, opcode::$opcode, tripwire_target); - apply_compute_gas_limit!(context); - return Err(InstructionResult::OutOfGas); + // REX7 charges the static entry even when the guard will reject, so the fee lands in + // the open segment and the revert does not refund it. Frozen specs keep charging only + // after the guard declines, which is the zero-charge reject the historical tests pin. + // Charged here rather than after the body on the success path — see the macro's doc + // comment. The charge does not return early: the detention tail below has to run on + // this path too. + if is_rex7 || !reject_disabled { + const STATIC_GAS: u64 = static_gas(opcode::$opcode); + if !context.interpreter.gas.record_regular_cost(STATIC_GAS) { + #[cfg(debug_assertions)] + debug_check_frozen_detention_window( + context.host, + opcode::$opcode, + tripwire_target, + ); + apply_compute_gas_limit!(context); + return Err(InstructionResult::OutOfGas); + } + } + if reject_disabled { + revert_volatile_access_disabled!( + context, + $opcode, + VolatileDataAccessType::Beneficiary + ); } // Delegate to the existing forward_gas_ext handler via reborrow so that @@ -1792,6 +2125,25 @@ pub mod volatile_data_ext { // not interpreter state, so it is safe in any interpreter state (including // `NewFrame` after a successful CALL). apply_compute_gas_limit!(context); + // REX7: re-clamp only when the body left this frame executing, which for this + // wrapper means the handler returned normally. Every `Err` stops the interpreter + // loop: a halt ends the frame, and the suspension that publishes a child frame is + // re-clamped by `AdditionalLimit::before_frame_run` when the frame resumes. + // + // The guard is load-bearing on the halting path, because this is the one wrapper that + // carries a failing body to its tail instead of returning at the inner call. revm's + // CALL body charges the value-transfer fee and the memory expansion for the argument + // and return ranges before the account load and the forwarding charge that can run + // out of gas, so a halting body leaves real charges inside the open segment with no + // body window left to record them. The epilogue re-opens that segment at the current + // counter, which would drop exactly those charges from the frame-exit settlement + // about to close it — leaving them neither enforced as work nor booked as destroyed. + // Clamping a frame that is already ending is wrong independently: its final result + // hands the hidden gas back and reads the EVM's own out-of-gas as a clamp-induced + // compute exceed. + if inner_outcome.is_ok() { + checkpoint_epilogue!(context); + } inner_outcome } }; @@ -1803,6 +2155,226 @@ pub mod volatile_data_ext { wrap_call_volatile_check!(static_call, STATICCALL, forward_gas_ext::static_call); wrap_call_volatile_check!(delegate_call, DELEGATECALL, forward_gas_ext::delegate_call); wrap_call_volatile_check!(call_code, CALLCODE, forward_gas_ext::call_code); + + /* Checkpoint variants of the volatile handlers (REX7+). + + Under checkpoint accounting the volatile opcodes stay wrapped — they are checkpoints. The + prologue settles the open plain segment and restores the gas clamp, revm's raw instruction runs + on the true counter, the body's own gas is recorded per opcode, the detention cap is applied + from the fully settled usage exactly as the per-opcode order applies it, and the epilogue + re-clamps against the possibly-lowered headroom. + + Each handler still charges the opcode's static gas at the position its per-opcode counterpart + charges it on the success path, because that position decides what an underfunded frame has + already done when it halts. The one REX7 change is charge-on-reject: a disable rejection + debits the static entry and then reverts, so the fee lands in the open segment for the + frame-exit settlement. A passing guard is unchanged — prologue, `gas_before`, then the + body charge — so the static fee is taken on the restored true counter. + + The frozen detention-window tripwire the per-opcode conditional wrapper carries is not + repeated here: it watches for historical transactions whose replay would diverge across a revm + bump, and no such transaction can exist for a spec with no activation history. The shared + CALL-family wrapper still has the tripwire; that copy is spec-gated so REX7 cannot trip it. */ + + /// Checkpoint form of [`wrap_op_detain_gas_unconditional`]: disabled guard, prologue, static + /// gas ahead of the raw instruction (the position these opcodes' revm bodies charge from), + /// body recording, detention cap, epilogue. + /// + /// A disable rejection charges the static entry first (REX7 charge-on-reject) and then reverts + /// without running the prologue or the body. A passing guard is the baseline order: prologue, + /// `gas_before`, then the charge, so the static fee is taken on the restored true counter. + macro_rules! wrap_checkpoint_detain_gas_unconditional { + ($fn_name:ident, $opcode:ident, $original_fn:path, $access_type:expr) => { + #[doc = concat!("`", stringify!($opcode), "` opcode as a checkpoint: segment settlement, raw instruction, gas detention, re-clamp.")] + #[inline] + pub fn $fn_name( + context: InstructionContext<'_, H, WIRE>, + ) -> InstructionExecResult { + if context.host.volatile_access_disabled() { + charge_static_gas!(context, $opcode); + revert_volatile_access_disabled!(context, $opcode, $access_type); + } + checkpoint_prologue!(context); + let gas_before = context.interpreter.gas.remaining(); + charge_static_gas!(context, $opcode); + + run_inner_instruction_or_abort!($original_fn, context, inner_outcome); + record_checkpoint_body_compute_gas!(context, gas_before, detention_tail); + apply_compute_gas_limit!(context); + checkpoint_epilogue!(context); + inner_outcome + } + }; + } + + /// Checkpoint form of [`wrap_op_detain_gas_conditional`]: beneficiary peek, prologue, raw + /// instruction, static gas after it (the position these opcodes' revm bodies charge from, so an + /// underfunded frame has already popped its operands and marked its access), body recording, + /// detention cap, epilogue. + /// + /// A disable rejection charges the static entry first (REX7 charge-on-reject) and then reverts + /// without running the body, so the success-path charge-after-load order — and the mark that + /// load produces — is unchanged. + macro_rules! wrap_checkpoint_detain_gas_conditional { + ($fn_name:ident, $opcode:ident, $original_fn:path) => { + #[doc = concat!("`", stringify!($opcode), "` opcode as a checkpoint: segment settlement, raw instruction, gas detention, re-clamp.")] + #[inline] + pub fn $fn_name, H: HostExt + ?Sized>( + context: InstructionContext<'_, H, WIRE>, + ) -> InstructionExecResult { + if let Some(addr_word) = context.interpreter.stack.inspect::<0>() { + let target: Address = addr_word.into_address(); + let beneficiary = context.host.beneficiary_address(); + if target == beneficiary && context.host.volatile_access_disabled() { + charge_static_gas!(context, $opcode); + revert_volatile_access_disabled!( + context, + $opcode, + VolatileDataAccessType::Beneficiary + ); + } + } + checkpoint_prologue!(context); + let gas_before = context.interpreter.gas.remaining(); + + run_inner_instruction_or_abort!($original_fn, context, inner_outcome); + charge_static_gas!(context, $opcode); + record_checkpoint_body_compute_gas!(context, gas_before, detention_tail); + apply_compute_gas_limit!(context); + checkpoint_epilogue!(context); + inner_outcome + } + }; + } + + wrap_checkpoint_detain_gas_unconditional!( + timestamp_checkpoint, + TIMESTAMP, + instructions::block_info::timestamp, + VolatileDataAccessType::Timestamp + ); + wrap_checkpoint_detain_gas_unconditional!( + block_number_checkpoint, + NUMBER, + instructions::block_info::block_number, + VolatileDataAccessType::BlockNumber + ); + wrap_checkpoint_detain_gas_unconditional!( + difficulty_checkpoint, + DIFFICULTY, + instructions::block_info::difficulty, + VolatileDataAccessType::Difficulty + ); + wrap_checkpoint_detain_gas_unconditional!( + gas_limit_opcode_checkpoint, + GASLIMIT, + instructions::block_info::gaslimit, + VolatileDataAccessType::GasLimit + ); + wrap_checkpoint_detain_gas_unconditional!( + basefee_checkpoint, + BASEFEE, + instructions::block_info::basefee, + VolatileDataAccessType::BaseFee + ); + wrap_checkpoint_detain_gas_unconditional!( + coinbase_checkpoint, + COINBASE, + instructions::block_info::coinbase, + VolatileDataAccessType::Coinbase + ); + wrap_checkpoint_detain_gas_unconditional!( + blockhash_checkpoint, + BLOCKHASH, + instructions::host::blockhash, + VolatileDataAccessType::BlockHash + ); + wrap_checkpoint_detain_gas_unconditional!( + blobbasefee_checkpoint, + BLOBBASEFEE, + instructions::block_info::blob_basefee, + VolatileDataAccessType::BlobBaseFee + ); + wrap_checkpoint_detain_gas_unconditional!( + blobhash_checkpoint, + BLOBHASH, + instructions::tx_info::blob_hash, + VolatileDataAccessType::BlobHash + ); + + wrap_checkpoint_detain_gas_conditional!( + balance_checkpoint, + BALANCE, + instructions::host::balance + ); + wrap_checkpoint_detain_gas_conditional!( + extcodesize_checkpoint, + EXTCODESIZE, + instructions::host::extcodesize + ); + wrap_checkpoint_detain_gas_conditional!( + extcodecopy_checkpoint, + EXTCODECOPY, + instructions::host::extcodecopy + ); + wrap_checkpoint_detain_gas_conditional!( + extcodehash_checkpoint, + EXTCODEHASH, + instructions::host::extcodehash + ); + + /// `SLOAD` as a checkpoint. Same oracle-volatile handling as [`sload`], but the raw revm + /// instruction runs unwrapped and the open segment settles in the prologue. A disable + /// rejection charges the static entry first (REX7 charge-on-reject) without running the + /// load, so the success-path charge-after-load order is unchanged. + #[inline] + pub fn sload_checkpoint( + context: InstructionContext<'_, H, WIRE>, + ) -> InstructionExecResult { + let target = context.interpreter.input.target_address(); + if target == ORACLE_CONTRACT_ADDRESS && context.host.volatile_access_disabled() { + charge_static_gas!(context, SLOAD); + revert_volatile_access_disabled!(context, SLOAD, VolatileDataAccessType::Oracle); + } + checkpoint_prologue!(context); + let gas_before = context.interpreter.gas.remaining(); + + run_inner_instruction_or_abort!(instructions::host::sload, context, inner_outcome); + charge_static_gas!(context, SLOAD); + record_checkpoint_body_compute_gas!(context, gas_before, detention_tail); + apply_compute_gas_limit!(context); + checkpoint_epilogue!(context); + inner_outcome + } + + /// `SELFBALANCE` as a checkpoint. Same beneficiary-volatile handling as [`selfbalance`], but + /// the raw revm instruction runs unwrapped and the open segment settles in the prologue. A + /// disable rejection charges the static entry first (REX7 charge-on-reject); a passing guard + /// charges after the prologue, at the same position as the baseline handler. + #[inline] + pub fn selfbalance_checkpoint( + context: InstructionContext<'_, H, WIRE>, + ) -> InstructionExecResult { + let target = context.interpreter.input.target_address(); + let beneficiary = context.host.beneficiary_address(); + if target == beneficiary && context.host.volatile_access_disabled() { + charge_static_gas!(context, SELFBALANCE); + revert_volatile_access_disabled!( + context, + SELFBALANCE, + VolatileDataAccessType::Beneficiary + ); + } + checkpoint_prologue!(context); + let gas_before = context.interpreter.gas.remaining(); + charge_static_gas!(context, SELFBALANCE); + + run_inner_instruction_or_abort!(instructions::host::selfbalance, context, inner_outcome); + record_checkpoint_body_compute_gas!(context, gas_before, detention_tail); + apply_compute_gas_limit!(context); + checkpoint_epilogue!(context); + inner_outcome + } } /// Extends opcodes with additional limit (kv update limit, data limit, etc.) enforcement. @@ -1861,6 +2433,9 @@ pub mod additional_limit_ext { set_halt_action!(context.interpreter, result); return Err(result); } + drop(additional_limit); + // REX7: re-clamp once every dimension this opcode touches has been recorded. + checkpoint_epilogue!(context); inner_outcome } @@ -1898,6 +2473,9 @@ pub mod additional_limit_ext { set_halt_action!(context.interpreter, result); return Err(result); } + drop(additional_limit); + // REX7: re-clamp once every dimension this opcode touches has been recorded. + checkpoint_epilogue!(context); inner_outcome } } @@ -1988,6 +2566,9 @@ pub mod storage_gas_ext { >( context: InstructionContext<'_, H, WIRE>, ) -> InstructionExecResult { + // REX7: settle the open segment and restore the clamp before any gas observation, + // so the storage charge and the body's 63/64 forwarding math see the true counter. + checkpoint_prologue!(context); // Captured at the very top so the single compute window covers all of the // opcode's compute work. let gas_before = context.interpreter.gas.remaining(); @@ -2030,9 +2611,7 @@ pub mod storage_gas_ext { .additional_limit() .borrow_mut() .try_consume_storage_stipend(new_account_storage_gas); - let charged = new_account_storage_gas - drained; - gas!(context.interpreter, charged); - charged + charge_storage_gas!(context, new_account_storage_gas - drained) } else { 0 }; @@ -2342,6 +2921,10 @@ pub mod storage_gas_ext { return Err(InstructionResult::StateChangeDuringStaticCall); } + // REX7: settle the open segment and restore the clamp before any gas observation, so the + // memory expansion, the storage charge and the body's forwarding math see the true counter. + checkpoint_prologue!(context); + // Captured before any gas movement so the single compute window covers the wrapper-side // CREATE2 memory expansion as well as the inner opcode. let gas_before = context.interpreter.gas.remaining(); @@ -2374,8 +2957,7 @@ pub mod storage_gas_ext { .additional_limit() .borrow_mut() .try_consume_storage_stipend(create_contract_storage_gas); - let storage_charged = create_contract_storage_gas - drained; - gas!(context.interpreter, storage_charged); + let storage_charged = charge_storage_gas!(context, create_contract_storage_gas - drained); // Run the raw inner create opcode (no `compute_gas_ext` wrapper — REX6 records compute gas // once below). @@ -2423,6 +3005,8 @@ pub mod storage_gas_ext { >( context: InstructionContext<'_, H, WIRE>, ) -> InstructionExecResult { + // REX7: settle the open segment and restore the clamp before any gas observation. + checkpoint_prologue!(context); // Captured at the very top so the single compute window covers the inner opcode. let gas_before = context.interpreter.gas.remaining(); let Some(len) = context.interpreter.stack.inspect::<1>() else { @@ -2451,6 +3035,15 @@ pub mod storage_gas_ext { // storage cost. let storage_charged = log_storage_cost.expect("gas_or_fail! above halts and returns on None"); + // The `gas_or_fail!` above is the storage-gas charge, so it gets the same segment + // exclusion `charge_storage_gas!` applies at every other charge site: the raw opcode below + // can halt (a static frame rejects `LOG` outright) before the recording that would + // otherwise subtract it. + context + .host + .additional_limit() + .borrow_mut() + .exclude_storage_gas_from_segment(storage_charged); // Run the raw opcode and record compute gas once after the body completes (canonical // metering order). Byte-equivalent to the pre-REX6 per-`N` `compute_gas_ext::logK` @@ -2483,6 +3076,8 @@ pub mod storage_gas_ext { >( context: InstructionContext<'_, H, WIRE>, ) -> InstructionExecResult { + // REX7: settle the open segment and restore the clamp before any gas observation. + checkpoint_prologue!(context); // Captured at the very top so the single compute window covers the inner opcode. let gas_before = context.interpreter.gas.remaining(); // The address to the underlying execution contract state @@ -2517,9 +3112,7 @@ pub mod storage_gas_ext { .additional_limit() .borrow_mut() .try_consume_storage_stipend(sstore_set_storage_gas); - let charged = sstore_set_storage_gas - drained; - gas!(context.interpreter, charged); - charged + charge_storage_gas!(context, sstore_set_storage_gas - drained) } else { 0 }; @@ -2559,6 +3152,11 @@ pub mod storage_gas_ext { >( context: InstructionContext<'_, H, WIRE>, ) -> InstructionExecResult { + // REX7: settle the open segment and restore the clamp before any gas observation — the + // beneficiary-creation storage charge below and the inner opcode both run on the true + // counter, which is what keeps the storage charge outside every compute window. + checkpoint_prologue!(context); + // Inside a static frame, revm's inner SELFDESTRUCT halts on the // static-context check without changing state. Skip the mega host work below // (two account inspections, SALT account-creation pricing, the storage-gas @@ -2607,7 +3205,7 @@ pub mod storage_gas_ext { }; let drained = context.host.additional_limit().borrow_mut().try_consume_storage_stipend(cost); - gas!(context.interpreter, cost - drained); + charge_storage_gas!(context, cost - drained); // Record resource usage for new beneficiary account context.host.additional_limit().borrow_mut().on_selfdestruct_new_account(); @@ -2958,8 +3556,16 @@ pub mod compute_gas_ext { } let pre_charged = if SELF_CHARGES_STATIC_GAS { 0 } else { const { static_gas(opcode::SELFDESTRUCT) } }; - let gas_used = pre_charged + gas_before.saturating_sub(context.interpreter.gas.remaining()); + let gas_after = context.interpreter.gas.remaining(); let mut additional_limit = context.host.additional_limit().borrow_mut(); + // The per-opcode `gas_before` window applies on every spec. Under checkpoint accounting the + // plain segment ahead of this opcode was already settled by the `checkpoint_prologue!` in + // `storage_gas_ext::selfdestruct`, which also restored the clamp; the window is re-opened + // here so the frame's final settlement cannot bill this body a second time. + let gas_used = pre_charged + gas_before.saturating_sub(gas_after); + if additional_limit.rex7_enabled() { + additional_limit.sync_checkpoint_baseline(gas_after); + } if !additional_limit.record_compute_gas_all_dims(gas_used) { // A successful inner SELFDESTRUCT has already set its return action, which the halt // replaces; the `Err` is what stops the interpreter loop. @@ -2969,6 +3575,24 @@ pub mod compute_gas_ext { } inner_outcome } + + /// `GAS` as a REX7 checkpoint. + /// + /// `GAS` has to be a checkpoint under gas-clamp enforcement even though it charges nothing but + /// its static gas: the prologue hands the clamp-hidden gas back before the raw instruction + /// reads the counter, so the value pushed on the stack is the true remaining and the clamp + /// stays invisible to any transaction that never exceeds a limit. + #[inline] + pub fn gas_checkpoint( + context: InstructionContext<'_, H, WIRE>, + ) -> InstructionExecResult { + checkpoint_prologue!(context); + let gas_before = context.interpreter.gas.remaining(); + run_inner_instruction_or_abort!(instructions::system::gas, context, inner_outcome); + record_checkpoint_body_compute_gas!(context, gas_before); + checkpoint_epilogue!(context); + inner_outcome + } } /// Trait to inspect the stack elements. diff --git a/crates/mega-evm/src/evm/interfaces.rs b/crates/mega-evm/src/evm/interfaces.rs index d00ab031..8839b25d 100644 --- a/crates/mega-evm/src/evm/interfaces.rs +++ b/crates/mega-evm/src/evm/interfaces.rs @@ -13,8 +13,8 @@ use revm::{ }; use crate::{ - constants, ExternalEnvTypes, IntoMegaethCfgEnv, MegaContext, MegaEvm, MegaHaltReason, - MegaHandler, MegaSpecId, MegaTransaction, MegaTransactionError, + constants, ExternalEnvTypes, IntoMegaethCfgEnv, MeasuredInspector, MegaContext, MegaEvm, + MegaHaltReason, MegaHandler, MegaSpecId, MegaTransaction, MegaTransactionError, }; /// Implementation of [`alloy_evm::Evm`] for `MegaETH` EVM. @@ -113,14 +113,19 @@ where self.inspect = enabled; } + /// Hands back the caller's own inspector, not the measurement shim it is executed inside. fn components(&self) -> (&Self::DB, &Self::Inspector, &Self::Precompiles) { - (&self.inner.ctx.journaled_state.database, &self.inner.inspector, &self.inner.precompiles) + ( + &self.inner.ctx.journaled_state.database, + self.inner.inspector.inner(), + &self.inner.precompiles, + ) } fn components_mut(&mut self) -> (&mut Self::DB, &mut Self::Inspector, &mut Self::Precompiles) { ( &mut self.inner.ctx.journaled_state.database, - &mut self.inner.inspector, + self.inner.inspector.inner_mut(), &mut self.inner.precompiles, ) } @@ -177,8 +182,10 @@ where { type Inspector = INSP; + /// Takes the caller's inspector by value and stores it wrapped in the measurement shim, so a + /// swapped-in inspector is measured exactly like one supplied at construction. fn set_inspector(&mut self, inspector: Self::Inspector) { - self.inner.inspector = inspector; + self.inner.inspector = MeasuredInspector::new(inspector); } fn inspect_one_tx(&mut self, tx: Self::Tx) -> Result { diff --git a/crates/mega-evm/src/evm/mod.rs b/crates/mega-evm/src/evm/mod.rs index a536920d..cdcf17f6 100644 --- a/crates/mega-evm/src/evm/mod.rs +++ b/crates/mega-evm/src/evm/mod.rs @@ -28,7 +28,9 @@ mod context; mod execution; mod factory; +mod frame; mod host; +mod inspector; mod instructions; mod interfaces; mod limit; @@ -46,6 +48,7 @@ pub use context::*; pub use execution::*; pub use factory::*; pub use host::*; +pub use inspector::*; pub use instructions::*; #[allow(unused_imports, unreachable_pub)] pub use interfaces::*; @@ -68,7 +71,7 @@ use revm::{ ExecuteEvm, InspectEvm, Inspector, Journal, }; -use crate::{BucketId, ExternalEnvTypes, LimitUsage, MegaTransaction}; +use crate::{AdditionalLimit, BucketId, ExternalEnvTypes, LimitUsage, MegaTransaction}; /// The main EVM implementation for the `MegaETH` chain. /// @@ -89,9 +92,14 @@ use crate::{BucketId, ExternalEnvTypes, LimitUsage, MegaTransaction}; #[allow(missing_debug_implementations)] #[allow(clippy::type_complexity)] pub struct MegaEvm { + /// The inner EVM, holding the user's inspector wrapped in the measurement shim. + /// + /// The wrapper is `MegaETH`'s, not the caller's: every entry point that accepts an inspector + /// wraps it here, and every accessor hands the unwrapped one back, so `INSP` stays the type + /// the caller named. See [`MeasuredInspector`]. inner: revm::context::Evm< MegaContext, - INSP, + MeasuredInspector, MegaInstructions, PrecompilesMap, EthFrame, @@ -109,6 +117,20 @@ pub struct MegaEvm { /// this view from what execution actually uses. The supported way to change configuration /// is to rebuild the EVM from a reconfigured context. mega_cfg: CfgEnv, + /// The journal decision a frame's classification reached and has not yet carried out (REX7). + /// + /// A frame's result can still be rewritten after the frame loop has produced it — by a late + /// frame-local resource exceed, which is only detectable once the frame's usage has been + /// weighed against its caller's budget. So under REX7 the decision travels from the frame + /// loop that took it to `frame_return_result`, which is past the last rewrite and still ahead + /// of the caller resuming. Frozen specs carry nothing here: they tell the journal at the + /// moment of classification, where revm does. + /// + /// Set by exactly one producer (the frame loops, both of which route through + /// `settle_and_commit_frame`) and taken by exactly one consumer, on the very next step of + /// revm's execution loop. It is `None` outside that one-step window, which + /// `settle_and_commit_frame` asserts in debug builds. + deferred_journal: Option, } impl core::fmt::Debug @@ -124,7 +146,7 @@ impl core::ops::Deref { type Target = revm::context::Evm< MegaContext, - INSP, + MeasuredInspector, MegaInstructions, PrecompilesMap, EthFrame, @@ -165,9 +187,14 @@ impl MegaEvm MegaEvm { /// # Returns /// /// A new `Evm` instance with the specified inspector enabled. + /// + /// The inspector is measured, and the resulting EVM is one the canonical block-execution path + /// will not admit a transaction from: admission is on the strength of a + /// [`TrustedObserver`] declaration, which this constructor does not ask for. An inspector + /// whose type carries one reaches a block through + /// [`with_trusted_inspector`](Self::with_trusted_inspector) instead. pub fn with_inspector(self, inspector: I) -> MegaEvm { let mega_cfg = self.mega_cfg; let inner = revm::context::Evm::new_with_inspector( self.inner.ctx, - inspector, + MeasuredInspector::new(inspector), + self.inner.instruction, + self.inner.precompiles, + ); + MegaEvm { inner, inspect: true, mega_cfg, deferred_journal: None } + } + + /// Creates a new `MegaETH` EVM instance with the given read-only inspector enabled at + /// runtime, and the measurement shim's per-callback work skipped. + /// + /// The bound is the whole of the difference from [`with_inspector`](Self::with_inspector): + /// `I`'s author has declared, in source, that none of its callbacks writes anything back to + /// the EVM, so there is nothing for the shim to measure and it delegates directly. Debug + /// builds measure anyway and assert that the declaration held. + /// + /// See [`TrustedObserver`] for what the declaration promises and what it may not be written + /// for, and [`DeclaredObserver`] for how a tracer this crate cannot name gets one. + /// + /// [`EvmFactory::create_evm_with_inspector`](alloy_evm::EvmFactory::create_evm_with_inspector) + /// cannot reach this — its bound is `I: Inspector` and its return type is fixed — so a node + /// that builds through the factory takes `create_evm(..).with_trusted_inspector(..)`, which + /// keeps the factory's dynamic precompiles. The block executor factory has its own entry, + /// [`MegaBlockExecutorFactory::create_executor_with_trusted_inspector`]( + /// crate::MegaBlockExecutorFactory::create_executor_with_trusted_inspector). + /// + /// The declaration is also what the canonical block-execution path admits an inspected + /// transaction on, so this is the constructor a node tracing block production or validation + /// has to reach. + pub fn with_trusted_inspector( + self, + inspector: I, + ) -> MegaEvm { + let mega_cfg = self.mega_cfg; + let inner = revm::context::Evm::new_with_inspector( + self.inner.ctx, + MeasuredInspector::new_trusted(inspector), self.inner.instruction, self.inner.precompiles, ); - MegaEvm { inner, inspect: true, mega_cfg } + MegaEvm { inner, inspect: true, mega_cfg, deferred_journal: None } } /// Creates a new `MegaETH` EVM instance with the inspector disabled at runtime. /// + /// The caller's inspector is dropped and replaced by `NoOpInspector`, carrying that type's own + /// [`TrustedObserver`] declaration — so an EVM this produces is admitted by the canonical + /// block-execution path even if its inspector is switched back on through + /// [`Evm::set_inspector_enabled`](alloy_evm::Evm::set_inspector_enabled). + /// /// # Returns /// /// A new `Evm` instance with the inspector disabled. @@ -206,11 +279,11 @@ impl MegaEvm { let mega_cfg = self.mega_cfg; let inner = revm::context::Evm::new_with_inspector( self.inner.ctx, - NoOpInspector, + MeasuredInspector::new_trusted(NoOpInspector), self.inner.instruction, self.inner.precompiles, ); - MegaEvm { inner, inspect: false, mega_cfg } + MegaEvm { inner, inspect: false, mega_cfg, deferred_journal: None } } /// Sets the transaction runtime limits for the EVM. @@ -222,7 +295,12 @@ impl MegaEvm { precompiles: self.inner.precompiles, frame_stack: self.inner.frame_stack, }; - Self { inner, inspect: self.inspect, mega_cfg: self.mega_cfg } + Self { + inner, + inspect: self.inspect, + mega_cfg: self.mega_cfg, + deferred_journal: self.deferred_journal, + } } /// Adds or overrides dynamic precompiles in the EVM. @@ -249,7 +327,12 @@ impl MegaEvm { precompiles, frame_stack: self.inner.frame_stack, }; - Self { inner, inspect: self.inspect, mega_cfg: self.mega_cfg } + Self { + inner, + inspect: self.inspect, + mega_cfg: self.mega_cfg, + deferred_journal: self.deferred_journal, + } } } @@ -328,7 +411,15 @@ impl MegaEvm { PrecompilesMap, EthFrame, > { - self.inner + // The measurement shim is an implementation detail of executing through `MegaEvm`; an + // EVM taken apart is no longer executing, so it is handed back unwrapped. + revm::context::Evm { + ctx: self.inner.ctx, + inspector: self.inner.inspector.into_inner(), + instruction: self.inner.instruction, + precompiles: self.inner.precompiles, + frame_stack: self.inner.frame_stack, + } } } @@ -359,16 +450,60 @@ where } else { ExecuteEvm::transact(self, tx)? }; + let trusted_inspector = self.inner.inspector.is_trusted(); + let undeclared_inspector = self.has_undeclared_inspector(); + let is_inside_sandbox = self.ctx().is_inside_sandbox(); + let spec = self.ctx().spec; let additional_limit = self.ctx().additional_limit.borrow(); let LimitUsage { data_size, kv_updates, compute_gas, state_growth } = additional_limit.get_usage(); - Ok(MegaTransactionOutcome { + let outcome = MegaTransactionOutcome { result_and_state, data_size, kv_updates, compute_gas_used: compute_gas, + compute_gas_destroyed: additional_limit.destroyed_compute_gas(), + compute_gas_enforced: additional_limit.enforced_compute_gas(), state_growth_used: state_growth, - }) + inspector_ledger: additional_limit.inspector_ledger(), + undeclared_inspector, + }; + debug_assert_envelope_accounted(spec, is_inside_sandbox, &additional_limit, &outcome); + debug_assert_trusted_observer_kept_its_promise(trusted_inspector, &outcome); + Ok(outcome) + } + + /// Whether this EVM's inspector was built from a [`TrustedObserver`](crate::TrustedObserver) + /// declaration, and so is delegated to unmeasured in release builds. + /// + /// A declaration is what the canonical block-execution path admits an inspected transaction + /// on, so this is the positive half of the question that path asks; the question itself is + /// [`has_undeclared_inspector`](Self::has_undeclared_inspector), which also accounts for an + /// EVM running no inspector at all. + pub const fn has_trusted_inspector(&self) -> bool { + self.inner.inspector.is_trusted() + } + + /// Whether this EVM runs an inspector whose type carries no + /// [`TrustedObserver`](crate::TrustedObserver) declaration. + /// + /// The canonical block-execution path refuses such a transaction outright, because what it + /// reports has to be what the EVM did on every node and the measurement shim cannot see an + /// edit made behind a callback boundary — the interpreter's stack or memory contents, or a + /// direct journal write. A declaration is a line someone wrote in source about a type they had + /// read, which is the only thing that answers that. + /// + /// False for an EVM with no inspector, for two independent reasons: revm's plain frame loop + /// never calls one, and the shim such an EVM carries wraps `NoOpInspector`, which is declared. + /// The second reason is the load-bearing one, because + /// [`Evm::set_inspector_enabled`](alloy_evm::Evm::set_inspector_enabled) is a public trait + /// method that turns the first one off without changing the inspector. + /// + /// False for an EVM built through [`with_trusted_inspector`](Self::with_trusted_inspector). + /// True for every other inspected EVM, including one whose inspector only observes — the + /// criterion is the declaration, not the behaviour of one run. + pub const fn has_undeclared_inspector(&self) -> bool { + self.inspect && !self.inner.inspector.is_trusted() } /// Inspect a transaction and return the outcome. The inspector used is the one set up already @@ -390,16 +525,31 @@ where tx: MegaTransaction, ) -> Result> { let result_and_state = InspectEvm::inspect_tx(self, tx)?; + let trusted_inspector = self.inner.inspector.is_trusted(); + // Not `has_undeclared_inspector()`: that reads the `inspect` flag, and this entry runs the + // inspecting loop whatever the flag says. An inspector swapped in through + // `InspectEvm::set_inspector` leaves the flag alone, so asking the flag here would report + // a transaction an undeclared inspector took part in as one that had none. + let undeclared_inspector = !trusted_inspector; + let is_inside_sandbox = self.ctx().is_inside_sandbox(); + let spec = self.ctx().spec; let additional_limit = self.ctx().additional_limit.borrow(); let LimitUsage { data_size, kv_updates, compute_gas, state_growth } = additional_limit.get_usage(); - Ok(MegaTransactionOutcome { + let outcome = MegaTransactionOutcome { result_and_state, data_size, kv_updates, compute_gas_used: compute_gas, + compute_gas_destroyed: additional_limit.destroyed_compute_gas(), + compute_gas_enforced: additional_limit.enforced_compute_gas(), state_growth_used: state_growth, - }) + inspector_ledger: additional_limit.inspector_ledger(), + undeclared_inspector, + }; + debug_assert_envelope_accounted(spec, is_inside_sandbox, &additional_limit, &outcome); + debug_assert_trusted_observer_kept_its_promise(trusted_inspector, &outcome); + Ok(outcome) } /// Get the bucket IDs used during transaction execution. @@ -412,6 +562,134 @@ where } } +/// Debug-only backstop on a `TrustedObserver` declaration, read once per transaction. +/// +/// The shim verifies the same thing after every callback it measures, which is what names the +/// callback that broke the promise. This asks it again where nothing can be missing: a rewrite +/// made at a callback whose own verification was never written — the shape a callback added later +/// takes — has no later callback to be caught at if it was the transaction's last. +/// +/// Both are debug-only for the same reason. A declared inspector books nothing, so in a release +/// build there is nothing here to read that is not zero by construction. +fn debug_assert_trusted_observer_kept_its_promise(trusted: bool, outcome: &MegaTransactionOutcome) { + debug_assert!( + !trusted || outcome.inspector_ledger.is_zero(), + "an inspector declared `TrustedObserver` wrote something back: {:?}", + outcome.inspector_ledger, + ); +} + +/// Debug-only check that a transaction's tracker lanes account for the whole envelope its receipt +/// reports (REX7+; before REX7 there is no destroyed lane and no non-compute lane, so there is +/// nothing to reconcile). +/// +/// This is the conservation law solved for the envelope — +/// [`ConservationTerms::envelope_for`](crate::ConservationTerms::envelope_for) — evaluated against +/// the destroyed remainder the *outcome* reports rather than the one the settlement derived: +/// +/// ```text +/// C + S + D − K − I == total_gas_spent +/// ``` +/// +/// The settlement site solved the same law for `D`, so on a transaction that settled against this +/// same envelope the check is that identity restated — and that is the point. Its reach is the +/// paths where the two are *not* the same, named below: the terms are re-read after settlement +/// finished, and the envelope is the one the receipt ended up carrying. +/// +/// The inspector term `I` is zero unless a rewriting inspector was attached: it is what the +/// measurement shim booked for gas the inspector wrote into an interpreter counter, a frame +/// envelope, or a returning frame's result, none of which the transaction's own envelope funded. +/// Subtracting it is what keeps the law stated over the EVM's gas rather than over the EVM's gas +/// plus an inspector's edits. +/// +/// The reported compute total is checked to be the sum of the two lanes it is supposed to split +/// into, so a consumer reading either lane and a consumer reading the total cannot disagree. +/// +/// The EIP-3529 refund and the EIP-7623 floor move the number a receipt reports without anyone +/// having burnt the difference; both are carried on the result as their own fields and applied +/// after the envelope is final, so the envelope this compares against is unaffected by either. +/// +/// # The receipt's other two numbers +/// +/// The law reaches one of the three figures a receipt carries. The other two are checked here, each +/// on the terms available to it, because a check that looked only at the envelope would pass on a +/// transaction whose sender was billed a different amount. +/// +/// The **used** figure is stated against the accounted envelope rather than against the reported +/// one, so the same lanes have to account for both numbers the receipt carries. It is a +/// consistency pin rather than an independent measurement of what an inspector did to the refund: +/// the EIP-3529 cap applies to the transaction's whole refund at once, over a sum in which the +/// EVM's own refunds and an inspector's are indistinguishable, so no in-process reading separates +/// them. What stops such a transaction is the block guard, which reads +/// [`InspectorLedger::is_zero`](crate::InspectorLedger::is_zero) and therefore sees the refund +/// lane. +/// +/// The **state-gas** figure is stated against the state-gas lane, and that one does bite. `MegaETH` +/// runs with EIP-8037 off on every path and every spec, so the transaction's own contribution to +/// it — the intrinsic state gas, the per-authorization state refund — is structurally zero and the +/// receipt's figure is exactly what the lane booked. That structural zero is the assumption both +/// EIP-8037 lanes rest on, and this is where it is pinned. +/// +/// What this catches that the settlement site's own cross-check cannot: a result whose envelope is +/// decided *after* settlement, or a path that produces a receipt without settling at all. Both +/// leave the settlement site's derived-versus-booked comparison perfectly happy and the reported +/// total wrong. A failed OP deposit is such a path — its receipt is rebuilt to report the whole +/// gas limit at the outermost error boundary — and is settled explicitly there. +/// +/// Skipped inside a keyless-deploy sandbox: a sandbox transaction never settles a derivation of +/// its own, because the law is stated over an outer transaction's final envelope and the sandbox's +/// gas is a charge inside its parent's. +#[inline] +fn debug_assert_envelope_accounted( + spec: MegaSpecId, + is_inside_sandbox: bool, + additional_limit: &AdditionalLimit, + outcome: &MegaTransactionOutcome, +) { + if cfg!(debug_assertions) && spec.is_enabled(MegaSpecId::REX7) && !is_inside_sandbox { + let envelope = outcome.result_and_state.result.gas().total_gas_spent(); + let terms = additional_limit.conservation_terms(); + debug_assert!( + outcome.compute_gas_used == + outcome.compute_gas_enforced + outcome.compute_gas_destroyed, + "the reported compute total must be the sum of the lanes it splits into: \ + reported {} vs enforced {} + destroyed {}", + outcome.compute_gas_used, + outcome.compute_gas_enforced, + outcome.compute_gas_destroyed, + ); + let accounted = terms.envelope_for(outcome.compute_gas_destroyed); + debug_assert!( + accounted == i128::from(envelope), + "the tracker lanes must account for the whole receipt envelope: \ + accounted {accounted} vs envelope {envelope} \ + (reported compute {}, reported destroyed {}, {terms})", + outcome.compute_gas_used, + outcome.compute_gas_destroyed, + ); + let gas = outcome.result_and_state.result.gas(); + let ledger = outcome.inspector_ledger; + let used_accounted = accounted - i128::from(gas.inner_refunded()); + debug_assert!( + i128::from(gas.tx_gas_used()) == used_accounted.max(i128::from(gas.floor_gas())), + "the same lanes must account for the used figure the receipt reports: \ + used {} vs accounted {used_accounted} (refunded {}, floor {}, \ + inspector refund lane {})", + gas.tx_gas_used(), + gas.inner_refunded(), + gas.floor_gas(), + ledger.refund.net(), + ); + debug_assert!( + i128::from(gas.state_gas_spent_final()) == ledger.state_gas.net().max(0), + "EIP-8037 is off on every MegaETH path, so the receipt's state gas is exactly what \ + the inspector lane booked: reported {} vs lane {}", + gas.state_gas_spent_final(), + ledger.state_gas.net(), + ); + } +} + impl MegaEvm { /// Get the block hashes used during transaction execution. /// @@ -426,7 +704,7 @@ impl MegaEvm MegaTransactionOutcome { + MegaTransactionOutcome { + result_and_state: ExecResultAndState { + result: ExecutionResult::Success { + reason: revm::context::result::SuccessReason::Stop, + gas: revm::context::result::ResultGas::new_with_state_gas(envelope, 0, 0, 0), + logs: Vec::new(), + output: revm::context::result::Output::Call(Bytes::new()), + }, + state: EvmState::default(), + }, + data_size: 0, + kv_updates: 0, + compute_gas_used: 0, + compute_gas_destroyed: 0, + compute_gas_enforced: 0, + state_growth_used: 0, + inspector_ledger: ledger, + undeclared_inspector: false, + } + } + + fn empty_limit(spec: MegaSpecId) -> AdditionalLimit { + AdditionalLimit::new(spec, EvmTxRuntimeLimits::from_spec(spec)) + } + + /// The terminal check has to fail loudly on a receipt whose envelope no lane accounts for. + /// Its reach is the paths where the envelope is decided after settlement, so a version of it + /// that reads the lanes and says nothing is the whole failure mode. + #[test] + #[cfg_attr( + debug_assertions, + should_panic(expected = "the tracker lanes must account for the whole receipt envelope") + )] + fn test_envelope_tripwire_fires_when_the_lanes_account_for_nothing() { + debug_assert_envelope_accounted( + MegaSpecId::REX7, + false, + &empty_limit(MegaSpecId::REX7), + &unaccounted_outcome(21_000, InspectorLedger::default()), + ); + } + + /// A sandbox transaction never settles a derivation of its own — the law is stated over an + /// outer transaction's final envelope, and the sandbox's gas is a charge inside its parent's. + /// The same lanes that trip the check outside a sandbox must be passed over inside one. + #[test] + fn test_envelope_tripwire_is_skipped_inside_a_sandbox() { + debug_assert_envelope_accounted( + MegaSpecId::REX7, + true, + &empty_limit(MegaSpecId::REX7), + &unaccounted_outcome(21_000, InspectorLedger::default()), + ); + } + + /// The declaration is a checked claim, not a comment: an inspector declared + /// `TrustedObserver` that booked anything must fail the transaction it took part in, even + /// when the booking was made at a callback whose own verification is missing. + #[test] + #[cfg_attr( + debug_assertions, + should_panic(expected = "an inspector declared `TrustedObserver` wrote something back") + )] + fn test_trusted_observer_tripwire_fires_on_a_declaration_that_did_not_hold() { + let ledger = InspectorLedger { gas: Lane::once(64), ..Default::default() }; + debug_assert_trusted_observer_kept_its_promise(true, &unaccounted_outcome(0, ledger)); + } } diff --git a/crates/mega-evm/src/evm/precompiles.rs b/crates/mega-evm/src/evm/precompiles.rs index 6246922c..3d14d9e2 100644 --- a/crates/mega-evm/src/evm/precompiles.rs +++ b/crates/mega-evm/src/evm/precompiles.rs @@ -5,12 +5,16 @@ #[cfg(not(feature = "std"))] use alloc as std; -use std::{boxed::Box, string::String, sync::Arc}; +use std::{ + boxed::Box, + string::{String, ToString}, + sync::Arc, +}; -use crate::{ExternalEnvTypes, MegaContext, MegaSpecId}; +use crate::{ExternalEnvTypes, MegaContext, MegaInnerContext, MegaSpecId}; use alloy_evm::{ - precompiles::{DynPrecompile, PrecompilesMap}, - Database, + precompiles::{DynPrecompile, Precompile, PrecompileInput, PrecompilesMap}, + Database, EvmInternals, }; use delegate::delegate; use once_cell::race::OnceBox; @@ -18,9 +22,9 @@ use op_revm::OpSpecId; use revm::{ context::Cfg, context_interface::ContextTr, - handler::{EthPrecompiles, PrecompileProvider}, + handler::{precompile_output_to_interpreter_result, EthPrecompiles, PrecompileProvider}, interpreter::{CallInputs, Gas, InterpreterResult}, - precompile::Precompiles, + precompile::{PrecompileHalt, PrecompileId, Precompiles}, primitives::{Address, AddressSet, HashMap}, }; @@ -224,6 +228,56 @@ impl Default for MegaPrecompiles { } } +/// Runs the precompile addressed by `inputs`, reporting the halt reason next to the +/// `InterpreterResult`. +/// +/// This is a mirror of `>>::run` +/// (`alloy_evm::precompiles`, alloy-evm 0.36.0 — the pinned version) rather than a call into it. +/// Delegating would +/// hand back only the converted `InterpreterResult`, and the conversion folds every non-out-of-gas +/// halt into one opaque `PrecompileError` code — so the caller could no longer tell a doorway +/// reject apart from a failure raised mid-computation, which is exactly the distinction the +/// compute-gas split needs. Mirroring keeps the raw `PrecompileStatus` in reach while the +/// `InterpreterResult` still comes out of the same public conversion, so the two paths agree +/// bit-for-bit (pinned by `test_precompile_mirror_matches_upstream_delegation`). +/// +/// Upgrading alloy-evm obliges a re-read of that upstream function: a new `PrecompileInput` +/// field, a different dispatch address, a result cache, or any other added step must be mirrored +/// here, or this silently stops being the same call. The next published version, 0.37.1, was read +/// against this one: its `run` body is byte-identical, and the differences in that module are in +/// how `PrecompilesMap` stores its dynamic lookup, which this function does not touch. +fn run_precompile_capturing_halt( + precompiles: &PrecompilesMap, + context: &mut MegaInnerContext, + inputs: &CallInputs, +) -> Result)>, String> { + let Some(precompile) = precompiles.get(&inputs.bytecode_address) else { + return Ok(None); + }; + + let (block, tx, cfg, journaled_state, _, local) = context.all_mut(); + + let output = { + let _span = + tracing::trace_span!("precompile", name = precompile.precompile_id().name()).entered(); + precompile.call(PrecompileInput { + data: inputs.input.as_bytes_local(local).as_ref(), + gas: inputs.gas_limit, + reservoir: inputs.reservoir, + caller: inputs.caller, + value: inputs.call_value(), + is_static: inputs.is_static, + internals: EvmInternals::new(journaled_state, block, cfg, tx), + target_address: inputs.target_address, + bytecode_address: inputs.bytecode_address, + }) + } + .map_err(|e| e.to_string())?; + + let halt = output.status.halt_reason().cloned(); + Ok(Some((precompile_output_to_interpreter_result(output, inputs.gas_limit), halt))) +} + impl PrecompileProvider> for PrecompilesMap { @@ -271,15 +325,17 @@ impl PrecompileProvider>::run( - self, - &mut context.inner, - inputs, - )?; + // Run the precompile against the context `MegaContext` wraps. `halt` is the precompile's + // own halt reason, which the `InterpreterResult` no longer carries — the accounting arms + // below read it instead of trying to rebuild it from the collapsed instruction result. + let maybe_output = run_precompile_capturing_halt(self, &mut context.inner, inputs)?; + // The helper's borrow of the map entry ends with the call. Re-read the dispatched + // identity here so the REX7 fixed-fee arm can key on it after that borrow is gone. + let is_kzg_identity = self.get(&address).is_some_and(|precompile| { + *precompile.precompile_id() == PrecompileId::KzgPointEvaluation + }); - Ok(maybe_output.map(|mut output| { + Ok(maybe_output.map(|(mut output, halt)| { // Upstream revm-handler (`precompile_output_to_interpreter_result`) calls // `gas.spend_all()` for every non-success/non-revert precompile status, so // error-path Gas now reports `total_gas_spent() == limit`. Frozen REX5 @@ -325,35 +381,114 @@ impl PrecompileProvider= GAS_COST`): record the declared fixed cost. revm's `PrecompileError` - // halt still consumes the parent's forwarded `gas_limit` from the EVM-gas meter, so - // compute-gas here is intentionally a separate number from the EVM-gas burn. Address - // match uses `bytecode_address` (see above) so DELEGATECALL/CALLCODE to KZG still hit - // this arm. - // * All other error paths (non-KZG, or KZG with `limit() < GAS_COST` meaning the - // wrapper's pre-check itself OOG'd before verification could run): after the - // spend_all undo above, `total_gas_spent() == 0` again. The parent still permanently - // loses the forwarded amount, so record `limit()` to match the EVM-gas burn (do not - // use `total_gas_spent()` here — the structural `limit()` is the intentional charge, - // independent of whether upstream spend_all'd). + // `limit() >= GAS_COST`): the call got through the gas gate and into the upstream + // body, so how far it got decides what was performed. Address match uses + // `bytecode_address` (see above) so DELEGATECALL/CALLCODE to KZG still hit this arm. + // Through REX6 the arm is address-only and the fixed cost is charged for every halt + // it sees. REX7 also requires the dispatched precompile's identity to be + // `KzgPointEvaluation` — a `Custom` (or any other) override at the KZG address is a + // different implementation and falls through to the generic arm — then splits the + // charge by halt reason (below), keeping the performed part on the enforcing lane and + // booking the rest of the forwarded envelope — the caller-supplied `gas_limit`, not + // the REX5-capped effective limit — as destroyed. The REX5 cap still prevents the + // precompile from *doing* more work than the remaining compute budget; the cap gap is + // part of the forwarded envelope, not work, so it belongs with the destroyed + // remainder. + // * All other error paths (non-KZG, KZG with `limit() < GAS_COST` meaning the wrapper's + // pre-check itself OOG'd before the body could run, or — REX7 only — a + // non-`KzgPointEvaluation` implementation sitting at the KZG address): after the + // spend_all undo above, `total_gas_spent() == 0` again. Through REX6 the parent still + // permanently loses the forwarded amount, so those specs record `limit()` as + // enforcing usage to match the EVM-gas burn. REX7 treats the same path as + // performed-zero / destroyed-all: no work ran, so nothing enforces, and the forwarded + // envelope (`gas_limit`, again uncapped) is reported only. + // + // Only the executed half is decided here. The destroyed half depends on how the call + // is *classified*, which an inspector's `call_end` can still rewrite after this point, + // so REX7 stages the two numbers this site knows — the uncapped forwarded envelope and + // the work performed — and the frame's settlement point takes the difference against + // the final classification, exactly as it does for an ordinary frame. Frozen specs + // stage nothing and are unaffected: they have no destroyed lane at all. if is_rex5_enabled { - let compute_gas = if output.result.is_ok_or_revert() { - output.gas.total_gas_spent() - } else if address == kzg_point_evaluation::ADDRESS && - output.gas.limit() >= kzg_point_evaluation::GAS_COST - { - // KZG with the wrapper's `gas_limit < GAS_COST` pre-check passed: upstream - // verification ran and returned a non-OOG error - // (`BlobInvalidInputLength` / `BlobMismatchedVersion` / - // `BlobVerifyKzgProofFailed`). Charge the fixed cost regardless of which - // error variant fired. Using the structural predicate - // (`limit() >= GAS_COST`) instead of an error-variant match keeps this arm - // robust against upstream KZG adding new non-OOG variants. - kzg_point_evaluation::GAS_COST + let is_rex7 = context.spec.is_enabled(MegaSpecId::REX7); + let address_clears_kzg_gas_gate = address == kzg_point_evaluation::ADDRESS && + output.gas.limit() >= kzg_point_evaluation::GAS_COST; + // REX7 keys the fixed-fee arm on the dispatched identity, not just the address. + // Frozen specs keep the address-only match, including a Custom override of KZG. + let take_kzg_fixed_fee_arm = if is_rex7 { + address_clears_kzg_gas_gate && is_kzg_identity + } else { + address_clears_kzg_gas_gate + }; + let mut additional_limit = context.additional_limit.borrow_mut(); + let executed = if output.result.is_ok_or_revert() { + let executed = output.gas.total_gas_spent(); + additional_limit.record_compute_gas(executed); + executed + } else if take_kzg_fixed_fee_arm { + // Inside the KZG body the halt reason marks how much of the fixed-price work + // the call bought before failing. `BlobInvalidInputLength` is the doorway: + // the input length is checked first, so a rejected length means the + // commitment was never read and nothing was computed. Every other halt is + // raised at or after the versioned-hash comparison, i.e. once verification is + // under way, and MegaETH prices verification as the whole fixed fee however + // far it got. + // + // The non-doorway side is a wildcard on purpose: an upstream KZG release that + // adds a halt reason lands there, which can only over-charge, never + // under-charge. So does the unreachable `halt == None` shape (a success or + // revert whose `gas_used` outran the gas limit and was converted to an + // out-of-gas), which no MegaETH-wired precompile can produce. + // + // REX6 and earlier take neither branch's split: they charge the fixed cost for + // every halt this arm sees, doorway rejects included. + if is_rex7 && matches!(halt, Some(PrecompileHalt::BlobInvalidInputLength)) { + 0 + } else { + let executed = kzg_point_evaluation::GAS_COST; + // This recording must not latch a limit exceed. If it did, the halt + // result would leave `frame_init` with a rescue that hands its whole + // remaining gas back to the sender, while the same envelope has just been + // booked as destroyed — the same gas reported twice, once refunded and + // once burnt. + // + // It cannot, and the REX5 forwarded-gas cap is what makes that true: the + // effective limit this arm tests against `GAS_COST` is + // `min(gas_limit, remaining)` for the very `remaining` the limit check + // consults, so reaching this arm at all means the fixed fee already fits + // inside both the frame and the transaction budget. Nothing between the + // cap and here moves that budget — the KZG precompile records no compute + // gas of its own. + debug_assert!( + additional_limit.current_call_remaining_compute_gas() >= executed, + "the forwarded-gas cap must keep the KZG fixed fee inside the \ + remaining compute budget", + ); + additional_limit.record_compute_gas(executed); + executed + } + } else if is_rex7 { + // Every wired precompile that reaches this arm halted on a pre-work input + // rejection, so nothing was performed and the whole forwarded envelope is + // destroyed. A precompile that can halt *after* doing work would need its + // own arm (as KZG has) or must express the failure as a revert, which the + // ok_or_revert branch above records as actual spend. + // + // REX7 also lands a non-`KzgPointEvaluation` override of the KZG address + // here: identity keying sent it off the fixed-fee arm, and a cheap halt is + // priced as performed-zero like every other generic halt. + 0 } else { - output.gas.limit() + let executed = output.gas.limit(); + additional_limit.record_compute_gas(executed); + executed }; - context.additional_limit.borrow_mut().record_compute_gas(compute_gas); + // Hoisted out of the arms on purpose: every arm yields the work it performed, and + // the staging that hands it to the settlement point happens once, so a new arm + // cannot be written that records work and forgets to stage it. Frozen specs stage + // nothing — they have no destroyed lane — which the entry point decides, not the + // arms. + additional_limit.stage_precompile_envelope(gas_limit, executed); } else if context.spec.is_enabled(MegaSpecId::MINI_REX) { context .additional_limit @@ -385,21 +520,42 @@ mod tests { use alloc as std; use std::{rc::Rc, vec::Vec}; - use super::{kzg_point_evaluation::GAS_COST, mini_rex, modexp, rex, MegaPrecompiles}; + use super::{ + kzg_point_evaluation::GAS_COST, mini_rex, modexp, rex, run_precompile_capturing_halt, + MegaPrecompiles, + }; use crate::{ - test_utils::MemoryDatabase, AdditionalLimit, EvmTxRuntimeLimits, MegaContext, MegaSpecId, + test_utils::MemoryDatabase, AdditionalLimit, EvmTxRuntimeLimits, FrameExit, MegaContext, + MegaSpecId, }; - use alloy_evm::precompiles::PrecompilesMap; + use alloy_evm::precompiles::{DynPrecompile, PrecompilesMap}; use alloy_primitives::{Address, Bytes, U256}; use core::cell::RefCell; use revm::{ - handler::PrecompileProvider, - interpreter::{CallInputs, CallScheme, CallValue, InputsImpl, InstructionResult}, - precompile::{PrecompileHalt, PrecompileOutput, PrecompileStatus}, + handler::{FrameResult, PrecompileProvider}, + interpreter::{ + CallInputs, CallOutcome, CallScheme, CallValue, InputsImpl, InstructionResult, + InterpreterResult, + }, + precompile::{PrecompileHalt, PrecompileId, PrecompileOutput, PrecompileStatus}, primitives::eip7823, }; use sha2::{Digest, Sha256}; + /// Runs the frame-init settlement that follows every precompile dispatch, so a `run`-level + /// probe sees the split at the point that decides it. + /// + /// `PrecompilesMap::run` records the work performed and stages the forwarded envelope; the + /// destroyed half is taken here, against the classification the caller will see. These probes + /// have no inspector, so the classification is the one `run` produced and the numbers are the + /// recording site's own — which is what makes them a pin on the split rather than on where it + /// happens to be computed. + fn settle_frame_init(limit: &RefCell, output: InterpreterResult) { + let mut outcome = CallOutcome::new(output, 0..0); + outcome.was_precompile_called = true; + limit.borrow_mut().finalize_frame(&mut FrameResult::Call(outcome), FrameExit::Refused, 0); + } + /// Wraps precompile call data into the [`CallInputs`] shape `PrecompilesMap::run` takes, /// carrying the bytecode address, static flag and gas limit that used to be separate /// arguments. @@ -483,6 +639,37 @@ mod tests { inputs } + /// Mirror of `generate_kzg_test_input()` truncated to 191 bytes — one short of the + /// required 192. Upstream rejects it at the doorway with + /// `PrecompileHalt::BlobInvalidInputLength`, before the commitment is looked at. + fn generate_wrong_length_kzg_test_input() -> InputsImpl { + let mut inputs = generate_kzg_test_input(); + let bytes = match inputs.input { + revm::interpreter::CallInput::Bytes(b) => b, + _ => panic!("expected Bytes"), + }; + let mut buf = bytes.to_vec(); + buf.pop(); + assert_eq!(buf.len(), 191, "the doorway probe must be one byte short of 192"); + inputs.input = revm::interpreter::CallInput::Bytes(Bytes::from(buf)); + inputs + } + + /// Mirror of `generate_kzg_test_input()` with the versioned hash's trailing byte flipped — + /// still 192 bytes, so upstream clears the length doorway and fails the following + /// commitment/versioned-hash comparison with `PrecompileHalt::BlobMismatchedVersion`. + fn generate_mismatched_version_kzg_test_input() -> InputsImpl { + let mut inputs = generate_kzg_test_input(); + let bytes = match inputs.input { + revm::interpreter::CallInput::Bytes(b) => b, + _ => panic!("expected Bytes"), + }; + let mut buf = bytes.to_vec(); + buf[31] ^= 0x01; + inputs.input = revm::interpreter::CallInput::Bytes(Bytes::from(buf)); + inputs + } + fn set_spec_for_context( precompiles_map: &mut PrecompilesMap, _context: &MegaContext, @@ -975,6 +1162,785 @@ mod tests { ); } + /// REX7 KZG verification failure: the fixed fee is the work performed (enforcing) and + /// the rest of the caller-supplied envelope is destroyed. The reported total therefore + /// equals the forwarded envelope, not just the fixed fee. + #[test] + fn test_kzg_precompile_rex7_verification_failure_splits_the_parent_loss() { + let mut db = MemoryDatabase::default(); + let mut context = MegaContext::new(&mut db, MegaSpecId::REX7); + let mut precompiles_map = PrecompilesMap::from_static( + MegaPrecompiles::new_with_spec(MegaSpecId::REX7).precompiles(), + ); + let inputs = generate_invalid_proof_kzg_test_input(); + let address = revm::precompile::kzg_point_evaluation::ADDRESS; + let forwarded_gas = 1_000_000u64; + + let result = + precompiles_map.run(&mut context, &call_inputs(&inputs, address, true, forwarded_gas)); + let output = result.expect("run ok").expect("Some output"); + assert!( + matches!(output.result, InstructionResult::PrecompileError), + "expected PrecompileError on invalid-proof; got {:?}", + output.result + ); + + settle_frame_init(&context.additional_limit, output); + let additional = context.additional_limit.borrow(); + assert_eq!( + additional.get_usage().compute_gas, + forwarded_gas, + "reported compute is the whole forwarded envelope", + ); + assert_eq!( + additional.burned_compute_gas(), + forwarded_gas - GAS_COST, + "the unused envelope is destroyed, not enforced", + ); + } + + /// REX7 generic error (blake2f malformed input): no work ran, so nothing enforces and the + /// whole caller-supplied envelope is destroyed. + #[test] + fn test_blake2f_precompile_rex7_malformed_input_destroys_the_envelope() { + let mut db = MemoryDatabase::default(); + let mut context = MegaContext::new(&mut db, MegaSpecId::REX7); + let mut precompiles_map = PrecompilesMap::from_static( + MegaPrecompiles::new_with_spec(MegaSpecId::REX7).precompiles(), + ); + let address = Address::with_last_byte(9); + let inputs = InputsImpl { + target_address: address, + bytecode_address: Some(address), + caller_address: address, + input: revm::interpreter::CallInput::Bytes(Bytes::from(vec![0xAAu8; 32])), + call_value: Default::default(), + }; + let forwarded_gas = 1_000_000u64; + + let result = + precompiles_map.run(&mut context, &call_inputs(&inputs, address, true, forwarded_gas)); + let output = result.expect("run ok").expect("Some output"); + assert!( + matches!(output.result, InstructionResult::PrecompileError), + "expected PrecompileError on wrong-length blake2f; got {:?}", + output.result + ); + + settle_frame_init(&context.additional_limit, output); + let additional = context.additional_limit.borrow(); + assert_eq!( + additional.get_usage().compute_gas, + forwarded_gas, + "reported compute is the whole forwarded envelope", + ); + assert_eq!( + additional.burned_compute_gas(), + forwarded_gas, + "generic error performed no work, so the whole envelope is destroyed", + ); + } + + /// REX7 + REX5 cap: `effective < gas_limit`, verification still runs. Destroyed is + /// `gas_limit − GAS_COST`, which includes the cap gap. Recording `effective − GAS_COST` + /// instead would drop that third piece of the forwarded envelope. + #[test] + fn test_kzg_precompile_rex7_cap_gap_is_destroyed_not_dropped() { + let mut db = MemoryDatabase::default(); + let mut context = MegaContext::new(&mut db, MegaSpecId::REX7); + set_tx_compute_gas_limit(&mut context, MegaSpecId::REX7, GAS_COST + 1_000); + let mut precompiles_map = PrecompilesMap::from_static( + MegaPrecompiles::new_with_spec(MegaSpecId::REX7).precompiles(), + ); + let inputs = generate_invalid_proof_kzg_test_input(); + let address = revm::precompile::kzg_point_evaluation::ADDRESS; + let forwarded_gas = 500_000u64; + + let result = + precompiles_map.run(&mut context, &call_inputs(&inputs, address, true, forwarded_gas)); + let output = result.expect("run ok").expect("Some output"); + assert!( + matches!(output.result, InstructionResult::PrecompileError), + "expected PrecompileError on invalid-proof under the cap; got {:?}", + output.result + ); + // Halt Gas stays on the capped effective limit. + assert_eq!(output.gas.limit(), GAS_COST + 1_000); + + settle_frame_init(&context.additional_limit, output); + let additional = context.additional_limit.borrow(); + assert_eq!( + additional.get_usage().compute_gas - additional.burned_compute_gas(), + GAS_COST, + "only the fixed fee enforces", + ); + assert_eq!( + additional.burned_compute_gas(), + forwarded_gas - GAS_COST, + "destroyed includes the cap gap (forwarded − effective) plus the unused effective \ + remainder", + ); + } + + /// The fixed-cost arm's `record_compute_gas(GAS_COST)` must never latch a limit exceed. + /// A latch there would make `frame_init` return a halt whose remaining gas is rescued for the + /// sender while the same envelope has already been booked as destroyed, reporting the gas + /// twice. + /// + /// The forwarded-gas cap is what rules it out, and the two halves of that coupling are pinned + /// here at the one gas unit where they meet. With the remaining budget exactly at the fixed + /// fee, the arm fires and lands exactly on the limit — never over it. One unit lower, the cap + /// pushes the effective limit below the fixed fee, so the wrapper's own gas gate fires and the + /// arm is not taken at all: there is no shape in which the recording is reached with less + /// budget than it records. + #[test] + fn test_kzg_fixed_cost_arm_cannot_latch_at_the_cap_boundary() { + for spec in [MegaSpecId::REX5, MegaSpecId::REX6, MegaSpecId::REX7] { + let inputs = generate_invalid_proof_kzg_test_input(); + let address = revm::precompile::kzg_point_evaluation::ADDRESS; + let forwarded_gas = 500_000u64; + + // Remaining budget exactly at the fixed fee: the tightest cap that still admits the + // arm. + let mut db = MemoryDatabase::default(); + let mut context = MegaContext::new(&mut db, spec); + set_tx_compute_gas_limit(&mut context, spec, GAS_COST); + let mut precompiles_map = + PrecompilesMap::from_static(MegaPrecompiles::new_with_spec(spec).precompiles()); + let output = precompiles_map + .run(&mut context, &call_inputs(&inputs, address, true, forwarded_gas)) + .expect("run ok") + .expect("Some output"); + assert!( + matches!(output.result, InstructionResult::PrecompileError), + "{spec:?}: verification must fail past the gas gate; got {:?}", + output.result, + ); + settle_frame_init(&context.additional_limit, output); + let additional = context.additional_limit.borrow(); + assert_eq!( + additional.get_usage().compute_gas - additional.burned_compute_gas(), + GAS_COST, + "{spec:?}: the fixed-cost arm fired and only the fixed fee enforces", + ); + assert!( + !additional.limit_exceeded(), + "{spec:?}: the fixed fee exactly exhausts the budget, which is not an exceed", + ); + drop(additional); + + // One unit lower: the cap forces the wrapper's gas gate, so the fixed-cost arm is + // never reached. + let mut db = MemoryDatabase::default(); + let mut context = MegaContext::new(&mut db, spec); + set_tx_compute_gas_limit(&mut context, spec, GAS_COST - 1); + let mut precompiles_map = + PrecompilesMap::from_static(MegaPrecompiles::new_with_spec(spec).precompiles()); + let output = precompiles_map + .run(&mut context, &call_inputs(&inputs, address, true, forwarded_gas)) + .expect("run ok") + .expect("Some output"); + assert!( + matches!(output.result, InstructionResult::PrecompileOOG), + "{spec:?}: one unit below the fixed fee must stop at the gas gate; got {:?}", + output.result, + ); + assert!( + output.gas.limit() < GAS_COST, + "{spec:?}: the capped effective limit is what excludes the fixed-cost arm", + ); + settle_frame_init(&context.additional_limit, output); + let additional = context.additional_limit.borrow(); + assert!( + !additional.limit_exceeded(), + "{spec:?}: the arm the cap excluded cannot latch either", + ); + } + } + + /// REX6 KZG verification failure stays on the historical single-lane recording: the + /// fixed fee is enforcing and nothing is destroyed. + #[test] + fn test_kzg_precompile_rex6_verification_failure_stays_single_lane() { + let mut db = MemoryDatabase::default(); + let mut context = MegaContext::new(&mut db, MegaSpecId::REX6); + let mut precompiles_map = PrecompilesMap::from_static( + MegaPrecompiles::new_with_spec(MegaSpecId::REX6).precompiles(), + ); + let inputs = generate_invalid_proof_kzg_test_input(); + let address = revm::precompile::kzg_point_evaluation::ADDRESS; + let forwarded_gas = 1_000_000u64; + + let result = + precompiles_map.run(&mut context, &call_inputs(&inputs, address, true, forwarded_gas)); + let output = result.expect("run ok").expect("Some output"); + assert!( + matches!(output.result, InstructionResult::PrecompileError), + "expected PrecompileError on invalid-proof; got {:?}", + output.result + ); + + settle_frame_init(&context.additional_limit, output); + let additional = context.additional_limit.borrow(); + assert_eq!( + additional.get_usage().compute_gas, + GAS_COST, + "REX6 still records only the fixed fee", + ); + assert_eq!(additional.burned_compute_gas(), 0, "REX6 has no destroyed lane"); + } + + // ── KZG failure split: doorway reject vs. verification under way ──────────────── + // + // A KZG call that clears the wrapper's gas gate can still fail in two very different + // places, and REX7 prices them differently. The probes below pin each variant on its own + // side of the split, pin the frozen REX6 amounts for the same inputs, and pin the upstream + // check order the split is derived from. + + /// Drives a KZG call that fails through the wired precompile table on `spec`. + /// + /// Returns the collapsed instruction result together with the tracker's reported and + /// destroyed compute gas, so each probe can state the split as + /// `reported - destroyed == enforced`. + fn record_kzg_failure( + spec: MegaSpecId, + inputs: &InputsImpl, + forwarded_gas: u64, + ) -> (InstructionResult, u64, u64) { + let mut db = MemoryDatabase::default(); + let mut context = MegaContext::new(&mut db, spec); + let mut precompiles_map = + PrecompilesMap::from_static(MegaPrecompiles::new_with_spec(spec).precompiles()); + let address = revm::precompile::kzg_point_evaluation::ADDRESS; + + let result = + precompiles_map.run(&mut context, &call_inputs(inputs, address, true, forwarded_gas)); + let output = result.expect("run ok").expect("Some output"); + assert!(!output.result.is_ok_or_revert(), "the probe must fail; got {:?}", output.result,); + let result = output.result; + settle_frame_init(&context.additional_limit, output); + + let additional = context.additional_limit.borrow(); + (result, additional.get_usage().compute_gas, additional.burned_compute_gas()) + } + + /// The premise the REX7 split rests on: upstream checks the input length before it reads + /// the commitment, so a wrong length is a doorway reject and a mismatched versioned hash + /// is not. If upstream ever reorders those checks, the split's "nothing was computed" + /// claim stops holding and this turns red before the accounting probes do. + #[test] + fn test_kzg_halt_reasons_distinguish_the_doorway_from_verification() { + let map = PrecompilesMap::from_static( + MegaPrecompiles::new_with_spec(MegaSpecId::REX7).precompiles(), + ); + let address = revm::precompile::kzg_point_evaluation::ADDRESS; + + for (label, inputs, expected) in [ + ( + "wrong length", + generate_wrong_length_kzg_test_input(), + PrecompileHalt::BlobInvalidInputLength, + ), + ( + "mismatched versioned hash", + generate_mismatched_version_kzg_test_input(), + PrecompileHalt::BlobMismatchedVersion, + ), + ( + "invalid proof", + generate_invalid_proof_kzg_test_input(), + PrecompileHalt::BlobVerifyKzgProofFailed, + ), + ] { + let mut db = MemoryDatabase::default(); + let mut context = MegaContext::new(&mut db, MegaSpecId::REX7); + let (_, halt) = run_precompile_capturing_halt( + &map, + &mut context.inner, + &call_inputs(&inputs, address, true, 1_000_000), + ) + .expect("run ok") + .expect("Some output"); + assert_eq!(halt, Some(expected), "{label}: unexpected KZG halt reason"); + } + } + + /// REX7 splits KZG failures by where they were raised: a doorway reject performed no work, + /// everything past the doorway is priced at the whole fixed fee. + #[test] + fn test_kzg_precompile_rex7_splits_failures_by_halt_reason() { + let forwarded = 1_000_000u64; + + let (result, reported, destroyed) = record_kzg_failure( + MegaSpecId::REX7, + &generate_wrong_length_kzg_test_input(), + forwarded, + ); + assert!( + matches!(result, InstructionResult::PrecompileError), + "a wrong-length reject is not an out-of-gas; got {result:?}", + ); + assert_eq!(reported, forwarded, "reported compute is the whole forwarded envelope"); + assert_eq!(destroyed, forwarded, "a doorway reject performed no work"); + assert_eq!(reported - destroyed, 0, "so nothing enforces"); + + for (label, inputs) in [ + ("mismatched versioned hash", generate_mismatched_version_kzg_test_input()), + ("invalid proof", generate_invalid_proof_kzg_test_input()), + ] { + let (_, reported, destroyed) = record_kzg_failure(MegaSpecId::REX7, &inputs, forwarded); + assert_eq!(reported, forwarded, "{label}: reported compute is the whole envelope"); + assert_eq!( + reported - destroyed, + GAS_COST, + "{label}: verification was under way, so the fixed fee is the work performed", + ); + } + } + + /// REX6 pin for the same three inputs: the frozen spec charges the fixed fee for every KZG + /// failure it sees, doorway rejects included, and has no destroyed lane. Any drift in the + /// REX7 arm that leaked back into the shared code path shows up here. + #[test] + fn test_kzg_precompile_rex6_charges_the_fixed_cost_for_every_failure() { + let forwarded = 1_000_000u64; + for (label, inputs) in [ + ("wrong length", generate_wrong_length_kzg_test_input()), + ("mismatched versioned hash", generate_mismatched_version_kzg_test_input()), + ("invalid proof", generate_invalid_proof_kzg_test_input()), + ] { + let (_, reported, destroyed) = record_kzg_failure(MegaSpecId::REX6, &inputs, forwarded); + assert_eq!(reported, GAS_COST, "{label}: REX6 records only the fixed fee"); + assert_eq!(destroyed, 0, "{label}: REX6 has no destroyed lane"); + } + } + + /// The knife edge of the fixed-cost arm's `limit() >= GAS_COST` predicate, taken on a + /// doorway-rejected input. + /// + /// At exactly `GAS_COST` the wrapper's `gas_limit < GAS_COST` pre-check passes by one, the + /// call reaches the length doorway, and the split books the envelope as destroyed. One gas + /// lower the wrapper halts out of gas before the doorway, which is the generic error arm. + /// Both arms destroy the same envelope under REX7, so the edge is invisible there — but + /// REX6 charges the fixed fee on one side and the (equal) forwarded limit on the other, and + /// pinning both keeps the predicate's boundary from drifting unnoticed. + #[test] + fn test_kzg_precompile_wrong_length_at_the_fixed_cost_boundary() { + let inputs = generate_wrong_length_kzg_test_input(); + + let (result, reported, destroyed) = record_kzg_failure(MegaSpecId::REX7, &inputs, GAS_COST); + assert!( + matches!(result, InstructionResult::PrecompileError), + "at exactly GAS_COST the wrapper's gas gate passes; got {result:?}", + ); + assert_eq!(reported, GAS_COST); + assert_eq!(destroyed, GAS_COST, "the doorway reject destroys the whole envelope"); + + let (result, reported, destroyed) = + record_kzg_failure(MegaSpecId::REX7, &inputs, GAS_COST - 1); + assert!( + matches!(result, InstructionResult::PrecompileOOG), + "one gas below GAS_COST the wrapper's gate halts first; got {result:?}", + ); + assert_eq!(reported, GAS_COST - 1); + assert_eq!(destroyed, GAS_COST - 1, "the generic arm destroys the envelope too"); + + // Frozen side of the same edge. + let (_, reported, destroyed) = record_kzg_failure(MegaSpecId::REX6, &inputs, GAS_COST); + assert_eq!(reported, GAS_COST, "REX6 charges the fixed fee at the boundary"); + assert_eq!(destroyed, 0); + let (_, reported, destroyed) = record_kzg_failure(MegaSpecId::REX6, &inputs, GAS_COST - 1); + assert_eq!(reported, GAS_COST - 1, "REX6 charges the forwarded limit below it"); + assert_eq!(destroyed, 0); + } + + // ── dyn-precompile halt accounting: identity keying vs. the generic arm ────────── + // + // `with_dyn_precompiles` can sit a `PrecompileId::Custom` implementation on any address, + // including KZG's. Through REX6 the fixed-fee arm is address-only, so that override still + // walks the KZG arm. REX7 keys the arm on identity as well, so the same override falls + // through to the generic halt arm (executed 0, whole envelope destroyed). The probes + // below pin both sides of the gate, and pin a Custom halt at a non-KZG address so the + // generic arm's dyn-halt shape has a direct assertion — the parity matrix only covers + // the revert shape. + + /// Address used to pin a Custom dyn-precompile halt off the KZG address. + const DYN_HALT_ADDRESS: Address = Address::with_last_byte(0x7f); + + /// Installs a cheap-halting dynamic precompile at `address` and drives one call. + /// + /// The closure returns `OutOfGas` without doing work, so a miss of the identity key + /// (REX7 still taking the KZG arm) charges `GAS_COST` as executed, while the generic + /// arm reports the whole envelope as destroyed. `PrecompileOOG` also distinguishes the + /// override from the wired KZG doorway (`PrecompileError` on a short input). + fn record_dyn_precompile_halt( + spec: MegaSpecId, + address: Address, + id: PrecompileId, + forwarded_gas: u64, + ) -> (InstructionResult, u64, u64) { + let mut db = MemoryDatabase::default(); + let mut context = MegaContext::new(&mut db, spec); + let mut precompiles_map = + PrecompilesMap::from_static(MegaPrecompiles::new_with_spec(spec).precompiles()); + precompiles_map.apply_precompile(&address, move |_| { + Some(DynPrecompile::new(id, |input| { + Ok(PrecompileOutput::halt(PrecompileHalt::OutOfGas, input.reservoir)) + })) + }); + let inputs = InputsImpl { + target_address: address, + bytecode_address: Some(address), + caller_address: address, + input: revm::interpreter::CallInput::Bytes(Bytes::new()), + call_value: Default::default(), + }; + + let output = precompiles_map + .run(&mut context, &call_inputs(&inputs, address, true, forwarded_gas)) + .expect("run ok") + .expect("Some output"); + assert!(!output.result.is_ok_or_revert(), "the probe must halt; got {:?}", output.result,); + let result = output.result; + settle_frame_init(&context.additional_limit, output); + + let additional = context.additional_limit.borrow(); + (result, additional.get_usage().compute_gas, additional.burned_compute_gas()) + } + + /// REX7: a Custom override of the KZG address is not the wired KZG implementation, so + /// the fixed-fee arm must not run. The generic arm books executed 0 and destroys the + /// whole forwarded envelope. + #[test] + fn test_rex7_custom_override_at_kzg_address_takes_the_generic_halt_arm() { + let kzg = revm::precompile::kzg_point_evaluation::ADDRESS; + let forwarded = 1_000_000u64; + + let (result, reported, destroyed) = record_dyn_precompile_halt( + MegaSpecId::REX7, + kzg, + PrecompileId::Custom("kzg-override-halt".into()), + forwarded, + ); + + assert!( + matches!(result, InstructionResult::PrecompileOOG), + "the override's cheap halt must surface as PrecompileOOG; got {result:?}", + ); + assert_eq!(reported, forwarded, "reported compute is the whole forwarded envelope"); + assert_eq!(destroyed, forwarded, "the generic arm destroys the whole envelope"); + assert_eq!(reported - destroyed, 0, "executed work must be zero, not the KZG fixed fee"); + } + + /// Frozen pin of the same override: REX6 still keys the fixed-fee arm on address alone, + /// so a Custom halt at the KZG address is charged as wired KZG. + #[test] + fn test_rex6_custom_override_at_kzg_address_still_takes_the_kzg_arm() { + let kzg = revm::precompile::kzg_point_evaluation::ADDRESS; + let forwarded = 1_000_000u64; + + let (result, reported, destroyed) = record_dyn_precompile_halt( + MegaSpecId::REX6, + kzg, + PrecompileId::Custom("kzg-override-halt".into()), + forwarded, + ); + + assert!( + matches!(result, InstructionResult::PrecompileOOG), + "the override's cheap halt must surface as PrecompileOOG; got {result:?}", + ); + assert_eq!(reported, GAS_COST, "REX6 charges the KZG fixed fee for an address match"); + assert_eq!(destroyed, 0, "REX6 has no destroyed lane"); + } + + /// REX7: a Custom dyn-precompile halt at a non-KZG address is the generic arm — + /// executed 0, whole envelope destroyed. The parity matrix only pins the revert shape. + #[test] + fn test_rex7_custom_dyn_halt_at_plain_address_takes_the_generic_halt_arm() { + let forwarded = 1_000_000u64; + + let (result, reported, destroyed) = record_dyn_precompile_halt( + MegaSpecId::REX7, + DYN_HALT_ADDRESS, + PrecompileId::Custom("plain-halt".into()), + forwarded, + ); + + assert!( + matches!(result, InstructionResult::PrecompileOOG), + "the dyn halt must surface as PrecompileOOG; got {result:?}", + ); + assert_eq!(reported, forwarded, "reported compute is the whole forwarded envelope"); + assert_eq!(destroyed, forwarded, "the generic arm destroys the whole envelope"); + assert_eq!(reported - destroyed, 0, "executed work must be zero"); + } + + // ── seam parity: the mirror must still be the upstream call ────────────────────── + // + // `run_precompile_capturing_halt` reimplements alloy-evm's `PrecompilesMap::run` so the + // precompile's halt reason survives the conversion. That is only safe while the + // reimplementation produces exactly what delegating would have produced, and nothing in + // the type system holds it there — an alloy-evm bump can change the upstream body + // underneath it. The probe below drives both paths over the same inputs and compares the + // whole `InterpreterResult`, so an upstream change the mirror has not picked up turns red + // here rather than silently shifting consensus. + + /// Address the parity matrix installs a dynamic precompile at. Outside the builtin table, + /// so with the dynamic precompile absent the same case doubles as a dispatch-miss probe. + const PARITY_DYN_ADDRESS: Address = Address::with_last_byte(0x7e); + + /// Builds the table both paths run against. + /// + /// `with_dynamic` installs a reverting precompile at [`PARITY_DYN_ADDRESS`], which also + /// flips the map into its dynamic representation — the `PrecompilesMap::get` branch the + /// builtin table never reaches, and the only way to produce a reverting precompile at all + /// (no builtin returns `PrecompileStatus::Revert`). + /// + /// It echoes the forwarded call value into its output and the forwarded reservoir into its + /// gas, so both become observable in the compared `InterpreterResult`. No builtin precompile + /// reads the call value, so without this the value cases in the matrix would only prove the + /// two paths agree on an input neither of them can act on. + fn parity_precompiles(with_dynamic: bool) -> PrecompilesMap { + let mut map = PrecompilesMap::from_static( + MegaPrecompiles::new_with_spec(MegaSpecId::REX7).precompiles(), + ); + if with_dynamic { + map.apply_precompile(&PARITY_DYN_ADDRESS, |_| { + Some(DynPrecompile::new(PrecompileId::Custom("parity-revert".into()), |input| { + let mut echoed = Vec::from(b"reverted".as_slice()); + echoed.extend_from_slice(&input.value.to_be_bytes::<32>()); + Ok(PrecompileOutput::revert( + input.gas / 4, + Bytes::from(echoed), + input.reservoir, + )) + })) + }); + } + map + } + + /// Sets the EIP-8037 state-gas reservoir on an already-built [`CallInputs`]. + /// + /// `reservoir` and `value` are two of the nine `PrecompileInput` fields the mirror forwards, + /// and no builtin precompile reads either — so an upstream step keyed on one of them would + /// pass a matrix that leaves both at zero. The cases built with these helpers vary them + /// instead, on both an `is_ok_or_revert` arm and a failing arm. + fn with_reservoir(mut inputs: CallInputs, reservoir: u64) -> CallInputs { + inputs.reservoir = reservoir; + inputs + } + + /// Sets the call value on an already-built [`CallInputs`], as a non-static CALL. + fn with_value(mut inputs: CallInputs, value: U256) -> CallInputs { + inputs.value = CallValue::Transfer(value); + inputs.is_static = false; + inputs + } + + /// One case per distinguishable path through the upstream body: dispatch miss, revert, + /// success, each KZG failure variant, the wrapper's own gas gate, a generic + /// malformed-input halt, an out-of-gas halt, and the DELEGATECALL bytecode-vs-target split. + /// Each of the two forwarded inputs no builtin consults — the state-gas reservoir and the + /// call value — additionally appears on a succeeding and on a failing case. + fn parity_cases() -> Vec<(&'static str, CallInputs)> { + let kzg = revm::precompile::kzg_point_evaluation::ADDRESS; + let ecrecover = Address::with_last_byte(1); + let identity = Address::with_last_byte(4); + let blake2f = Address::with_last_byte(9); + let not_a_precompile = Address::with_last_byte(0xff); + + let plain = |address: Address, data: Vec| InputsImpl { + target_address: address, + bytecode_address: Some(address), + caller_address: address, + input: revm::interpreter::CallInput::Bytes(Bytes::from(data)), + call_value: Default::default(), + }; + + vec![ + ( + "dispatch miss", + call_inputs( + &plain(not_a_precompile, vec![1, 2, 3]), + not_a_precompile, + true, + 1_000_000, + ), + ), + ( + "dynamic revert", + call_inputs( + &plain(PARITY_DYN_ADDRESS, vec![7; 8]), + PARITY_DYN_ADDRESS, + true, + 1_000_000, + ), + ), + ("success", call_inputs(&plain(identity, vec![0xAB; 64]), identity, true, 1_000_000)), + ("kzg success", call_inputs(&generate_kzg_test_input(), kzg, true, 1_000_000)), + ( + "kzg wrong length", + call_inputs(&generate_wrong_length_kzg_test_input(), kzg, true, 1_000_000), + ), + ( + "kzg mismatched versioned hash", + call_inputs(&generate_mismatched_version_kzg_test_input(), kzg, true, 1_000_000), + ), + ( + "kzg invalid proof", + call_inputs(&generate_invalid_proof_kzg_test_input(), kzg, true, 1_000_000), + ), + ( + "kzg below the fixed cost", + call_inputs(&generate_kzg_test_input(), kzg, true, GAS_COST - 1), + ), + ( + "blake2f malformed input", + call_inputs(&plain(blake2f, vec![0xAA; 32]), blake2f, true, 1_000_000), + ), + ( + "ecrecover out of gas", + call_inputs(&plain(ecrecover, vec![0u8; 128]), ecrecover, true, 100), + ), + ( + "delegatecall to kzg", + call_inputs_with_scheme( + &generate_invalid_proof_kzg_test_input(), + Address::repeat_byte(0xAB), + kzg, + true, + 200_000, + CallScheme::DelegateCall, + ), + ), + ( + "success with reservoir", + with_reservoir( + call_inputs(&plain(identity, vec![0xCD; 96]), identity, true, 1_000_000), + 250_000, + ), + ), + ( + // The dynamic precompile echoes `input.reservoir` straight into its output, so + // this is the one case where a mirrored reservoir is directly observable in the + // returned `InterpreterResult` rather than only forwarded. + "dynamic revert with reservoir", + with_reservoir( + call_inputs( + &plain(PARITY_DYN_ADDRESS, vec![7; 8]), + PARITY_DYN_ADDRESS, + true, + 1_000_000, + ), + 250_000, + ), + ), + ( + "kzg invalid proof with reservoir", + with_reservoir( + call_inputs(&generate_invalid_proof_kzg_test_input(), kzg, true, 1_000_000), + 250_000, + ), + ), + ( + "success with value", + with_value( + call_inputs(&plain(identity, vec![0xEF; 96]), identity, false, 1_000_000), + U256::from(7u64), + ), + ), + ( + // The dynamic precompile echoes `input.value` into its output bytes, so this is + // the case that observes a mirrored call value rather than only forwarding it. + "dynamic revert with value", + with_value( + call_inputs( + &plain(PARITY_DYN_ADDRESS, vec![7; 8]), + PARITY_DYN_ADDRESS, + false, + 1_000_000, + ), + U256::from(7u64), + ), + ), + ( + "blake2f malformed input with value", + with_value( + call_inputs(&plain(blake2f, vec![0xAA; 32]), blake2f, false, 1_000_000), + U256::from(7u64), + ), + ), + ] + } + + #[test] + fn test_precompile_mirror_matches_upstream_delegation() { + for with_dynamic in [false, true] { + for (label, inputs) in parity_cases() { + // Mirror path. + let mut mirror_db = MemoryDatabase::default(); + let mut mirror_ctx = MegaContext::new(&mut mirror_db, MegaSpecId::REX7); + let mirror_map = parity_precompiles(with_dynamic); + let mirrored = + run_precompile_capturing_halt(&mirror_map, &mut mirror_ctx.inner, &inputs) + .expect("the mirror must not fail fatally"); + + // Delegated path: alloy-evm's own provider impl, reached exactly as the + // pre-mirror code reached it. + let mut delegate_db = MemoryDatabase::default(); + let mut delegate_ctx = MegaContext::new(&mut delegate_db, MegaSpecId::REX7); + let mut delegate_map = parity_precompiles(with_dynamic); + let delegated = PrecompileProvider::>::run( + &mut delegate_map, + &mut delegate_ctx.inner, + &inputs, + ) + .expect("delegation must not fail fatally"); + + match (mirrored, delegated) { + (None, None) => {} + (Some((mirror_result, halt)), Some(delegate_result)) => { + assert_eq!( + mirror_result.result, delegate_result.result, + "{label} (dynamic table: {with_dynamic}): instruction result", + ); + assert_eq!( + mirror_result.output, delegate_result.output, + "{label} (dynamic table: {with_dynamic}): output bytes", + ); + assert_eq!( + mirror_result.gas, delegate_result.gas, + "{label} (dynamic table: {with_dynamic}): gas", + ); + // Whole-struct equality also covers `Gas`'s private tracker and memory + // fields, which the accessors above do not reach. + assert_eq!( + mirror_result, delegate_result, + "{label} (dynamic table: {with_dynamic})", + ); + // The captured signal must agree with the code the conversion collapsed + // it into; a halt reason next to a success would mean the mirror read + // the wrong output. + assert_eq!( + halt.is_some(), + !mirror_result.result.is_ok_or_revert(), + "{label} (dynamic table: {with_dynamic}): captured halt reason must \ + match the collapsed result", + ); + } + (mirror, delegate) => panic!( + "{label} (dynamic table: {with_dynamic}): dispatch disagreed — mirror \ + returned {:?}, delegation returned {:?}", + mirror.is_some(), + delegate.is_some(), + ), + } + } + } + } + /// Direct unit coverage for `PrecompileProvider::contains` on the Mega /// `PrecompilesMap` wrapper. /// diff --git a/crates/mega-evm/src/evm/result.rs b/crates/mega-evm/src/evm/result.rs index e865360a..882f877c 100644 --- a/crates/mega-evm/src/evm/result.rs +++ b/crates/mega-evm/src/evm/result.rs @@ -29,9 +29,120 @@ pub struct MegaTransactionOutcome { /// The number of KV updates. pub kv_updates: u64, /// The compute gas used. + /// + /// This is the transaction's full reported total, which under Rex7+ also carries whatever an + /// exceptionally halted frame destroyed rather than performed. It is the number to report and + /// to accumulate into block-level compute accounting; it is not the number to compare against + /// a limit — see [`compute_gas_destroyed`](Self::compute_gas_destroyed). + /// + /// These two fields are the uninspected execution's split, and an observation-only inspector + /// leaves them exactly there. An inspector's edits to interpreter gas counters and to frame + /// gas limits are measured at the callback boundary and kept out of the split, and an edit to + /// a returning frame's result is booked at that frame's settlement — all three are reported on + /// [`inspector_ledger`](Self::inspector_ledger), which is how a consumer tells an execution + /// the EVM produced alone from one an inspector took part in. pub compute_gas_used: u64, + /// The part of [`compute_gas_used`](Self::compute_gas_used) the transaction destroyed rather + /// than performed (Rex7+, always 0 before). + /// + /// Derived from what the transaction spent, not summed from the sites that destroyed it: gas + /// the transaction burnt is either work the trackers recorded, `MegaETH` storage gas, or a + /// budget something threw away without executing anything for it, and this field is the last + /// of the three read off as the remainder. + /// + /// The derivation runs once, where the transaction's gas envelope is final. A transaction that + /// never reaches that point reports zero: a validation reject, which produces no receipt and + /// has no envelope to split, and the pre-execution intrinsic overrun, which is a validation + /// reject on every spec that has this lane — see `MegaHandler::before_execution`, whose + /// short-circuit books the split for a future spec that could reach it but is not itself a + /// settlement point. + /// + /// This is a reported number and nothing else. Destroyed gas is not work the network did, so + /// no resource limit is evaluated against it at any level — a consumer that accumulates this + /// outcome into a further limit reads + /// [`compute_gas_enforced`](Self::compute_gas_enforced), which comes off the enforcement lane + /// itself rather than out of this subtraction. + /// + /// Same inspector caveat as [`compute_gas_used`](Self::compute_gas_used): the field is the + /// uninspected split unless [`inspector_ledger`](Self::inspector_ledger) says otherwise. + pub compute_gas_destroyed: u64, + /// The part of [`compute_gas_used`](Self::compute_gas_used) every compute-gas limit is + /// evaluated against: the work the transaction performed, with Rex7+ destroyed remainders left + /// out (equal to `compute_gas_used` before Rex7, which destroys nothing). + /// + /// Read straight off the lane the transaction enforced its own compute limit on, built from + /// the per-opcode and checkpoint recordings — deliberately *not* reconstructed as + /// `compute_gas_used - compute_gas_destroyed`. The two are equal, and a cross-check in debug + /// builds fails loudly if they ever stop being, but they are equal by agreement of two + /// independent measurements rather than by construction. Every further limit this outcome + /// feeds — today the block compute-gas counter — is enforcement, and enforcement stays on the + /// measurement it was always on. + pub compute_gas_enforced: u64, /// The state growth used. pub state_growth_used: u64, + /// What an inspector did to this transaction, measured rather than inferred. + /// + /// `MegaETH` wraps every inspector it is handed in a measurement shim. The EVM does not + /// execute inside an inspector callback, so anything that changes across one is the + /// inspector's doing by construction, and this is what the shim booked: gas written into a + /// live interpreter's counter ([`gas`](crate::InspectorLedger::gas)), into the envelope a + /// frame is about to be built with ([`env`](crate::InspectorLedger::env)), into a + /// returning frame's result ([`result`](crate::InspectorLedger::result)), how many + /// rewrites the shim refused + /// outright ([`rejected_rewrites`](crate::InspectorLedger::rejected_rewrites)), and how many + /// rewrote what the execution *did* without moving any gas at all + /// ([`interventions`](crate::InspectorLedger::interventions)). + /// + /// # Sign convention + /// + /// Every gas lane is signed and reads from the transaction's point of view: **positive is gas + /// conjured** — gas that exists in the execution but that nothing debited from the + /// transaction's envelope — and **negative is gas destroyed** — gas the envelope funded that + /// the execution never got the benefit of. The lanes are net, so an injection and a matching + /// removal cancel. + /// + /// # When it is zero + /// + /// [`InspectorLedger::is_zero`](crate::InspectorLedger::is_zero) holds for every transaction + /// that ran without an inspector and for every observation-only inspector — which is every + /// tracer. A non-zero ledger means this outcome's gas numbers describe an execution an + /// inspector took part in, and the block-execution path refuses to admit one into a block for + /// exactly that reason. + /// + /// The converse does not hold, and reading it that way is the mistake this field invites. What + /// is measured is what the shim can see at a callback boundary: gas that moved, and arguments + /// that came back changed. An inspector that reaches past those — editing the interpreter's + /// stack or memory, writing the journal directly, or editing the pending action — leaves this + /// empty while changing the state the transaction produces. That is why block admission rests + /// on [`undeclared_inspector`](Self::undeclared_inspector) and this field is only the backstop + /// behind it. + /// + /// # What it is for + /// + /// Reporting, and that refusal. No resource limit is ever evaluated against it, and no + /// adjustment recorded here is ever counted as work: the interpreter-counter lane shifts the + /// compute measurement's baseline as it books, so the edit settles outside the measured span + /// and the gas clamp is re-derived on the spot, while the other two lanes move a gas budget + /// rather than a recording and so never enter the measurement at all. It is also the `I` term + /// of the conservation law — see [`ConservationTerms`](crate::ConservationTerms) — which is + /// why an outcome carrying gas numbers is not fully described without it. + pub inspector_ledger: crate::InspectorLedger, + + /// Whether an inspector whose type carries no + /// [`TrustedObserver`](crate::TrustedObserver) declaration took part in this transaction. + /// + /// This is the canonical block path's admission criterion, and it is deliberately *not* + /// [`inspector_ledger`](Self::inspector_ledger). The ledger reports what the measurement shim + /// could see; an inspector that reaches past a callback boundary — editing the interpreter's + /// stack or memory contents, or writing the journal directly — changes the transaction while + /// leaving every lane at zero. So an execution is admitted into a block on the strength of a + /// declaration made in source about the inspector's type, and refused without one. + /// + /// False for a transaction that ran with no inspector at all, and for one whose inspector was + /// built through [`MegaEvm::with_trusted_inspector`](crate::MegaEvm::with_trusted_inspector). + /// True for every other inspected run, including one whose inspector only observes: the + /// question is what the type's author declared, not what this particular run happened to do. + pub undeclared_inspector: bool, } /// Identifies which stage of block execution produced a state change. @@ -109,9 +220,18 @@ pub enum MegaHaltReason { }, /// Compute gas limit exceeded ComputeGasLimitExceeded { - /// The configured compute gas limit + /// The configured compute gas limit that was exceeded. + /// + /// Relation to `actual` depends on the enforcement model: + /// - Per-opcode enforcement (through Rex6): the crossing opcode has already recorded its + /// cost, so `actual > limit`. + /// - Gas-clamp enforcement (Rex7+): the crossing opcode is stopped before it executes and + /// its cost is not recorded, so the *enforced* usage stays at or below `limit`. `actual` + /// is the transaction's full reported total, which also carries the remainders of any + /// frame that halted exceptionally earlier in the transaction — those are reported but + /// never enforced, and they can push `actual` above `limit`. limit: u64, - /// The actual compute gas usage + /// The actual compute gas usage at the halt. actual: u64, }, /// State growth limit exceeded @@ -136,9 +256,18 @@ pub enum MegaHaltReason { access_type: VolatileDataAccess, /// The effective detained compute gas limit that was exceeded. /// In REX4+ this is `usage_at_access + cap` (relative); pre-REX4 it equals the raw cap - /// (absolute). Always satisfies `actual > limit`. + /// (absolute). + /// + /// Relation to `actual` depends on the enforcement model: + /// - Per-opcode enforcement (through Rex6): the crossing opcode has already recorded its + /// cost, so `actual > limit`. + /// - Gas-clamp enforcement (Rex7+): the crossing opcode is stopped before it executes and + /// its cost is not recorded, so the *enforced* usage stays at or below `limit`. `actual` + /// is the transaction's full reported total, which also carries the remainders of any + /// frame that halted exceptionally earlier in the transaction — those are reported but + /// never enforced, and they can push `actual` above `limit`. limit: u64, - /// The actual compute gas usage + /// The actual compute gas usage at the halt. actual: u64, }, } diff --git a/crates/mega-evm/src/limit/AGENTS.md b/crates/mega-evm/src/limit/AGENTS.md index c4799024..c41b1728 100644 --- a/crates/mega-evm/src/limit/AGENTS.md +++ b/crates/mega-evm/src/limit/AGENTS.md @@ -5,6 +5,7 @@ Resource metering subsystem for transaction and frame limits across compute gas, ## STRUCTURE - `limit.rs`: `AdditionalLimit` coordinator and frame/tx lifecycle hooks. +- `destroyed.rs`: closed `InstructionResult` classification for the destroyed-remainder protocol (swallow / return / unreachable, no catch-all) and the producer × accounting-site table a revm bump diffs against. - `compute_gas.rs`: compute gas tracking, detention limits, frame budgets. - `data_size.rs`: tx/frame data accounting with revert-aware discard paths. - `kv_update.rs`: tx/frame KV accounting with revert-aware discard paths. @@ -17,6 +18,19 @@ Resource metering subsystem for transaction and frame limits across compute gas, - Limit-check order is deterministic and shared by all opcode paths. - Distinguish TX-level exceed (halt/OutOfGas) from frame-local exceed (revert). - All trackers push/pop per-frame in lockstep with EVM frame lifecycle hooks. +- `AdditionalLimit::finalize_frame` is the single point a frame's outcome is settled — the destroyed-remainder booking, the frame-init refusal booking, the gas rescue, and the REX7 frame-local absorb — and it runs after the last callback that can rewrite the frame's classification and before the journal decision. + Put a new frame-exit settlement there, not in a lifecycle hook on either side of it. + The pops stay in `before_frame_return_result`: the paths that reach a caller without ever running a frame would double-pop. +- A frame result's remaining gas is swallowed or returned by `destroyed_disposition`, not by `is_ok_or_revert()`. + Every `InstructionResult` variant has an arm; a new variant is a compile error until it is classified. + All four readers are inside `finalize_frame`: the three destroyed bookings plus the inspector-edit split, which asks the same question about the same remainder. + A site that only mirrors an upstream branch keyed on `is_ok_or_revert()` — the precompile dispatch undoing revm's `spend_all()` and rebuilding the `Gas` object revm's refund logic reads — keeps upstream's predicate, because it has to move with upstream rather than with our classification. + A new destroyed-remainder *producer* belongs on the table in `destroyed.rs`, with its own accounting site. + The early-fail arms of `make_call_frame` / `make_create_frame` / `classify_create_return` are a second closed set with no type-level tie — diff them by hand on a revm bump against `tests/rex7/result_space_tripwire.rs`. +- A frame-local exceed a frame could not latch — the one defined against its *caller's* budget after the merge — is settled in `before_frame_return_result` instead, and under REX7 before the pops rather than after them. + `peek_check_limit_after_pop` answers the post-merge question over `FrameLimitTracker::view_after_pop`, so the reading is the merged one and only the timing moves; the pop that follows reads a revert and discards the frame's usage. + Every dimension answers it with its own `check_limit` body over a `FrameLimitView`, and the two readings are cross-checked against each other on every frame return in debug builds. + Add a dimension's `check_limit_after_pop` when adding a dimension, and extend `view_after_pop` when adding a lane the pop moves. - Synthetic frame results still require empty-frame pushes for stack alignment. - Gas rescue must exclude any system-granted stipend gas. - Revert paths must roll back discardable usage for data/KV/state growth trackers. @@ -26,6 +40,8 @@ Resource metering subsystem for transaction and frame limits across compute gas, - Do not encode frame-local exceeds as halts. - They must be reverts with bounded payload. - Do not read tracker totals after an exceeded-limit revert path unless using tracker-owned finalized APIs. +- Do not run a fresh `check_limit()` inside `finalize_frame`: a per-frame exceed is defined by the frame's usage weighed against its *caller's* budget after the merge, which nothing at that point can read. + The pre-pop settlement in `before_frame_return_result` is where that question belongs, and it reads the merged numbers rather than the current ones. - Avoid duplicating limit checks inside opcode handlers when the tracker already enforces the same dimension. ## WHERE TO LOOK @@ -34,3 +50,4 @@ Resource metering subsystem for transaction and frame limits across compute gas, - Change compute detention behavior: `compute_gas.rs` and detention callers in `evm` module. - Change frame budget forwarding logic: `frame_limit.rs` and each tracker’s frame hooks. - Change storage call stipend semantics: `storage_call_stipend.rs` and `limit.rs` integration points. +- Classify a new `InstructionResult` variant or add a destroyed-remainder producer: `destroyed.rs` plus `tests/rex7/result_space_tripwire.rs`. diff --git a/crates/mega-evm/src/limit/checkpoint.rs b/crates/mega-evm/src/limit/checkpoint.rs new file mode 100644 index 00000000..40da78b7 --- /dev/null +++ b/crates/mega-evm/src/limit/checkpoint.rs @@ -0,0 +1,350 @@ +//! REX7+ checkpoint settlement and gas-clamp state. +//! +//! Holds the spec latch, the open-segment interpreter-gas baseline, the clamp +//! in force for the current plain-opcode segment, and the detention-attribution +//! flag for a clamp-induced out-of-gas. Cross-tracker orchestration (reading +//! compute-gas headroom, latching `has_exceeded_limit`) stays on +//! [`AdditionalLimit`](super::AdditionalLimit). + +use super::compute_gas::ClampBinding; +use crate::MegaSpecId; + +/// Tracks REX7+ checkpoint-accounting and gas-clamp state for one transaction. +#[derive(Debug, Clone)] +pub(crate) struct CheckpointTracker { + /// REX7+: whether compute gas settles at checkpoints rather than per opcode. + /// + /// When set, plain opcodes run unwrapped and record nothing; the interpreter's own gas + /// counter is read at each checkpoint and the whole segment since the previous one is + /// recorded in a single call. + rex7_enabled: bool, + + /// Interpreter gas remaining at the start of the current unsettled segment — the previous + /// checkpoint, or the frame entry / resume that opened the window. Only meaningful while a + /// frame is running and only when [`rex7_enabled`](Self::rex7_enabled) is active. Re-synced + /// at every [`before_frame_run`](super::AdditionalLimit::before_frame_run) (which covers both + /// frame entry and every resume after a child frame's outcome is merged back) and at every + /// checkpoint prologue and body recording. + baseline: u64, + + /// Gas-clamp enforcement (REX7+): the clamp in force for the plain-opcode segment the + /// current frame is inside, so that revm's own per-opcode gas checks enforce the compute + /// headroom at no per-opcode cost. + /// + /// Present only while the current frame is inside a plain segment: every checkpoint takes it + /// before running its body — so CALL forwarding, `GAS` and storage charges observe the true + /// counter — and re-applies it on the way out, and the frame's final result takes it via + /// [`settle_frame_final_result`](super::AdditionalLimit::settle_frame_final_result). + clamp: Option, + + /// Whether a clamp-induced out-of-gas was latched while gas detention was the binding TX-level + /// constraint. + /// + /// [`ComputeGasTracker::is_detained_exceed`] requires `used > detained_limit`, which a + /// clamp-stopped transaction never reaches — the crossing opcode is stopped before it + /// executes, so usage stays at or below the limit. The halt-reason attribution consults + /// this flag instead, keeping the reported reason `VolatileDataAccessOutOfGas` exactly as + /// per-opcode enforcement reports it. + latched_detained: bool, + + /// EVM gas the transaction spends that is neither compute work nor a destroyed remainder. + /// + /// Every gas unit the transaction burns is exactly one of three things: work the trackers + /// record as compute gas, `MegaETH` storage gas, or budget an exceptional halt threw away. + /// This field is the running total of the second kind, which makes the third derivable at the + /// transaction's settlement point instead of having to be booked at each site that destroys + /// one — see [`AdditionalLimit::conservation_terms`]( + /// super::AdditionalLimit::conservation_terms). + /// + /// Signed because one contributor is a difference rather than a charge: the `KeylessDeploy` + /// sandbox boundary hands the parent one number for what the sandbox cost and another for what + /// it performed, and an inner EIP-3529 refund can make the second exceed the first. + non_compute_gas: i128, + + /// `CALL_STIPEND` gas revm mints into child frames, which the transaction's envelope never + /// funded. + /// + /// A value-transferring `CALL` / `CALLCODE` debits the caller only the gas it forwards, and + /// then hands the child a frame budget of `forwarded + CALL_STIPEND`. The extra stipend is + /// conjured at the frame boundary — it is the protocol's EIP-150 subsidy, notionally paid for + /// by the `CALLVALUE` fee, but mechanically it is never taken out of anyone's gas counter. + /// No frame records the same gas twice: from REX5 the caller's compute window subtracts + /// exactly what the caller contributed, `gas_limit - CALL_STIPEND`. + /// + /// The minted gas leaves the transaction one stipend richer per such call, however it is used. + /// Spent by the callee, it is recorded as work no envelope paid for; returned when the child + /// exits, it shrinks the envelope by the same amount. A child that never runs at all — a frame + /// init that fails on balance or call depth — refunds the whole budget, mint included, and so + /// shrinks the envelope exactly as a child that returned it would. Every outcome leaves the + /// frames' recorded work exceeding what the transaction spent by one `CALL_STIPEND` per call, + /// which is why the mint, not the child frame, is what this field counts. + /// + /// So the recorded compute total is *not* a partition of the gas the transaction spent, and + /// the destroyed-remainder derivation has to account for the minted gas before the two sides + /// can agree. The behaviour is REX5 semantics and is frozen; this field only measures it. + minted_call_stipend: u64, + + /// The transaction's destroyed compute gas, as derived from the conservation law once its + /// envelope was final — the number the transaction reports. + /// + /// Zero until the settlement point writes it, so a transaction that never reaches settlement + /// reports nothing destroyed: a validation reject, which produces no receipt to report into, + /// and the pre-execution intrinsic overrun, which is itself a validation reject on every spec + /// that has this lane. See [`AdditionalLimit::settle_destroyed_compute_gas`]( + /// super::AdditionalLimit::settle_destroyed_compute_gas). + settled_destroyed: u64, +} + +/// A gas clamp in force for one plain-opcode segment (REX7+). +/// +/// The clamp is a lifecycle, not an amount. It is recorded exactly while it **binds** — while the +/// interpreter's true remaining gas was at or above the compute headroom when the segment opened — +/// and a `hidden` of zero is a binding clamp whose two budgets happened to coincide, not the +/// absence of one. When the frame's own gas would run out ahead of the compute headroom no clamp +/// is recorded at all, and an out-of-gas inside that segment stays the EVM's own. +#[derive(Clone, Copy, Debug)] +pub(crate) struct ClampState { + /// Interpreter gas hidden from the interpreter for this segment. + pub(crate) hidden: u64, + /// The constraint the clamp was bound to, captured at the moment it was applied. + pub(crate) binding: ClampBinding, +} + +impl CheckpointTracker { + pub(crate) fn new(spec: MegaSpecId) -> Self { + Self { + rex7_enabled: spec.is_enabled(MegaSpecId::REX7), + baseline: 0, + clamp: None, + latched_detained: false, + non_compute_gas: 0, + minted_call_stipend: 0, + settled_destroyed: 0, + } + } + + pub(crate) fn reset(&mut self) { + self.baseline = 0; + self.clamp = None; + self.latched_detained = false; + self.non_compute_gas = 0; + self.minted_call_stipend = 0; + self.settled_destroyed = 0; + } + + /// Whether compute gas settles at checkpoints (REX7+) rather than per opcode. + #[inline] + pub(crate) fn rex7_enabled(&self) -> bool { + self.rex7_enabled + } + + /// Interpreter gas remaining at the start of the current unsettled segment. + #[inline] + pub(crate) fn baseline(&self) -> u64 { + self.baseline + } + + /// Re-opens the settlement window at `remaining`, without recording anything. + #[inline] + pub(crate) fn sync_baseline(&mut self, remaining: u64) { + self.baseline = remaining; + } + + /// Moves the open segment's baseline down by `amount` of `MegaETH` storage gas just charged to + /// the interpreter, so the charge sits outside the segment rather than inside it. + /// + /// A checkpoint body normally subtracts its own storage charge when it closes its measurement + /// window. A body that aborts — a static-context `LOG`, a `SELFDESTRUCT` whose inner + /// instruction runs out of gas — never reaches that subtraction, and the frame-exit settlement + /// that follows would then bill the charge as compute. Excluding it from the baseline as it is + /// charged makes the exclusion hold on both paths; on the normal path the body's own window + /// re-syncs the baseline afterwards, so this is invisible there. + /// + /// No-op before REX7, where nothing measures against a baseline. + #[inline] + pub(crate) fn exclude_storage_gas_from_segment(&mut self, amount: u64) { + if self.rex7_enabled { + self.baseline = self.baseline.saturating_sub(amount); + } + } + + /// Takes the outstanding clamp so the caller can hand its hidden gas back to the interpreter, + /// returning that amount. + #[inline] + pub(crate) fn restore_hidden(&mut self) -> u64 { + self.clamp.take().map_or(0, |clamp| clamp.hidden) + } + + /// Whether a clamp is currently outstanding. + #[inline] + pub(crate) fn has_clamp(&self) -> bool { + self.clamp.is_some() + } + + /// Records the clamp in force for the segment that starts now. + #[inline] + pub(crate) fn set_clamp(&mut self, hidden: u64, binding: ClampBinding) { + self.clamp = Some(ClampState { hidden, binding }); + } + + /// Takes the outstanding clamp, if any. + #[inline] + pub(crate) fn take_clamp(&mut self) -> Option { + self.clamp.take() + } + + /// Whether a clamp-induced out-of-gas was latched under a detained TX-level constraint. + #[inline] + pub(crate) fn latched_detained(&self) -> bool { + self.latched_detained + } + + /// Records whether the just-latched clamp exceed was under a detained TX-level constraint. + #[inline] + pub(crate) fn set_latched_detained(&mut self, latched: bool) { + self.latched_detained = latched; + } + + /// Adds `amount` to the transaction's non-compute EVM gas — see + /// [`non_compute_gas`](Self::non_compute_gas). No-op before REX7, where nothing derives the + /// destroyed remainder. + #[inline] + pub(crate) fn record_non_compute_gas(&mut self, amount: i128) { + if self.rex7_enabled { + self.non_compute_gas += amount; + } + } + + /// The transaction's non-compute EVM gas so far — see + /// [`non_compute_gas`](Self::non_compute_gas). + #[inline] + pub(crate) fn non_compute_gas(&self) -> i128 { + self.non_compute_gas + } + + /// Books one `CALL_STIPEND` minted into a child frame — see + /// [`minted_call_stipend`](Self::minted_call_stipend). No-op before REX7. + #[inline] + pub(crate) fn record_minted_call_stipend(&mut self, amount: u64) { + if self.rex7_enabled { + self.minted_call_stipend += amount; + } + } + + /// The transaction's minted `CALL_STIPEND` total — see + /// [`minted_call_stipend`](Self::minted_call_stipend). + #[inline] + pub(crate) fn minted_call_stipend(&self) -> u64 { + self.minted_call_stipend + } + + /// Stores the destroyed compute gas the settlement point derived — see + /// [`settled_destroyed`](Self::settled_destroyed). + #[inline] + pub(crate) fn set_settled_destroyed(&mut self, amount: u64) { + self.settled_destroyed = amount; + } + + /// The destroyed compute gas the settlement point derived — see + /// [`settled_destroyed`](Self::settled_destroyed). + #[inline] + pub(crate) fn settled_destroyed(&self) -> u64 { + self.settled_destroyed + } + + /// Returns the unsettled segment usage and re-opens the window at `remaining`. + #[inline] + pub(crate) fn take_segment(&mut self, remaining: u64) -> u64 { + let gas_used = self.baseline.saturating_sub(remaining); + self.baseline = remaining; + gas_used + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dirty_tracker() -> CheckpointTracker { + let mut tracker = CheckpointTracker::new(MegaSpecId::REX7); + tracker.sync_baseline(99_999); + tracker.set_clamp(7, ClampBinding { headroom: 1, frame_local: false, limit: 1 }); + tracker.set_latched_detained(true); + tracker.record_non_compute_gas(4_242); + tracker.record_minted_call_stipend(2_300); + tracker + } + + /// `AdditionalLimit::reset` (called from `MegaContext::on_new_tx`) must wipe leftover + /// checkpoint state so a reused context cannot leak the previous transaction's baseline, + /// outstanding clamp, or detention-attribution flag into the next one. + #[test] + fn test_reset_clears_baseline_clamp_and_latched_detained() { + let mut tracker = dirty_tracker(); + + tracker.reset(); + + assert_eq!(tracker.baseline(), 0, "reset must drop the previous transaction's baseline"); + assert!( + tracker.take_clamp().is_none(), + "reset must drop an outstanding clamp left by the previous transaction" + ); + assert!( + !tracker.latched_detained(), + "reset must drop a leftover detention-attribution flag" + ); + assert_eq!( + tracker.non_compute_gas(), + 0, + "reset must drop the previous transaction's non-compute gas" + ); + assert_eq!( + tracker.minted_call_stipend(), + 0, + "reset must drop the previous transaction's minted call stipend" + ); + } + + /// The non-compute lane is REX7-only state: a frozen spec must keep it at zero so the + /// derivation it feeds can never be read as meaningful there. + #[test] + fn test_non_compute_gas_is_inert_before_rex7() { + let mut tracker = CheckpointTracker::new(MegaSpecId::REX6); + tracker.record_non_compute_gas(1_000); + assert_eq!(tracker.non_compute_gas(), 0, "pre-REX7 must not accumulate non-compute gas"); + } + + /// The sandbox boundary can hand the lane a negative contribution, so the accumulator must be + /// signed rather than saturating at zero. + #[test] + fn test_non_compute_gas_accumulates_signed() { + let mut tracker = CheckpointTracker::new(MegaSpecId::REX7); + tracker.record_non_compute_gas(100); + tracker.record_non_compute_gas(-250); + assert_eq!(tracker.non_compute_gas(), -150); + } + + /// Like the non-compute lane, the minted stipend is REX7-only state. + #[test] + fn test_minted_call_stipend_is_inert_before_rex7() { + let mut tracker = CheckpointTracker::new(MegaSpecId::REX6); + tracker.record_minted_call_stipend(2_300); + assert_eq!( + tracker.minted_call_stipend(), + 0, + "pre-REX7 must not accumulate the minted call stipend" + ); + } + + /// One transaction can make several value-transferring calls, and each mints its own stipend + /// into its child — so the term accumulates rather than latching a single one. + #[test] + fn test_minted_call_stipend_accumulates_per_call() { + let mut tracker = CheckpointTracker::new(MegaSpecId::REX7); + tracker.record_minted_call_stipend(2_300); + tracker.record_minted_call_stipend(2_300); + // A call that mints no stipend must leave the running total alone. + tracker.record_minted_call_stipend(0); + assert_eq!(tracker.minted_call_stipend(), 4_600); + } +} diff --git a/crates/mega-evm/src/limit/compute_gas.rs b/crates/mega-evm/src/limit/compute_gas.rs index d37e27ca..41ea50f7 100644 --- a/crates/mega-evm/src/limit/compute_gas.rs +++ b/crates/mega-evm/src/limit/compute_gas.rs @@ -6,6 +6,25 @@ use super::{ }; use crate::{JournalInspectTr, MegaSpecId}; +/// The constraint that bounds the gas clamp for one plain-opcode segment. +/// +/// Captured when the clamp is applied, so a clamp-induced out-of-gas can be classified against the +/// constraint that was in force at the time rather than against whatever the tracker looks like +/// once the frame has already failed. +#[derive(Clone, Copy, Debug)] +pub(crate) struct ClampBinding { + /// The compute headroom the interpreter is allowed to keep seeing. + pub(crate) headroom: u64, + /// `true` when the current frame's compute budget is what binds, `false` when the TX-level + /// (possibly detained) limit is. + pub(crate) frame_local: bool, + /// The binding constraint's own limit. A clamp-induced exceed reports this — as + /// `MegaLimitExceeded.limit` in the frame-local revert payload, or as + /// `ComputeGasLimitExceeded.limit` in the transaction halt — so it has to be the budget that + /// actually stopped execution, exactly as the non-clamp check path reports it. + pub(crate) limit: u64, +} + /// A frame-limit-based compute gas tracker using `FrameLimitTracker`. /// /// Unlike the other trackers (`DataSizeTracker`, `KVUpdateTracker`, `StateGrowthTracker`), compute @@ -38,6 +57,22 @@ pub(crate) struct ComputeGasTracker { /// The effective compute gas limit, which may be dynamically lowered by gas detention /// (volatile data access). Always <= `frame_tracker.tx_limit()`. detained_limit: u64, + /// Compute gas settled from the **destroyed** remainders of exceptionally halted frames + /// (REX7+). + /// + /// Recorded into the TX-level lane of `frame_tracker`, so it shows up in the transaction's + /// reported compute total and in block-level accounting, and subtracted back out of every + /// limit comparison. A destroyed remainder is gas the EVM threw away without executing + /// anything for it; letting it trip a limit would turn an ordinary EVM halt into a + /// resource-limit failure with the remaining gas rescued for the sender, changing a receipt + /// that must stay identical to per-opcode accounting. + /// + /// Only the remainder lands here. The work an exceptionally halted frame actually performed + /// before it failed is recorded through [`record_gas_used`](Self::record_gas_used) like any + /// other work, so it shrinks the parent frame's and the transaction's budgets — otherwise the + /// code that runs after the failed frame returns could spend the same headroom twice. Always 0 + /// before REX7. + burned: u64, frame_tracker: FrameLimitTracker<()>, } @@ -45,6 +80,7 @@ impl ComputeGasTracker { pub(crate) fn new(spec: MegaSpecId, tx_limit: u64) -> Self { Self { detained_limit: tx_limit, + burned: 0, frame_tracker: FrameLimitTracker::new(spec, tx_limit), rex1_enabled: spec.is_enabled(MegaSpecId::REX1), rex4_enabled: spec.is_enabled(MegaSpecId::REX4), @@ -74,7 +110,7 @@ impl ComputeGasTracker { pub(crate) fn set_detained_limit(&mut self, cap: u64) { let new_limit = if self.rex4_enabled { // REX4+: cap is relative to current usage (limits post-access computation) - self.tx_usage().saturating_add(cap) + self.enforced_tx_usage().saturating_add(cap) } else { // Pre-REX4: cap is absolute cap @@ -97,7 +133,7 @@ impl ComputeGasTracker { /// At that point `frame_stack.last()` is the caller's frame, so /// `current_frame_remaining()` gives the caller's remaining compute gas. pub(crate) fn current_call_remaining(&self) -> u64 { - let tx_remaining = self.tx_limit().saturating_sub(self.tx_usage()); + let tx_remaining = self.tx_limit().saturating_sub(self.enforced_tx_usage()); if self.rex4_enabled { self.frame_tracker.current_frame_remaining().min(tx_remaining) } else { @@ -110,10 +146,41 @@ impl ComputeGasTracker { self.detained_limit } + /// Returns the base (undetained) TX compute gas limit. + pub(crate) fn base_tx_limit(&self) -> u64 { + self.frame_tracker.tx_limit() + } + + /// Returns the constraint the gas clamp must bind to at this point in the transaction. + /// + /// The headroom is the tighter of the current frame's remaining compute budget (Rex4+) and + /// the TX-level remaining under the effective (possibly detained) limit — the same pair + /// [`check_limit`](TxRuntimeLimit::check_limit) enforces. Gas hidden beyond this headroom is + /// therefore reachable only by a transaction that would exceed one of those two limits. + /// Equal remainders bind to the TX-level constraint, so a top-level frame — where the two are + /// equal whenever the same base limit still governs both — halts with gas rescue rather than + /// absorbing the exceed into a revert. + #[inline] + pub(crate) fn clamp_binding(&self) -> ClampBinding { + let tx_limit = self.tx_limit(); + let tx_remaining = tx_limit.saturating_sub(self.enforced_tx_usage()); + if self.rex4_enabled { + let frame_remaining = self.frame_tracker.current_frame_remaining(); + if frame_remaining < tx_remaining { + return ClampBinding { + headroom: frame_remaining, + frame_local: true, + limit: self.frame_tracker.current_frame_limit(), + }; + } + } + ClampBinding { headroom: tx_remaining, frame_local: false, limit: tx_limit } + } + /// Returns `true` when gas detention is the binding TX-level constraint, i.e., the detained /// limit is tighter than the base TX limit AND actual usage exceeds it. pub(crate) fn is_detained_exceed(&self) -> bool { - let used = self.tx_usage(); + let used = self.enforced_tx_usage(); used > self.detained_limit && self.detained_limit < self.frame_tracker.tx_limit() } @@ -146,6 +213,44 @@ impl ComputeGasTracker { } } + /// Records the destroyed remainder of an exceptionally halted frame (REX7+). + /// + /// Counts toward the transaction's reported compute total and block-level accounting, and is + /// excluded from every limit comparison — see [`burned`](Self::burned). + pub(crate) fn record_burned_gas(&mut self, amount: u64) { + self.burned = self.burned.saturating_add(amount); + self.frame_tracker.add_tx_persistent(amount); + } + + /// Reclassifies `amount` of already-merged usage as a destroyed remainder (REX7+). + /// + /// The sandbox path merges one compute total through + /// [`merge_persistent_usage`](Self::merge_persistent_usage) and then declares how much of it + /// the sandbox destroyed, rather than adding the amount a second time. + pub(crate) fn merge_burned_usage(&mut self, amount: u64) { + self.burned = self.burned.saturating_add(amount); + } + + /// The destroyed remainders inside [`tx_usage`](TxRuntimeLimit::tx_usage) — see + /// [`burned`](Self::burned). + #[inline] + pub(crate) fn burned_usage(&self) -> u64 { + self.burned + } + + /// Total recorded usage minus the destroyed remainders that must not enforce. + /// + /// This is the transaction's claim about how much compute work it actually performed. It is + /// built only from [`record_gas_used`](Self::record_gas_used) and + /// [`merge_persistent_usage`](Self::merge_persistent_usage) — a + /// [`record_burned_gas`](Self::record_burned_gas) raises `net_usage` and `burned` by the same + /// amount and so cancels here — which is what lets the destroyed remainder be re-derived from + /// it independently of how it was booked. + #[inline] + pub(crate) fn enforced_tx_usage(&self) -> u64 { + self.frame_tracker.net_usage().saturating_sub(self.burned) + } + /// Merges external persistent usage into the TX-level entry. /// /// Used by `KeylessDeploy` (REX5+) to propagate sandbox compute gas consumption @@ -153,6 +258,73 @@ impl ComputeGasTracker { pub(crate) fn merge_persistent_usage(&mut self, amount: u64) { self.frame_tracker.add_tx_persistent(amount); } + + /// Pushes a frame with an explicit budget, for tests that need a specific frame-local edge + /// without executing a transaction to get there. + #[cfg(test)] + pub(crate) fn push_frame_with_limit_for_test(&mut self, limit: u64) { + self.frame_tracker.push_frame_with_limit(limit, ()); + } + + /// [`check_limit`](TxRuntimeLimit::check_limit) evaluated as if `extra` gas had already been + /// recorded, without recording it. + /// + /// This is the whole verdict a caller would get by recording and then checking: the Rex4+ + /// per-frame budget first, then the TX-level (possibly detained) limit, reported exactly as + /// enforcement reports them. A caller that must decide whether to make a charge at all asks + /// here; `check_limit` is this method at `extra = 0`, so there is no second copy of the + /// predicate to drift. + #[inline] + pub(crate) fn check_limit_with_extra(&self, extra: u64) -> LimitCheck { + self.check_limit_with_extra_on(&self.frame_tracker, extra) + } + + /// [`check_limit`](TxRuntimeLimit::check_limit) as it will read once the current frame has + /// been popped and merged into its caller, computed without popping it. + #[inline] + pub(crate) fn check_limit_after_pop(&self, success: bool) -> LimitCheck { + self.check_limit_with_extra_on(&self.frame_tracker.view_after_pop(success), 0) + } + + /// [`check_limit_with_extra`](Self::check_limit_with_extra) against an explicit reading of the + /// tracker. + /// + /// The reading is a parameter so that one body can answer both questions asked of this check: + /// what it says now, and what it will say once a returning frame has been merged into its + /// caller. A frame return needs the second answer before the merge happens, and a second copy + /// of the predicates would be free to drift from the first. + pub(crate) fn check_limit_with_extra_on( + &self, + r: &R, + extra: u64, + ) -> LimitCheck { + if self.rex4_enabled { + let frame_check = r.frame_check(LimitKind::ComputeGas, extra); + if frame_check.exceeded_limit() { + return frame_check; + } + // Do not early-return on frame WithinLimit: + // 1) pre-frame intrinsic compute gas is recorded in `tx_entry`, outside current frame + // budget; + // 2) `detained_limit` can be lowered at runtime by volatile-data access. + // So TX-level detained check must still run even when frame check is within limit. + } + // TX-level detained check (all specs): total usage vs effective limit (min of tx/detained). + // The comparison runs on enforced usage — burned remainders are excluded — while the + // reported `used` is the full settled total, so a halt reason states the usage the + // transaction actually ends with. The two coincide on every spec before REX7. + let limit = self.tx_limit(); + if r.net_usage().saturating_sub(self.burned).saturating_add(extra) > limit { + LimitCheck::ExceedsLimit { + kind: LimitKind::ComputeGas, + frame_local: false, + limit, + used: r.net_usage().saturating_add(extra), + } + } else { + LimitCheck::WithinLimit + } + } } impl TxRuntimeLimit for ComputeGasTracker { @@ -172,6 +344,7 @@ impl TxRuntimeLimit for ComputeGasTracker { #[inline] fn reset(&mut self) { self.frame_tracker.reset(); + self.burned = 0; // Rex1+: reset detained limit to original TX limit between transactions. // Pre-Rex1: the detained limit persists across transactions. if self.rex1_enabled { @@ -191,30 +364,7 @@ impl TxRuntimeLimit for ComputeGasTracker { /// when the current frame budget is still within limit. #[inline] fn check_limit(&self) -> LimitCheck { - if self.rex4_enabled { - let frame_check = self.frame_tracker.exceeds_current_frame_limit(LimitKind::ComputeGas); - if frame_check.exceeded_limit() { - return frame_check; - } - // Do not early-return on frame WithinLimit: - // 1) pre-frame intrinsic compute gas is recorded in `tx_entry`, outside current frame - // budget; - // 2) `detained_limit` can be lowered at runtime by volatile-data access. - // So TX-level detained check must still run even when frame check is within limit. - } - // TX-level detained check (all specs): total usage vs effective limit (min of tx/detained). - let limit = self.tx_limit(); - let used = self.tx_usage(); - if used > limit { - LimitCheck::ExceedsLimit { - kind: LimitKind::ComputeGas, - frame_local: false, - limit, - used, - } - } else { - LimitCheck::WithinLimit - } + self.check_limit_with_extra(0) } #[inline] @@ -267,4 +417,37 @@ mod tests { tracker.record_gas_used(1); assert!(tracker.is_detained_exceed(), "usage > detained_limit must be a detained exceed"); } + + /// A frame's usage is weighed against its *caller's* budget only once the two have been + /// merged, so the pre-merge reading has to answer a question the live one cannot: the frame + /// below is already over its budget while the frame above is still inside its own. + /// + /// Compute gas is persistent, so the merge happens whether the frame returns or reverts and + /// the answer is the same either way. + #[test] + fn test_check_limit_after_pop_sees_a_frame_local_exceed_the_live_check_cannot() { + let mut tracker = ComputeGasTracker::new(MegaSpecId::REX4, 10_000); + tracker.push_frame_with_limit_for_test(100); + tracker.record_gas_used(60); + tracker.push_frame_with_limit_for_test(60); + tracker.record_gas_used(60); + + assert_eq!( + tracker.check_limit(), + LimitCheck::WithinLimit, + "the child is exactly at its own budget, and nothing else is over", + ); + for success in [true, false] { + assert_eq!( + tracker.check_limit_after_pop(success), + LimitCheck::ExceedsLimit { + kind: LimitKind::ComputeGas, + limit: 100, + used: 120, + frame_local: true, + }, + "the merged caller is 20 over its own budget (success: {success})", + ); + } + } } diff --git a/crates/mega-evm/src/limit/conservation.rs b/crates/mega-evm/src/limit/conservation.rs new file mode 100644 index 00000000..e6564462 --- /dev/null +++ b/crates/mega-evm/src/limit/conservation.rs @@ -0,0 +1,251 @@ +//! The transaction-level gas conservation law, as one set of terms. +//! +//! Three places state the same law: the settlement that derives what a transaction destroyed, the +//! re-settlement that follows a rewritten envelope, and the terminal check that the tracker lanes +//! account for the whole receipt. They used to restate it three times, each with its own +//! rearrangement and its own hand-written assertion message. This module holds it once. + +use core::fmt; + +/// The terms of the transaction-level gas conservation law. +/// +/// # The law +/// +/// Every unit of EVM gas a transaction burns is one of three things: compute work the trackers +/// enforced, `MegaETH` storage gas, or a budget something threw away without executing anything +/// for it. Two producers sit outside that partition and have to be corrected for — the +/// `CALL_STIPEND` revm mints into a value-transferring call's child frame without debiting the +/// caller, and whatever an inspector wrote into the execution from outside it. What is left is an +/// identity: +/// +/// ```text +/// spent = C + S + D − K − I +/// ``` +/// +/// | term | meaning | +/// | ---- | ---------------------------------------------------------------------------- | +/// | `spent` | the envelope the transaction burnt, read where it is final | +/// | `C` | [`enforced_compute_gas`](Self::enforced_compute_gas) — the work performed | +/// | `S` | [`non_compute_gas`](Self::non_compute_gas) — `MegaETH` storage gas | +/// | `D` | the destroyed remainder — budget thrown away without work | +/// | `K` | [`minted_call_stipend`](Self::minted_call_stipend) — gas minted, never debited | +/// | `I` | [`inspector_conjured_gas`](Self::inspector_conjured_gas) — gas from outside the EVM | +/// +/// `D` is deliberately not a field: it is the one term nothing measures directly, and each caller +/// supplies the reading it holds. [`destroyed_for`](Self::destroyed_for) solves the law for it, +/// [`envelope_for`](Self::envelope_for) solves the law for `spent` given one, and neither is a +/// second law — they are the same identity rearranged. +/// +/// # Sign conventions +/// +/// `S` and `I` are signed. `S` because the sandbox boundary can return gas to the lane, and `I` +/// because an inspector destroys gas as readily as it conjures it: positive is gas that exists in +/// the execution but that nothing debited from the transaction's envelope, negative is gas the +/// envelope funded that no frame ever received. +/// +/// # Before REX7 +/// +/// Every term but `C` is structurally zero: no lane records non-compute gas, no site mints a +/// stipend into the law, nothing is destroyed, and the outcome's enforced total is its reported +/// total. The law holds there too, but trivially, which is why the assertions that read it are +/// gated to REX7+. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ConservationTerms { + /// `C` — the compute gas the transaction performed, off the lane every compute-gas limit is + /// evaluated against. + pub enforced_compute_gas: u64, + + /// `S` — the EVM gas the transaction spent that is neither compute work nor a destroyed + /// remainder: the `MegaETH` storage-gas surcharges, the `MegaETH` share of intrinsic gas, the + /// code-deposit charge, and the sandbox boundary's residue. + pub non_compute_gas: i128, + + /// `K` — the `CALL_STIPEND` total this transaction's value-transferring calls minted into + /// their child frames. + /// + /// revm mints it without debiting the caller, so recorded work exceeds the envelope by one + /// stipend per such call and the law needs it added back. + pub minted_call_stipend: u64, + + /// `I` — the net gas an inspector conjured, across every lane the measurement shim books. + /// + /// Zero for every transaction that ran without an inspector and for every observation-only + /// inspector, which is why the law reads the same as it always did on those paths. + pub inspector_conjured_gas: i128, + + /// What the sites that destroyed a budget booked as they destroyed it — *not* a term of the + /// law, and never read by [`destroyed_for`](Self::destroyed_for). + /// + /// The derivation and this total are two independent measurements of the same quantity. They + /// agree, and [`unbooked_for`](Self::unbooked_for) is the gap a caller checks or settles; + /// deriving one from the other would collapse the cross-check into a tautology. + pub booked_destroyed_compute_gas: u64, +} + +impl ConservationTerms { + /// Solves the law for `D`: `D = spent + K + I − S − C`. + /// + /// Signed on purpose. A negative result means the recorded lanes together claim more gas than + /// the transaction spent, which is a defect to report rather than a value to clamp — clamping + /// inside the law would hide the half of the mismatch space where the bookings over-count. + #[inline] + pub const fn destroyed_for(&self, tx_gas_spent: u64) -> i128 { + (tx_gas_spent as i128) + (self.minted_call_stipend as i128) + self.inspector_conjured_gas - + self.non_compute_gas - + (self.enforced_compute_gas as i128) + } + + /// Solves the law for `spent`: `spent = C + S + D − K − I`. + /// + /// The reading to pass for `D` is the transaction's *reported* destroyed total — what the + /// receipt's compute total carries — because this direction is what checks that the lanes + /// account for the envelope that receipt reports. + #[inline] + pub const fn envelope_for(&self, destroyed_compute_gas: u64) -> i128 { + (self.enforced_compute_gas as i128) + self.non_compute_gas + (destroyed_compute_gas as i128) - + (self.minted_call_stipend as i128) - + self.inspector_conjured_gas + } + + /// The gap between what the law derives for `D` and what the per-site bookings hold. + /// + /// Zero whenever the two measurements agree. A caller either asserts that (the cross-check at + /// settlement) or books the difference (the rewritten-envelope re-settlement, where the + /// receipt's envelope grows past every site that could have booked it). + #[inline] + pub const fn unbooked_for(&self, tx_gas_spent: u64) -> i128 { + self.destroyed_for(tx_gas_spent) - (self.booked_destroyed_compute_gas as i128) + } +} + +impl fmt::Display for ConservationTerms { + /// The whole term set, in the order the law states it, for assertion messages. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "enforced compute {}, non-compute {}, minted stipend {}, inspector conjured {}, \ + booked destroyed {}", + self.enforced_compute_gas, + self.non_compute_gas, + self.minted_call_stipend, + self.inspector_conjured_gas, + self.booked_destroyed_compute_gas, + ) + } +} + +#[cfg(test)] +mod tests { + #[cfg(not(feature = "std"))] + use alloc as std; + + use super::*; + + fn terms() -> ConservationTerms { + ConservationTerms { + enforced_compute_gas: 21_000, + non_compute_gas: 5_000, + minted_call_stipend: 2_300, + inspector_conjured_gas: -400, + booked_destroyed_compute_gas: 0, + } + } + + /// The two directions are one identity, so solving for either term and substituting it back + /// must return the reading it started from — for any term set, including one whose signed + /// lanes point in opposite directions. + #[test] + fn test_the_two_directions_are_the_same_law() { + let terms = terms(); + // Above the point where this term set's derivation turns non-negative, so the round-trip + // stays inside the domain a real transaction produces. + for spent in [24_100_u64, 100_000, 1_000_000] { + let destroyed = terms.destroyed_for(spent); + assert!(destroyed >= 0, "fixture check: {destroyed} must be a real remainder"); + assert_eq!( + terms.envelope_for(destroyed as u64), + i128::from(spent), + "solving for the destroyed remainder and substituting it back must close", + ); + } + for destroyed in [0_u64, 1, 5_000] { + let spent = terms.envelope_for(destroyed); + assert!(spent >= 0, "fixture check: {spent} must be a real envelope"); + assert_eq!( + terms.destroyed_for(spent as u64), + i128::from(destroyed), + "and so must solving for the envelope and substituting that back", + ); + } + } + + /// Each term enters the law with the sign the doc claims, and the two directions carry + /// opposite signs for the same term. + #[test] + fn test_each_term_moves_the_law_in_its_documented_direction() { + let base = terms(); + let spent = 100_000; + let destroyed = base.destroyed_for(spent); + + let more_compute = + ConservationTerms { enforced_compute_gas: base.enforced_compute_gas + 1, ..base }; + assert_eq!(more_compute.destroyed_for(spent), destroyed - 1, "C reduces D"); + assert_eq!( + more_compute.envelope_for(0), + base.envelope_for(0) + 1, + "and raises the envelope", + ); + + let more_storage = ConservationTerms { non_compute_gas: base.non_compute_gas + 1, ..base }; + assert_eq!(more_storage.destroyed_for(spent), destroyed - 1, "S reduces D"); + + let more_stipend = + ConservationTerms { minted_call_stipend: base.minted_call_stipend + 1, ..base }; + assert_eq!(more_stipend.destroyed_for(spent), destroyed + 1, "K raises D"); + + let more_conjured = + ConservationTerms { inspector_conjured_gas: base.inspector_conjured_gas + 1, ..base }; + assert_eq!(more_conjured.destroyed_for(spent), destroyed + 1, "I raises D"); + assert_eq!( + more_conjured.envelope_for(0), + base.envelope_for(0) - 1, + "and lowers the envelope", + ); + } + + /// The booked total is the cross-check operand, not a term: it moves the gap and nothing else. + #[test] + fn test_the_booked_total_is_not_a_term_of_the_law() { + let base = terms(); + let booked = ConservationTerms { booked_destroyed_compute_gas: 777, ..base }; + + assert_eq!( + booked.destroyed_for(100_000), + base.destroyed_for(100_000), + "the derivation must not read the bookings it is checked against", + ); + assert_eq!(booked.envelope_for(0), base.envelope_for(0)); + assert_eq!(booked.unbooked_for(100_000), base.unbooked_for(100_000) - 777); + } + + /// An all-zero term set is the pre-REX7 shape: the law degenerates to `spent == 0`. + #[test] + fn test_the_default_term_set_is_the_trivial_law() { + let terms = ConservationTerms::default(); + assert_eq!(terms.destroyed_for(0), 0); + assert_eq!(terms.envelope_for(0), 0); + assert_eq!(terms.unbooked_for(0), 0); + } + + /// The term set is rendered into every assertion message the law raises, and a message that + /// names no term is a failing invariant with nothing to debug it by. Pins the full text: the + /// order the law states the terms in, and the signed lanes' signs. + #[test] + fn test_display_renders_every_term_in_the_order_the_law_states_them() { + assert_eq!( + std::format!("{}", terms()), + "enforced compute 21000, non-compute 5000, minted stipend 2300, \ + inspector conjured -400, booked destroyed 0", + ); + } +} diff --git a/crates/mega-evm/src/limit/data_size.rs b/crates/mega-evm/src/limit/data_size.rs index 20bff89c..6559cfce 100644 --- a/crates/mega-evm/src/limit/data_size.rs +++ b/crates/mega-evm/src/limit/data_size.rs @@ -133,6 +133,53 @@ impl DataSizeTracker { self.frame_tracker.add_tx_persistent(amount); } + /// [`check_limit`](TxRuntimeLimit::check_limit) as it will read once the current frame has + /// been popped and merged into its caller, computed without popping it. + #[inline] + pub(crate) fn check_limit_after_pop(&self, success: bool) -> super::LimitCheck { + self.check_limit_on(&self.frame_tracker.view_after_pop(success)) + } + + /// [`check_limit`](TxRuntimeLimit::check_limit) against an explicit reading of the tracker. + /// + /// The reading is a parameter so that one body can answer both questions asked of this check: + /// what it says now, and what it will say once a returning frame has been merged into its + /// caller. A frame return needs the second answer before the merge happens, and a second copy + /// of the predicates would be free to drift from the first. + pub(crate) fn check_limit_on(&self, r: &R) -> super::LimitCheck { + if self.rex4_enabled { + let frame_check = r.frame_check(super::LimitKind::DataSize, 0); + if frame_check.exceeded_limit() { + return frame_check; + } + // TX-level fallthrough: defense-in-depth safety net. + // In Rex4+ during execution, per-frame budgets are derived from remaining TX + // budget, so this should only exceed when no frame exists (intrinsic overflow). + } + let used = r.net_usage(); + let limit = self.frame_tracker.tx_limit(); + if used > limit { + // Defense-in-depth: pre-REX5, the only mid-execution writer to `tx_entry` is + // `before_tx_start` (which runs before any frame is pushed), so a TX-level + // exceed with an active frame indicates a budget-accounting bug. REX5+ adds + // `record_oracle_hint_bytes` which legitimately writes to `tx_entry` mid- + // execution to meter oracle-hint payloads as TX-scoped side-channel cost, so + // the invariant is only asserted on pre-REX5 specs. + debug_assert!( + !self.rex4_enabled || self.rex5_enabled || !r.has_frame(), + "DataSize TX-level exceeded with active frame — budget invariant violated" + ); + super::LimitCheck::ExceedsLimit { + kind: super::LimitKind::DataSize, + limit, + used, + frame_local: false, + } + } else { + super::LimitCheck::WithinLimit + } + } + /// Returns the remaining data size budget for the current call frame, capped by /// the TX-level remaining. pub(crate) fn current_call_remaining(&self) -> u64 { @@ -170,38 +217,7 @@ impl TxRuntimeLimit for DataSizeTracker { /// (intrinsic usage is recorded in `tx_entry` before the first frame is pushed). /// In pre-Rex4, checks total data size across all frames against the TX limit. fn check_limit(&self) -> super::LimitCheck { - if self.rex4_enabled { - let frame_check = - self.frame_tracker.exceeds_current_frame_limit(super::LimitKind::DataSize); - if frame_check.exceeded_limit() { - return frame_check; - } - // TX-level fallthrough: defense-in-depth safety net. - // In Rex4+ during execution, per-frame budgets are derived from remaining TX - // budget, so this should only exceed when no frame exists (intrinsic overflow). - } - let used = self.tx_usage(); - let limit = self.frame_tracker.tx_limit(); - if used > limit { - // Defense-in-depth: pre-REX5, the only mid-execution writer to `tx_entry` is - // `before_tx_start` (which runs before any frame is pushed), so a TX-level - // exceed with an active frame indicates a budget-accounting bug. REX5+ adds - // `record_oracle_hint_bytes` which legitimately writes to `tx_entry` mid- - // execution to meter oracle-hint payloads as TX-scoped side-channel cost, so - // the invariant is only asserted on pre-REX5 specs. - debug_assert!( - !self.rex4_enabled || self.rex5_enabled || !self.frame_tracker.has_active_frame(), - "DataSize TX-level exceeded with active frame — budget invariant violated" - ); - super::LimitCheck::ExceedsLimit { - kind: super::LimitKind::DataSize, - limit, - used, - frame_local: false, - } - } else { - super::LimitCheck::WithinLimit - } + self.check_limit_on(&self.frame_tracker) } /// Records the data size of a transaction at the start of execution. diff --git a/crates/mega-evm/src/limit/destroyed.rs b/crates/mega-evm/src/limit/destroyed.rs new file mode 100644 index 00000000..3f623476 --- /dev/null +++ b/crates/mega-evm/src/limit/destroyed.rs @@ -0,0 +1,140 @@ +//! Destroyed-remainder classification for a frame result's [`InstructionResult`]. +//! +//! The conservation law defines a transaction's destroyed total from the envelope. The per-site +//! bookings that cross-check it still have to decide, per result, whether the remaining gas was +//! swallowed (book it) or handed back (book nothing). +//! +//! [`destroyed_disposition`] is the closed table for that: every variant has an explicit arm and +//! there is no `_`, so a revm bump that adds one fails to compile until a human assigns it. It +//! used to be `is_ok_or_revert()` — a catch-all on the halt side, which would have swallowed a new +//! variant without anyone classifying it. +//! +//! # Where the table is read +//! +//! Four sites, all inside [`finalize_frame`](super::AdditionalLimit::finalize_frame): +//! `settle_exceptional_halt_burn`, `settle_frame_init_reject_burn`, +//! `settle_precompile_envelope`, and `settle_inspector_result_gas`. The first three book a +//! destroyed remainder; the fourth books none but answers the same question for an inspector's +//! edit — an edit to a returned result moves what the transaction spends, an edit to a swallowed +//! one does not. +//! +//! A site that has to stay byte-identical with an upstream branch keyed on `is_ok_or_revert()` +//! keeps that predicate instead: the precompile dispatch mirrors an upstream decision rather than +//! stating one on `MegaETH`'s books, so it follows upstream's predicate wherever it goes. +//! +//! # Producers × accounting sites +//! +//! Every producer that can destroy an envelope books at exactly one site below. Completeness of +//! the *reported* total is still the conservation law; this is what the per-site bookings — and a +//! revm-upgrade diff — are checked against. +//! +//! | Producer | Accounting site | Notes | +//! | --- | --- | --- | +//! | Frame-run exceptional halt, including create-return rejects | `finalize_frame` on `FrameExit::Ran` → `settle_exceptional_halt_burn` | [`DestroyedDisposition::Swallow`] | +//! | Frame-init refusal from revm (`make_call_frame` / `make_create_frame` early-fail arms) | `finalize_frame` on `FrameExit::Refused` → `settle_frame_init_reject_burn` | per variant: collision / overflow-payment swallow; depth / funds / empty-code / nonce-overflow return | +//! | Synthetic frame-init refusal (interceptor, inspector intercept, REX5 depth guard) | the same burn, on `FrameExit::RefusedSynthetically` | same classification | +//! | Precompile halt | → `settle_precompile_envelope`, against the staged forwarded envelope and executed work | the staged slot, not `CallOutcome::was_precompile_called`, routes a result here | +//! | `KeylessDeploy` synthetic halt that keeps the call's gas | `sandbox/execution.rs::destroying_oog_frame_result`, which books remaining *before* the result spends the envelope | swallow; `finalize_frame` then sees remaining 0 | +//! | Failed-deposit receipt rewrite | [`AdditionalLimit::settle_rewritten_envelope`](super::AdditionalLimit::settle_rewritten_envelope) | the gap between the rebuilt envelope and the per-site bookings | +//! | Intrinsic pre-frame out-of-gas | `MegaHandler::before_execution` | unreachable on REX7 (REX5+ rejects that transaction in validation) | +//! +//! A new producer belongs on this table with its own site, not as a silent extra call to +//! `record_burned_gas`. A new [`InstructionResult`] variant belongs in [`destroyed_disposition`]. +//! +//! # The other closed tables +//! +//! `tests/rex7/gas_surface.rs` closes a perpendicular axis: it enumerates the *carriers* — which +//! field of which object carries gas and which lane books it — while this one enumerates the +//! *endings*. `finalize_frame` composes the two answers. +//! +//! `make_call_frame`, `make_create_frame` and `classify_create_return` each return a result without +//! running a child body on a fixed list of arms. Those arms are not an enum, so a revm bump that +//! adds one does not fail this match; the upgrade checklist diffs them by hand against the list in +//! `tests/rex7/result_space_tripwire.rs`. +//! +//! One live mismatch is load-bearing: a CREATE whose nonce cannot be bumped returns +//! [`InstructionResult::Return`], not [`InstructionResult::NonceOverflow`]. The variant is still +//! classified, so that if an arm ever starts producing it the booking is defined. + +use revm::interpreter::InstructionResult; + +/// What the destroyed-remainder protocol does with a frame result's remaining gas. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DestroyedDisposition { + /// Remaining gas is erased back into the caller. Book nothing. + Return, + /// Remaining gas is never handed back. Book it as destroyed. + Swallow, + /// Cannot appear as a frame result. Settlement must not observe it. + Unreachable, +} + +impl DestroyedDisposition { + /// Whether the protocol books this result's remaining gas as destroyed. + pub const fn swallows(self) -> bool { + matches!(self, Self::Swallow) + } +} + +/// Classifies `result` for the destroyed-remainder protocol. +/// +/// Every [`InstructionResult`] variant has an arm. A new variant is a compile error until it is +/// assigned [`Return`](DestroyedDisposition::Return), [`Swallow`](DestroyedDisposition::Swallow), +/// or [`Unreachable`](DestroyedDisposition::Unreachable). +pub const fn destroyed_disposition(result: InstructionResult) -> DestroyedDisposition { + match result { + // Success / revert: the caller gets the remaining gas back. + InstructionResult::Stop | + InstructionResult::Return | + InstructionResult::SelfDestruct | + InstructionResult::Revert | + InstructionResult::CallTooDeep | + InstructionResult::OutOfFunds | + InstructionResult::CreateInitCodeStartingEF00 | + InstructionResult::InvalidEOFInitCode | + InstructionResult::InvalidExtDelegateCallTarget => DestroyedDisposition::Return, + + // Internal interpreter state. Never a `FrameResult`. + InstructionResult::Suspend => DestroyedDisposition::Unreachable, + + // Exceptional halt: the remaining gas is gone. + InstructionResult::OutOfGas | + InstructionResult::MemoryOOG | + InstructionResult::MemoryLimitOOG | + InstructionResult::PrecompileOOG | + InstructionResult::InvalidOperandOOG | + InstructionResult::ReentrancySentryOOG | + InstructionResult::OpcodeNotFound | + InstructionResult::CallNotAllowedInsideStatic | + InstructionResult::StateChangeDuringStaticCall | + InstructionResult::InvalidFEOpcode | + InstructionResult::InvalidJump | + InstructionResult::NotActivated | + InstructionResult::StackUnderflow | + InstructionResult::StackOverflow | + InstructionResult::OutOfOffset | + InstructionResult::CreateCollision | + InstructionResult::OverflowPayment | + InstructionResult::PrecompileError | + InstructionResult::NonceOverflow | + InstructionResult::CreateContractSizeLimit | + InstructionResult::CreateContractStartingWithEF | + InstructionResult::CreateInitCodeSizeLimit | + InstructionResult::FatalExternalError | + InstructionResult::InvalidImmediateEncoding => DestroyedDisposition::Swallow, + } +} + +/// Whether a frame result with this instruction result has remaining gas the protocol books as +/// destroyed. +/// +/// Debug builds panic if [`DestroyedDisposition::Unreachable`] appears: that variant is not a +/// frame result, and reaching settlement with it means the classification table is stale. +pub(crate) fn remaining_is_destroyed(result: InstructionResult) -> bool { + let class = destroyed_disposition(result); + debug_assert!( + !matches!(class, DestroyedDisposition::Unreachable), + "unreachable InstructionResult at a destroyed-remainder settlement: {result:?}" + ); + class.swallows() +} diff --git a/crates/mega-evm/src/limit/frame_limit.rs b/crates/mega-evm/src/limit/frame_limit.rs index 03d1a34b..96e24d35 100644 --- a/crates/mega-evm/src/limit/frame_limit.rs +++ b/crates/mega-evm/src/limit/frame_limit.rs @@ -32,6 +32,101 @@ pub(crate) struct CallFrameInfo { charged_parent_update: bool, } +/// The two things a resource-limit check reads out of a [`FrameLimitTracker`]: the current frame's +/// budget, when there is one, and the transaction's net usage. +/// +/// Two readings exist at a frame return — the tracker as it stands, and the tracker as it will +/// stand once the returning frame has been popped and merged into its caller. Each dimension's +/// check body is written once against this trait and specialized over both, so the pre-merge +/// question can be asked with no second copy of the predicates to drift from the first. +/// +/// A trait rather than a value on purpose: the live reading is the per-opcode hot path, and +/// materializing a struct for it — eagerly computing a net usage the body may not reach, and +/// pushing the frame's budget through a reference — costs measurably more than reading the tracker +/// where the body asks. Monomorphized, the live specialization is the code that was there before +/// the pre-merge reading existed. +pub(crate) trait LimitReading { + /// Whether the current frame has exceeded its frame-local budget, evaluated as if `extra` more + /// usage had already been recorded against it. + fn frame_check(&self, kind: LimitKind, extra: u64) -> LimitCheck; + /// `Σ(persistent + discardable) − Σ refund` across the TX entry and every frame on the stack. + fn net_usage(&self) -> u64; + /// Whether a frame is on the stack — the predicate the TX-level budget invariants assert on. + fn has_frame(&self) -> bool; +} + +/// The reading a pending pop would produce, computed without popping. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct FrameLimitView { + /// The current frame's budget, or `None` when no frame is on the stack. + frame: Option, + /// `Σ(persistent + discardable) − Σ refund` across the TX entry and every frame on the stack. + net_usage: u64, +} + +/// One frame's budget, as a limit check reads it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct FrameBudget { + limit: u64, + used: u64, + refund: u64, +} + +impl LimitReading for FrameLimitView { + #[inline] + fn frame_check(&self, kind: LimitKind, extra: u64) -> LimitCheck { + frame_budget_check(self.frame, kind, extra) + } + + #[inline] + fn net_usage(&self) -> u64 { + self.net_usage + } + + #[inline] + fn has_frame(&self) -> bool { + self.frame.is_some() + } +} + +impl LimitReading for FrameLimitTracker { + #[inline] + fn frame_check(&self, kind: LimitKind, extra: u64) -> LimitCheck { + frame_budget_check(self.frame_budget(), kind, extra) + } + + #[inline] + fn net_usage(&self) -> u64 { + Self::net_usage(self) + } + + #[inline] + fn has_frame(&self) -> bool { + self.has_active_frame() + } +} + +/// The one frame-local exceed predicate, shared by every reading of it. +/// +/// A separate copy per call site — or per view — would be free to drift from the one enforcement +/// actually uses, which is exactly what a pre-merge reading must not do. +#[inline] +fn frame_budget_check(budget: Option, kind: LimitKind, extra: u64) -> LimitCheck { + match budget { + Some(entry) + if entry.used.saturating_add(extra).saturating_sub(entry.refund) > entry.limit => + { + LimitCheck::ExceedsLimit { + kind, + limit: entry.limit, + used: entry.used.saturating_add(extra), + frame_local: true, + } + } + _ => LimitCheck::WithinLimit, + } +} + #[derive(Debug, Clone)] pub(crate) struct FrameLimitTracker { /// Top-level (TX-scope) entry. Holds the TX limit and accumulates usage @@ -98,7 +193,7 @@ impl FrameLimitEntry { /// /// Computed as `limit - (used - refund)`, clamped to `[0, limit]`. /// The net usage (`used - refund`) is computed first to stay consistent with - /// the exceed check in `exceeds_current_frame_limit`. + /// the exceed check in `frame_budget_check`. #[inline] pub(crate) fn remaining(&self) -> u64 { self.limit.saturating_sub(self.used().saturating_sub(self.refund)) @@ -220,19 +315,59 @@ impl FrameLimitTracker { child } - /// Returns whether the current frame has exceeded its frame-local limit. - /// If exceeded, `frame_local` is always `true` since this checks per-frame budgets. - pub(crate) fn exceeds_current_frame_limit(&self, kind: LimitKind) -> LimitCheck { - match self.frame_stack.last() { - Some(entry) if entry.used().saturating_sub(entry.refund) > entry.limit => { - LimitCheck::ExceedsLimit { - kind, - limit: entry.limit, - used: entry.used(), - frame_local: true, - } + /// The current frame's budget, as a limit check reads it. + #[inline] + fn frame_budget(&self) -> Option { + self.frame_stack.last().map(|entry| FrameBudget { + limit: entry.limit, + used: entry.used(), + refund: entry.refund, + }) + } + + /// What the limit checks will read out of this tracker once the current frame has been popped + /// and merged into its caller — computed without popping it. + /// + /// This mirrors [`pop_frame`](Self::pop_frame) term for term, and is the *only* place that + /// mirrors it: the child's persistent usage always moves up, its discardable usage and refund + /// move up on success and vanish otherwise, and the cached totals lose exactly what vanished. + /// A frame return cross-checks the two against each other in debug builds. + pub(crate) fn view_after_pop(&self, success: bool) -> FrameLimitView { + let Some(child) = self.frame_stack.last() else { + // Nothing to pop: `pop_frame` on an empty stack changes nothing. + return FrameLimitView { frame: self.frame_budget(), net_usage: self.net_usage() }; + }; + let frame = self.frame_stack.len().checked_sub(2).map(|parent_index| { + let parent = &self.frame_stack[parent_index]; + let persistent = parent.persistent_usage + child.persistent_usage; + let (discardable, refund) = if success { + (parent.discardable_usage + child.discardable_usage, parent.refund + child.refund) + } else { + (parent.discardable_usage, parent.refund) + }; + FrameBudget { + limit: parent.limit, + used: persistent.checked_add(discardable).expect("overflow"), + refund, } - _ => LimitCheck::WithinLimit, + }); + let net_usage = if success { + self.net_usage() + } else { + (self.cached_total_used - child.discardable_usage) + .saturating_sub(self.cached_total_refund - child.refund) + }; + FrameLimitView { frame, net_usage } + } + + /// Returns the budget of the current frame, in the same form + /// [`frame_budget_check`] reports it on an exceed. + /// + /// If the frame stack is empty (before the first frame is pushed), returns the TX-level limit. + pub(crate) fn current_frame_limit(&self) -> u64 { + match self.frame_stack.last() { + Some(entry) => entry.limit, + None => self.tx_entry.limit, } } @@ -531,6 +666,88 @@ mod tests { const ADDR: Address = address!("0000000000000000000000000000000000001234"); + /// Every question a dimension's `check_limit` body can put to a reading. + /// + /// Comparing two readings through this rather than field for field is deliberate: it is what + /// the bodies consume, so a reading that agrees here cannot make a body decide differently. + fn everything_a_check_body_reads(reading: &impl LimitReading) -> (Vec, u64, bool) { + let kinds = [ + LimitKind::DataSize, + LimitKind::KVUpdate, + LimitKind::ComputeGas, + LimitKind::StateGrowth, + ]; + let checks = kinds + .into_iter() + .flat_map(|kind| [0u64, 1, 7].map(move |extra| (kind, extra))) + .map(|(kind, extra)| reading.frame_check(kind, extra)) + .collect(); + (checks, reading.net_usage(), reading.has_frame()) + } + + /// The pre-pop reading must be the post-pop reading, exactly. + /// + /// A frame return decides whether the returning frame overran its caller's budget from + /// [`FrameLimitTracker::view_after_pop`], one step before the pop that would produce that + /// reading naturally. The whole ordering rests on the two being the same numbers, so drive a + /// tracker into every shape the merge distinguishes — persistent-only, discardable, refunds, + /// a refund larger than the discardable usage it accompanies, and both depths at which the + /// merge target differs (a parent frame, and the TX entry) — and compare the predicted + /// reading against the one a real pop produces, field for field. + #[test] + fn test_view_after_pop_matches_the_view_a_real_pop_produces() { + // (label, child persistent, child discardable, child refund, extra frames beneath) + let shapes: [(&str, u64, u64, u64, usize); 6] = [ + ("empty child", 0, 0, 0, 1), + ("persistent only", 30, 0, 0, 1), + ("discardable only", 0, 40, 0, 1), + ("mixed", 7, 40, 11, 1), + ("refund above discardable", 7, 5, 40, 1), + ("child of the tx entry", 7, 40, 11, 0), + ]; + + for (label, persistent, discardable, refund, parents) in shapes { + for success in [true, false] { + let mut tracker = FrameLimitTracker::<()>::new(MegaSpecId::REX7, 10_000); + tracker.add_tx_persistent(90); + for _ in 0..parents { + tracker.push_frame(()); + // Give the parent a reading of its own on every lane, so a merge that + // dropped a term would show up rather than cancel. + tracker.add_frame_persistent(13); + tracker.add_frame_discardable(21); + tracker.add_frame_refund(5); + } + tracker.push_frame(()); + tracker.add_frame_persistent(persistent); + tracker.add_frame_discardable(discardable); + tracker.add_frame_refund(refund); + + let predicted = tracker.view_after_pop(success); + tracker.pop_frame(success); + assert_eq!( + everything_a_check_body_reads(&predicted), + everything_a_check_body_reads(&tracker), + "{label} (success={success}): the pre-pop reading must equal the post-pop one", + ); + } + } + } + + /// The pre-pop reading of an empty stack is the reading itself: `pop_frame` on an empty stack + /// changes nothing, and the top-level frame return reaches this with the stack already popped. + #[test] + fn test_view_after_pop_on_an_empty_stack_is_the_current_view() { + let mut tracker = FrameLimitTracker::<()>::new(MegaSpecId::REX7, 10_000); + tracker.add_tx_persistent(90); + for success in [true, false] { + assert_eq!( + everything_a_check_body_reads(&tracker.view_after_pop(success)), + everything_a_check_body_reads(&tracker), + ); + } + } + /// `set_created_address` on an empty frame stack must be a no-op. #[test] fn test_set_created_address_empty_stack_is_noop() { diff --git a/crates/mega-evm/src/limit/inspector_ledger.rs b/crates/mega-evm/src/limit/inspector_ledger.rs new file mode 100644 index 00000000..2ef4fcde --- /dev/null +++ b/crates/mega-evm/src/limit/inspector_ledger.rs @@ -0,0 +1,458 @@ +//! The ledger of what an inspector did to a transaction. +//! +//! Nothing here enforces anything. The gas lanes are the part of a transaction's gas movement the +//! EVM did not produce, kept apart so enforcement can ignore it and the conservation law can +//! account for it; the two counters record rewrites that move no gas at all. + +/// One signed lane of the ledger, and how much traffic it carried. +/// +/// Two numbers because two consumers ask different questions. The conservation law needs the +/// **net**: gas written into one object and taken back out of another really did leave the +/// envelope where it was. The block guard needs the **gross**: two edits that cancel are two +/// edits, and in between them the frame held a number the EVM would never have given it — or the +/// two landed in different frames and only one survived to the receipt. +/// +/// So the gross is not a diagnostic beside the net; it is what [`InspectorLedger::is_zero`] is +/// defined over. +/// +/// Both halves saturate. A ledger feeds no identity beyond the law's own term, and a saturated +/// lane answers the guard the same way an exact one would; an overflow panic would not. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Lane { + /// The sum of every booking, signed — what the transaction's envelope actually moved by. + net: i128, + /// The sum of every booking's magnitude — how much traffic this lane carried, in either + /// direction. + gross: u128, +} + +impl Lane { + /// A lane that carried one booking of `net`, whose gross is therefore `|net|`. + /// + /// A lane moved in both directions needs [`of`](Self::of): the two numbers are then + /// independent. + #[inline] + pub const fn once(net: i128) -> Self { + Self { net, gross: net.unsigned_abs() } + } + + /// A lane with both numbers stated, for a caller expecting bookings in both directions. + #[inline] + pub const fn of(net: i128, gross: u128) -> Self { + Self { net, gross } + } + + /// What the transaction's envelope moved by on this lane. + #[inline] + pub const fn net(self) -> i128 { + self.net + } + + /// How much traffic this lane carried, counting both directions. + #[inline] + pub const fn gross(self) -> u128 { + self.gross + } + + /// Whether nothing was ever booked here. + /// + /// Read off the gross, not the net: a lane whose bookings cancelled carried traffic, and the + /// whole point of the pair is that the guard can tell that from a lane nobody touched. + #[inline] + pub const fn is_zero(self) -> bool { + self.gross == 0 + } + + /// Books one movement on this lane. + #[inline] + pub(crate) const fn book(&mut self, delta: i128) { + self.book_crossing(delta); + self.book_movement(delta); + } + + /// Records that an edit of `delta` crossed a callback boundary, without saying yet whether it + /// moved the transaction's envelope. + /// + /// The pair with [`book_movement`](Self::book_movement), for the lanes that cannot answer the + /// second question where they answer the first. An edit to a frame's result moves the envelope + /// only if the frame hands its remainder back, which the classification decides and no + /// boundary knows — so the traffic is recorded here, at the boundary, and the movement is + /// booked at the frame's settlement point. + /// + /// Splitting them is what keeps the guard's question answerable on those lanes. An edit whose + /// frame then halts moves nothing and must stay out of the net, but it is still an edit the + /// inspector made, and one that can change what the transaction produces before the + /// classification catches up with it. + #[inline] + pub(crate) const fn book_crossing(&mut self, delta: i128) { + self.gross = self.gross.saturating_add(delta.unsigned_abs()); + } + + /// Books a movement whose traffic [`book_crossing`](Self::book_crossing) already recorded. + #[inline] + pub(crate) const fn book_movement(&mut self, delta: i128) { + self.net = self.net.saturating_add(delta); + } +} + +/// What an inspector conjured, destroyed, rewrote, or had refused, as measured at the callback +/// boundaries. +/// +/// The EVM does not execute inside a callback, so anything visible across one is the inspector's +/// by construction rather than by attribution. The shim snapshots before delegating and after, and +/// the difference lands here. +/// +/// # Sign convention +/// +/// Every gas field is a [`Lane`] whose net reads from the transaction's point of view: positive is +/// gas that exists in the execution but that nothing debited from the envelope, negative is gas +/// the envelope funded that no frame received. +/// +/// # What it does not measure +/// +/// What a callback does behind the shim's back: the *contents* of the interpreter's stack, memory, +/// return buffer, calldata and code, and the journal. Telling whether those came back changed +/// needs a snapshot of unbounded state that a per-opcode boundary cannot take. Every constant-time +/// reading of the interpreter is the exception and all of it is taken, landing on +/// [`interventions`](Self::interventions). +/// +/// So an empty ledger says no gas moved that the EVM did not move, and nothing the shim was handed +/// came back different. It does not say the transaction is the one the EVM would have produced +/// alone. +/// +/// # Why the lanes are grouped as they are +/// +/// A receipt reports its spent envelope, the refund applied to it, and — under EIP-8037 — the state +/// gas consumed. Which of the three a lane moves is what decides whether the conservation law can +/// see it: +/// +/// - [`gas`](Self::gas), [`env`](Self::env), [`result`](Self::result) and +/// [`reservoir`](Self::reservoir) move the envelope, and their nets sum into +/// [`conjured_gas`](Self::conjured_gas), the law's `I` term; +/// - [`refund`](Self::refund) moves the refund, which the law — stated over `limit - remaining` — +/// cannot see; +/// - [`state_gas`](Self::state_gas) moves the receipt's state-gas figure, which it cannot reach +/// either. +/// +/// All six are read by [`is_zero`](Self::is_zero) through their gross halves. +/// +/// # What consumes it +/// +/// [`conjured_gas`](Self::conjured_gas) is the term the destroyed-remainder derivation adds to the +/// envelope; without it, gas created out of nothing reads as the transaction having spent less +/// than it did and the derived total can go negative. Everything else here is reported and nothing +/// more — no limit is compared against it, and enforcement never sees an inspector's adjustment, +/// because the site that books one shifts the checkpoint baseline by the same amount. +/// +/// Cumulative over the transaction and deliberately not a per-frame stack. A caller wanting one +/// frame's aggregate reads the ledger at that frame's entry and exit and subtracts; the type is +/// `Copy` and every field is a running total. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct InspectorLedger { + /// Gas the inspector wrote into interpreter gas counters, at every callback handed a live + /// [`Interpreter`](revm::interpreter::Interpreter). + /// + /// A running frame's counter is its own budget, so raising it hands the frame gas the caller + /// never forwarded and lowering it takes gas the caller will never get back. A callback that + /// removed the pending action lands here too: with no action left, the frame carries on + /// spending exactly what the counter holds. + pub gas: Lane, + + /// Gas the inspector wrote into a frame *envelope* — the `gas_limit` a call or create frame is + /// about to be built with. + /// + /// The caller's own `CALL` / `CREATE` opcode debited the forwarded amount before any callback + /// ran, so a raised limit is gas nobody paid for and a lowered one is gas the caller paid for + /// and no frame receives. + /// + /// A synthetic outcome moves this lane's net by nothing: the frame is intercepted and the EVM + /// never reads the inputs the same callback edited. Gas the inspector then sizes that outcome + /// from travels on [`result`](Self::result) instead. + /// + /// The lane also carries an edit made one step earlier, to the `gas_limit` inside a pending + /// `NewFrame` action. That object is the one the caller's opcode produced, so its debit is + /// already behind it and a later interception cannot un-make the edit — it is booked either + /// way. Its traffic is booked where it is made and its movement when the child's frame-start + /// callback can tell an edit from an interception. + pub env: Lane, + + /// Gas the inspector wrote into a frame *result* — what the frame hands back to its caller. + /// + /// The lane's two halves are booked in two different places, because the two questions are + /// answered in two different places. Whether an edit *moved the envelope* depends on how the + /// frame ends: a returning or reverting frame's remainder is reclaimed by its caller, a + /// halting one's is not handed back at all. Only the frame's settlement point knows that, so + /// it books the net. Whether the inspector *made* an edit is known at the boundary, so that is + /// where the traffic is booked. + /// + /// Splitting them is load-bearing rather than tidy. An edit staged at `step_end` into a + /// construction frame's pending `Return` action is charged the code deposit out of that same + /// action before anything settles, so it can turn a successful creation into an `OutOfGas` + /// that deploys nothing — while the classification and output a boundary compares stay exactly + /// where they were. Booking only the net would leave that transaction reading as untouched. + /// + /// The same lane carries the gas of a result the inspector produced outright by answering a + /// frame with a synthetic outcome. There is no EVM-produced number on the other side, so it is + /// measured against the envelope the answering callback was handed, which the transaction did + /// fund; it settles at the same point and by the same classification. + pub result: Lane, + + /// The EIP-8037 state-gas pool the transaction ends holding, which is gas nothing funded. + /// + /// `MegaETH` runs with EIP-8037 off on every path and every spec, so a non-zero reservoir is + /// the inspector's in whole. It moves the envelope — the receipt reports + /// `limit - remaining - reservoir` as spent — so it joins + /// [`conjured_gas`](Self::conjured_gas). + /// + /// Settled once from the final figure rather than differenced at a boundary, for two reasons + /// that each rule a boundary out. revm propagates a reservoir between frames by *replacement*, + /// so an edit made with a `NewFrame` action pending is erased by the child that action builds. + /// And `state_gas_spent` converts into a reservoir on a failing frame, at a site no callback + /// sees. The final number is exactly the part of every edit that survived. + pub reservoir: Lane, + + /// EIP-8037 state gas the inspector wrote into the `state_gas_spent` counters. + /// + /// The reservoir's counterpart on the spending side, and settled the same way. revm reads it + /// at two places regardless of whether the EIP is enabled: a successful transaction reports + /// its final value, and a failing frame folds it into its caller's reservoir. The second is + /// already inside [`reservoir`](Self::reservoir), so this lane carries the first — and stays + /// out of [`conjured_gas`](Self::conjured_gas), because the receipt's state-gas figure is not + /// the envelope and adding it would make the law wrong by exactly this amount. + pub state_gas: Lane, + + /// Gas the inspector wrote into the `refunded` counters of the `Gas` objects it is handed. + /// + /// The one receipt number the conservation law cannot see: the law is stated over + /// `limit - remaining`, which no refund enters. What a refund does reach is what the sender + /// pays. So the lane exists for [`is_zero`](Self::is_zero) and is kept out of + /// [`conjured_gas`](Self::conjured_gas). + /// + /// Nominal in two senses, and deliberately so. Not what survived the EIP-3529 *cap*, because + /// splitting that cap between the EVM's refunds and an inspector's needs a priority rule the + /// protocol does not have — EVM-first, inspector-first and pro rata are all defensible, so + /// none of them is a measurement. And not what survived the *frame chain*, because a refund + /// reaches the receipt only if every frame above the edited one returns successfully, which no + /// boundary can answer without a refund stack aligned to the frame lifecycle. + /// + /// The gross half is what makes the second safe: a `+R` on a surviving frame and a `−R` on a + /// rolled-back one are equal and opposite where they are booked and not where they land. + /// Over-stating costs nothing here, because the lane feeds no identity; under-stating would + /// admit a transaction whose receipt an inspector moved. + pub refund: Lane, + + /// How many rewrites the shim refused because their shape is forbidden. + /// + /// Two shapes are, both because the journal decision they would have to move with was already + /// taken where no callback can reach it: a contract creation rewritten from failure into + /// success, after the journal reverted and the deposit predicates rejected the code; and any + /// `*_end` moving the classification of a result *frame init* produced across the success / + /// revert / halt boundary, which revm and `MegaETH`'s interceptors both decide before + /// returning. + /// + /// A non-zero count means the transaction failed with an `EVMError::Custom` rather than being + /// given a receipt. + pub rejected_rewrites: u32, + + /// How many rewrites the shim saw that change what the execution *did* rather than what it + /// cost. + /// + /// The six gas lanes above answer "did the transaction's numbers move". This answers the + /// other half — "was the transaction left alone" — for the part of it a callback boundary can + /// see, which is the arguments the shim itself is handed and the constant-time readings it + /// can take off a live interpreter: + /// + /// - a frame result whose classification or returned output came back changed, at each of the + /// three callbacks that can change one (`call_end`, `create_end`, `frame_end` — revm runs + /// the variant-specific one and then the generic one over the same result, so each is + /// counted where it happens rather than once at the end); + /// - a finished outcome's metadata — where a call's return data lands in its caller's memory, + /// which address a creation reports, the two EIP-8037 and precompile-log flags beside them — + /// which sits outside the `InterpreterResult` those callbacks also hold; + /// - a frame's inputs edited anywhere but in their gas limit, at each of the three callbacks + /// that can edit them (`frame_start`, `call`, `create`); + /// - a frame the inspector answered itself, with a synthetic outcome instead of letting the + /// EVM build it; + /// - any constant-time reading of a live interpreter's working set, at each of the four + /// callbacks handed one. The rule the shim's snapshot is built on is stated over the cost of + /// the reading rather than over a list, so it covers the program counter and the code's + /// identity, revm's `continue_execution` flag, the stack's length, the return buffer's + /// identity, the memory's size and window offset, the memo of how far that memory has been + /// paid for, the frame's four identifying fields and its calldata's identity, the static + /// flag and the spec id. Two of those are rewrites with no other trace at all: a stepped + /// program counter deletes an instruction from the frame, and moving the memory together + /// with its memo leaves every interpreter invariant intact while changing what the next + /// expanding opcode is charged. + /// + /// Gas edits are deliberately excluded — a gas limit or a result's remaining gas moving is + /// what the lanes above are for, and counting it here would say the same thing twice. + /// + /// A classification rewrite is the shape that made this lane necessary: it moves no gas + /// anywhere, so every gas lane stays zero while the transaction produces different state and + /// a different receipt. + pub interventions: u32, +} + +impl InspectorLedger { + /// The gas the inspector conjured, net of what it destroyed — the term the destroyed-remainder + /// derivation adds to the transaction's envelope. + #[inline] + pub const fn conjured_gas(&self) -> i128 { + self.gas.net() + self.env.net() + self.result.net() + self.reservoir.net() + } + + /// Whether the inspector left the transaction's gas accounting exactly as the EVM produced it. + /// + /// True for every observation-only inspector, and for every transaction that ran without one. + /// Not the converse of "an inspector changed something": see the type's own documentation for + /// the rewrites that move no gas and so leave this true. + /// + /// Each lane is asked through its gross half, so a lane an inspector moved and moved back is + /// not a lane it left alone. + #[inline] + pub const fn is_zero(&self) -> bool { + self.gas.is_zero() && + self.env.is_zero() && + self.result.is_zero() && + self.reservoir.is_zero() && + self.state_gas.is_zero() && + self.refund.is_zero() && + self.rejected_rewrites == 0 && + self.interventions == 0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The derivation term is the net of both lanes, so an injection into an interpreter counter + /// and a matching reduction of a frame's envelope cancel — the transaction's envelope really is + /// unmoved in that case. + #[test] + fn test_conjured_gas_is_the_net_of_both_lanes() { + let ledger = InspectorLedger { + gas: Lane::once(2_300), + env: Lane::once(-2_300), + ..InspectorLedger::default() + }; + assert_eq!(ledger.conjured_gas(), 0); + assert!(!ledger.is_zero(), "the lanes moved, even though they cancel"); + } + + /// ★ Two bookings on *one* lane that cancel are the shape a net-only guard admitted. + /// + /// The net is what the conservation law needs and it is genuinely zero here — the transaction's + /// envelope really did end where it started. What is not zero is that the lane carried + /// traffic, and between the two bookings the execution saw a number the EVM would never have + /// produced. + #[test] + fn test_bookings_that_cancel_on_one_lane_are_not_zero() { + let mut lane = Lane::default(); + lane.book(1); + lane.book(-1); + assert_eq!(lane.net(), 0, "the envelope is unmoved, and the law must read it that way"); + assert_eq!(lane.gross(), 2, "but the lane carried two bookings"); + assert!(!lane.is_zero()); + + let ledger = InspectorLedger { gas: lane, ..InspectorLedger::default() }; + assert_eq!(ledger.conjured_gas(), 0); + assert!(!ledger.is_zero(), "the guard must refuse a transaction whose lanes cancelled"); + } + + /// The same, on the lane where the two halves land in different frames — one that survives to + /// the receipt and one the journal rolls back. + #[test] + fn test_refund_bookings_that_cancel_are_not_zero() { + let mut refund = Lane::default(); + refund.book(2_000); + refund.book(-2_000); + let ledger = InspectorLedger { refund, ..InspectorLedger::default() }; + assert_eq!( + ledger.conjured_gas(), + 0, + "the refund lane is not a term of the law in either direction", + ); + assert!(!ledger.is_zero()); + } + + /// A lane nobody booked is the only zero lane. + #[test] + fn test_an_untouched_lane_is_the_only_zero_one() { + assert!(Lane::default().is_zero()); + assert!(!Lane::once(1).is_zero()); + assert!(!Lane::once(-1).is_zero()); + assert!(!Lane::of(0, 2).is_zero(), "a cancelled lane is not an untouched one"); + } + + /// `once` states the gross a single booking produces, which is what a caller expecting one + /// booking means. + #[test] + fn test_once_is_a_single_booking() { + let mut lane = Lane::default(); + lane.book(-2_300); + assert_eq!(lane, Lane::once(-2_300)); + } + + /// Both halves saturate rather than overflow. + #[test] + fn test_a_lane_saturates() { + let mut lane = Lane::of(i128::MAX, u128::MAX); + lane.book(i128::MAX); + assert_eq!(lane.net(), i128::MAX); + assert_eq!(lane.gross(), u128::MAX); + + let mut down = Lane::of(i128::MIN, 0); + down.book(i128::MIN); + assert_eq!(down.net(), i128::MIN); + assert_eq!(down.gross(), i128::MIN.unsigned_abs()); + } + + /// The reservoir is envelope-moving gas and joins the law's term; the refund and the + /// state-gas figure are not, and must not. + #[test] + fn test_only_the_envelope_moving_lanes_are_conjured_gas() { + let reservoir = + InspectorLedger { reservoir: Lane::once(10_000), ..InspectorLedger::default() }; + assert_eq!( + reservoir.conjured_gas(), + 10_000, + "a reservoir lowers the envelope the receipt reports, so the law needs it back", + ); + assert!(!reservoir.is_zero()); + + for ledger in [ + InspectorLedger { refund: Lane::once(20_000), ..InspectorLedger::default() }, + InspectorLedger { state_gas: Lane::once(5_000), ..InspectorLedger::default() }, + ] { + assert_eq!( + ledger.conjured_gas(), + 0, + "the law is stated over `limit - remaining`, which neither of these enters", + ); + assert!(!ledger.is_zero(), "but the block guard still has to see it: {ledger:?}"); + } + } + + /// A refused rewrite moves no gas but must still show the transaction was not left alone. + #[test] + fn test_a_rejected_rewrite_alone_is_not_zero() { + let ledger = InspectorLedger { rejected_rewrites: 1, ..InspectorLedger::default() }; + assert_eq!(ledger.conjured_gas(), 0); + assert!(!ledger.is_zero()); + } + + /// A classification rewrite is the shape the gas lanes cannot see: it moves nothing, so the + /// only thing standing between it and an all-zero ledger is this counter. + #[test] + fn test_an_intervention_alone_is_not_zero() { + let ledger = InspectorLedger { interventions: 1, ..InspectorLedger::default() }; + assert_eq!(ledger.conjured_gas(), 0); + assert!(!ledger.is_zero()); + } + + #[test] + fn test_default_ledger_is_zero() { + assert!(InspectorLedger::default().is_zero()); + } +} diff --git a/crates/mega-evm/src/limit/kv_update.rs b/crates/mega-evm/src/limit/kv_update.rs index a1339b68..ad33e057 100644 --- a/crates/mega-evm/src/limit/kv_update.rs +++ b/crates/mega-evm/src/limit/kv_update.rs @@ -88,6 +88,47 @@ impl KVUpdateTracker { self.frame_tracker.add_tx_persistent(amount); } + /// [`check_limit`](TxRuntimeLimit::check_limit) as it will read once the current frame has + /// been popped and merged into its caller, computed without popping it. + #[inline] + pub(crate) fn check_limit_after_pop(&self, success: bool) -> super::LimitCheck { + self.check_limit_on(&self.frame_tracker.view_after_pop(success)) + } + + /// [`check_limit`](TxRuntimeLimit::check_limit) against an explicit reading of the tracker. + /// + /// The reading is a parameter so that one body can answer both questions asked of this check: + /// what it says now, and what it will say once a returning frame has been merged into its + /// caller. A frame return needs the second answer before the merge happens, and a second copy + /// of the predicates would be free to drift from the first. + pub(crate) fn check_limit_on(&self, r: &R) -> super::LimitCheck { + if self.rex4_enabled { + let frame_check = r.frame_check(super::LimitKind::KVUpdate, 0); + if frame_check.exceeded_limit() { + return frame_check; + } + // TX-level fallthrough: defense-in-depth safety net. + // In Rex4+ during execution, per-frame budgets are derived from remaining TX + // budget, so this should only exceed when no frame exists (intrinsic overflow). + } + let used = r.net_usage(); + let limit = self.frame_tracker.tx_limit(); + if used > limit { + debug_assert!( + !self.rex4_enabled || !r.has_frame(), + "KVUpdate TX-level exceeded with active frame — budget invariant violated" + ); + super::LimitCheck::ExceedsLimit { + kind: super::LimitKind::KVUpdate, + limit, + used, + frame_local: false, + } + } else { + super::LimitCheck::WithinLimit + } + } + /// Returns the remaining KV update budget for the current call frame, capped by /// the TX-level remaining. pub(crate) fn current_call_remaining(&self) -> u64 { @@ -126,32 +167,7 @@ impl TxRuntimeLimit for KVUpdateTracker { /// (intrinsic usage is recorded in `tx_entry` before the first frame is pushed). /// In pre-Rex4, checks total KV updates across all frames against the TX limit. fn check_limit(&self) -> super::LimitCheck { - if self.rex4_enabled { - let frame_check = - self.frame_tracker.exceeds_current_frame_limit(super::LimitKind::KVUpdate); - if frame_check.exceeded_limit() { - return frame_check; - } - // TX-level fallthrough: defense-in-depth safety net. - // In Rex4+ during execution, per-frame budgets are derived from remaining TX - // budget, so this should only exceed when no frame exists (intrinsic overflow). - } - let used = self.tx_usage(); - let limit = self.frame_tracker.tx_limit(); - if used > limit { - debug_assert!( - !self.rex4_enabled || !self.frame_tracker.has_active_frame(), - "KVUpdate TX-level exceeded with active frame — budget invariant violated" - ); - super::LimitCheck::ExceedsLimit { - kind: super::LimitKind::KVUpdate, - limit, - used, - frame_local: false, - } - } else { - super::LimitCheck::WithinLimit - } + self.check_limit_on(&self.frame_tracker) } /// Records the KV updates at the start of a transaction. @@ -283,7 +299,10 @@ impl TxRuntimeLimit for KVUpdateTracker { #[cfg(test)] mod tests { - use super::*; + use super::{ + super::{LimitCheck, LimitKind}, + *, + }; /// `record_account_update` must charge exactly one KV update against the current frame /// (used by REX5+ SELFDESTRUCT-beneficiary metering); it must not be a no-op. @@ -301,4 +320,47 @@ mod tests { fn test_tx_limit_reports_configured_limit() { assert_eq!(KVUpdateTracker::new(MegaSpecId::MINI_REX, 4_321).tx_limit(), 4_321); } + + /// A frame's usage is weighed against its *caller's* budget only once the two have been + /// merged, so the pre-merge reading has to answer a question the live one cannot: the caller + /// is already over its budget while the frame on top is still inside its own. + /// + /// A child receives 98% of its caller's remaining budget, so merging one that stayed inside + /// its own budget cannot by itself push the caller past its. The charge that breaks that + /// arithmetic is the REX6 creator nonce bump, which lands on the caller's lane after the + /// child's budget has already been computed — `record_parent_discardable` below. + /// + /// The answer depends on how the frame ends: a reverting child's discardable updates vanish + /// instead of merging, and the caller stays inside its budget. + #[test] + fn test_check_limit_after_pop_sees_a_frame_local_exceed_the_live_check_cannot() { + let mut tracker = KVUpdateTracker::new(MegaSpecId::REX6, 1_000); + tracker.frame_tracker.push_dummy_frame(); + tracker.frame_tracker.push_dummy_frame(); + tracker.record_discardable(500); + tracker.frame_tracker.push_dummy_frame(); + tracker.record_discardable(470); + tracker.record_parent_discardable(20); + + assert_eq!( + tracker.check_limit(), + LimitCheck::WithinLimit, + "the top frame is exactly at its own budget, and the transaction is under its limit", + ); + assert_eq!( + tracker.check_limit_after_pop(true), + LimitCheck::ExceedsLimit { + kind: LimitKind::KVUpdate, + limit: 980, + used: 990, + frame_local: true, + }, + "the merged caller is 10 over the budget it was pushed with", + ); + assert_eq!( + tracker.check_limit_after_pop(false), + LimitCheck::WithinLimit, + "a reverting frame's updates vanish rather than merging", + ); + } } diff --git a/crates/mega-evm/src/limit/limit.rs b/crates/mega-evm/src/limit/limit.rs index b584c921..1381fe96 100644 --- a/crates/mega-evm/src/limit/limit.rs +++ b/crates/mega-evm/src/limit/limit.rs @@ -13,8 +13,8 @@ use revm::{ }; use super::{ - compute_gas, data_size, frame_limit::TxRuntimeLimit, kv_update, state_growth, - storage_call_stipend, + checkpoint, compute_gas, conservation, data_size, destroyed, frame_limit::TxRuntimeLimit, + inspector_ledger, kv_update, state_growth, storage_call_stipend, }; use crate::{ EvmTxRuntimeLimits, JournalInspectTr, MegaHaltReason, MegaSpecId, MegaTransaction, @@ -23,6 +23,47 @@ use crate::{ use super::LimitCheck; +/// How a frame reached the outcome [`AdditionalLimit::finalize_frame`] is settling. +/// +/// The three shapes differ in what the frame's remaining gas means and in which of them the +/// settlement is allowed to reach at all, so the caller names the shape rather than the settlement +/// guessing it from the result. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FrameExit { + /// The frame ran and produced its own result. + Ran, + /// The frame was refused before it could run, by a path that went through the limit tracker's + /// frame-init accounting: a resource limit already over its budget, or one of revm's own + /// frame-init rejections. + Refused, + /// The frame was refused by a synthetic result that never reached that accounting — a system + /// contract interceptor's, or an inspector's. + /// + /// Frozen specs leave such a refusal's envelope entirely alone, which is the gap REX4's + /// pre-dispatch limit check was added to narrow and which REX7 closes: without a settlement + /// here, an envelope that is neither handed back nor booked as destroyed leaves the + /// conservation law short by exactly that amount. + RefusedSynthetically, +} + +/// What a precompile's recording site knows about its call, held until the frame's settlement +/// point can decide the split (REX7+). +/// +/// A precompile is answered inside the frame init and never becomes a child frame, so its +/// recording site is the only place that knows both numbers: the envelope is the caller-supplied +/// forwarded amount rather than the REX5-capped effective limit, and the work is `MegaETH`'s own +/// price for what the call performed, which a halting precompile's gas object does not carry. +/// Carrying them to the settlement point is what lets the split be taken against the +/// classification the caller is finally handed, beside every other frame outcome, rather than +/// being decided early and separately. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct PrecompileEnvelope { + /// The gas the caller forwarded, uncapped. + forwarded: u64, + /// The work the precompile performed, already recorded on the enforcing lane. + executed: u64, +} + /// Additional limits for the `MegaETH` EVM beyond standard EVM limits. /// /// This struct coordinates four independent resource limits: compute gas, data size, @@ -107,6 +148,60 @@ pub struct AdditionalLimit { /// A tracker for the `STORAGE_CALL_STIPEND` granted to value-transferring calls (REX4+). pub(crate) storage_call_stipend: storage_call_stipend::StorageCallStipendTracker, + + /// A tracker for REX7+ checkpoint settlement and gas-clamp state. + pub(crate) checkpoint: checkpoint::CheckpointTracker, + + /// What the inspector — if any — did to this transaction's gas accounting. + /// + /// Written only by the measurement shim every inspector is wrapped in, and read only by the + /// conservation law and by reporting. It stays at its default for every transaction that runs + /// without an inspector and for every observation-only inspector. + inspector: inspector_ledger::InspectorLedger, + + /// The precompile call whose split is waiting for its frame's settlement point (REX7+). + /// + /// At most one can ever be outstanding: a precompile is answered inside a frame init and the + /// same frame init settles the result a few statements later, with no room for another frame + /// to start in between. [`finalize_frame`](Self::finalize_frame) takes it unconditionally, so + /// it cannot outlive the frame that staged it. + staged_precompile: Option, + + /// Gas an inspector wrote into a *terminating* pending action, waiting for the frame that + /// action ends to reach its settlement point. + /// + /// Staged rather than booked for the same reason a frame result's edit is: the action becomes + /// the frame's result, and whether an edit to it moves anything depends on the classification + /// the caller ends up seeing. Only one frame can have one outstanding — a frame that has set + /// its terminating action starts no more children — and + /// [`finalize_frame`](Self::finalize_frame) takes it. + staged_action_result_gas: i128, + + /// Gas an inspector wrote into a *suspending* pending action, waiting for the frame-start + /// callback of the child it is about to build. + /// + /// That callback is the first point at which the edit can be told apart from an interception, + /// and it runs immediately after the action is handed on, with nothing in between that could + /// stage another one. + staged_action_env_gas: i128, + + /// The envelope a callback that answered a frame itself was handed, waiting for the + /// settlement point of the frame it answered. + /// + /// An interception produces a whole frame result out of nothing, so the reading its + /// settlement needs is not a difference the shim can take — it is this baseline against the + /// gas the result turns out to carry. At most one can be outstanding: revm stops at the + /// first callback that answers, and the frame init that asked settles a few statements later. + staged_interception_envelope: Option, + + /// Whether the frame result the `*_end` callbacks are being handed came out of frame init + /// rather than out of a frame that ran. + /// + /// Set by the inspected frame-init path around the one place it runs those callbacks over a + /// result the EVM produced, and cleared as soon as they return. The measurement shim reads it + /// to decide whether a classification rewrite is one the journal decision can still follow — + /// see [`is_settling_frame_init_result`](Self::is_settling_frame_init_result). + settling_frame_init_result: bool, } /// The usage of the additional limits. @@ -134,6 +229,13 @@ impl AdditionalLimit { kv_update: kv_update::KVUpdateTracker::new(spec, limits.tx_kv_updates_limit), compute_gas: compute_gas::ComputeGasTracker::new(spec, limits.tx_compute_gas_limit), storage_call_stipend: storage_call_stipend::StorageCallStipendTracker::new(spec), + checkpoint: checkpoint::CheckpointTracker::new(spec), + inspector: inspector_ledger::InspectorLedger::default(), + staged_precompile: None, + staged_action_result_gas: 0, + staged_action_env_gas: 0, + staged_interception_envelope: None, + settling_frame_init_result: false, } } } @@ -175,6 +277,607 @@ impl AdditionalLimit { self.data_size.reset(); self.kv_update.reset(); self.storage_call_stipend.reset(); + self.checkpoint.reset(); + self.inspector = inspector_ledger::InspectorLedger::default(); + self.staged_precompile = None; + self.staged_action_result_gas = 0; + self.staged_action_env_gas = 0; + self.staged_interception_envelope = None; + self.settling_frame_init_result = false; + } + + /// Whether compute gas settles at checkpoints (REX7+) rather than per opcode. + #[inline] + pub(crate) fn rex7_enabled(&self) -> bool { + self.checkpoint.rex7_enabled() + } + + /// Interpreter gas remaining at the start of the current unsettled segment. + /// + /// Settlement sites that need to subtract their own storage gas or forwarded child gas read + /// this instead of a per-opcode `gas_before` capture, so the measured delta covers every + /// unwrapped plain opcode executed since the previous checkpoint. + #[inline] + pub(crate) fn checkpoint_baseline(&self) -> u64 { + self.checkpoint.baseline() + } + + /// Re-opens the settlement window at `remaining`, without recording anything. + /// + /// Used by settlement sites that compute their own segment amount; every such site must + /// call this once it has recorded, so a later settlement cannot bill the segment twice. + #[inline] + pub(crate) fn sync_checkpoint_baseline(&mut self, remaining: u64) { + self.checkpoint.sync_baseline(remaining); + } + + /// Moves the open segment's baseline down by `amount` of `MegaETH` storage gas just charged to + /// the interpreter, so the charge sits outside the segment rather than inside it. + /// + /// A checkpoint body normally subtracts its own storage charge when it closes its measurement + /// window. A body that aborts — a static-context `LOG`, a `SELFDESTRUCT` whose inner + /// instruction runs out of gas — never reaches that subtraction, and the frame-exit settlement + /// that follows would then bill the charge as compute. Excluding it from the baseline as it is + /// charged makes the exclusion hold on both paths; on the normal path the body's own window + /// re-syncs the baseline afterwards, so this is invisible there. + /// + /// No-op before REX7, where nothing measures against a baseline. + /// + /// The same charge is also the canonical funnel for the transaction's in-frame `MegaETH` + /// storage gas, so it feeds the non-compute lane the destroyed-remainder derivation reads. + #[inline] + pub(crate) fn exclude_storage_gas_from_segment(&mut self, amount: u64) { + self.checkpoint.exclude_storage_gas_from_segment(amount); + self.checkpoint.record_non_compute_gas(i128::from(amount)); + } + + /// Records EVM gas the transaction spends that is neither compute work nor a destroyed + /// remainder (REX7+). + /// + /// The in-frame storage-gas charges arrive through + /// [`exclude_storage_gas_from_segment`](Self::exclude_storage_gas_from_segment); this is the + /// entry point for the contributions that are charged outside an open settlement segment — + /// the `MegaETH` share of intrinsic gas, the code-deposit storage charge, the `KeylessDeploy` + /// interceptor's caller-materialisation charge, and the sandbox boundary's residue. + #[inline] + pub(crate) fn record_non_compute_gas(&mut self, amount: i128) { + self.checkpoint.record_non_compute_gas(amount); + } + + /// The terms of the transaction's gas conservation law, as they stand right now — see + /// [`ConservationTerms`](conservation::ConservationTerms), which states the law and both of + /// its rearrangements. + /// + /// Every site that derives, re-settles or checks a transaction's gas accounting reads the law + /// from here, so the law exists once and the terms cannot drift apart between the places that + /// use them. + /// + /// Meaningful once the transaction's envelope is final. Read earlier, the terms are simply + /// the partial totals recorded so far — and the envelope a caller solves the law against must + /// be read at the one moment it is final too, after the resource-limit rescue has been handed + /// back and before `post_execution` applies the EIP-3529 refund and the EIP-7623 floor. Gas + /// that is rescued for the sender and gas the clamp was hiding are both erased from the + /// envelope before that point, so neither can reach the subtraction. + #[inline] + pub fn conservation_terms(&self) -> conservation::ConservationTerms { + conservation::ConservationTerms { + enforced_compute_gas: self.enforced_compute_gas(), + non_compute_gas: self.non_compute_gas(), + minted_call_stipend: self.minted_call_stipend(), + inspector_conjured_gas: self.inspector_conjured_gas(), + booked_destroyed_compute_gas: self.burned_compute_gas(), + } + } + + /// The `CALL_STIPEND` total this transaction's value-transferring calls minted into their + /// child frames — the term that keeps recorded compute gas from being a partition of what the + /// transaction spent. Always 0 before REX7. + #[inline] + pub(crate) fn minted_call_stipend(&self) -> u64 { + self.checkpoint.minted_call_stipend() + } + + /// Settles the transaction's destroyed compute gas from the conservation law and stores it as + /// the number the transaction reports (REX7+; a no-op before, where nothing is destroyed). + /// + /// `tx_gas_spent` must be the envelope the transaction actually burnt, read at the one moment + /// it is final: after op-revm has normalised the gas object and the resource-limit rescue has + /// been handed back, and before `post_execution` applies the EIP-3529 refund and the EIP-7623 + /// floor. Those two move the number the receipt reports without anybody having burnt the + /// difference, so reading after them would fold a refund into the destroyed total. Gas that is + /// rescued for the sender and gas the clamp was hiding are both erased from the envelope + /// before this point, so neither can reach the subtraction either. + /// + /// The per-site destroyed bookings do not feed this number. They stay as the independent + /// second opinion the `debug_assert` below cross-checks the derivation against, so a site that + /// destroys an envelope without booking it — or a spend the non-compute lane does not know + /// about — still fails loudly in debug builds and in the test corpus. + /// + /// A negative derivation is defended against rather than expected: it would mean the recorded + /// compute and non-compute lanes together claim more gas than the transaction spent, which no + /// spec produces today. Debug builds trip on it; release builds clamp to zero so a reporting + /// defect cannot wrap into an enormous destroyed total. + #[inline] + pub(crate) fn settle_destroyed_compute_gas(&mut self, tx_gas_spent: u64) { + if !self.rex7_enabled() { + return; + } + let terms = self.conservation_terms(); + let derived = terms.destroyed_for(tx_gas_spent); + debug_assert!( + derived >= 0, + "derived destroyed compute gas is negative: {derived} (spent {tx_gas_spent}, {terms})", + ); + debug_assert!( + terms.unbooked_for(tx_gas_spent) == 0, + "destroyed compute gas disagrees with the conservation law: derived {derived} \ + (spent {tx_gas_spent}, {terms})", + ); + let settled = u64::try_from(derived.max(0)).unwrap_or(u64::MAX); + self.checkpoint.set_settled_destroyed(settled); + } + + /// Settles a transaction whose reported envelope is rewritten after every `MegaETH` + /// settlement has already run (REX7+; a no-op before, where nothing is destroyed). + /// + /// One such rewrite exists. An OP deposit is not allowed to fail, so a deposit that does fail + /// has its receipt rebuilt to report the whole `gas_limit`, with the journal rolled back to + /// nothing but the nonce bump and the mint. That rebuild happens at the outermost error + /// boundary, past every site that records or settles, so neither the per-site bookings nor + /// [`settle_destroyed_compute_gas`](Self::settle_destroyed_compute_gas) can see it. Two + /// shapes arrive here: + /// + /// - A validation reject, which never reached a settlement at all. Its lanes hold only what + /// `validate` recorded before returning the error, and the whole rest of the rewritten + /// envelope is unaccounted. + /// - An execution halt, which settled correctly against the envelope it really burnt and is + /// then raised back to `gas_limit`. The gap is exactly what the resource-limit rescue had + /// handed back to the sender, which the rewrite takes away again. + /// + /// Both are the same accounting event: the receipt burns an envelope that nothing was + /// executed for. So the difference between what the conservation law derives for the rewritten + /// envelope and what the per-site bookings already hold is destroyed compute gas. Booking it + /// makes the reported total cover the receipt; re-settling against the rewritten envelope + /// keeps the derived total and the bookings agreeing, which is what the cross-check in + /// `settle_destroyed_compute_gas` verifies. + /// + /// Enforcement is deliberately untouched. [`record_burned_gas`](Self::record_burned_gas) + /// raises the reported total and the destroyed lane by the same amount, so + /// [`enforced_compute_gas`](Self::enforced_compute_gas) — what every limit comparison and the + /// block's admission counter read — does not move. A deposit rejected before it executed + /// anything must not consume block compute capacity for work it never performed. + /// + /// The difference is non-negative on every shape that reaches here. The rewritten envelope is + /// the transaction's `gas_limit`. A rejected deposit's lanes hold at most the intrinsic gas + /// requirement it had already cleared against that limit when the reject fired, and nothing is + /// booked as destroyed yet. A halted deposit settled against the same limit less whatever the + /// resource-limit rescue returned, so raising the envelope back to the limit can only add. + /// + /// The one shape that would break that is a synthetic pre-frame halt which books a destroyed + /// remainder without settling — it would leave the `MegaETH` share of intrinsic gas booked as + /// non-compute *and* the whole envelope booked as destroyed, double-counting it. No spec with + /// the destroyed lane reaches one: an intrinsic overrun has been a validation reject since + /// REX5. A spec that re-opened that path would have to settle it at its own site. Debug builds + /// trip on a negative difference; release builds book nothing for it. + #[inline] + pub(crate) fn settle_rewritten_envelope(&mut self, envelope_gas_spent: u64) { + if !self.rex7_enabled() { + return; + } + let terms = self.conservation_terms(); + let unbooked = terms.unbooked_for(envelope_gas_spent); + debug_assert!( + unbooked >= 0, + "rewritten envelope destroys a negative amount: {unbooked} \ + (envelope {envelope_gas_spent}, {terms})", + ); + self.record_burned_gas(u64::try_from(unbooked.max(0)).unwrap_or(u64::MAX)); + self.settle_destroyed_compute_gas(envelope_gas_spent); + } + + /// The transaction's destroyed compute gas, as settled by + /// [`settle_destroyed_compute_gas`](Self::settle_destroyed_compute_gas) — the part of + /// [`get_usage`](Self::get_usage)'s `compute_gas` that is reported and accounted but never + /// enforced. + /// + /// This is the reporting answer, and the only one a caller outside this module should use. + /// [`burned_compute_gas`](Self::burned_compute_gas) is the per-site booking that backs the + /// transaction's own enforcement and cross-checks this derivation; the two agree, and reading + /// the wrong one would silently pick the wrong side of that check. + #[inline] + pub(crate) fn destroyed_compute_gas(&self) -> u64 { + self.checkpoint.settled_destroyed() + } + + /// Books one `CALL_STIPEND` minted into a child invocation that the caller never funded + /// (REX7+). + /// + /// Called from the CALL-family settlement once the opcode has handed the invocation on, which + /// is where the mint is created — not once a child frame runs. A frame init that then fails on + /// balance or call depth refunds the whole child budget, mint included, to the caller, so the + /// envelope shrinks against recorded work by exactly one stipend just as a child that ran and + /// returned it would. The one path that mints nothing is the compute-limit abort, which + /// discards the pending child and returns its forwarded gas before the EVM sees it. + #[inline] + pub(crate) fn record_minted_call_stipend(&mut self, amount: u64) { + self.checkpoint.record_minted_call_stipend(amount); + } + + /// What the inspector did to this transaction's gas accounting, as measured at the callback + /// boundaries — see [`InspectorLedger`](inspector_ledger::InspectorLedger). + /// + /// Default (all-zero) for every transaction that ran without an inspector and for every + /// observation-only inspector. Cumulative over the whole transaction: a caller that wants the + /// aggregate over one frame, or over any other window, reads this at both ends of the window + /// and takes the difference. + #[inline] + pub fn inspector_ledger(&self) -> inspector_ledger::InspectorLedger { + self.inspector + } + + /// The net gas the inspector conjured — the term + /// [`ConservationTerms`](conservation::ConservationTerms) adds to the envelope so that gas + /// nobody funded does not read as the transaction having spent less than it did. + #[inline] + pub(crate) fn inspector_conjured_gas(&self) -> i128 { + self.inspector.conjured_gas() + } + + /// Books an adjustment an inspector made to a live interpreter's gas counter, and restores + /// correct accounting and enforcement around it. + /// + /// The single entry point for interpreter-counter adjustments. `remaining_before` is what the + /// shim snapshotted before delegating and `gas.remaining()` is what the callback left behind. + /// + /// `reaches_envelope` is whether the EVM will read that counter again — false exactly when the + /// interpreter is already holding a terminating action, whose own copy is what the caller + /// reclaims from. It gates the ledger and nothing else: an edit nobody will read moves no gas, + /// but `MegaETH`'s tail settlement does read this counter after the action is set, so the + /// baseline shifts either way or the edit would be measured as work the frame performed. + /// + /// Then, in order: + /// + /// 1. **The open segment is settled against the pre-callback counter** (REX7+, + /// `IN_OPEN_SEGMENT`), which is what keeps the adjustment out of enforcement. Compute gas is + /// a drop in this counter, so an injection left inside the segment would read as *less* work + /// than the frame performed — free compute headroom. Closing at `remaining_before` and + /// reopening at the adjusted counter measures exactly the work. + /// 2. **The clamp is re-derived** from the usage just settled, as a checkpoint's epilogue does. + /// The clamp hides gas beyond the headroom from the interpreter, and gas written in after it + /// was applied is not hidden by it. + /// + /// `IN_OPEN_SEGMENT` is false at `initialize_interp`, which runs after a frame is built and + /// before its settlement window opens; the frame's entry hook opens that window on the adjusted + /// counter a moment later and absorbs the adjustment for free. + /// + /// Records through the unguarded entry for the same reason the frame-exit tail settlement does: + /// a callback can run right after an opcode whose pre-inner recorder deliberately left a + /// non-compute dimension unlatched, and the latch-protocol guard would trip on it. A latched + /// exceed does not stop the interpreter here — a callback cannot fail an instruction — and the + /// latch is sticky, so this moves *when* a halt lands, never whether it does. + pub(crate) fn record_inspector_gas_adjustment( + &mut self, + gas: &mut Gas, + remaining_before: u64, + reaches_envelope: bool, + ) { + let remaining_after = gas.remaining(); + if remaining_after == remaining_before { + return; + } + if reaches_envelope { + self.inspector.gas.book(i128::from(remaining_after) - i128::from(remaining_before)); + } + + if !IN_OPEN_SEGMENT || !self.rex7_enabled() { + return; + } + + // Close the open segment against the counter as the EVM left it, so the adjustment sits + // outside the measured span. Both the baseline and `remaining_before` live in the clamped + // domain, so the difference telescopes over exactly the opcodes that ran since the last + // checkpoint. + let segment = self.checkpoint_baseline().saturating_sub(remaining_before); + let hidden = self.checkpoint_restore_hidden(); + gas.erase_cost(hidden); + self.sync_checkpoint_baseline(gas.remaining()); + let _ = self.record_compute_gas_unguarded(segment); + + // Re-derive the clamp for the segment that starts now, from the usage just settled. + let hide = self.checkpoint_clamp_amount(gas.remaining()); + if hide > 0 { + let clamped = gas.record_regular_cost(hide); + debug_assert!(clamped, "clamp amount exceeds remaining gas"); + self.sync_checkpoint_baseline(gas.remaining()); + } + } + + /// Books an adjustment an inspector made to a frame's envelope — the `gas_limit` the frame is + /// about to be built with. + /// + /// The caller's `CALL` / `CREATE` opcode debited the forwarded amount before any inspector + /// callback ran, so raising the limit hands the child gas the transaction never paid for, and + /// lowering it makes gas the caller paid for reach nobody. Either way the transaction's + /// envelope no longer matches the work its frames recorded, and the conservation law needs the + /// difference. + /// + /// Call this only when the adjusted inputs actually reach a frame. A callback that returns a + /// synthetic outcome has intercepted the frame, and the inputs it edited are dropped without + /// being read. + #[inline] + pub(crate) fn record_inspector_env_adjustment(&mut self, delta: i128) { + self.inspector.env.book(delta); + } + + /// Books the envelope movement of an edit whose traffic + /// [`stage_inspector_action_env_adjustment`](Self::stage_inspector_action_env_adjustment) + /// already counted. + #[inline] + pub(crate) fn record_staged_inspector_env_movement(&mut self, delta: i128) { + self.inspector.env.book_movement(delta); + } + + /// Stages an adjustment an inspector made to the gas a *terminating* pending action carries. + /// + /// The action is the object that becomes the frame's result, so this is the same measurement + /// as an edit made at the frame's last callback, taken one step earlier — and it is settled at + /// the same place, [`finalize_frame`](Self::finalize_frame), for the same reason: whether the + /// edit moves anything at all depends on the classification the caller ends up seeing. + #[inline] + pub(crate) fn stage_inspector_action_result_adjustment(&mut self, delta: i128) { + // The traffic is booked here, at the boundary that measured it, and only the movement + // waits for the classification. Two edits to one frame's action would otherwise sum to + // nothing before either was counted. + self.inspector.result.book_crossing(delta); + self.staged_action_result_gas += delta; + } + + /// Stages an adjustment an inspector made to the gas a *suspending* pending action carries — + /// the envelope the child frame is about to be built with. + /// + /// Same lane as an edit made at the frame-start callback, taken one step earlier, and booked + /// there: the shim takes it back out at that callback, which is the first point that can tell + /// the edit apart from an interception. + #[inline] + pub(crate) fn stage_inspector_action_env_adjustment(&mut self, delta: i128) { + self.inspector.env.book_crossing(delta); + self.staged_action_env_gas += delta; + } + + /// Takes the staged suspending-action adjustment, for the frame-start callback to book. + #[inline] + pub(crate) fn take_inspector_action_env_adjustment(&mut self) -> i128 { + core::mem::take(&mut self.staged_action_env_gas) + } + + /// Stages the envelope a callback that answered a frame itself was handed. + /// + /// The number recorded is the gas limit as that callback *received* it, not as it left it. + /// That is the envelope the transaction actually funded: the caller's `CALL` / `CREATE` + /// opcode debited it, and any edit an earlier callback made to it on the way here was booked + /// on the envelope lane as it was made. An edit the answering callback itself makes is + /// deliberately not part of the baseline — see + /// [`record_inspector_env_adjustment`](Self::record_inspector_env_adjustment) for why it + /// reaches no frame, and note that whatever of it survives into the result the caller is + /// handed is measured here instead, as part of that result. + #[inline] + pub(crate) fn stage_inspector_interception_envelope(&mut self, envelope: u64) { + self.staged_interception_envelope = Some(envelope); + } + + /// Takes the staged interception envelope, for the frame init that asked to settle against. + #[inline] + pub(crate) fn take_inspector_interception_envelope(&mut self) -> Option { + self.staged_interception_envelope.take() + } + + /// Marks the window in which the `*_end` callbacks are being handed a result frame init + /// produced, with no child frame ever built. + /// + /// The inspected frame-init path opens this immediately before it runs those callbacks over a + /// result the frame init produced — an empty-code call, a precompile, a system contract + /// interceptor, a refusal — and closes it as soon as they return. The other two shapes that + /// reach the same callbacks never open it: a frame that ran settles somewhere else entirely, + /// and a result an inspector answered the frame with is the inspector's own, with no journal + /// decision behind it for a rewrite to contradict. + /// + /// It is a plain flag rather than a counter because it cannot nest: the EVM does not execute + /// inside a callback, so nothing can start a second frame init while one is open. + #[inline] + pub(crate) fn set_settling_frame_init_result(&mut self, settling: bool) { + debug_assert!( + self.settling_frame_init_result != settling, + "the frame-init settlement window is opened and closed in pairs, and cannot nest", + ); + self.settling_frame_init_result = settling; + } + + /// Whether the frame result a `*_end` callback is holding came out of frame init. + /// + /// Such a result carries a journal decision that was taken before any callback ran, and that + /// no callback can reach: revm's `make_call_frame` commits an empty-code call's value transfer + /// and reverts a failing precompile's inside itself, and `MegaETH`'s interceptors decide + /// theirs before they return — the `KeylessDeploy` one by merging a sandbox's whole state. + /// The REX7 deferral covers the frame loops and not this, so a rewrite that moves such a + /// result across the success / revert / halt boundary is refused rather than followed. + #[inline] + pub fn is_settling_frame_init_result(&self) -> bool { + self.settling_frame_init_result + } + + /// Books an adjustment an inspector made to a pending action the same callback then removed, + /// leaving the frame to carry on from its own counter. + /// + /// With no action left there is nothing for the edit to travel in, so it lands where the + /// frame's remaining budget already lives — the same lane a counter edit takes, and for the + /// same reason: the frame will spend what it now holds. + #[inline] + pub(crate) fn record_inspector_action_counter_adjustment(&mut self, delta: i128) { + self.inspector.gas.book(delta); + } + + /// Books an adjustment an inspector made to a refund counter — see + /// [`InspectorLedger::refund`](inspector_ledger::InspectorLedger::refund). + /// + /// Booked and nothing else: no limit reads it, the conservation law has no term for it, and + /// the transaction's gas accounting is unmoved by it. Its one consumer is + /// [`InspectorLedger::is_zero`](inspector_ledger::InspectorLedger::is_zero), which is what the + /// canonical block path asks before admitting a transaction — and a refund is what the sender + /// pays, so a receipt an inspector moved this way has to be refused like any other. + #[inline] + pub(crate) fn record_inspector_refund_adjustment(&mut self, delta: i128) { + self.inspector.refund.book(delta); + } + + /// Books the EIP-8037 state-gas dimension a transaction ends holding, at the one point it is + /// final — see [`InspectorLedger::reservoir`](inspector_ledger::InspectorLedger::reservoir). + /// + /// Both numbers are structurally zero on every `MegaETH` path and every spec: EIP-8037 is off, + /// so no instruction charges state gas, no site fills a reservoir, and nothing here fires for + /// a transaction that ran without a rewriting inspector. What is non-zero is therefore the + /// inspector's in whole, which is why this reads the final figures rather than differencing + /// two readings the way every other lane does. + /// + /// Call this after op-revm has normalised the top-level gas object and before the destroyed + /// remainder is settled: the reservoir is what the settlement's envelope has to be reduced by, + /// and the conservation law reads the lane back out of that envelope. + #[inline] + pub(crate) fn record_inspector_state_gas_dimension( + &mut self, + reservoir: u64, + state_gas_spent: i64, + ) { + self.inspector.reservoir.book(i128::from(reservoir)); + self.inspector.state_gas.book(i128::from(state_gas_spent)); + } + + /// Counts one rewrite the shim refused because its shape is forbidden — see + /// [`InspectorLedger::rejected_rewrites`](inspector_ledger::InspectorLedger::rejected_rewrites). + #[inline] + pub(crate) fn record_inspector_rejected_rewrite(&mut self) { + self.inspector.rejected_rewrites = self.inspector.rejected_rewrites.saturating_add(1); + } + + /// Counts one rewrite that changes what the execution did rather than what it cost — see + /// [`InspectorLedger::interventions`](inspector_ledger::InspectorLedger::interventions). + #[inline] + pub(crate) fn record_inspector_intervention(&mut self) { + self.inspector.interventions = self.inspector.interventions.saturating_add(1); + } + + /// The EVM gas the transaction has spent that is neither compute work nor destroyed (REX7+, + /// always 0 before) — the second term of + /// [`ConservationTerms`](conservation::ConservationTerms). + #[inline] + pub(crate) fn non_compute_gas(&self) -> i128 { + self.checkpoint.non_compute_gas() + } + + /// The compute gas the transaction claims to have performed: the reported total less the + /// destroyed remainders — the third term of + /// [`ConservationTerms`](conservation::ConservationTerms), and the number every compute-gas + /// limit comparison runs against. + #[inline] + pub(crate) fn enforced_compute_gas(&self) -> u64 { + self.compute_gas.enforced_tx_usage() + } + + /// Takes the outstanding clamp so the caller can hand its hidden gas back to the interpreter, + /// returning that amount. + /// + /// Every checkpoint prologue calls this before running its body, and the frame's final result + /// calls it before the result propagates, so the clamp is never observable outside a plain + /// segment. + #[inline] + pub(crate) fn checkpoint_restore_hidden(&mut self) -> u64 { + self.checkpoint.restore_hidden() + } + + /// Applies the gas clamp for the segment that starts at `remaining`, and returns the amount + /// the caller must debit from the interpreter's counter. + /// + /// The clamp is recorded — and the segment therefore enforces the compute limit — whenever the + /// true remaining reaches the compute headroom, including when the two are exactly equal and + /// nothing needs to be hidden. When the frame's own gas would run out first, no clamp is + /// recorded: an out-of-gas in that segment is the EVM's own, and reclassifying it as a compute + /// exceed would rescue gas the transaction never had a claim to. + /// + /// Records nothing and returns 0 when clamping does not apply: the transaction is exempt from + /// per-tx metering, or a limit has already been latched (the enclosing site halts on it + /// instead). + #[inline] + pub(crate) fn checkpoint_clamp_amount(&mut self, remaining: u64) -> u64 { + debug_assert!(!self.checkpoint.has_clamp(), "clamp applied while a clamp is outstanding"); + if !self.has_exceeded_limit.within_limit() { + return 0; + } + let binding = self.compute_gas.clamp_binding(); + let Some(hidden) = remaining.checked_sub(binding.headroom) else { + return 0; + }; + self.checkpoint.set_clamp(hidden, binding); + hidden + } + + /// Latches a clamp-induced out-of-gas as a compute gas limit exceed. + /// + /// The crossing opcode never executed — revm's own gas check stopped it at the clamp boundary — + /// so its cost is not in the recorded usage and an ordinary [`check_limit`](Self::check_limit) + /// pass sees usage at or below the limit. The latch is therefore stamped directly, from the + /// constraint that bound the clamp: `frame_local` decides the shape the existing frame-result + /// machinery produces (frame-local absorb to revert; TX-level mark plus gas rescue), and the + /// constraint's own `limit` is what that shape reports — the sub-frame budget for a frame-local + /// binding, the effective TX limit otherwise, matching what the non-clamp check path writes. + #[inline] + fn latch_clamp_exceed(&mut self, binding: &compute_gas::ClampBinding) { + if !self.has_exceeded_limit.within_limit() { + return; + } + self.has_exceeded_limit = LimitCheck::ExceedsLimit { + kind: super::LimitKind::ComputeGas, + frame_local: binding.frame_local, + limit: binding.limit, + used: self.compute_gas.tx_usage(), + }; + // Preserve the volatile-detention attribution: when the binding TX-level constraint at + // clamp time was the detained limit, the halt must classify as `VolatileDataAccessOutOfGas` + // exactly as per-opcode enforcement classifies it. + self.checkpoint.set_latched_detained( + !binding.frame_local && + self.compute_gas.detained_limit() < self.compute_gas.base_tx_limit(), + ); + } + + /// Finalises what the frame's own result decides about the clamp: restores any outstanding + /// clamp into the result's gas and latches a clamp-induced out-of-gas as the compute exceed it + /// stands for. + /// + /// Must run before anything reads or charges the result's gas — in particular before the + /// execution-layer code-deposit storage charge, which would otherwise observe the clamped copy + /// and mis-fire an out-of-gas on a CREATE frame that is nowhere near its limits. It is also + /// what puts the result's gas into the true domain, which is where the destroyed remainder of + /// an exceptionally halted frame is later read from. + /// + /// A clamp can only be outstanding when the frame ended inside a plain-opcode segment, because + /// every checkpoint prologue takes it before its body. An out-of-gas exit from such a segment + /// is a clamp artifact: the true counter held `hidden` more gas than the interpreter could see, + /// and the crossing opcode was stopped at the clamp boundary *before executing* — exactly the + /// gas-clamp enforcement point. When the crossing opcode would have exceeded the true + /// remaining as well, the compute classification still wins: the two are indistinguishable + /// here, and attributing the halt to the resource limit keeps the sender's remaining gas + /// refundable. + pub(crate) fn settle_frame_final_result(&mut self, result: &mut InterpreterResult) { + if !self.checkpoint.rex7_enabled() { + return; + } + if let Some(clamp) = self.checkpoint.take_clamp() { + result.gas.erase_cost(clamp.hidden); + // `MemoryOOG` is the same gas shortage reported from the memory-expansion path; every + // other result either is unrelated to gas or cannot arise from a plain opcode. + if matches!(result.result, InstructionResult::OutOfGas | InstructionResult::MemoryOOG) { + self.latch_clamp_exceed(&clamp.binding); + } + } } /// Test-only setter for [`has_exceeded_limit`](Self::has_exceeded_limit). Bypasses every @@ -210,6 +913,52 @@ impl AdditionalLimit { self.has_exceeded_limit = LimitCheck::Exempt; } + /// The destroyed remainders the per-site bookings recorded, summed as they happened (REX7+, + /// always 0 before) — **not** the number the transaction reports. + /// + /// This is the sum that separates the recorded compute total into the work every limit is + /// evaluated against and the remainder none of them sees, so it is what the transaction's own + /// enforcement runs on, and what a tracker merging this transaction's usage — today the + /// `KeylessDeploy` sandbox boundary — must carry alongside the total, or the receiving tracker + /// re-enforces gas the EVM already destroyed. + /// + /// It is also the second opinion the settlement point's `debug_assert` holds the derivation + /// to. For the reported destroyed total use + /// [`destroyed_compute_gas`](Self::destroyed_compute_gas). + #[inline] + pub(crate) fn burned_compute_gas(&self) -> u64 { + self.compute_gas.burned_usage() + } + + /// Records a destroyed remainder into the non-enforcing compute-gas lane (REX7+). + /// + /// Raises the reported total and leaves every limit comparison unchanged — the same + /// [`ComputeGasTracker::record_burned_gas`](compute_gas::ComputeGasTracker::record_burned_gas) + /// the interpreter-frame halt path uses. + #[inline] + pub(crate) fn record_burned_gas(&mut self, amount: u64) { + self.compute_gas.record_burned_gas(amount); + } + + /// Hands the precompile recording site's two numbers to the frame's settlement point (REX7+; + /// a no-op before, where nothing is destroyed). + /// + /// `executed` must already have been recorded on the enforcing lane — it is the work the call + /// performed, which does not depend on how the call is classified afterwards. Only the + /// destroyed half does, and that is what the settlement point derives from these two numbers + /// and the final classification. See [`PrecompileEnvelope`]. + #[inline] + pub(crate) fn stage_precompile_envelope(&mut self, forwarded: u64, executed: u64) { + if !self.rex7_enabled() { + return; + } + debug_assert!( + self.staged_precompile.is_none(), + "a precompile's envelope outlived the frame init that staged it", + ); + self.staged_precompile = Some(PrecompileEnvelope { forwarded, executed }); + } + /// Gets the usage of the additional limits. #[inline] pub fn get_usage(&self) -> LimitUsage { @@ -318,10 +1067,15 @@ impl AdditionalLimit { &self, access_type: VolatileDataAccess, ) -> Option { - self.compute_gas.is_detained_exceed().then(|| MegaHaltReason::VolatileDataAccessOutOfGas { - access_type, - limit: self.compute_gas.detained_limit(), - actual: self.compute_gas.tx_usage(), + // `is_detained_exceed` covers per-opcode enforcement, where usage crossed the detained + // limit. `latched_detained` covers gas-clamp enforcement, where the crossing opcode + // was stopped before executing and usage therefore stays at or below the limit. + (self.compute_gas.is_detained_exceed() || self.checkpoint.latched_detained()).then(|| { + MegaHaltReason::VolatileDataAccessOutOfGas { + access_type, + limit: self.compute_gas.detained_limit(), + actual: self.compute_gas.tx_usage(), + } }) } @@ -386,6 +1140,54 @@ impl AdditionalLimit { self.has_exceeded_limit } + /// [`check_limit`](Self::check_limit) as it will read once the returning frame has been + /// popped and merged into its caller — asked before the merge, and latching nothing. + /// + /// A per-frame budget is defined by the frame's usage weighed against its *caller's* budget + /// after the merge, so that is the only question worth asking at a frame return. Asked where + /// its numbers naturally appear — after the pop — it comes too late for the answer to change + /// what the merge did with the frame's usage or what the journal did with its state. This asks + /// the same question one step earlier, which is the whole of why it exists. + /// + /// "The same question" is meant literally: every dimension runs its own `check_limit` body, + /// in `check_limit`'s order, over a reading of its tracker taken as if the pop had happened. + /// The only thing that differs is where the numbers come from, and + /// [`FrameLimitTracker::view_after_pop`](super::FrameLimitTracker::view_after_pop) is the + /// single place that computes them. `before_frame_return_result` cross-checks the two readings + /// against each other on every frame return in debug builds. + /// + /// `success` is the merge the pop would perform — the returning frame's classification as it + /// stands when this is asked, before anything this answer causes rewrites it. + pub(crate) fn peek_check_limit_after_pop(&self, success: bool) -> LimitCheck { + // Sticky short-circuit, mirroring `check_limit`: a latched exceed or an exemption is what + // that pass would return, whatever the sub-trackers hold. + if !self.has_exceeded_limit.within_limit() { + return self.has_exceeded_limit; + } + + let data_size_check = self.data_size.check_limit_after_pop(success); + if data_size_check.exceeded_limit() { + return data_size_check; + } + + let kv_update_check = self.kv_update.check_limit_after_pop(success); + if kv_update_check.exceeded_limit() { + return kv_update_check; + } + + let compute_gas_check = self.compute_gas.check_limit_after_pop(success); + if compute_gas_check.exceeded_limit() { + return compute_gas_check; + } + + let state_growth_check = self.state_growth.check_limit_after_pop(success); + if state_growth_check.exceeded_limit() { + return state_growth_check; + } + + self.has_exceeded_limit + } + /// `true` when a per-tx resource limit has already been latched as exceeded — the exact /// condition [`frame_result_if_exceeding_limit`](Self::frame_result_if_exceeding_limit) halts /// the transaction on. `WithinLimit` and `Exempt` both return `false`. Reads the latched @@ -418,8 +1220,33 @@ impl AdditionalLimit { /// This runs on every metered opcode, so it is the hottest hook in the whole tracker. /// `#[inline]` lets the record + within-limit check fold directly into the per-opcode /// wrapper, removing a call across the `RefMut` boundary. + /// + /// Surfacing an exceed here is what turns a latched non-compute overflow into a halt, so + /// the call sites are also the positions a halt can land on: every metered opcode under + /// per-opcode accounting, and every checkpoint under checkpoint accounting. #[inline] pub(crate) fn record_compute_gas(&mut self, compute_gas_used: u64) -> bool { + self.record_compute_gas_impl::(compute_gas_used) + } + + /// Records the compute gas used without the latch-protocol guard. + /// + /// The guard in [`record_compute_gas_impl`](Self::record_compute_gas_impl) asserts that no + /// non-compute dimension is over limit without having latched, which holds at every position + /// an opcode can record from. It does not hold at a frame's final settlement: a pre-inner + /// recorder whose opcode then failed (SELFDESTRUCT's beneficiary accounting) deliberately + /// leaves its usage unlatched, and the frame is about to pop and discard it. Recording it + /// through the guarded entry point would trip the assert on that path. + #[inline] + pub(crate) fn record_compute_gas_unguarded(&mut self, compute_gas_used: u64) -> bool { + self.record_compute_gas_impl::(compute_gas_used) + } + + #[inline] + fn record_compute_gas_impl( + &mut self, + compute_gas_used: u64, + ) -> bool { // Record unconditionally, even when another dimension has already latched an exceed: // the compute work was performed, and the recorded total feeds the transaction outcome // and block-level compute accounting. Skipping the record would under-report compute @@ -437,13 +1264,15 @@ impl AdditionalLimit { // only if every non-compute mutation site already latched its own exceed. If a // non-compute dimension is over limit but not yet latched, some mutation site is missing // its `check_limit()` — catch it here in tests, not in production. The sub-tracker - // `check_limit()` calls are non-mutating, so this compiles out of release builds. (The - // one pre-inner recorder, SELFDESTRUCT, routes through `record_compute_gas_all_dims`, not - // this method, so it never trips this.) + // `check_limit()` calls are non-mutating, so this compiles out of release builds. The one + // pre-inner recorder, SELFDESTRUCT, routes through `record_compute_gas_all_dims`, not this + // method, so it never trips this; the frame-final settlement, which can observe that same + // recorder's usage after its opcode failed, opts out via `GUARD_LATCH_PROTOCOL`. debug_assert!( - !self.data_size.check_limit().exceeded_limit() && - !self.kv_update.check_limit().exceeded_limit() && - !self.state_growth.check_limit().exceeded_limit(), + !GUARD_LATCH_PROTOCOL || + (!self.data_size.check_limit().exceeded_limit() && + !self.kv_update.check_limit().exceeded_limit() && + !self.state_growth.check_limit().exceeded_limit()), "non-compute limit exceeded without latching: a mutation site is missing check_limit()", ); // Recording compute gas can only change the compute-gas dimension, so check just that one @@ -482,8 +1311,9 @@ impl AdditionalLimit { /// refunded to the sender. The storage-stipend tracker decides how `gas.remaining()` /// maps to the refundable balance — see /// `StorageCallStipendTracker::effective_remaining_for_rescue`. - pub(crate) fn rescue_gas(&mut self, gas: &Gas) { - self.rescued_gas += self.storage_call_stipend.effective_remaining_for_rescue(gas); + pub(crate) fn rescue_gas(&mut self, gas: &Gas, remaining: u64) { + self.rescued_gas += + self.storage_call_stipend.effective_remaining_for_rescue(gas, remaining); } /// Drains up to `amount` from the current frame's storage stipend allowance and @@ -494,16 +1324,19 @@ impl AdditionalLimit { self.storage_call_stipend.try_consume(amount) } - /// Rescue remaining gas from a frame result if a TX-level additional limit has been - /// exceeded. + /// Rescues a frame's remaining gas for the sender if a TX-level additional limit has been + /// exceeded, and refunds it in `last_frame_result`. /// - /// This must be called before any inspector callback (`frame_end`) that might modify the - /// gas via `spend_all()`, so the correct `gas.remaining()` value is captured. - /// The rescued gas is later refunded to the transaction sender in `last_frame_result`. - pub(crate) fn try_rescue_gas(&mut self, gas: &Gas) { + /// `remaining` is the gas the EVM left in the result, which is not always the number the + /// result now carries: an inspector callback runs between the two, and a callback that spends + /// the result down — the shape `GasInspector` takes on an error — must not be able to take + /// the sender's refund with it. Every frame the transaction unwinds through rescues the part + /// of the envelope it was still holding, and those parts are disjoint, so the sum is the whole + /// of what the halted transaction never spent. + pub(crate) fn try_rescue_gas(&mut self, gas: &Gas, remaining: u64) { let limit_check = self.check_limit(); if limit_check.exceeded_limit() && !limit_check.is_frame_local() { - self.rescue_gas(gas); + self.rescue_gas(gas, remaining); } } @@ -614,7 +1447,7 @@ impl AdditionalLimit { /// /// Returns `Some(FrameResult)` if a TX-level limit is already exceeded. pub(crate) fn frame_result_if_exceeding_limit( - &mut self, + &self, frame_input: &FrameInput, ) -> Option { if !self.limit_exceeded() { @@ -627,7 +1460,7 @@ impl AdditionalLimit { /// /// Shared by `before_frame_init` (limit exceeded after pushing sub-tracker frames) /// and `frame_result_if_exceeding_limit` (intrinsic overflow before frame push). - fn create_exceeded_limit_result(&mut self, frame_input: &FrameInput) -> Option { + fn create_exceeded_limit_result(&self, frame_input: &FrameInput) -> Option { let (gas_limit, return_memory_offset) = match frame_input { FrameInput::Call(inputs) => { (inputs.gas_limit, Some(inputs.return_memory_offset.clone())) @@ -636,14 +1469,14 @@ impl AdditionalLimit { FrameInput::Empty => unreachable!(), }; let output = self.has_exceeded_limit.revert_data(); - let result = create_exceeding_limit_frame_result( + // The gas this result carries is rescued in `finalize_frame`, along with every other + // refused frame's, once the last callback that can rewrite it has run. + Some(create_exceeding_limit_frame_result( self.exceeding_instruction_result(), Gas::new(gas_limit), return_memory_offset, output, - ); - self.try_rescue_gas(result.gas()); - Some(result) + )) } /// Hook called when a new execution frame is successfully initialized in `frame_init` and needs @@ -657,18 +1490,17 @@ impl AdditionalLimit { self.data_size.after_frame_init_on_frame(frame); self.kv_update.after_frame_init_on_frame(frame); self.compute_gas.after_frame_init_on_frame(frame); - } else if let ItemOrResult::Result(result) = init_result { - // Rescue gas if a TX-level limit was exceeded. This covers the - // before_frame_init early-return path and any other Result from frame_init. - self.try_rescue_gas(result.gas()); } + // A `Result` needs no work here. A frame init that refuses to build a frame settles in + // `finalize_frame`, like every other frame outcome, so that whatever an inspector's + // callback does to the refusal is already in it. } /// Hook called before a frame run. If the limit is exceeded, return an interpreter result /// indicating that the limit is exceeded. pub(crate) fn before_frame_run( &mut self, - frame: &EthFrame, + frame: &mut EthFrame, ) -> Option { self.state_growth.before_frame_run(frame); self.data_size.before_frame_run(frame); @@ -683,33 +1515,206 @@ impl AdditionalLimit { output, )); } + + // Checkpoint accounting: apply the gas clamp and open the settlement window at the + // frame's clamped gas. This hook runs both at frame entry and at every resume after a child + // frame's outcome — including the gas it returned — has been merged back into this frame's + // interpreter, so the window always starts at an instruction boundary with the + // interpreter's counter in its real, post-merge state. No clamp can be outstanding + // here: every suspension point (the CALL / CREATE checkpoint prologue) and every + // frame end restores it first. + if self.checkpoint.rex7_enabled() { + debug_assert!(!self.checkpoint.has_clamp(), "frame resumed with a clamp outstanding"); + let hide = self.checkpoint_clamp_amount(frame.interpreter.gas.remaining()); + if hide > 0 { + let clamped = frame.interpreter.gas.record_regular_cost(hide); + debug_assert!(clamped, "clamp amount exceeds remaining gas"); + } + self.checkpoint.sync_baseline(frame.interpreter.gas.remaining()); + } None } - /// Hook called after frame action processing in `frame_run`. + /// Records the compute gas a frame's own classification spent — the code deposit of a + /// contract creation, on the specs that read the charge back off the result instead of + /// weighing it beforehand. /// - /// Records compute gas cost induced in frame action processing (e.g., code deposit cost), - /// marks the frame result as exceeding limit if needed, and rescues gas if a TX-level limit - /// was exceeded (before any inspector callback that might modify gas). - pub(crate) fn after_frame_run( + /// Frozen: REX5 onwards weigh the same charge at the frame's exit and pass `None` here, so + /// the live callers are the specs through REX4. Their reading is a difference between the + /// result's gas before and after classification, which is why this stays ahead of the last + /// mutating callback rather than joining [`finalize_frame`](Self::finalize_frame): a callback + /// editing that gas would otherwise land inside the difference and be recorded as work. + pub(crate) fn settle_post_action_charge( &mut self, result: &mut FrameResult, - gas_remaining_before_process_action: Option, + gas_remaining_before_classification: Option, ) { - if let Some(gas_remaining_before) = gas_remaining_before_process_action { - let compute_gas_cost = gas_remaining_before.saturating_sub(result.gas().remaining()); - if !self.record_compute_gas(compute_gas_cost) { - mark_frame_result_as_exceeding_limit( - result, - self.exceeding_instruction_result(), - Default::default(), + let Some(gas_remaining_before) = gas_remaining_before_classification else { + return; + }; + let compute_gas_cost = gas_remaining_before.saturating_sub(result.gas().remaining()); + if !self.record_compute_gas(compute_gas_cost) { + mark_frame_result_as_exceeding_limit( + result, + self.exceeding_instruction_result(), + Default::default(), + ); + } + } + + /// Settles a frame's outcome, once and for all. + /// + /// # Where this sits + /// + /// After the last callback that can rewrite the frame's classification, and before the journal + /// is told what to do with the frame. Everything here reads the classification the caller will + /// actually see, and everything the journal does follows from what this leaves behind. That + /// ordering is the whole point: a settlement taken earlier books a result that may still + /// change, and a journal decision taken earlier leaves state behind that the reported result + /// denies. + /// + /// # What it does not cover + /// + /// The gas clamp's restore and its out-of-gas latch stay ahead of this point, in + /// [`settle_frame_final_result`](Self::settle_frame_final_result). The latch's input is the + /// interpreter's own exit classification, which the create-return classification overwrites — + /// a creation that cannot afford its code deposit ends `OutOfGas` for a reason that has + /// nothing to do with the clamp — and the code-deposit settlement that runs between the two + /// reads the latch. Both halves therefore stay where their inputs are still intact. + /// + /// The frame's tracker entries are popped later still, when the frame is handed back to its + /// caller. Popping here would double-pop the paths that reach the caller without running a + /// frame at all. + pub(crate) fn finalize_frame( + &mut self, + result: &mut FrameResult, + exit: FrameExit, + inspector_gas_delta: i128, + ) { + // First, because everything below reads the classification: a frame-local exceed rewrites + // it to a revert. + self.absorb_frame_local_exceed(result); + + // Taken unconditionally, so a staged envelope can never outlive the frame that staged it. + let staged_precompile = self.staged_precompile.take(); + // Everything an inspector wrote into this result, whether it wrote it into the frame's + // terminating action or into the result the action became. The two settle as one number, + // because whether either moved the envelope is the one question the classification below + // answers. Their traffic is not summed: the staged half booked its own at the boundary + // that measured it, and this books the last callback's. + self.inspector.result.book_crossing(inspector_gas_delta); + let inspector_gas_delta = + inspector_gas_delta + core::mem::take(&mut self.staged_action_result_gas); + // The gas the EVM itself left in this result. Every settlement below is defined against + // it: the last callback's edit to the number is the inspector's, and the two are only the + // same object on a frame no callback touched. + let evm_remaining = evm_own_remaining(result.gas().remaining(), inspector_gas_delta); + let rescuable = + self.settle_inspector_result_gas(result, inspector_gas_delta, evm_remaining); + + match exit { + FrameExit::Ran => { + debug_assert!( + staged_precompile.is_none(), + "a precompile never becomes a frame, so it cannot reach a Ran settlement", ); + // The burn before the rescue: the rescue's `check_limit` is what latches a + // TX-level exceed, and a latched exceed is exactly the case whose remainder is + // handed back rather than destroyed. + self.settle_exceptional_halt_burn(result, evm_remaining); + self.try_rescue_gas(result.gas(), rescuable); + } + FrameExit::Refused | FrameExit::RefusedSynthetically => { + // The rescue before the burn, for the mirror-image reason: here the latch the + // rescue produces is what tells the burn the envelope is being handed back. + if exit == FrameExit::Refused || self.rex7_enabled() { + self.try_rescue_gas(result.gas(), rescuable); + } + self.settle_frame_init_reject_burn(result, evm_remaining, staged_precompile); } } - // Rescue gas if a TX-level additional limit has been exceeded. - // This must happen before any inspector callback (`frame_end`) that might modify - // the gas via `spend_all()`, so the correct `gas.remaining()` value is captured. - self.try_rescue_gas(result.gas()); + } + + /// Books what an inspector did to a frame result's gas, and reports the gas the EVM itself + /// left in that result. + /// + /// `delta` covers both places such an edit can be made: the frame's last mutating callback, + /// and — one step earlier, through `LoopControl` — the terminating action that *becomes* this + /// result. They are one number measured on either side of the classification, so they settle + /// as one. + /// + /// Whether such an edit moves anything depends on the frame's final classification, which is + /// why this can only run here: + /// + /// - a returning or reverting frame hands its remaining gas back to its caller, so an edit to + /// that number really does change what the transaction spends. It goes to the ledger, and the + /// conservation law reads it back out of the envelope; + /// - a swallowed (halting) frame hands nothing back, so the edit changes nothing the + /// transaction spends. The rescue is then taken on `evm_remaining`, the EVM's own number — an + /// inspector does not perform work, and gas it removed from a doomed result was never the + /// inspector's to destroy. + /// + /// Which of the two a result is comes from + /// [`destroyed_disposition`](super::destroyed_disposition), not from revm's `is_ok_or_revert` + /// catch-all: a new [`InstructionResult`] variant is a compile error until it is classified. + /// + /// Returns the number the resource-limit rescue may hand back to the sender, which is the + /// result as it now stands on the first case and the EVM's own on the second. The destroyed + /// settlements always take `evm_remaining`, because a booked edit is already accounted for on + /// the ledger and booking it a second time as a destroyed remainder would double it. + fn settle_inspector_result_gas( + &mut self, + result: &FrameResult, + delta: i128, + evm_remaining: u64, + ) -> u64 { + if delta == 0 { + return evm_remaining; + } + if destroyed::remaining_is_destroyed(result.instruction_result()) { + evm_remaining + } else { + self.inspector.result.book_movement(delta); + result.gas().remaining() + } + } + + /// Absorbs a frame-local resource exceed the frame itself latched, into the frame's own + /// result (REX7+). + /// + /// A frame that overran a per-frame budget reverts: the exceed is the frame's, its caller is + /// free to carry on, and the gas the frame still held goes back to that caller. Running the + /// rewrite here rather than on the way out to the caller is what makes the frame's state + /// follow it — the journal decision is still ahead, and it reads this same result — so a + /// frame that reports a revert has reverted, rather than reporting one over state that stayed + /// committed. That split is what a contract creation needs closed most: a constructor that + /// ran to a successful exit and is then rewritten leaves deployed code and emitted logs + /// behind an otherwise-failed frame. + /// + /// Only an exceed that is *already latched* is absorbed here — one the frame recorded against + /// its own budget while it ran, or one its exit settlement stamped. This deliberately does not + /// run a fresh [`check_limit`](Self::check_limit): a fresh pass at this point would weigh the + /// frame's usage against its own budget, whereas the pass that runs on the way out to the + /// caller weighs it after the frame's usage has been merged into the caller's. Those are + /// different questions with different answers, and the second one is the one the per-frame + /// budgets are defined by. So a late first detection stays where it is, and this settles the + /// one that produces the split. + /// + /// Frozen specs absorb everything later, on the way out to the caller, and leave a + /// successfully-exited frame's state committed under the revert they report. + fn absorb_frame_local_exceed(&mut self, result: &mut FrameResult) { + if !self.checkpoint.rex7_enabled() { + return; + } + let limit_check = self.has_exceeded_limit; + if limit_check.exceeded_limit() && limit_check.is_frame_local() { + self.has_exceeded_limit = LimitCheck::WithinLimit; + mark_frame_result_as_exceeding_limit( + result, + InstructionResult::Revert, + limit_check.revert_data(), + ); + } } /// Hook called when a frame finishes running in `frame_run`. If the limit is exceeded, mark @@ -719,6 +1724,35 @@ impl AdditionalLimit { frame: &'a EthFrame, action: &'a mut InterpreterAction, ) { + // Checkpoint accounting: the frame has produced its final action, so settle the tail + // segment — everything since the last checkpoint — against the interpreter's gas counter. + // `frame.interpreter.gas` still holds the loop-exit value here (the clamp restore and the + // code-deposit storage charge both mutate only the action's gas copy), and both it and the + // baseline live in the same clamped domain, so the delta telescopes over exactly the + // unwrapped plain opcodes that ran since. A checkpoint that already settled and halted + // leaves `baseline == remaining` (delta 0), and a CALL abort path's forwarded-gas + // `erase_cost` can only raise `remaining` above the baseline, which the saturation turns + // into 0. Any exceed recorded here is latched, and the frame result marking below / in + // `before_frame_return_result` surfaces it. The clamp restore itself already happened, in + // `settle_frame_final_result`, before the execution-layer hook charged code-deposit storage + // gas against the action's gas. + // + // This delta is the work the frame *performed*, so it settles the same way — through the + // enforcing path — however the frame ended. A frame that halts exceptionally still ran the + // opcodes ahead of its failure, and a parent frame keeps executing after absorbing that + // failure; leaving the executed tail out of enforcement would let the code after the failed + // frame spend the same headroom a second time. What such a frame additionally destroys — + // the budget it never gets to spend — is settled after action processing, outside + // enforcement, by `settle_exceptional_halt_burn`. + if self.checkpoint.rex7_enabled() { + if let InterpreterAction::Return(_) = action { + let remaining = frame.interpreter.gas.remaining(); + let gas_used = self.checkpoint.take_segment(remaining); + let _ = self.record_compute_gas_unguarded(gas_used); + self.refresh_latched_compute_usage(); + } + } + self.state_growth.after_frame_run(frame, action); self.data_size.after_frame_run(frame, action); self.kv_update.after_frame_run(frame, action); @@ -753,8 +1787,93 @@ impl AdditionalLimit { } } + /// The verdict [`record_compute_gas`](Self::record_compute_gas) would reach for `charge`, + /// without recording it and without latching anything. + /// + /// Recording and then reacting is the right shape for work that has already happened: the gas + /// was spent whatever the verdict says. It is the wrong shape for a charge that is still + /// conditional — one the EVM only takes if the frame survives — because a charge skipped after + /// being recorded leaves compute gas in the tracker that nothing ever spent. Such a caller asks + /// here first and records only on the answer that lets the charge happen. + /// + /// The verdict is produced by the same predicate enforcement uses, evaluated at `charge` more + /// usage, so there is no gap between what this reports and what recording would produce. + #[inline] + pub(crate) fn would_exceed_compute_gas(&self, charge: u64) -> LimitCheck { + // Sticky short-circuit, mirroring `record_compute_gas`: an already-latched `ExceedsLimit` + // is what the caller would observe, and `Exempt` suppresses the decision entirely. + if !self.has_exceeded_limit.within_limit() { + return self.has_exceeded_limit; + } + self.compute_gas.check_limit_with_extra(charge) + } + + /// Records the canonical code-deposit compute gas of a CREATE frame that is about to deposit + /// its code, or reports the result rewrite that stops the deposit from happening (REX7+). + /// + /// `charge` is the gas revm charges the frame for the deposit, and it is charged only if the + /// frame's result is still successful when the action is processed. So the decision has to be + /// made here, ahead of that: recording it and marking the result afterwards would leave the + /// tracker holding compute gas for a deposit that then never happened. + /// + /// Returns `Some((result, output))` when the charge cannot be afforded, for the caller to write + /// onto the frame's result: + /// + /// - a frame-local exceed reverts the frame and is settled here — nothing is recorded and + /// nothing is latched, because with the charge not made the transaction is within its limits + /// and the frames above are free to continue; + /// - a TX-level exceed is latched, which is what the transaction halts and rescues its gas on, + /// exactly as it would have had the charge been recorded. + /// + /// Returns `None` when the charge fits, having recorded it. + pub(crate) fn settle_create_code_deposit_compute_gas( + &mut self, + charge: u64, + ) -> Option<(InstructionResult, Bytes)> { + let check = self.would_exceed_compute_gas(charge); + if !check.exceeded_limit() { + let recorded = self.record_compute_gas(charge); + debug_assert!(recorded, "the peek and the record must reach the same verdict"); + return None; + } + + let output = check.revert_data(); + if check.is_frame_local() { + return Some((InstructionResult::Revert, output)); + } + + self.has_exceeded_limit = check; + // Preserve the volatile-detention attribution the recorded path would have produced: with + // nothing recorded, usage stays at or below the detained limit, so `is_detained_exceed` + // cannot see that detention is what the charge ran into. + self.checkpoint.set_latched_detained( + self.compute_gas.detained_limit() < self.compute_gas.base_tx_limit(), + ); + Some((Self::EXCEEDING_LIMIT_INSTRUCTION_RESULT, output)) + } + /// Hook called when returning a frame result to parent frame in `frame_return_result` or /// `last_frame_result`. May modify the frame result in place if the limit is exceeded. + /// + /// # The late frame-local exceed + /// + /// A per-frame budget is defined by the frame's usage weighed against its *caller's* budget + /// after the merge, so a frame can overrun one without anything having noticed while it ran. + /// This hook is where that is first detectable. + /// + /// REX7 asks the question ahead of the pop, through + /// [`peek_check_limit_after_pop`](Self::peek_check_limit_after_pop), and writes the answer onto + /// the frame's result before anything acts on it. One classification then drives all three + /// things that follow from it: the caller is told the frame reverted, the pop discards the + /// frame's usage the way it discards any reverting frame's, and the journal — whose decision + /// waits until this hook has run — rolls the frame's state back. Weighing the usage before the + /// merge would be a different question with a different answer, which is why the *reading* is + /// taken as of after the merge even though the *decision* is taken before it. + /// + /// Frozen specs keep the split: the check runs after the pop, the merge has already happened + /// on the frame's original classification, and revm decided commit-or-revert from that same + /// classification before the result ever reached this hook — so a frame that ran to a + /// successful exit is already committed and stays committed under the rewritten `Revert`. pub(crate) fn before_frame_return_result( &mut self, result: &mut FrameResult, @@ -765,6 +1884,27 @@ impl AdditionalLimit { // used to distinguish these two cases. let duplicate_return_frame_result = LAST_FRAME && !self.data_size.has_active_frame(); + // The merge the pop below is about to perform, read before anything can rewrite it. + let merges_usage = result.instruction_result().is_ok(); + // Frozen specs need this only for the debug cross-check under the pop, and the `cfg!` is a + // constant, so their release builds skip it entirely. + let peeked = (!duplicate_return_frame_result && + (self.rex7_enabled() || cfg!(debug_assertions))) + .then(|| self.peek_check_limit_after_pop(merges_usage)); + + if self.rex7_enabled() { + if let Some(check) = peeked { + if check.is_frame_local() { + // Nothing is latched and nothing needs clearing: the peek only read. + mark_frame_result_as_exceeding_limit( + result, + InstructionResult::Revert, + check.revert_data(), + ); + } + } + } + // Pop frame from the frame limit trackers. self.state_growth.before_frame_return_result::(result); self.data_size.before_frame_return_result::(result); @@ -774,32 +1914,29 @@ impl AdditionalLimit { // Pop stipend from stack and burn unused stipend (Rex4+). self.storage_call_stipend.before_frame_return_result::(result); - // Frame-level limit handling (Rex4+): check if the child frame exceeded its - // frame-local budget. The detection may not have happened during execution, so - // we call check_limit() here to ensure it's caught. - // If frame-local, absorb it — clear the exceed flag and change to Revert so - // remaining gas returns to the caller. This works at any depth including the - // top-level frame. - // - // The rewrite changes the reported result, not the journal. revm decides - // commit-or-revert from the frame's original instruction result, before the - // `FrameResult` ever reaches this hook, so a frame that ran to a successful exit is - // already committed and stays committed under the rewritten Revert. let limit_check = self.check_limit(); + + // The peek and this check are one question asked on either side of the merge. Whenever the + // merge the peek was asked about is the merge that happened — which is every frame return + // on a frozen spec, and every REX7 one the peek did not itself rewrite — the two readings + // must be identical, down to the reported `limit` and `used`. This is what stands between + // the pre-pop decision and a drift in what counts as a frame-local exceed. + debug_assert!( + peeked.is_none_or(|peeked| result.instruction_result().is_ok() != merges_usage || + peeked == limit_check), + "the pre-pop peek and the post-pop check disagreed: {peeked:?} vs {limit_check:?}" + ); + + // Frame-level limit handling (Rex4+): if frame-local, absorb it — clear the exceed flag + // and change to Revert so remaining gas returns to the caller. This works at any depth + // including the top-level frame. Under REX7 the settlement above has already taken the + // frame-local case, and what reaches here is a second reading of a caller that the discard + // could not bring back within its budget. if limit_check.exceeded_limit() && !duplicate_return_frame_result { if limit_check.is_frame_local() { let output = limit_check.revert_data(); self.has_exceeded_limit = LimitCheck::WithinLimit; - match result { - FrameResult::Call(o) => { - o.result.result = InstructionResult::Revert; - o.result.output = output; - } - FrameResult::Create(o) => { - o.result.result = InstructionResult::Revert; - o.result.output = output; - } - } + mark_frame_result_as_exceeding_limit(result, InstructionResult::Revert, output); } else { // Gas should already have been rescued at the point where the limit was // exceeded (frame_result_if_exceeding_limit, before_frame_init, @@ -814,12 +1951,195 @@ impl AdditionalLimit { } } + /// Re-reads a latched TX-level compute exceed's usage from the tracker (REX7+). + /// + /// A clamp-induced exceed is latched at the frame's final result, before the frame-exit + /// settlement closes the plain segment the crossing opcode stopped inside. The latch is sticky, + /// so the halt reason built later from [`check_limit`](Self::check_limit) would otherwise + /// report the usage as it stood one settlement short of final — which is not the number the + /// transaction's compute total ends on. The detention path never had this problem: it rebuilds + /// its halt reason from live tracker usage. + /// + /// Only TX-level exceeds are refreshed. A frame-local exceed's `used` is the frame's own + /// figure, which the frame-local revert payload does not carry, so rewriting it with a + /// transaction-level total would only blur what it means. + #[inline] + fn refresh_latched_compute_usage(&mut self) { + let usage = self.compute_gas.tx_usage(); + if let LimitCheck::ExceedsLimit { + kind: super::LimitKind::ComputeGas, + frame_local: false, + used, + .. + } = &mut self.has_exceeded_limit + { + *used = usage; + } + } + + /// Settles the remainder an exceptionally halted frame destroys, as non-enforcing compute gas + /// (REX7+). + /// + /// An exceptional halt returns no gas: the top-level frame's whole envelope is spent by the + /// transaction's final gas accounting, and an inner frame's remainder is simply never handed + /// back to its caller. The interpreter zeroes its own counter only for a plain `OutOfGas`, so + /// the frame-exit delta cannot see that destroyed budget on any other classification. The + /// result's own gas can: by the time this runs, + /// [`settle_frame_final_result`](Self::settle_frame_final_result) has handed back whatever the + /// clamp was hiding and the code-deposit storage charge has been taken, so + /// `result.gas().remaining()` is exactly what the frame still held and will not get to keep. + /// + /// Runs **after** action processing, which is the first point the classification is final: + /// revm's create-return can still turn a successful constructor into a canonical code-deposit + /// out-of-gas, an EIP-3541 reject or a runtime code-size reject, and each of those destroys the + /// frame's remainder just like a halt from the interpreter loop. + /// + /// Only the destroyed part goes to the tracker's non-enforcing lane — the work performed ahead + /// of the failure already settled through the enforcing path in + /// [`after_frame_run_instructions`](Self::after_frame_run_instructions). Enforcing the + /// destroyed part would turn an ordinary EVM halt into a resource-limit failure with the + /// remaining gas rescued for the sender, which is exactly the receipt change the + /// exceptional-halt carve-out forbids. + /// + /// Not reached when a resource limit is already latched: that path destroys nothing, because + /// the frame either reverts to its parent (frame-local) or halts the transaction with its gas + /// rescued (TX-level) — including a clamp-induced out-of-gas, which + /// [`settle_frame_final_result`](Self::settle_frame_final_result) latches earlier in this + /// frame exit. + fn settle_exceptional_halt_burn(&mut self, result: &FrameResult, evm_remaining: u64) { + if !self.checkpoint.rex7_enabled() || + self.limit_exceeded() || + !destroyed::remaining_is_destroyed(result.instruction_result()) + { + return; + } + self.compute_gas.record_burned_gas(evm_remaining); + } + + /// Settles the envelope a frame that never started destroys, as non-enforcing compute gas + /// (REX7+). + /// + /// A frame init can refuse to build a frame and hand back a result instead. Such a result + /// carries the whole child budget as `remaining`, and what the caller does with it is decided + /// by the classification alone: a success or a revert is erased back into the caller's + /// counter, while an exceptional halt is not — the caller simply never sees that gas again. + /// The child never runs, so the frame-exit settlement that splits an ordinary exceptional halt + /// cannot see it either, and without this the destroyed budget would be missing from the + /// transaction's reported total while the conservation law still derives it from the envelope. + /// + /// Only a swallowed classification books anything. The success and revert shapes reaching this + /// site — an empty-code call, a nonce overflow, a depth or balance rejection — destroy nothing + /// precisely because their gas is erased back into the caller. The split is + /// [`destroyed_disposition`](super::destroyed_disposition): a new [`InstructionResult`] + /// variant is a compile error until it is classified. + /// + /// A precompile result takes [`settle_precompile_envelope`](Self::settle_precompile_envelope) + /// instead. It reaches this point the same way and is settled against the same classification, + /// but neither of the two numbers the formula above uses is the right one for it: the envelope + /// it destroys is the caller's forwarded amount rather than the REX5-capped budget its result + /// carries, and a precompile that failed after doing work has that work priced by `MegaETH` + /// rather than spent down in its gas object. + /// + /// Which of the two a result takes is decided by whether its dispatch staged an envelope, not + /// by `CallOutcome::was_precompile_called`. The flag is on a result an inspector's `call_end` + /// is handed by mutable reference, so it is not something the accounting may key on; the + /// staged slot is written before any callback runs and read once. + /// + /// Nothing is booked once a limit is latched, which is also why this runs after the rescue in + /// [`after_frame_init`](Self::after_frame_init) rather than before it. A TX-level exceed + /// rescues this same remaining gas for the sender and erases it from the envelope, and a + /// frame-local exceed is absorbed in + /// [`before_frame_return_result`](Self::before_frame_return_result), which rewrites the result + /// to a revert and so returns the gas to the caller. Either way the envelope is not destroyed, + /// and booking it would report gas that was handed back. + fn settle_frame_init_reject_burn( + &mut self, + result: &FrameResult, + evm_remaining: u64, + staged_precompile: Option, + ) { + if !self.checkpoint.rex7_enabled() || self.limit_exceeded() { + return; + } + if let Some(staged) = staged_precompile { + self.settle_precompile_envelope(staged, result, evm_remaining); + return; + } + if !destroyed::remaining_is_destroyed(result.instruction_result()) { + return; + } + self.compute_gas.record_burned_gas(evm_remaining); + } + + /// Settles what a precompile call destroyed, against the classification its caller will + /// actually see (REX7+). + /// + /// The recording site staged the two numbers only it knows — the forwarded envelope and the + /// work performed — and the classification decides the rest, exactly as it does for an + /// ordinary frame: a returned classification hands the remainder back to the caller, a + /// swallowed one does not. Which of the two a result is comes from + /// [`destroyed_disposition`](super::destroyed_disposition), the same closed table the two + /// frame burns route through. So the envelope this call consumed is + /// + /// ```text + /// consumed = forwarded − (returned to the caller) + /// ``` + /// + /// and everything in it that was not the work performed is destroyed. + /// + /// The difference cannot go the other way. A swallowed classification consumes the whole + /// forwarded envelope, which every arm's `executed` fits inside; a returned one leaves a `Gas` + /// normalised onto that envelope and spent down by exactly `executed`. The one shape that + /// would break both — a halting precompile whose classification is rewritten to a success, + /// whose reset `Gas` then hands the caller the fixed fee back — is refused before this runs, + /// because a precompile's result comes out of frame init. + fn settle_precompile_envelope( + &mut self, + staged: PrecompileEnvelope, + result: &FrameResult, + evm_remaining: u64, + ) { + let returned = if destroyed::remaining_is_destroyed(result.instruction_result()) { + 0 + } else { + evm_remaining + }; + let consumed = staged.forwarded.saturating_sub(returned); + debug_assert!( + consumed >= staged.executed, + "a precompile cannot perform more work than the envelope it consumed", + ); + self.compute_gas.record_burned_gas(consumed.saturating_sub(staged.executed)); + } + /// Merges resource usage from a sandbox execution into this tracker. /// /// Used by `KeylessDeploy` (REX5+) to propagate sandbox resource consumption /// back to the parent transaction. - pub(crate) fn merge_usage(&mut self, usage: LimitUsage) { + /// + /// `burned_compute_gas` is the part of `usage.compute_gas` the sandbox destroyed rather than + /// performed (REX7+, always 0 before). It is already inside the merged total, so it is only + /// reclassified here — the parent reports it and never enforces it, exactly as the sandbox + /// did. Merging it as ordinary usage instead would let a sandbox frame's ordinary EVM halt + /// fail the outer transaction on a resource limit. + /// + /// `sandbox_gas_used` is the EVM gas the sandbox cost the parent's own counter: the parent + /// pre-debits a reservation and gets the unused part back, so this is what the parent's + /// envelope is short by. Whatever of it the sandbox did not spend on compute work is + /// non-compute gas from the parent's point of view — the sandbox's own storage gas, less any + /// refund the sandbox's receipt handed back — and joins the lane the destroyed-remainder + /// derivation reads. The difference is taken against the merged total rather than the enforced + /// part so the sandbox's destroyed remainder stays destroyed on the parent's books instead of + /// being reclassified as storage gas. + pub(crate) fn merge_usage( + &mut self, + usage: LimitUsage, + burned_compute_gas: u64, + sandbox_gas_used: u64, + ) { + self.record_non_compute_gas(i128::from(sandbox_gas_used) - i128::from(usage.compute_gas)); self.compute_gas.merge_persistent_usage(usage.compute_gas); + self.compute_gas.merge_burned_usage(burned_compute_gas); self.data_size.merge_persistent_usage(usage.data_size); self.kv_update.merge_persistent_usage(usage.kv_updates); self.state_growth.merge_persistent_usage(usage.state_growth); @@ -925,6 +2245,17 @@ impl AdditionalLimit { /// # Returns /// /// A `FrameResult` indicating that the limit is exceeded with the given instruction result. +/// Undoes the last mutating callback's edit to a frame result's gas, reporting the number the EVM +/// itself left there. +/// +/// The EVM does not execute inside an inspector callback, so the difference across one is the +/// inspector's by construction, and every settlement that asks what the *transaction* spent has to +/// ask it of the EVM's number rather than of the rewritten one. +#[inline] +fn evm_own_remaining(remaining: u64, inspector_gas_delta: i128) -> u64 { + (i128::from(remaining) - inspector_gas_delta).clamp(0, i128::from(u64::MAX)) as u64 +} + fn create_exceeding_limit_frame_result( instruction_result: InstructionResult, gas: Gas, @@ -1059,6 +2390,7 @@ mod tests { use revm::context::tx::TxEnvBuilder; use super::{super::LimitKind, *}; + use crate::VolatileDataAccess; fn test_limits() -> EvmTxRuntimeLimits { EvmTxRuntimeLimits { @@ -1228,6 +2560,355 @@ mod tests { ); } + /// A frame-local exceed the frame latched while it ran is absorbed at the frame's settlement + /// point under REX7, which is ahead of the journal decision — so the state the frame leaves + /// behind is rolled back with the revert it reports, instead of staying committed under it. + /// + /// Frozen specs must not absorb here: they take the journal decision first, and absorbing + /// ahead of it would revert state their replay keeps. + #[test] + fn test_rex7_absorbs_a_latched_frame_local_exceed_before_the_journal_decision() { + for spec in [MegaSpecId::REX6, MegaSpecId::REX7] { + let mut limit = AdditionalLimit::new(spec, EvmTxRuntimeLimits::from_spec(spec)); + limit.set_has_exceeded_limit_for_test(LimitCheck::ExceedsLimit { + kind: LimitKind::ComputeGas, + limit: 10, + used: 11, + frame_local: true, + }); + + let mut result = stopped_call_result(50_000); + limit.finalize_frame(&mut result, FrameExit::Ran, 0); + + if spec.is_enabled(MegaSpecId::REX7) { + assert_eq!( + result.instruction_result(), + InstructionResult::Revert, + "REX7 must absorb here, so the journal decision that follows reverts too", + ); + assert!( + !limit.limit_exceeded(), + "an absorbed frame-local exceed must not stop the frames above it", + ); + } else { + assert_eq!( + result.instruction_result(), + InstructionResult::Stop, + "a frozen spec absorbs on the way out to the caller, after the journal", + ); + assert!(limit.limit_exceeded(), "and so it still has the exceed to absorb"); + } + } + } + + /// The settlement must not go looking for an exceed of its own. A fresh check here would + /// weigh a frame's usage against its own budget, while the check that decides a per-frame + /// exceed weighs it against the caller's, after the frame's usage has been merged in — so a + /// fresh check here fails frames the per-frame budgets do not. + #[test] + fn test_the_settlement_does_not_detect_a_frame_local_exceed_of_its_own() { + let mut limit = AdditionalLimit::new( + MegaSpecId::REX7, + EvmTxRuntimeLimits::from_spec(MegaSpecId::REX7).with_tx_compute_gas_limit(10), + ); + limit.push_empty_frame(); + // An order of magnitude past the frame's budget, and unlatched: only a fresh check + // inside the settlement could find it. + let _ = limit.record_compute_gas_unguarded(100); + assert!( + limit.check_limit().is_frame_local(), + "the fixture must be a frame-local exceed a fresh check would find", + ); + limit.set_has_exceeded_limit_for_test(LimitCheck::WithinLimit); + + let mut result = stopped_call_result(50_000); + limit.finalize_frame(&mut result, FrameExit::Ran, 0); + + assert_eq!( + result.instruction_result(), + InstructionResult::Stop, + "the settlement absorbs what the frame latched, and nothing else", + ); + } + + fn oog_result() -> InterpreterResult { + InterpreterResult::new(InstructionResult::OutOfGas, Bytes::new(), Gas::new(100_000)) + } + + fn rex7_limit() -> AdditionalLimit { + AdditionalLimit::new(MegaSpecId::REX7, EvmTxRuntimeLimits::from_spec(MegaSpecId::REX7)) + } + + /// A frame-local clamp exceed while detention is active must not be attributed to + /// detention: the child reverts with `MegaLimitExceeded`, and `latched_detained` stays + /// clear so a later halt cannot be rewritten as `VolatileDataAccessOutOfGas`. + /// + /// Kills `&&` → `||` in `latch_clamp_exceed`: the `||` would fire on the detained-limit + /// arm alone and stamp the frame-local exceed as detained. + #[test] + fn test_latch_clamp_exceed_frame_local_with_detention_is_not_detained() { + let mut limit = rex7_limit(); + limit.set_compute_gas_limit(1); + assert!( + limit.compute_gas.detained_limit() < limit.compute_gas.base_tx_limit(), + "the fixture must actually tighten detention" + ); + limit.checkpoint.set_clamp( + 50, + compute_gas::ClampBinding { headroom: 100, frame_local: true, limit: 1_000 }, + ); + + limit.settle_frame_final_result(&mut oog_result()); + + assert!( + limit.has_exceeded_limit.is_frame_local(), + "the exceed must stay frame-local; got {:?}", + limit.has_exceeded_limit + ); + assert!( + !limit.checkpoint.latched_detained(), + "a frame-local clamp must not inherit detention attribution" + ); + assert!( + limit.detained_compute_gas_halt_reason(VolatileDataAccess::TIMESTAMP).is_none(), + "frame-local + detention must not classify as VolatileDataAccessOutOfGas" + ); + } + + /// A TX-level clamp exceed when detention did not tighten (`detained_limit == base`) + /// must stay a compute-gas halt, not `VolatileDataAccessOutOfGas`. + /// + /// Kills `<` → `<=` in `latch_clamp_exceed`: at equality the `<=` mutant stamps + /// `latched_detained` and the halt-reason remap would blame detention. + #[test] + fn test_latch_clamp_exceed_tx_level_without_tightened_detention_is_not_detained() { + let mut limit = rex7_limit(); + assert_eq!( + limit.compute_gas.detained_limit(), + limit.compute_gas.base_tx_limit(), + "the fixture is the detained == base knife edge" + ); + let tx_limit = limit.compute_gas.base_tx_limit(); + limit.checkpoint.set_clamp( + 50, + compute_gas::ClampBinding { headroom: 100, frame_local: false, limit: tx_limit }, + ); + + limit.settle_frame_final_result(&mut oog_result()); + + assert!( + !limit.checkpoint.latched_detained(), + "detained_limit == base_tx_limit must not count as a detained clamp" + ); + assert!( + limit.detained_compute_gas_halt_reason(VolatileDataAccess::TIMESTAMP).is_none(), + "a TX-level clamp with no tightened detention must not classify as \ + VolatileDataAccessOutOfGas" + ); + } + + /// The same `AdditionalLimit` is reused across transactions (`on_new_tx` calls `reset`). + /// Leftover checkpoint state from a detained clamp halt must not pollute the next + /// transaction's halt classification. + #[test] + fn test_reset_clears_checkpoint_so_the_next_tx_is_not_classified_as_detained() { + let mut limit = rex7_limit(); + limit.set_compute_gas_limit(1); + limit.checkpoint.sync_baseline(99_999); + limit.checkpoint.set_clamp( + 50, + compute_gas::ClampBinding { headroom: 100, frame_local: false, limit: 1 }, + ); + limit.settle_frame_final_result(&mut oog_result()); + assert!( + limit.checkpoint.latched_detained(), + "TX1's detained clamp must stamp the attribution flag" + ); + assert!( + limit.detained_compute_gas_halt_reason(VolatileDataAccess::TIMESTAMP).is_some(), + "TX1 must classify as a detained halt" + ); + + limit.reset(); + + assert_eq!(limit.checkpoint.baseline(), 0, "reset must drop TX1's baseline"); + assert!( + limit.checkpoint.take_clamp().is_none(), + "reset must drop any clamp TX1 left behind" + ); + assert!( + !limit.checkpoint.latched_detained(), + "reset must drop TX1's detention-attribution flag" + ); + assert!( + limit.detained_compute_gas_halt_reason(VolatileDataAccess::TIMESTAMP).is_none(), + "TX2 must not inherit TX1's VolatileDataAccessOutOfGas attribution" + ); + } + + /// The peek must reach exactly the verdict recording would have produced. Recording the + /// charge and then checking, versus asking first, are compared on the same tracker state + /// across the whole knife edge — the one place a second copy of `used > limit` could drift + /// from the copy enforcement runs. + #[test] + fn test_peek_matches_record_then_check_across_the_edge() { + const FRAME_BUDGET: u64 = 1_000; + for charge in 0..=(FRAME_BUDGET + 2) { + let mut peeked = rex7_limit(); + peeked.compute_gas.push_frame_with_limit_for_test(FRAME_BUDGET); + let mut recorded = rex7_limit(); + recorded.compute_gas.push_frame_with_limit_for_test(FRAME_BUDGET); + + let peek = peeked.would_exceed_compute_gas(charge); + let within = recorded.record_compute_gas(charge); + + assert_eq!( + peek.exceeded_limit(), + !within, + "charge {charge}: the peek and the record must agree on the verdict", + ); + assert_eq!( + peek, recorded.has_exceeded_limit, + "charge {charge}: the peek must report what the record latched", + ); + } + } + + /// The frame-local arm of the code-deposit settlement reverts the frame without touching the + /// tracker: nothing recorded, nothing latched. Both matter — a recorded charge would be + /// compute gas the deposit never spent, and a latched exceed would outlive the frame that is + /// already being reverted for it. + #[test] + fn test_create_code_deposit_frame_local_arm_records_and_latches_nothing() { + let mut limit = rex7_limit(); + limit.compute_gas.push_frame_with_limit_for_test(100); + let before = limit.get_usage().compute_gas; + + let rewrite = limit.settle_create_code_deposit_compute_gas(101); + + let (result, output) = rewrite.expect("an unaffordable charge must rewrite the result"); + assert_eq!(result, InstructionResult::Revert, "a frame-local exceed reverts the frame"); + assert!(!output.is_empty(), "the revert must carry the MegaLimitExceeded payload"); + assert_eq!(limit.get_usage().compute_gas, before, "the charge must not be recorded"); + assert_eq!(latched_kind(&limit), None, "the frame-local arm must not latch"); + } + + /// The TX-level arm latches instead, which is what the transaction halts and rescues its gas + /// on — but still records nothing. With nothing recorded, usage stays under the detained + /// limit, so the halt can only keep blaming detention through the latched flag. + #[test] + fn test_create_code_deposit_tx_level_arm_latches_detention_without_recording() { + let mut limit = rex7_limit(); + // A frame budget far above the charge, so the transaction limit is what binds. + limit.compute_gas.push_frame_with_limit_for_test(u64::MAX); + limit.set_compute_gas_limit(10); + let before = limit.get_usage().compute_gas; + + let rewrite = limit.settle_create_code_deposit_compute_gas(11); + + let (result, _) = rewrite.expect("an unaffordable charge must rewrite the result"); + assert_eq!( + result, + AdditionalLimit::EXCEEDING_LIMIT_INSTRUCTION_RESULT, + "a TX-level exceed halts the transaction", + ); + assert_eq!(limit.get_usage().compute_gas, before, "the charge must not be recorded"); + assert_eq!(latched_kind(&limit), Some(LimitKind::ComputeGas), "the TX-level arm latches"); + assert!( + limit.detained_compute_gas_halt_reason(VolatileDataAccess::empty()).is_some(), + "the halt must still be attributable to detention", + ); + } + + /// An affordable charge is recorded like any other work and leaves the result alone. + #[test] + fn test_create_code_deposit_affordable_charge_is_recorded() { + let mut limit = rex7_limit(); + limit.compute_gas.push_frame_with_limit_for_test(100); + let before = limit.get_usage().compute_gas; + + assert!( + limit.settle_create_code_deposit_compute_gas(100).is_none(), + "an affordable charge must not rewrite the result", + ); + assert_eq!(limit.get_usage().compute_gas, before + 100, "an affordable charge is recorded",); + assert_eq!(latched_kind(&limit), None, "an affordable charge must not latch"); + } + + /// With no volatile access in play the detained limit was never lowered, so an unaffordable + /// code-deposit charge is the transaction's own compute limit and nothing else. Blaming it on + /// detention would report `VolatileDataAccessOutOfGas` with a limit figure that never moved. + #[test] + fn test_create_code_deposit_tx_level_arm_leaves_an_undetained_exceed_undetained() { + let mut limits = test_limits(); + limits.tx_compute_gas_limit = 10; + let mut limit = AdditionalLimit::new(MegaSpecId::REX7, limits); + // A frame budget far above the charge, so the transaction limit is what binds. + limit.compute_gas.push_frame_with_limit_for_test(u64::MAX); + + let rewrite = limit.settle_create_code_deposit_compute_gas(11); + + let (result, _) = rewrite.expect("an unaffordable charge must rewrite the result"); + assert_eq!( + result, + AdditionalLimit::EXCEEDING_LIMIT_INSTRUCTION_RESULT, + "a TX-level exceed halts the transaction", + ); + assert_eq!(latched_kind(&limit), Some(LimitKind::ComputeGas), "the TX-level arm latches"); + assert!( + limit.detained_compute_gas_halt_reason(VolatileDataAccess::empty()).is_none(), + "a limit that was never lowered cannot be what detained the transaction", + ); + } + + /// An inspector's edit to a running frame's counter is booked on its lane whether or not a + /// measured segment is open, and the segment is closed only where one is. + /// + /// `initialize_interp` is the callback with no open segment: the frame was built a moment ago + /// and its entry hook has not opened the window yet, so the baseline still belongs to another + /// frame. Closing a segment there would settle the distance between that baseline and this + /// frame's counter as compute gas this frame performed. + #[test] + fn test_an_adjustment_outside_an_open_segment_settles_no_segment() { + let mut limit = rex7_limit(); + // A baseline left behind by the frame that is still suspended below this one. + limit.checkpoint.sync_baseline(1_000_000); + let mut gas = Gas::new(500_064); + + limit.record_inspector_gas_adjustment::(&mut gas, 500_000, true); + + assert_eq!( + limit.get_usage().compute_gas, + 0, + "no segment is open, so there is no distance to settle as work", + ); + assert_eq!(gas.remaining(), 500_064, "and the counter is left exactly as the EVM had it"); + assert_eq!( + limit.inspector_ledger().gas.net(), + 64, + "the edit itself is still booked on the counter lane", + ); + } + + /// A callback that removed the frame's pending action leaves the frame carrying on from its + /// own counter, so an edit that had travelled in that action lands on the counter lane. + /// + /// The lane's gross is what the block guard reads, so two edits that cancel are still two + /// edits: a transaction an inspector took part in must not read as one the EVM produced alone. + #[test] + fn test_a_removed_actions_adjustment_is_booked_on_the_counter_lane() { + let mut limit = rex7_limit(); + assert!(limit.inspector_ledger().is_zero(), "a fresh ledger has seen nothing"); + + limit.record_inspector_action_counter_adjustment(64); + limit.record_inspector_action_counter_adjustment(-64); + + let ledger = limit.inspector_ledger(); + assert_eq!(ledger.gas.net(), 0, "the two edits cancel on the net"); + assert_eq!(ledger.gas.gross(), 128, "and the lane still carries both"); + assert!(!ledger.is_zero(), "so the guard refuses a transaction that saw them"); + } + /// `mark_frame_result_as_exceeding_limit` rewrites both frame-result variants in place. #[test] fn test_mark_frame_result_as_exceeding_limit_rewrites_both_variants() { @@ -1256,4 +2937,60 @@ mod tests { assert_eq!(create_outcome.result.result, InstructionResult::OutOfGas); assert_eq!(create_outcome.result.output, output); } + + /// The settlement point turns the signed derivation into the number the transaction reports, + /// clamping the one direction that must never reach a consumer. + /// + /// A negative derivation means the recorded compute and non-compute lanes together claim more + /// gas than the transaction spent, which no spec produces today — the guard is defence, not an + /// expected shape. Driving it at the seam is the only way to reach it: every end-to-end fixture + /// that could produce it would have to break the conservation law first. + /// + /// Debug builds trip the assert instead of clamping, so the test asserts the panic there and + /// the clamp in release. + #[test] + #[cfg_attr( + debug_assertions, + should_panic(expected = "derived destroyed compute gas is negative") + )] + fn test_negative_derivation_is_clamped_to_zero() { + let mut limit = AdditionalLimit::new(MegaSpecId::REX7, test_limits()); + // Claim more non-compute gas than the envelope the settlement is handed. + limit.record_non_compute_gas(1_000); + + limit.settle_destroyed_compute_gas(100); + + assert_eq!( + limit.destroyed_compute_gas(), + 0, + "a negative derivation must clamp to zero rather than wrap into an enormous total", + ); + } + + /// The sandbox boundary hands the lane a difference, not a charge, so a sandbox whose own + /// EIP-3529 refund outgrew its storage gas drives the non-compute lane negative. The + /// derivation has to stay correct across that sign change — a lane that saturated at zero + /// would silently under-report the destroyed remainder by the whole overshoot. + /// + /// The end-to-end shape that produces a negative lane is in the REX7 suite; this pins the + /// arithmetic at the seam, where the sign can be set directly. + #[test] + fn test_derivation_survives_a_negative_non_compute_lane() { + let mut limit = AdditionalLimit::new(MegaSpecId::REX7, test_limits()); + // A sandbox that cost the parent 1,000 gas while recording 3,000 of compute work: the + // 2,000 difference is refund the sandbox's own receipt handed back. + limit.merge_usage(LimitUsage { compute_gas: 3_000, ..Default::default() }, 0, 1_000); + assert_eq!(limit.non_compute_gas(), -2_000, "the lane must carry the difference signed"); + // On top of that, a frame destroyed 4,000 of its budget. + limit.record_burned_gas(4_000); + + // 5,000 spent = 3,000 enforced + (−2,000) non-compute + 4,000 destroyed. + limit.settle_destroyed_compute_gas(5_000); + + assert_eq!( + limit.destroyed_compute_gas(), + 4_000, + "the negative lane must add to the destroyed remainder, not saturate away", + ); + } } diff --git a/crates/mega-evm/src/limit/mod.rs b/crates/mega-evm/src/limit/mod.rs index a536565a..fb74e59d 100644 --- a/crates/mega-evm/src/limit/mod.rs +++ b/crates/mega-evm/src/limit/mod.rs @@ -1,17 +1,24 @@ use alloy_primitives::Bytes; use alloy_sol_types::SolError; +mod checkpoint; mod compute_gas; +mod conservation; mod data_size; +mod destroyed; mod frame_limit; +mod inspector_ledger; mod kv_update; #[allow(clippy::module_inception)] mod limit; mod state_growth; mod storage_call_stipend; +pub use conservation::*; pub use data_size::*; -pub(crate) use frame_limit::{FrameLimitTracker, TxRuntimeLimit}; +pub use destroyed::*; +pub(crate) use frame_limit::{FrameLimitTracker, LimitReading, TxRuntimeLimit}; +pub use inspector_ledger::*; pub use limit::*; use crate::MegaHaltReason; @@ -65,7 +72,7 @@ impl LimitKind { /// see [`crate::is_system_originated`]). The `Exempt` state is **sticky**: once `AdditionalLimit` /// stores it in `has_exceeded_limit`, `check_limit` short-circuits and the sub-tracker checks /// are skipped, so no later overflow can overwrite it. -#[derive(Debug, Default, Clone, Copy)] +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub enum LimitCheck { /// All limits are within their configured thresholds. #[default] diff --git a/crates/mega-evm/src/limit/state_growth.rs b/crates/mega-evm/src/limit/state_growth.rs index 55baa9dd..bdc8d571 100644 --- a/crates/mega-evm/src/limit/state_growth.rs +++ b/crates/mega-evm/src/limit/state_growth.rs @@ -161,6 +161,42 @@ impl StateGrowthTracker { self.frame_tracker.add_tx_persistent(amount); } + /// [`check_limit`](TxRuntimeLimit::check_limit) as it will read once the current frame has + /// been popped and merged into its caller, computed without popping it. + #[inline] + pub(crate) fn check_limit_after_pop(&self, success: bool) -> super::LimitCheck { + self.check_limit_on(&self.frame_tracker.view_after_pop(success)) + } + + /// [`check_limit`](TxRuntimeLimit::check_limit) against an explicit reading of the tracker. + /// + /// The reading is a parameter so that one body can answer both questions asked of this check: + /// what it says now, and what it will say once a returning frame has been merged into its + /// caller. A frame return needs the second answer before the merge happens, and a second copy + /// of the predicates would be free to drift from the first. + pub(crate) fn check_limit_on(&self, r: &R) -> super::LimitCheck { + if self.spec.is_enabled(MegaSpecId::REX4) { + let frame_check = r.frame_check(super::LimitKind::StateGrowth, 0); + if frame_check.exceeded_limit() { + return frame_check; + } + // TX-level fallthrough: catches Rex5 pre-frame authority usage and any + // future TX-level state-growth contribution. + } + let used = r.net_usage(); + let limit = self.frame_tracker.tx_limit(); + if used > limit { + super::LimitCheck::ExceedsLimit { + kind: super::LimitKind::StateGrowth, + limit, + used, + frame_local: false, + } + } else { + super::LimitCheck::WithinLimit + } + } + /// Returns the remaining state growth budget for the current call frame, capped by /// the TX-level remaining. pub(crate) fn current_call_remaining(&self) -> u64 { @@ -203,27 +239,7 @@ impl TxRuntimeLimit for StateGrowthTracker { /// usage — and any frame-level overflow that has already been popped into `tx_entry`. /// For pre-Rex4, checks total net growth across all frames against the TX limit. fn check_limit(&self) -> super::LimitCheck { - if self.spec.is_enabled(MegaSpecId::REX4) { - let frame_check = - self.frame_tracker.exceeds_current_frame_limit(super::LimitKind::StateGrowth); - if frame_check.exceeded_limit() { - return frame_check; - } - // TX-level fallthrough: catches Rex5 pre-frame authority usage and any - // future TX-level state-growth contribution. - } - let used = self.tx_usage(); - let limit = self.frame_tracker.tx_limit(); - if used > limit { - super::LimitCheck::ExceedsLimit { - kind: super::LimitKind::StateGrowth, - limit, - used, - frame_local: false, - } - } else { - super::LimitCheck::WithinLimit - } + self.check_limit_on(&self.frame_tracker) } /// No-op. @@ -364,7 +380,10 @@ impl TxRuntimeLimit for StateGrowthTracker { #[cfg(test)] mod tests { - use super::*; + use super::{ + super::{LimitCheck, LimitKind}, + *, + }; /// `reset` must clear accumulated TX-level state-growth usage so a tracker reused /// across transactions does not leak growth from the previous one. @@ -440,4 +459,47 @@ mod tests { fn test_tx_limit_reports_configured_limit() { assert_eq!(StateGrowthTracker::new(MegaSpecId::REX5, 4_321).tx_limit(), 4_321); } + + /// A frame's growth is weighed against its *caller's* budget only once the two have been + /// merged, so the pre-merge reading has to answer a question the live one cannot: the caller + /// is already over its budget while the frame on top is still inside its own. + /// + /// A child receives 98% of its caller's remaining budget, so merging one that stayed inside + /// its own budget cannot by itself push the caller past its. What breaks that arithmetic is a + /// charge that reaches the caller's lane after the child's budget has already been fixed, + /// which is what the parent-lane write below stands for. + /// + /// The answer depends on how the frame ends: a reverting frame's growth vanishes instead of + /// merging, and the caller stays inside its budget. + #[test] + fn test_check_limit_after_pop_sees_a_frame_local_exceed_the_live_check_cannot() { + let mut tracker = StateGrowthTracker::new(MegaSpecId::REX5, 1_000); + tracker.push_frame(); + tracker.push_frame(); + tracker.record_growth(500); + tracker.push_frame(); + tracker.record_growth(470); + tracker.frame_tracker.add_parent_discardable(20); + + assert_eq!( + tracker.check_limit(), + LimitCheck::WithinLimit, + "the top frame is exactly at its own budget, and the transaction is under its limit", + ); + assert_eq!( + tracker.check_limit_after_pop(true), + LimitCheck::ExceedsLimit { + kind: LimitKind::StateGrowth, + limit: 980, + used: 990, + frame_local: true, + }, + "the merged caller is 10 over the budget it was pushed with", + ); + assert_eq!( + tracker.check_limit_after_pop(false), + LimitCheck::WithinLimit, + "a reverting frame's growth vanishes rather than merging", + ); + } } diff --git a/crates/mega-evm/src/limit/storage_call_stipend.rs b/crates/mega-evm/src/limit/storage_call_stipend.rs index 36ced6ae..84d9b1c4 100644 --- a/crates/mega-evm/src/limit/storage_call_stipend.rs +++ b/crates/mega-evm/src/limit/storage_call_stipend.rs @@ -177,20 +177,24 @@ impl StorageCallStipendTracker { self.stack.last().map(|frame| frame.remaining).unwrap_or(0) } - /// Portion of `gas.remaining()` to add to `rescued_gas` on a TX-level limit exceed. - /// REX5 returns `gas.remaining()` directly (allowance never entered `gas.limit()`). + /// Portion of `remaining` to add to `rescued_gas` on a TX-level limit exceed. + /// REX5 returns `remaining` directly (allowance never entered `gas.limit()`). /// REX4 excludes the current frame's stipend so system-granted gas is not refunded /// to the sender. - pub(crate) fn effective_remaining_for_rescue(&self, gas: &Gas) -> u64 { + /// + /// `remaining` is passed rather than read off `gas` because the caller settles a frame after + /// an inspector callback has had a chance to edit the result, and the sender's refund is owed + /// on what the EVM left behind. `gas` is still read for its limit, which no callback moves. + pub(crate) fn effective_remaining_for_rescue(&self, gas: &Gas, remaining: u64) -> u64 { if self.rex5_enabled { - return gas.remaining(); + return remaining; } let stipend = self.current_frame_stipend(); if stipend > 0 { let original_limit = gas.limit().saturating_sub(stipend); - gas.remaining().min(original_limit) + remaining.min(original_limit) } else { - gas.remaining() + remaining } } diff --git a/crates/mega-evm/src/sandbox/execution.rs b/crates/mega-evm/src/sandbox/execution.rs index 0a831df6..cb70606d 100644 --- a/crates/mega-evm/src/sandbox/execution.rs +++ b/crates/mega-evm/src/sandbox/execution.rs @@ -174,7 +174,9 @@ pub fn execute_keyless_deploy_call let cost = constants::rex2::KEYLESS_DEPLOY_OVERHEAD_GAS; let has_sufficient_gas = gas.record_regular_cost(cost); if !has_sufficient_gas { - return make_halt!(); + // The call cannot even pay the dispatch overhead, so nothing has been recorded as + // compute gas and nothing is rescued — the whole envelope is destroyed. + return destroying_oog_frame_result(ctx, &gas, &return_memory_offset); } if ctx.spec.is_enabled(MegaSpecId::REX3) { let mut additional_limit = ctx.additional_limit.borrow_mut(); @@ -197,8 +199,13 @@ pub fn execute_keyless_deploy_call // `last_frame_result` then erases `rescued_gas` from the final spend, so // the receipt's `gas_used` excludes the rescued amount. Pre-REX6 specs // leave the un-rescued full-spend in place for replay parity. + // + // Nothing is destroyed on this branch under REX7: the rescue hands the whole + // post-overhead remainder back to the sender, so the only gas actually lost is + // the overhead already recorded as compute above. Booking the rescued remainder + // as destroyed as well would report gas that was refunded. if ctx.spec.is_enabled(MegaSpecId::REX6) { - additional_limit.try_rescue_gas(&gas); + additional_limit.try_rescue_gas(&gas, gas.remaining()); } let mut result = make_halt!(); mark_frame_result_as_exceeding_limit( @@ -648,7 +655,24 @@ fn run_sandbox_ctx( let is_rex6_enabled = sandbox_ctx.mega_spec().is_enabled(MegaSpecId::REX6); let mut sandbox_evm = MegaEvm::new(sandbox_ctx); let result = sandbox_evm.transact_raw(sandbox_tx); - let limit_usage = sandbox_evm.ctx.additional_limit.borrow().get_usage(); + let limit_usage = { + let additional_limit = sandbox_evm.ctx.additional_limit.borrow(); + SandboxUsage { + usage: additional_limit.get_usage(), + // The per-site booking, deliberately, not the sandbox's own derived report. What + // crosses this boundary is the split the parent must *enforce* on: the parent adds + // the sandbox's whole total to its own and then declares this much of it + // non-enforcing, which is how it inherits the sandbox's executed compute. The + // parent's reported destroyed total is settled once, from the conservation law, at + // the outer transaction's settlement point — this number is an input to the term + // that derivation reads, not a second place destroyed gas gets reported. + // + // The sandbox never settles a derivation of its own: the law is stated over a + // transaction's final envelope, and the sandbox's gas is a charge inside the outer + // transaction's envelope rather than one of its own. + burned_compute_gas: additional_limit.burned_compute_gas(), + } + }; let volatile_accesses = sandbox_evm.ctx.volatile_data_tracker.borrow().get_volatile_data_accessed(); process_sandbox_transact_result( @@ -686,7 +710,7 @@ pub enum SandboxOutcome { /// Wire-shape dispatch for what the outer caller should report. completion: SandboxCompletion, /// Resource usage from the sandbox's additional limit trackers. - limit_usage: LimitUsage, + limit_usage: SandboxUsage, /// Volatile-access footprint to merge into the parent after sandbox return. volatile_accesses: VolatileDataAccess, }, @@ -696,6 +720,22 @@ pub enum SandboxOutcome { Rejected(KeylessDeployError), } +/// Resource usage a completed sandbox hands to the parent, with Rex7's compute-gas split intact. +/// +/// [`LimitUsage`] carries one number per dimension, which is all the parent needs for three of +/// them. Compute gas needs two: the parent must report the sandbox's whole total but must enforce +/// only the part the sandbox performed, exactly as the sandbox itself did. Collapsing the two at +/// this boundary would let an ordinary EVM halt inside a sandboxed constructor fail the outer +/// transaction on a resource limit. +#[derive(Debug, Clone, Copy, Default)] +pub struct SandboxUsage { + /// Full reported usage. `compute_gas` includes `burned_compute_gas`. + pub usage: LimitUsage, + /// The part of `usage.compute_gas` the sandbox destroyed rather than performed — the + /// remainders of exceptionally halted sandbox frames (Rex7+, always 0 before). + pub burned_compute_gas: u64, +} + /// Wire-shape dispatch for a completed sandbox execution. /// /// `Deployed` and `EmptyCode` both surface as success-shape outer returns: the @@ -793,7 +833,7 @@ impl SandboxCompletion { /// surface (`Deployed { addr }` returned for create+SELFDESTRUCT) for replay parity. fn process_sandbox_transact_result( result: Result, E>, - limit_usage: LimitUsage, + limit_usage: SandboxUsage, volatile_accesses: VolatileDataAccess, is_rex5_enabled: bool, is_rex6_enabled: bool, @@ -935,13 +975,13 @@ fn process_sandbox_transact_result( fn apply_sandbox_post_accounting( ctx: &MegaContext, gas: &mut Gas, - limit_usage: LimitUsage, + limit_usage: SandboxUsage, volatile_accesses: VolatileDataAccess, reservation: u64, sandbox_gas_used: u64, return_memory_offset: &core::ops::Range, ) -> Option { - merge_sandbox_limit_usage(ctx, limit_usage); + merge_sandbox_limit_usage(ctx, limit_usage, sandbox_gas_used); ctx.volatile_data_tracker.borrow_mut().merge_accesses_from_bitmap(volatile_accesses); refund_unused_sandbox_gas(gas, reservation, sandbox_gas_used); reject_if_tx_limit_overflow(ctx, gas, return_memory_offset) @@ -993,8 +1033,13 @@ fn charge_caller_materialization_pre_sandbox( ctx: &MegaContext, - limit_usage: LimitUsage, + limit_usage: SandboxUsage, + sandbox_gas_used: u64, ) { - ctx.additional_limit.borrow_mut().merge_usage(limit_usage); + ctx.additional_limit.borrow_mut().merge_usage( + limit_usage.usage, + limit_usage.burned_compute_gas, + sandbox_gas_used, + ); } /// Returns the unused portion of the sandbox's pre-debited gas reservation to the @@ -1039,6 +1094,9 @@ fn refund_unused_sandbox_gas(gas: &mut Gas, reservation: u64, sandbox_gas_used: /// (rescue remaining gas, return as exceeding-limit OOG), `None` when the /// merged usage fits. /// +/// This is the rescued shape, not the destroying one: the remaining gas goes back to the +/// sender, so under REX7 nothing on this path is booked as destroyed. +/// /// State must not be merged after this returns `Some(_)`; the footprint /// side effects already applied by `apply_sandbox_post_accounting` remain. fn reject_if_tx_limit_overflow( @@ -1051,7 +1109,7 @@ fn reject_if_tx_limit_overflow( if !limit_check.exceeded_limit() || limit_check.is_frame_local() { return None; } - limit.rescue_gas(gas); + limit.rescue_gas(gas, gas.remaining()); let mut result = oog_frame_result(gas.limit(), return_memory_offset); mark_frame_result_as_exceeding_limit( &mut result, @@ -1061,6 +1119,30 @@ fn reject_if_tx_limit_overflow( Some(result) } +/// Builds the [`oog_frame_result`] shape for a synthetic halt that keeps the whole envelope, +/// recording the part it destroys (REX7+). +/// +/// The `KeylessDeploy` interceptor produces its results inside `frame_init`, before a child EVM +/// frame exists, so the frame-exit settlement that splits an ordinary exceptional halt never +/// sees them. The split is taken here instead, by the same formula that settlement uses: the +/// gas the frame still held when it gave up is destroyed, and whatever it had already recorded +/// as compute gas stays on the enforcing lane. `gas` must therefore be read *before* the halt +/// result is built — the result itself reports zero remaining, because the envelope is gone. +/// +/// Only for halts that keep the envelope. A halt whose remaining gas is rescued for the sender +/// destroys nothing: the rescue is a refund, and booking the same gas here as well would report +/// gas that was handed back. +fn destroying_oog_frame_result( + ctx: &MegaContext, + gas: &Gas, + return_memory_offset: &core::ops::Range, +) -> FrameResult { + if ctx.spec.is_enabled(MegaSpecId::REX7) { + ctx.additional_limit.borrow_mut().record_burned_gas(gas.remaining()); + } + oog_frame_result(gas.limit(), return_memory_offset) +} + /// Single source of truth for the `OutOfGas` halt `FrameResult` shape (empty return /// data, all gas consumed). Callers that need the exceeding-limit marker apply /// `mark_frame_result_as_exceeding_limit` on the returned value. @@ -1259,7 +1341,7 @@ mod tests { }); let out = process_sandbox_transact_result( result, - LimitUsage::default(), + SandboxUsage::default(), VolatileDataAccess::empty(), true, false, @@ -1285,7 +1367,7 @@ mod tests { }); let out = process_sandbox_transact_result( result, - LimitUsage::default(), + SandboxUsage::default(), VolatileDataAccess::empty(), true, false, @@ -1306,7 +1388,7 @@ mod tests { Err(FakeTxErr { is_tx: false, msg: "db blew up" }); let out = process_sandbox_transact_result( result, - LimitUsage::default(), + SandboxUsage::default(), VolatileDataAccess::empty(), true, false, @@ -1325,7 +1407,7 @@ mod tests { Err(FakeTxErr { is_tx: true, msg: "intrinsic gas too low" }); let out = process_sandbox_transact_result( result, - LimitUsage::default(), + SandboxUsage::default(), VolatileDataAccess::empty(), true, false, diff --git a/crates/mega-evm/src/test_utils/inspectors.rs b/crates/mega-evm/src/test_utils/inspectors.rs index b1e40a1d..2cd7a455 100644 --- a/crates/mega-evm/src/test_utils/inspectors.rs +++ b/crates/mega-evm/src/test_utils/inspectors.rs @@ -270,6 +270,12 @@ impl GasInspector { } } +/// Read-only: every callback records into this inspector's own trace tree and returns the EVM +/// exactly what it was handed — `None` from both frame-entry callbacks, and no write to any +/// interpreter, frame input or outcome it is shown. Declared so that tests exercising the +/// canonical block-execution path with an inspector can be admitted by it. +impl crate::TrustedObserver for GasInspector {} + impl Inspector for GasInspector { fn call(&mut self, _context: &mut CTX, inputs: &mut CallInputs) -> Option { // Create a new trace node for this call diff --git a/crates/mega-evm/tests/block_executor/compute_gas_lanes.rs b/crates/mega-evm/tests/block_executor/compute_gas_lanes.rs new file mode 100644 index 00000000..4ca666bc --- /dev/null +++ b/crates/mega-evm/tests/block_executor/compute_gas_lanes.rs @@ -0,0 +1,401 @@ +//! The Rex7 executed / destroyed compute-gas split, as the block sees it. +//! +//! A frame that halts exceptionally destroys the budget it was still holding. Rex7 reports that +//! remainder as compute gas but enforces no limit against it — the transaction level already +//! settled that, and the block level has to reach the same answer, because a transaction that +//! destroyed a large gas envelope while performing almost no work would otherwise close the +//! block's compute capacity for every transaction behind it. +//! +//! So the block keeps two compute-gas counters: `block_compute_gas_used` reports every +//! transaction's whole total, and `block_compute_gas_enforced` carries only the work performed and +//! is what admission compares. These tests drive both counters through the real commit path, in +//! the two shapes that produce a destroyed remainder — an ordinary exceptional frame, and one +//! nested inside the `KeylessDeploy` sandbox, which merges a whole separate tracker back across a +//! boundary the classification has to survive. +//! +//! Nothing is destroyed before Rex7, so the frozen specs are pinned here too: the two counters +//! must not diverge by so much as a gas unit under Rex6. + +use std::convert::Infallible; + +use alloy_evm::{block::BlockExecutor, EvmEnv, EvmFactory}; +use alloy_op_evm::block::receipt_builder::OpAlloyReceiptBuilder; +use alloy_primitives::{address, Address, Bytes, Signature, TxKind, B256, U256}; +use alloy_sol_types::SolCall as _; +use mega_evm::{ + alloy_consensus::{transaction::Recovered, Signed, TxLegacy}, + test_utils::{BytecodeBuilder, MemoryDatabase}, + BlockLimits, IKeylessDeploy, MegaBlockExecutionCtx, MegaBlockExecutor, MegaEvmFactory, + MegaHardforkConfig, MegaSpecId, MegaTxEnvelope, TestExternalEnvs, KEYLESS_DEPLOY_ADDRESS, +}; +use revm::{ + bytecode::opcode::{ADD, STOP}, + context::BlockEnv, + database::State, +}; + +/// Sends every transaction in these tests. +const CALLER: Address = address!("2000000000000000000000000000000000000002"); +/// `ADD` on an empty stack: the call halts on its first opcode, so nearly the whole envelope it +/// was forwarded is destroyed rather than performed. +const HALTING: Address = address!("1000000000000000000000000000000000000001"); +/// `STOP`: the cheap transaction that has to still fit in the block afterwards. +const CHEAP: Address = address!("1000000000000000000000000000000000000002"); + +/// Gas envelope the halting transaction destroys. Large enough that its reported compute total +/// alone dwarfs [`BLOCK_COMPUTE_GAS_LIMIT`], while the work it performed before failing — one +/// three-gas `ADD` on top of the intrinsic cost — stays far below it. +const HALTING_TX_GAS_LIMIT: u64 = 5_000_000; + +/// Gas envelope the `KeylessDeploy` sandbox destroys, passed as the call's gas-limit override. +const SANDBOX_GAS_OVERRIDE: u64 = 4_000_000; + +/// The block compute-gas ceiling these tests build around: above what any of these transactions +/// executes, below what the halting ones report. +const BLOCK_COMPUTE_GAS_LIMIT: u64 = 1_000_000; + +/// Builds a legacy transaction from `CALLER`. +fn envelope(nonce: u64, gas_limit: u64, to: Address, input: Bytes) -> MegaTxEnvelope { + let tx = TxLegacy { + chain_id: Some(8453), + nonce, + gas_price: 1_000_000, + gas_limit, + to: TxKind::Call(to), + value: U256::ZERO, + input, + }; + MegaTxEnvelope::Legacy(Signed::new_unchecked(tx, Signature::test_signature(), B256::ZERO)) +} + +/// The pre-EIP-155 deployment transaction the `KeylessDeploy` sandbox replays, carrying a +/// constructor that halts on its first opcode. +fn keyless_deploy_call_data() -> Bytes { + let init_code = BytecodeBuilder::default().append(ADD).append(STOP).build(); + let tx = TxLegacy { + nonce: 0, + gas_price: 100_000_000_000, + gas_limit: 200_000, + to: TxKind::Create, + value: U256::ZERO, + input: init_code, + chain_id: None, + }; + let word = U256::from_be_bytes([0x33; 32]); + let signed = Signed::new_unchecked(tx, Signature::new(word, word, false), B256::ZERO); + let mut encoded = Vec::new(); + signed.rlp_encode(&mut encoded); + + Bytes::from( + IKeylessDeploy::keylessDeployCall { + keylessDeploymentTransaction: Bytes::from(encoded), + gasLimitOverride: U256::from(SANDBOX_GAS_OVERRIDE), + } + .abi_encode(), + ) +} + +/// The database every test here runs against: the two callees plus a funded sender. +fn build_db() -> MemoryDatabase { + let mut db = MemoryDatabase::default(); + db.set_account_code(HALTING, BytecodeBuilder::default().append(ADD).append(STOP).build()); + db.set_account_code(CHEAP, BytecodeBuilder::default().stop().build()); + db.set_account_balance(CALLER, U256::from(1_000_000_000_000_000_000u64)); + db +} + +/// What one committed transaction contributed, and where the block's counters stood afterwards. +#[derive(Debug, Clone, Copy)] +struct Contribution { + /// Whether the transaction's own execution result reports success. + succeeded: bool, + /// The transaction's full reported compute total. + reported: u64, + /// The part of `reported` its exceptionally halted frames destroyed. + destroyed: u64, + /// The block's reported compute counter after the commit. + block_reported: u64, + /// The block's enforced compute counter after the commit. + block_enforced: u64, + /// Whether the block considers itself full after the commit. + block_full: bool, +} + +/// Runs `txs` through one block at `spec`, committing each in turn, and returns what each +/// contributed. Stops at the first transaction the block refuses to admit, so the returned vector +/// is shorter than `txs` exactly when the block closed early. +fn run_block( + spec: MegaSpecId, + block_compute_gas_limit: u64, + txs: &[MegaTxEnvelope], +) -> Vec { + run_block_reporting(spec, block_compute_gas_limit, txs, None) +} + +/// [`run_block`], with a test channel that rewrites each transaction's **reported** destroyed +/// total between execution and commit. +/// +/// `reported_destroyed` is the value handed to the block in place of the one the transaction +/// derived, which is how a test forces the derived report and the per-site enforcement lane apart +/// — a divergence execution itself cannot produce, since the two are cross-checked against each +/// other at settlement. The `Contribution` still carries what the transaction really derived. +fn run_block_reporting( + spec: MegaSpecId, + block_compute_gas_limit: u64, + txs: &[MegaTxEnvelope], + reported_destroyed: Option, +) -> Vec { + let mut db = build_db(); + let mut state = State::builder().with_database(&mut db).build(); + let external_envs = TestExternalEnvs::::new(); + let evm_factory = MegaEvmFactory::new().with_external_env_factory(external_envs); + + let mut cfg_env = revm::context::CfgEnv::default(); + cfg_env.spec = spec; + let block_env = BlockEnv { + number: U256::from(1000), + timestamp: U256::from(1_800_000_000), + gas_limit: 30_000_000, + ..Default::default() + }; + let evm = evm_factory.create_evm(&mut state, EvmEnv::new(cfg_env, block_env)); + + let block_ctx = MegaBlockExecutionCtx::new( + B256::ZERO, + None, + Bytes::new(), + BlockLimits::no_limits().with_block_compute_gas_limit(block_compute_gas_limit), + ); + let chain_spec = MegaHardforkConfig::default().with_all_activated_through(spec); + let mut executor = + MegaBlockExecutor::new(evm, block_ctx, chain_spec, OpAlloyReceiptBuilder::default()); + + let mut contributions = Vec::new(); + for tx in txs { + let Ok(mut outcome) = executor.run_transaction(Recovered::new_unchecked(tx, CALLER)) else { + break; + }; + let succeeded = outcome.result.is_success(); + let reported = outcome.compute_gas_used; + let destroyed = outcome.compute_gas_destroyed; + if let Some(rewritten) = reported_destroyed { + outcome.compute_gas_destroyed = rewritten; + } + executor.commit_transaction_outcome(outcome).expect("the commit must be admitted too"); + + let limiter = &executor.block_limiter; + contributions.push(Contribution { + succeeded, + reported, + destroyed, + block_reported: limiter.block_compute_gas_used, + block_enforced: limiter.block_compute_gas_enforced, + block_full: limiter.is_block_limit_reached(), + }); + } + + let (_, receipts) = executor.finish().expect("the block must finish"); + assert_eq!( + receipts.receipts.len(), + contributions.len(), + "every admitted transaction must have produced a receipt" + ); + contributions +} + +/// Asserts the shape both destroyed-remainder tests need from their first transaction: it reported +/// past the block's compute ceiling, but performed far too little to have earned that. +fn assert_reports_past_the_limit_without_performing_it(label: &str, first: &Contribution) { + assert!(first.destroyed > 0, "{label}: the frame must have destroyed a remainder"); + assert!( + first.reported > BLOCK_COMPUTE_GAS_LIMIT, + "{label}: the reported total must exceed the block limit, got {}", + first.reported, + ); + assert!( + first.reported - first.destroyed < BLOCK_COMPUTE_GAS_LIMIT, + "{label}: the work performed must stay under the block limit, got {}", + first.reported - first.destroyed, + ); +} + +/// An ordinary exceptional frame: a call that halts on its first opcode with a large envelope +/// still in hand. +/// +/// The block must report what the transaction reported — destroyed remainder included, which is +/// what makes the reported counter cross the ceiling — and must still admit the cheap transaction +/// behind it, because the enforced counter only ever saw the three gas the `ADD` charged before +/// underflowing. +#[test] +fn test_rex7_destroyed_remainder_reports_at_block_level_without_closing_the_block() { + let txs = [ + envelope(0, HALTING_TX_GAS_LIMIT, HALTING, Bytes::new()), + envelope(1, 100_000, CHEAP, Bytes::new()), + ]; + let block = run_block(MegaSpecId::REX7, BLOCK_COMPUTE_GAS_LIMIT, &txs); + + assert_eq!(block.len(), 2, "the cheap transaction must still fit in the block"); + let [halting, cheap] = [block[0], block[1]]; + + assert!(!halting.succeeded, "the first transaction must halt"); + assert_reports_past_the_limit_without_performing_it("ordinary frame", &halting); + + assert_eq!( + halting.block_reported, halting.reported, + "the block's reported statistic must carry the destroyed remainder" + ); + assert_eq!( + halting.block_enforced, + halting.reported - halting.destroyed, + "the block must enforce only the work performed" + ); + assert!( + !halting.block_full, + "a destroyed remainder must not fill the block's compute capacity" + ); + + assert!(cheap.succeeded, "the second transaction must execute normally"); + assert_eq!( + cheap.block_reported, + halting.reported + cheap.reported, + "the reported counter keeps accumulating whole totals" + ); + assert_eq!( + cheap.block_enforced, + halting.block_enforced + cheap.reported, + "a transaction that destroys nothing advances both counters by the same amount" + ); +} + +/// The same shape, one boundary deeper: the frame that halts lives inside the `KeylessDeploy` +/// sandbox, whose usage is merged back into the outer transaction through a separate tracker. +/// +/// The outer transaction succeeds — the sandbox reports a failed deployment through the +/// keyless-deploy wire contract rather than failing itself — so nothing about the outer result +/// hints that a remainder was destroyed. If the merge or the outcome dropped the classification, +/// the block would silently enforce a sandbox's destroyed budget against every transaction behind +/// it. +#[test] +fn test_rex7_sandbox_destroyed_remainder_does_not_close_the_block_either() { + let txs = [ + envelope(0, 30_000_000, KEYLESS_DEPLOY_ADDRESS, keyless_deploy_call_data()), + envelope(1, 100_000, CHEAP, Bytes::new()), + ]; + let block = run_block(MegaSpecId::REX7, BLOCK_COMPUTE_GAS_LIMIT, &txs); + + assert_eq!(block.len(), 2, "the cheap transaction must still fit in the block"); + let [sandbox, cheap] = [block[0], block[1]]; + + assert!(sandbox.succeeded, "the keyless deploy reports the constructor failure, it is not one"); + assert_reports_past_the_limit_without_performing_it("sandbox frame", &sandbox); + + assert_eq!( + sandbox.block_reported, sandbox.reported, + "the block's reported statistic must carry the sandbox's destroyed remainder" + ); + assert_eq!( + sandbox.block_enforced, + sandbox.reported - sandbox.destroyed, + "the block must enforce only the work the sandbox performed" + ); + assert!(!sandbox.block_full, "a sandbox's destroyed remainder must not fill the block"); + assert!(cheap.succeeded, "the second transaction must execute normally"); +} + +/// Block admission must not move when the transaction's *reported* destroyed total does. +/// +/// The reported number is derived from a conservation law over the transaction's gas envelope; the +/// number the block admits on is the compute-gas recordings' own enforcement lane. The two agree — +/// a settlement cross-check fails loudly in debug builds if they ever stop — but only one of them +/// is a measurement of work performed, and admission runs on that one. Here the two are forced +/// apart at the seam the block reads, in both directions, which is a divergence execution cannot +/// produce on its own. +/// +/// The block must be indifferent. A block that reached its enforced counter by subtracting the +/// reported total would not be: the fixture reports five times the block's compute ceiling, so +/// under-reporting the destroyed part would close the block on the spot and refuse the transaction +/// behind it, and over-reporting it would credit the transaction with no work at all. That is the +/// difference between a lost term in the law misreporting a statistic and repacking blocks. +#[test] +fn test_rex7_block_admission_ignores_the_reported_destroyed_total() { + let txs = [ + envelope(0, HALTING_TX_GAS_LIMIT, HALTING, Bytes::new()), + envelope(1, 100_000, CHEAP, Bytes::new()), + ]; + let honest = run_block(MegaSpecId::REX7, BLOCK_COMPUTE_GAS_LIMIT, &txs); + + assert_eq!(honest.len(), 2, "the honest run must admit both transactions"); + assert!(honest[0].destroyed > 0, "the fixture must destroy a remainder to misreport one"); + assert!( + honest[0].reported > BLOCK_COMPUTE_GAS_LIMIT, + "the reported total must clear the block ceiling, or rewriting the destroyed part could \ + not change admission however it were read; got {}", + honest[0].reported, + ); + + // Under-reporting the destroyed part, then over-reporting it past the reported total itself. + for rewritten in [0, u64::MAX] { + let poisoned = + run_block_reporting(MegaSpecId::REX7, BLOCK_COMPUTE_GAS_LIMIT, &txs, Some(rewritten)); + + assert_eq!( + poisoned.len(), + honest.len(), + "reported destroyed {rewritten}: the block admitted a different set of transactions", + ); + for (index, (seen, expected)) in poisoned.iter().zip(&honest).enumerate() { + assert_eq!( + seen.block_enforced, expected.block_enforced, + "reported destroyed {rewritten}, tx {index}: the enforced counter moved", + ); + assert_eq!( + seen.block_reported, expected.block_reported, + "reported destroyed {rewritten}, tx {index}: the reported counter moved", + ); + assert_eq!( + seen.block_full, expected.block_full, + "reported destroyed {rewritten}, tx {index}: block admission changed", + ); + } + } +} + +/// Executed work still fills the block: the split is a classification, not a way out of the block +/// compute-gas limit. +#[test] +fn test_rex7_executed_work_still_closes_the_block() { + // A ceiling below what a single halting transaction's `ADD`-plus-intrinsic work costs. + let txs = [ + envelope(0, HALTING_TX_GAS_LIMIT, HALTING, Bytes::new()), + envelope(1, 100_000, CHEAP, Bytes::new()), + ]; + let block = run_block(MegaSpecId::REX7, 1, &txs); + + assert_eq!(block.len(), 1, "the block must close once the enforced counter reaches the limit"); + assert!(block[0].block_full, "the work performed does fill a block this small"); +} + +/// Rex6 destroys nothing, so the enforced counter must track the reported one exactly — through a +/// block that mixes a successful transaction with one that halts on its first opcode, the shape +/// that diverges under Rex7. +#[test] +fn test_rex6_block_compute_counters_never_diverge() { + let txs = [ + envelope(0, HALTING_TX_GAS_LIMIT, HALTING, Bytes::new()), + envelope(1, 100_000, CHEAP, Bytes::new()), + envelope(2, HALTING_TX_GAS_LIMIT, HALTING, Bytes::new()), + ]; + let block = run_block(MegaSpecId::REX6, u64::MAX, &txs); + + assert_eq!(block.len(), 3, "no transaction here approaches an unlimited block"); + for (index, contribution) in block.iter().enumerate() { + assert_eq!( + contribution.destroyed, 0, + "tx {index}: no spec before Rex7 destroys compute gas" + ); + assert_eq!( + contribution.block_reported, contribution.block_enforced, + "tx {index}: the two counters must stay identical on a frozen spec" + ); + } +} diff --git a/crates/mega-evm/tests/block_executor/declared_observer.rs b/crates/mega-evm/tests/block_executor/declared_observer.rs new file mode 100644 index 00000000..a61c5572 --- /dev/null +++ b/crates/mega-evm/tests/block_executor/declared_observer.rs @@ -0,0 +1,414 @@ +//! `DeclaredObserver` forwards the whole `Inspector` trait, and nothing else. +//! +//! The wrapper exists so that a node declaring a foreign tracer read-only writes one line instead +//! of a hundred, and the hundred it replaces were dangerous: every `Inspector` method has a default +//! body, so a callback revm adds and a hand-written forwarder misses is not a compile error but a +//! callback the wrapped tracer silently stops receiving. A tracer whose output is quietly short a +//! frame is worse than one that fails to build. +//! +//! Moving the forwarding into this crate does not remove that hazard, it concentrates it — there is +//! one forwarder now, and it is this crate's to keep complete. Three tests hold it: +//! +//! - [`test_every_callback_the_trait_declares_is_forwarded`] invokes each callback directly and +//! checks the inner inspector received it. This is the pin on the set as it stands, and the +//! overriding recorder makes a rename or a removal upstream a compile error. +//! - [`test_wrapping_changes_no_callback_a_recorder_sees`] runs one transaction twice, bare and +//! wrapped, and compares the callback sequences element for element. +//! - [`test_wrapping_changes_no_trace_the_production_tracer_produces`] does the same with +//! `revm-inspectors`' own tracer instead of a fixture recorder. That is the one that survives an +//! upgrade: a callback added to the trait is added to `TracingInspector` too, so a forwarder that +//! has not grown the new method produces a different trace here while a fixture recorder written +//! before the upgrade would notice nothing. + +use alloy_primitives::{address, Address, Bytes, Log, U256}; +use mega_evm::{ + test_utils::{BytecodeBuilder, MemoryDatabase}, + DeclaredObserver, EvmTxRuntimeLimits, MegaContext, MegaEvm, MegaSpecId, MegaTransaction, + MegaTransactionNew as _, +}; +use revm::{ + bytecode::opcode::{CALL, CREATE, MSTORE, MSTORE8, POP, SELFDESTRUCT, STOP}, + handler::FrameResult, + interpreter::{ + interpreter::EthInterpreter, CallInput, CallInputs, CallOutcome, CallScheme, CallValue, + CreateInputs, CreateOutcome, CreateScheme, FrameInput, Gas, InstructionResult, Interpreter, + InterpreterResult, InterpreterTypes, + }, + Inspector, +}; + +/// Sends the fixture transaction. +const CALLER: Address = address!("2000000000000000000000000000000000000002"); +/// The entry contract: logs, calls, creates and writes a slot. +const CONTRACT: Address = address!("1000000000000000000000000000000000000001"); +/// The callee, which self-destructs so that the last callback of the trait fires too. +const CALLEE: Address = address!("1000000000000000000000000000000000000002"); +/// Where the callee sends its balance. +const BENEFICIARY: Address = address!("1000000000000000000000000000000000000003"); + +/// Gas the entry contract forwards to its inner call. +const INNER_CALL_GAS: u64 = 60_000; + +// --- the recorder --------------------------------------------------------------------------- + +/// Every callback `Inspector` declares today, in the order the trait declares them. +/// +/// Restated as data so that [`test_every_callback_the_trait_declares_is_forwarded`] can compare a +/// set against it. The compile-time half of the same pin is [`Recorder`]'s impl below, which +/// overrides all of them: a callback upstream renames or removes stops this file building. +const CALLBACKS: [&str; 12] = [ + "initialize_interp", + "step", + "step_end", + "log", + "log_full", + "frame_start", + "frame_end", + "call", + "call_end", + "create", + "create_end", + "selfdestruct", +]; + +/// Writes down the name of every callback it is handed, in order, and changes nothing. +#[derive(Default)] +struct Recorder { + seen: Vec<&'static str>, +} + +impl Recorder { + /// The distinct callbacks this recorder was handed, so a comparison names the missing one + /// rather than an index into a thousand-element sequence. + fn distinct(&self) -> Vec<&'static str> { + let mut seen: Vec<&'static str> = self.seen.clone(); + seen.sort_unstable(); + seen.dedup(); + seen + } +} + +impl Inspector for Recorder { + fn initialize_interp(&mut self, _interp: &mut Interpreter, _context: &mut CTX) { + self.seen.push("initialize_interp"); + } + + fn step(&mut self, _interp: &mut Interpreter, _context: &mut CTX) { + self.seen.push("step"); + } + + fn step_end(&mut self, _interp: &mut Interpreter, _context: &mut CTX) { + self.seen.push("step_end"); + } + + fn log(&mut self, _context: &mut CTX, _log: Log) { + self.seen.push("log"); + } + + fn log_full(&mut self, _interp: &mut Interpreter, _context: &mut CTX, _log: Log) { + self.seen.push("log_full"); + } + + fn frame_start( + &mut self, + _context: &mut CTX, + _frame_input: &mut FrameInput, + ) -> Option { + self.seen.push("frame_start"); + None + } + + fn frame_end( + &mut self, + _context: &mut CTX, + _frame_input: &FrameInput, + _frame_result: &mut FrameResult, + ) { + self.seen.push("frame_end"); + } + + fn call(&mut self, _context: &mut CTX, _inputs: &mut CallInputs) -> Option { + self.seen.push("call"); + None + } + + fn call_end(&mut self, _context: &mut CTX, _inputs: &CallInputs, _outcome: &mut CallOutcome) { + self.seen.push("call_end"); + } + + fn create(&mut self, _context: &mut CTX, _inputs: &mut CreateInputs) -> Option { + self.seen.push("create"); + None + } + + fn create_end( + &mut self, + _context: &mut CTX, + _inputs: &CreateInputs, + _outcome: &mut CreateOutcome, + ) { + self.seen.push("create_end"); + } + + fn selfdestruct(&mut self, _contract: Address, _target: Address, _value: U256) { + self.seen.push("selfdestruct"); + } +} + +// --- the fixture ---------------------------------------------------------------------------- + +/// The code the created contract leaves behind: nothing. +fn init_code() -> Vec { + vec![STOP] +} + +/// One `LOG1`, one inner `CALL` to a self-destructing callee, one `CREATE`, one `SSTORE`. +/// +/// Written so that as many of the trait's callbacks as a transaction can reach fire in a single +/// run. `log` is the one that cannot: revm calls it only for the logs a precompile emitted, and no +/// precompile this crate registers emits any. It is covered by the direct-invocation pin instead. +fn caller_code() -> Bytes { + let init = init_code(); + let mut builder = BytecodeBuilder::default() + // A word in memory for the LOG to read. + .push_number(0xAAu64) + .push_number(0u64) + .append(MSTORE) + // LOG1(offset=0, size=32, topic=1) + .push_number(1u64) + .push_number(32u64) + .push_number(0u64) + .append(revm::bytecode::opcode::LOG1) + // CALL(gas, CALLEE, value=0, argsOffset=0, argsSize=0, retOffset=0, retSize=0) + .push_number(0u64) + .push_number(0u64) + .push_number(0u64) + .push_number(0u64) + .push_number(0u64) + .push_address(CALLEE) + .push_number(u128::from(INNER_CALL_GAS)) + .append(CALL) + .append(POP); + for (offset, byte) in init.iter().enumerate() { + builder = builder.push_number(u64::from(*byte)).push_number(offset as u64).append(MSTORE8); + } + builder + .push_number(init.len() as u64) // size + .push_number(0u64) // offset + .push_number(0u64) // value + .append(CREATE) + .append(POP) + .sstore(U256::from(1), U256::from(9)) + .append(STOP) + .build() +} + +fn build_db() -> MemoryDatabase { + let mut db = MemoryDatabase::default(); + db.set_account_code(CONTRACT, caller_code()); + db.set_account_code( + CALLEE, + BytecodeBuilder::default().push_address(BENEFICIARY).append(SELFDESTRUCT).build(), + ); + db.set_account_balance(CALLEE, U256::from(1_000u64)); + db.set_account_balance(CALLER, U256::from(1_000_000_000_000_000_000u64)); + db +} + +fn fixture_tx() -> MegaTransaction { + let mut tx = MegaTransaction::new( + revm::context::tx::TxEnvBuilder::default() + .caller(CALLER) + .call(CONTRACT) + .gas_limit(1_000_000) + .build_fill(), + ); + tx.enveloped_tx = Some(Bytes::new()); + tx +} + +/// Runs the fixture with `inspector` attached to a measured shim. +/// +/// Measured on both sides deliberately: the comparison is about what the wrapper forwards, so the +/// shim underneath must be the same one in both runs. A declared shim would take a different path +/// in release builds and make the two runs differ for a reason that is not the wrapper's. +fn run_with(inspector: I) +where + I: for<'a> Inspector>, +{ + let mut db = build_db(); + let mut evm = MegaEvm::new( + MegaContext::new(&mut db, MegaSpecId::REX7) + .with_tx_runtime_limits(EvmTxRuntimeLimits::from_spec(MegaSpecId::REX7)), + ) + .with_inspector(inspector); + + let outcome = evm.execute_transaction(fixture_tx()).expect("the fixture must execute"); + assert!( + outcome.result_and_state.result.is_success(), + "fixture check: {:?}", + outcome.result_and_state.result, + ); +} + +// --- the tests ------------------------------------------------------------------------------ + +/// Each of the twelve callbacks reaches the inspector inside the wrapper. +/// +/// Invoked directly rather than through a transaction, so that the two callbacks no fixture can +/// reach — `log`, which only a precompile's output produces — are covered along with the rest. What +/// this cannot see is a callback added upstream, since a name that does not exist cannot be listed; +/// [`test_wrapping_changes_no_trace_the_production_tracer_produces`] is what covers that direction. +#[test] +fn test_every_callback_the_trait_declares_is_forwarded() { + let mut wrapper = DeclaredObserver(Recorder::default()); + let inspector: &mut dyn Inspector<(), EthInterpreter> = &mut wrapper; + + let mut interpreter = Interpreter::::default(); + let mut call_inputs = sample_call_inputs(); + let mut create_inputs = sample_create_inputs(); + let mut call_outcome = sample_call_outcome(); + let mut create_outcome = sample_create_outcome(); + let mut frame_input = FrameInput::Call(Box::new(sample_call_inputs())); + let mut frame_result = FrameResult::Call(sample_call_outcome()); + + inspector.initialize_interp(&mut interpreter, &mut ()); + inspector.step(&mut interpreter, &mut ()); + inspector.step_end(&mut interpreter, &mut ()); + inspector.log(&mut (), Log::default()); + inspector.log_full(&mut interpreter, &mut (), Log::default()); + inspector.frame_start(&mut (), &mut frame_input); + inspector.frame_end(&mut (), &frame_input, &mut frame_result); + inspector.call(&mut (), &mut call_inputs); + inspector.call_end(&mut (), &call_inputs, &mut call_outcome); + inspector.create(&mut (), &mut create_inputs); + inspector.create_end(&mut (), &create_inputs, &mut create_outcome); + inspector.selfdestruct(Address::ZERO, Address::ZERO, U256::ZERO); + + assert_eq!( + wrapper.0.seen, CALLBACKS, + "every callback the trait declares must reach the inspector inside the wrapper, once, \ + unchanged", + ); +} + +/// `log_full`'s default body calls `log`, so forwarding one and not the other is not a silent +/// no-op but a rerouted callback — which is the failure a set comparison would miss. +/// +/// The recorder above overrides both, so if `DeclaredObserver::log_full` were dropped the default +/// body would forward to `DeclaredObserver::log`, the inner inspector would record `log` where the +/// EVM sent `log_full`, and the assertion in +/// [`test_every_callback_the_trait_declares_is_forwarded`] would fail on the order rather than on +/// the membership. This states that dependency so a later edit does not weaken that assertion to a +/// set comparison. +#[test] +fn test_log_full_is_forwarded_as_itself_and_not_through_log() { + let mut wrapper = DeclaredObserver(Recorder::default()); + let inspector: &mut dyn Inspector<(), EthInterpreter> = &mut wrapper; + let mut interpreter = Interpreter::::default(); + + inspector.log_full(&mut interpreter, &mut (), Log::default()); + + assert_eq!(wrapper.0.seen, ["log_full"], "the wrapper must not collapse `log_full` into `log`"); +} + +/// One transaction, run twice: the callbacks a recorder sees wrapped are the ones it sees bare. +/// +/// The fixture reaches eleven of the twelve, nested frames and a self-destruct included, so a +/// forwarder that dropped one would show up as a shorter sequence rather than as a subtle +/// difference in what the transaction produced. +#[test] +fn test_wrapping_changes_no_callback_a_recorder_sees() { + let mut bare = Recorder::default(); + run_with(&mut bare); + + let mut wrapped = DeclaredObserver(Recorder::default()); + run_with(&mut wrapped); + + assert_eq!(bare.distinct(), wrapped.0.distinct(), "the wrapper must not drop a whole callback",); + assert_eq!( + bare.seen, wrapped.0.seen, + "and must not change the order or the number of times each one fires", + ); + + let reached = bare.distinct(); + let missing: Vec<&&str> = CALLBACKS.iter().filter(|name| !reached.contains(name)).collect(); + assert_eq!( + missing, + [&"log"], + "fixture check: the fixture must keep reaching every callback a transaction can reach, so \ + that the comparison above stays worth making", + ); +} + +/// The same comparison against `revm-inspectors`' own tracer, whose callback set grows with revm's. +/// +/// This is the test that survives an upgrade. A callback added to the `Inspector` trait gets a +/// default body, so nothing here stops compiling and a recorder written against today's trait +/// records nothing new. `TracingInspector` is upgraded along with the trait, though — so if the +/// wrapper has not grown the new method, the tracer receives it bare and not wrapped, and the two +/// traces stop matching. +#[test] +fn test_wrapping_changes_no_trace_the_production_tracer_produces() { + use revm_inspectors::tracing::{TracingInspector, TracingInspectorConfig}; + + let trace_of = |wrap: bool| { + let mut tracer = TracingInspector::new(TracingInspectorConfig::all()); + if wrap { + run_with(DeclaredObserver(&mut tracer)); + } else { + run_with(&mut tracer); + } + (tracer.traces().nodes().len(), format!("{:?}", tracer.traces().nodes())) + }; + + let (bare_frames, bare) = trace_of(false); + let (wrapped_frames, wrapped) = trace_of(true); + + assert_eq!( + bare_frames, 3, + "fixture check: the tracer must have recorded the entry frame, the inner call and the \ + creation", + ); + assert_eq!(wrapped_frames, bare_frames, "the wrapper must not cost the tracer a frame"); + assert_eq!(bare, wrapped, "the tracer must see the same execution wrapped as bare"); +} + +// --- sample arguments for the direct-invocation pin ------------------------------------------ + +fn sample_gas() -> Gas { + Gas::new(1) +} + +fn sample_call_inputs() -> CallInputs { + CallInputs { + input: CallInput::Bytes(Bytes::new()), + return_memory_offset: 0..0, + gas_limit: 1, + reservoir: 0, + bytecode_address: Address::ZERO, + known_bytecode: Default::default(), + target_address: Address::ZERO, + caller: Address::ZERO, + value: CallValue::Transfer(U256::ZERO), + scheme: CallScheme::Call, + is_static: false, + charged_new_account_state_gas: false, + } +} + +fn sample_create_inputs() -> CreateInputs { + CreateInputs::new(Address::ZERO, CreateScheme::Create, U256::ZERO, Bytes::new(), 1, 0) +} + +fn sample_result() -> InterpreterResult { + InterpreterResult::new(InstructionResult::Stop, Bytes::new(), sample_gas()) +} + +fn sample_call_outcome() -> CallOutcome { + CallOutcome::new(sample_result(), 0..0) +} + +fn sample_create_outcome() -> CreateOutcome { + CreateOutcome::new(sample_result(), None) +} diff --git a/crates/mega-evm/tests/block_executor/inspector.rs b/crates/mega-evm/tests/block_executor/inspector.rs index cac0eb10..bc142244 100644 --- a/crates/mega-evm/tests/block_executor/inspector.rs +++ b/crates/mega-evm/tests/block_executor/inspector.rs @@ -6,7 +6,10 @@ use std::{cell::Cell, convert::Infallible}; use alloy_consensus::{Signed, TxLegacy}; -use alloy_evm::{block::BlockExecutor, EvmEnv}; +use alloy_evm::{ + block::{BlockExecutor, BlockExecutorFactory}, + EvmEnv, EvmFactory, IntoTxEnv, +}; use alloy_op_evm::block::receipt_builder::OpAlloyReceiptBuilder; use alloy_primitives::{address, Address, Bytes, Signature, TxKind, B256, U256}; use mega_evm::{ @@ -116,12 +119,13 @@ fn test_inspector_works_with_block_executor() { let block_ctx = MegaBlockExecutionCtx::new(B256::ZERO, None, Bytes::new(), BlockLimits::no_limits()); - // Create inspector + // Create inspector. `GasInspector` only records, and its type says so — which is what the + // canonical block path admits an inspected transaction on. let inspector = GasInspector::new(); // Create block executor with inspector let mut executor = block_executor_factory - .create_executor_with_inspector(&mut state, block_ctx, evm_env, inspector); + .create_executor_with_trusted_inspector(&mut state, block_ctx, evm_env, inspector); // Execute transaction let tx = create_transaction(0, 1_000_000); @@ -279,16 +283,36 @@ fn test_inspector_early_return_with_additional_limits() { // Create inspector that skips nested calls let inspector = SkipNestedCallInspector::default(); - // Create block executor with inspector - let mut executor = block_executor_factory - .create_executor_with_inspector(&mut state, block_ctx, evm_env, inspector); + // Built the way a node builds one: the EVM carries the inspector, and the executor is made + // from it through the `alloy_evm` trait entry. The factory has no undeclared-inspector + // constructor, because an executor that refuses every transaction is not an API. + let evm = block_executor_factory + .evm_factory() + .create_evm(&mut state, evm_env) + .with_inspector(inspector); + let mut executor = as BlockExecutorFactory>::create_executor( + &block_executor_factory, + evm, + block_ctx, + ); // Execute transaction - this triggers a nested CALL that the inspector intercepts let tx = create_transaction(0, 1_000_000); - // Before the fix, this would panic with "frame stack is empty" - let result = executor.execute_transaction(&tx); - assert!(result.is_ok(), "Transaction should succeed: {:?}", result.err()); + // Driven through the executor's EVM rather than through the executor: an inspector that + // answers a frame itself is a rewriting inspector, and the canonical path admits an inspected + // transaction only on a read-only declaration, which this type cannot be given. The EVM + // supports the interception in full, which is what this test is about — before the fix it + // panicked with "frame stack is empty", so completing at all means every push found its pop. + let outcome = executor + .evm_mut() + .execute_transaction(tx.into_tx_env()) + .expect("the EVM supports the interception in full"); + assert_eq!( + outcome.inspector_ledger.interventions, 1, + "the interception must be measured: {:?}", + outcome.inspector_ledger, + ); // Verify the inspector intercepted the nested call assert_eq!( @@ -303,13 +327,6 @@ fn test_inspector_early_return_with_additional_limits() { 2, "call_end should be invoked for both the main call and the intercepted nested call" ); - - // Finish the block - let block_result = executor.finish(); - assert!(block_result.is_ok(), "Block should finish successfully"); - - let (_, receipts) = block_result.unwrap(); - assert_eq!(receipts.receipts.len(), 1, "Should have 1 receipt"); } /// An inspector that returns early for create operations, skipping frame execution. @@ -387,9 +404,18 @@ fn test_inspector_early_return_create_with_additional_limits() { // Create inspector that skips create operations let inspector = SkipCreateInspector::default(); - // Create block executor with inspector - let mut executor = block_executor_factory - .create_executor_with_inspector(&mut state, block_ctx, evm_env, inspector); + // Built the way a node builds one: the EVM carries the inspector, and the executor is made + // from it through the `alloy_evm` trait entry. The factory has no undeclared-inspector + // constructor, because an executor that refuses every transaction is not an API. + let evm = block_executor_factory + .evm_factory() + .create_evm(&mut state, evm_env) + .with_inspector(inspector); + let mut executor = as BlockExecutorFactory>::create_executor( + &block_executor_factory, + evm, + block_ctx, + ); // Execute contract creation transaction - this triggers the CREATE that the inspector // intercepts Init code is just STOP (0x00) @@ -397,9 +423,18 @@ fn test_inspector_early_return_create_with_additional_limits() { let init_code = Bytes::from(vec![0x00]); let tx = create_deploy_transaction(0, 10_000_000, init_code); - // Before the fix, this would panic with "frame stack is empty" - let result = executor.execute_transaction(&tx); - assert!(result.is_ok(), "Transaction should succeed: {:?}", result.err()); + // Driven through the EVM for the same reason as above: an intercepting inspector has no place + // on the canonical block path, and getting as far as a completed outcome is what says the + // frame stacks stayed aligned. + let outcome = executor + .evm_mut() + .execute_transaction(tx.into_tx_env()) + .expect("the EVM supports the interception in full"); + assert_eq!( + outcome.inspector_ledger.interventions, 1, + "the interception must be measured: {:?}", + outcome.inspector_ledger, + ); // Verify the inspector intercepted the create operation assert_eq!( @@ -414,11 +449,4 @@ fn test_inspector_early_return_create_with_additional_limits() { 1, "create_end should be invoked for the intercepted create" ); - - // Finish the block - let block_result = executor.finish(); - assert!(block_result.is_ok(), "Block should finish successfully"); - - let (_, receipts) = block_result.unwrap(); - assert_eq!(receipts.receipts.len(), 1, "Should have 1 receipt"); } diff --git a/crates/mega-evm/tests/block_executor/inspector_guard.rs b/crates/mega-evm/tests/block_executor/inspector_guard.rs new file mode 100644 index 00000000..1abeddd7 --- /dev/null +++ b/crates/mega-evm/tests/block_executor/inspector_guard.rs @@ -0,0 +1,900 @@ +//! The canonical block-execution path admits an inspected transaction only on a declaration. +//! +//! `MegaETH` supports rewriting inspectors in full — the measurement shim books what they do and +//! the conservation law accounts for it — but supporting a rewrite is not the same as letting it +//! into a block. Block production and block validation have to produce the same numbers for the +//! same block on every node, and an inspector is one node's configuration: what it writes into a +//! gas counter reaches the receipt, the transaction's reported compute total, and through it the +//! block's cumulative counters. +//! +//! What it can also do is reach past every boundary the shim watches. Editing the contents of the +//! interpreter's stack or its memory, or writing the journal directly, changes what the +//! transaction produces and leaves every lane of the ledger at zero — so an empty ledger cannot be +//! what a block is admitted on. What can is a `TrustedObserver` declaration: a line written in +//! source, about one concrete type, by someone who had read it. +//! +//! So every entry on the canonical path — the two that run a transaction and the one funnel that +//! admits a result — refuses an inspector its type never declared, *before* running it. The ledger +//! is kept as the backstop behind that: it catches a declaration that did not hold, and a result +//! that reaches the commit funnel already carrying a rewrite from somewhere this executor cannot +//! see. Both refusals are errors rather than assertions, because they are boundaries held against +//! an embedder and have to hold in the binaries that build and validate blocks; the tests here +//! therefore pass identically in debug and release builds. +//! +//! The green half matters as much as the red: every inspector on this path today is a tracer, and +//! a tracer must keep working. That is what the declared-observer tests pin. + +use std::convert::Infallible; + +use alloy_evm::{ + block::{BlockExecutor, BlockExecutorFactory}, + EvmEnv, EvmFactory, +}; +use alloy_op_evm::block::receipt_builder::OpAlloyReceiptBuilder; +use alloy_primitives::{address, Address, Bytes, Signature, TxHash, TxKind, B256, U256}; +use mega_evm::{ + alloy_consensus::{transaction::Recovered, Signed, TxLegacy}, + alloy_evm::block::BlockExecutionError, + test_utils::{BytecodeBuilder, MemoryDatabase}, + BlockLimits, DeclaredObserver, InspectorLedger, Lane, MegaBlockExecutionCtx, + MegaBlockExecutorFactory, MegaEvmFactory, MegaHardforkConfig, MegaSpecId, + MegaTransactionNew as _, MegaTransactionOutcome, MegaTxEnvelope, TestExternalEnvs, +}; +use revm::{ + bytecode::opcode::{CALL, POP, STOP}, + context::{BlockEnv, Cfg, ContextTr}, + database::State, + inspector::NoOpInspector, + interpreter::{ + interpreter_types::MemoryTr, CallInputs, CallOutcome, InstructionResult, Interpreter, + InterpreterTypes, + }, + Inspector, +}; + +/// Sends every transaction in these tests. +const CALLER: Address = address!("2000000000000000000000000000000000000002"); +/// A callee with enough plain opcodes for an inspector to land an edit mid-run. +const CONTRACT: Address = address!("1000000000000000000000000000000000000001"); +/// A second callee, so a tracer has a nested frame to record. +const CALLEE: Address = address!("1000000000000000000000000000000000000002"); + +/// Gas the injecting inspector writes into the interpreter's counter. +const INJECTED: u64 = 7_000; + +/// Refund the refund-writing inspector records. +const REFUNDED: i64 = 3_000; + +/// Writes gas into the running interpreter's counter, once — the smallest rewrite that moves the +/// ledger's [`gas`](InspectorLedger::gas) lane. +#[derive(Default)] +struct GasInjector { + applied: bool, +} + +impl Inspector for GasInjector { + fn step(&mut self, interp: &mut Interpreter, _context: &mut CTX) { + if self.applied { + return; + } + self.applied = true; + interp.gas.erase_cost(INJECTED); + } +} + +/// Rewrites the classification of the fixture's inner call, once — the smallest rewrite that moves +/// no gas at all. +/// +/// Every one of the ledger's gas lanes stays at zero under this inspector: the call's remaining +/// gas, its envelope and every interpreter counter are exactly what the EVM left. What changes is +/// what the transaction did — the callee's storage write is rolled back and the caller reads a +/// failure — which is why the ledger cannot be a gas-only check. +#[derive(Default)] +struct CallFailer { + applied: bool, +} + +impl Inspector for CallFailer { + fn call_end(&mut self, _context: &mut CTX, inputs: &CallInputs, outcome: &mut CallOutcome) { + if self.applied || inputs.target_address != CALLEE { + return; + } + self.applied = true; + outcome.result.result = InstructionResult::Revert; + } +} + +/// Writes a refund into the running interpreter's counter, once — the rewrite that moves what the +/// sender pays without moving the envelope at all. +/// +/// Every gas lane stays at zero under this inspector, and so does the conservation law: the law is +/// stated over `total_gas_spent`, which is `limit - remaining`, and a refund enters neither term. +/// What moves is the receipt's `gas_used`, which is the number the sender is billed on — so a +/// gas-lane criterion would admit it and two nodes would disagree about a receipt. +#[derive(Default)] +struct RefundWriter { + applied: bool, +} + +impl Inspector for RefundWriter { + fn step(&mut self, interp: &mut Interpreter, _context: &mut CTX) { + if self.applied { + return; + } + self.applied = true; + interp.gas.record_refund(REFUNDED); + } +} + +/// Grows the frame's memory and the memo of how far it has been paid for, once — the rewrite that +/// reaches through no argument the shim is handed at all. +/// +/// Neither half is a rewrite on its own: moving the memo alone leaves the EVM reading out of +/// bounds, moving the memory alone leaves the growth charged for twice. Moving both leaves the +/// interpreter in a state it could have reached by paying, having paid nothing, and the next +/// expanding opcode inside the new bound is charged nothing at all. No gas moves at the moment the +/// edit is made, no frame input and no frame result exists, and the pending action is untouched — +/// so this is the shape the constant-time working-set reading exists for. +#[derive(Default)] +struct MemoryGrower { + applied: bool, +} + +impl Inspector for MemoryGrower { + fn step(&mut self, interp: &mut Interpreter, context: &mut CTX) { + if self.applied { + return; + } + let words = interp.memory.size() / 32 + 1; + if !interp.memory.resize(words * 32) { + return; + } + // Priced through revm's own table, so the memo is exactly what the EVM would have written + // had the frame paid for the growth. + let cost = context.cfg().gas_params().memory_cost(words); + interp.gas.memory_mut().set_words_num(words, cost); + self.applied = true; + } +} + +/// Counts callbacks and changes nothing — the shape every tracer in production has, with no +/// declaration about its type. +#[derive(Default)] +struct Observer { + steps: u64, +} + +impl Inspector for Observer { + fn step(&mut self, _interp: &mut Interpreter, _context: &mut CTX) { + self.steps += 1; + } +} + +fn envelope(nonce: u64) -> MegaTxEnvelope { + let tx = TxLegacy { + chain_id: Some(8453), + nonce, + gas_price: 1_000_000, + gas_limit: 1_000_000, + to: TxKind::Call(CONTRACT), + value: U256::ZERO, + input: Bytes::new(), + }; + MegaTxEnvelope::Legacy(Signed::new_unchecked(tx, Signature::test_signature(), B256::ZERO)) +} + +fn build_db() -> MemoryDatabase { + let mut code = BytecodeBuilder::default(); + for _ in 0..16 { + code = code.push_number(1u64).append(POP); + } + let code = code + .sstore(U256::from(1), U256::from(9)) + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(0u64) // argsSize + .push_number(0u64) // argsOffset + .push_number(0u64) // value + .push_address(CALLEE) + .push_number(50_000u64) // gas + .append(CALL) + .append(POP) + .append(STOP) + .build(); + let mut db = MemoryDatabase::default(); + db.set_account_code(CONTRACT, code); + db.set_account_code(CALLEE, BytecodeBuilder::default().stop().build()); + db.set_account_balance(CALLER, U256::from(1_000_000_000_000_000_000u64)); + db +} + +fn evm_env(spec: MegaSpecId) -> EvmEnv { + let mut cfg_env = revm::context::CfgEnv::default(); + cfg_env.set_spec_and_mainnet_gas_params(spec); + EvmEnv::new( + cfg_env, + BlockEnv { + number: U256::from(1000), + timestamp: U256::from(1_800_000_000), + gas_limit: 30_000_000, + ..Default::default() + }, + ) +} + +fn executor_factory( + spec: MegaSpecId, +) -> MegaBlockExecutorFactory< + MegaHardforkConfig, + MegaEvmFactory>, + OpAlloyReceiptBuilder, +> { + MegaBlockExecutorFactory::new( + MegaHardforkConfig::default().with_all_activated_through(spec), + MegaEvmFactory::new().with_external_env_factory(TestExternalEnvs::::new()), + OpAlloyReceiptBuilder::default(), + ) +} + +fn block_ctx() -> MegaBlockExecutionCtx { + MegaBlockExecutionCtx::new(B256::ZERO, None, Bytes::new(), BlockLimits::no_limits()) +} + +/// Unwraps `MegaETH`'s own error out of the `alloy_evm` boxing. +/// +/// Reached by downcast rather than by matching the message: the error crosses the `alloy_evm` +/// boundary as a boxed `dyn Error`, and a consumer that wants to react to it — a sequencer that +/// would rather drop the transaction than fail the block — has to get the typed value back. +#[track_caller] +fn expect_mega_error(err: &BlockExecutionError) -> &mega_evm::MegaBlockExecutionError { + let internal = err.as_internal().unwrap_or_else(|| { + panic!("the refusal must be an internal error, not a verdict on the transaction: {err:?}") + }); + let other = internal + .as_other() + .unwrap_or_else(|| panic!("the refusal must carry MegaETH's own error: {internal:?}")); + other + .downcast_ref::() + .unwrap_or_else(|| panic!("the refusal must survive the boxing as a typed value: {other}")) +} + +/// Asserts the refusal is the admission rule's, and that it names the transaction. +#[track_caller] +fn expect_undeclared(err: &BlockExecutionError, expected_hash: TxHash) { + match expect_mega_error(err) { + mega_evm::MegaBlockExecutionError::UndeclaredInspector { tx_hash } => { + assert_eq!(*tx_hash, expected_hash, "the refusal must name the transaction it refused"); + } + other => panic!("expected the undeclared-inspector refusal, got {other:?}"), + } +} + +/// Asserts the refusal is the ledger backstop's, and returns what it was refused over. +#[track_caller] +fn expect_adjusted(err: &BlockExecutionError, expected_hash: TxHash) -> InspectorLedger { + match expect_mega_error(err) { + mega_evm::MegaBlockExecutionError::InspectorAdjustedAccounting { tx_hash, ledger } => { + assert_eq!(*tx_hash, expected_hash, "the refusal must name the transaction it refused"); + assert!(!ledger.is_zero(), "a refusal over an empty ledger is a refusal of nothing"); + **ledger + } + other => panic!("expected the ledger backstop's refusal, got {other:?}"), + } +} + +/// Runs the fixture transaction on an EVM the test drives itself, with `inspector` attached. +/// +/// This is the path an embedder keeps: `MegaEvm` supports a rewriting inspector in full and +/// reports what it did. Every rewrite shape below is measured here and refused at the block +/// executor's entries, which is what says the boundary is where it is claimed to be. +fn run_off_path(inspector: I) -> MegaTransactionOutcome +where + I: for<'a> Inspector>, +{ + let mut db = build_db(); + let mut evm = mega_evm::MegaEvm::new( + mega_evm::MegaContext::new(&mut db, MegaSpecId::REX7) + .with_tx_runtime_limits(mega_evm::EvmTxRuntimeLimits::from_spec(MegaSpecId::REX7)), + ) + .with_inspector(inspector); + + let mut tx = mega_evm::MegaTransaction::new( + revm::context::tx::TxEnvBuilder::default() + .caller(CALLER) + .call(CONTRACT) + .gas_limit(1_000_000) + .build_fill(), + ); + tx.enveloped_tx = Some(Bytes::new()); + evm.execute_transaction(tx).expect("the EVM supports the rewrite in full") +} + +/// The producer entry: an inspector nobody declared read-only never runs on this path at all. +/// +/// The inspector here only observes, and is refused anyway. That is the whole change of criterion: +/// what a transaction is admitted on is what the inspector's type promises, not what this +/// particular run was measured to have done — because the measurement cannot see an edit made to +/// the interpreter's stack contents or straight into the journal. +#[test] +fn test_run_transaction_refuses_an_undeclared_inspector() { + let mut db = build_db(); + let mut state = State::builder().with_database(&mut db).build(); + let factory = executor_factory(MegaSpecId::REX7); + let evm = factory + .evm_factory() + .create_evm(&mut state, evm_env(MegaSpecId::REX7)) + .with_inspector(Observer::default()); + let mut executor = as BlockExecutorFactory>::create_executor( + &factory, + evm, + block_ctx(), + ); + + let tx = envelope(0); + let err = executor + .run_transaction(Recovered::new_unchecked(&tx, CALLER)) + .expect_err("the canonical path must refuse an undeclared inspector"); + + expect_undeclared(&err, *tx.hash()); + assert_eq!( + executor.evm().inspector.steps, + 0, + "the refusal must come before execution: an undeclared inspector does not get to run", + ); + assert_eq!( + executor.block_limiter.block_compute_gas_used, 0, + "a refused transaction must leave the block's counters where they were", + ); +} + +/// The other producer entry, reached through the `alloy_evm` trait rather than the inherent +/// method: the two resolve their transaction sizes differently and share no body, so a guard on +/// one says nothing about the other. +#[test] +fn test_execute_transaction_without_commit_refuses_it_too() { + let mut db = build_db(); + let mut state = State::builder().with_database(&mut db).build(); + let factory = executor_factory(MegaSpecId::REX7); + let evm = factory + .evm_factory() + .create_evm(&mut state, evm_env(MegaSpecId::REX7)) + .with_inspector(GasInjector::default()); + let mut executor = as BlockExecutorFactory>::create_executor( + &factory, + evm, + block_ctx(), + ); + + let tx = envelope(0); + let err = executor + .execute_transaction_without_commit(&Recovered::new_unchecked(&tx, CALLER)) + .expect_err("the trait entry must refuse it as well"); + + expect_undeclared(&err, *tx.hash()); + assert!(!executor.evm().inspector.applied, "and must refuse before the inspector runs"); + assert!(executor.receipts.is_empty(), "nothing may have been recorded"); +} + +/// The consumer entry: a result whose producer this executor never saw is refused at the commit +/// funnel, before it can touch anything. +/// +/// This is the entry that has to hold. Execution and commit are separate steps — the parallel +/// executor speculatively runs many transactions and commits the survivors one by one — so a +/// result arriving here may have been produced by a different executor instance, by an embedder +/// driving `MegaEvm` itself, or built by hand. What the outcome carries is the only thing the +/// funnel can read. +#[test] +fn test_commit_refuses_a_result_produced_under_an_undeclared_inspector() { + let mut db = build_db(); + let mut state = State::builder().with_database(&mut db).build(); + let mut executor = executor_factory(MegaSpecId::REX7).create_executor( + &mut state, + block_ctx(), + evm_env(MegaSpecId::REX7), + ); + + let tx = envelope(0); + let mut outcome = executor + .run_transaction(Recovered::new_unchecked(&tx, CALLER)) + .expect("fixture check: an uninspected run must be admitted"); + assert!( + !outcome.inner.undeclared_inspector, + "fixture check: an uninspected run carries no inspector to declare", + ); + + // The shape a result produced elsewhere arrives in: the numbers are execution's, and the + // outcome says an inspector nobody declared took part in producing them. + outcome.inner.undeclared_inspector = true; + + let err = + executor.commit_transaction_outcome(outcome).expect_err("the commit funnel must refuse it"); + + expect_undeclared(&err, *tx.hash()); + assert!(executor.receipts.is_empty(), "no receipt may have been pushed"); + assert_eq!( + executor.block_limiter.block_gas_used, 0, + "and no limiter counter may have been advanced", + ); + assert!( + executor.take_pending_commit_error().is_none(), + "the fallible entry reports rather than latches, so the executor stays usable", + ); +} + +/// The backstop: a result that says nothing about its inspector, and carries a ledger that does. +/// +/// The declaration covers the executor's own producers; it cannot cover a result built by hand or +/// produced by a version of the pipeline that did not fill the field in. The ledger is what is +/// left, and it is read at the same funnel. +#[test] +fn test_commit_refuses_a_result_an_inspector_took_part_in() { + let mut db = build_db(); + let mut state = State::builder().with_database(&mut db).build(); + let mut executor = executor_factory(MegaSpecId::REX7).create_executor( + &mut state, + block_ctx(), + evm_env(MegaSpecId::REX7), + ); + + let tx = envelope(0); + let mut outcome = executor + .run_transaction(Recovered::new_unchecked(&tx, CALLER)) + .expect("fixture check: an uninspected run must be admitted"); + assert!( + outcome.inner.inspector_ledger.is_zero(), + "fixture check: an uninspected run reports an empty ledger", + ); + + outcome.inner.inspector_ledger = InspectorLedger { gas: Lane::once(1), ..Default::default() }; + + let err = + executor.commit_transaction_outcome(outcome).expect_err("the commit funnel must refuse it"); + + assert_eq!(expect_adjusted(&err, *tx.hash()).gas, Lane::once(1)); + assert!(executor.receipts.is_empty(), "no receipt may have been pushed"); + assert_eq!( + executor.block_limiter.block_gas_used, 0, + "and no limiter counter may have been advanced", + ); +} + +/// The infallible commit hook has no way to report the refusal, so it latches it and the block +/// fails at `finish` — the same contract it already holds for a late block-limit rejection. +#[test] +fn test_the_infallible_commit_hook_latches_the_refusal() { + let mut db = build_db(); + let mut state = State::builder().with_database(&mut db).build(); + let mut executor = executor_factory(MegaSpecId::REX7).create_executor( + &mut state, + block_ctx(), + evm_env(MegaSpecId::REX7), + ); + + let tx = envelope(0); + let outcome = executor + .run_transaction(Recovered::new_unchecked(&tx, CALLER)) + .expect("fixture check: an uninspected run must be admitted"); + let mut result = mega_evm::MegaBlockTxResult { + tx_type: tx.tx_type(), + tx_hash: *tx.hash(), + gas_limit: 1_000_000, + tx_size: outcome.tx_size, + da_size: outcome.da_size, + depositor: outcome.depositor, + inner: outcome.inner, + }; + result.inner.inspector_ledger = InspectorLedger { env: Lane::once(-5), ..Default::default() }; + + let gas = executor.commit_transaction(result); + assert_eq!(gas.tx_gas_used(), 0, "a transaction that contributed nothing must report zero gas",); + let latched = executor + .pending_commit_error() + .expect("the refusal must be latched where `finish` will find it"); + assert_eq!(expect_adjusted(latched, *tx.hash()).env, Lane::once(-5)); + + let err = executor.finish().expect_err("the block must not finish over a latched refusal"); + expect_adjusted(&err, *tx.hash()); +} + +/// The refusal governs the configuration a block is built with, which no historical block covers, +/// so it is not gated on a spec — the same inspector is refused on a frozen one. +#[test] +fn test_the_refusal_is_not_spec_gated() { + for spec in [MegaSpecId::MINI_REX, MegaSpecId::REX4, MegaSpecId::REX6, MegaSpecId::REX7] { + let mut db = build_db(); + let mut state = State::builder().with_database(&mut db).build(); + let factory = executor_factory(spec); + let evm = factory + .evm_factory() + .create_evm(&mut state, evm_env(spec)) + .with_inspector(GasInjector::default()); + let mut executor = + as BlockExecutorFactory>::create_executor( + &factory, + evm, + block_ctx(), + ); + + let tx = envelope(0); + let err = executor + .run_transaction(Recovered::new_unchecked(&tx, CALLER)) + .err() + .unwrap_or_else(|| panic!("{spec:?}: the inspector must be refused on every spec")); + expect_undeclared(&err, *tx.hash()); + } +} + +/// The green half: a declared observer is left alone, and the block it helps build is bit-identical +/// to the one built without it. +/// +/// The inspector is the same [`Observer`] the refusal test uses, wrapped in `DeclaredObserver` and +/// so declared — the two runs do exactly the same thing and only one of them is admitted, which is +/// what says the criterion really is the declaration and not the behaviour of the run. +#[test] +fn test_a_declared_observer_still_builds_a_block() { + let build = |observe: bool| { + let mut db = build_db(); + let mut state = State::builder().with_database(&mut db).build(); + let tx = envelope(0); + let factory = executor_factory(MegaSpecId::REX7); + let (gas_used, steps) = if observe { + let mut executor = factory.create_executor_with_trusted_inspector( + &mut state, + block_ctx(), + evm_env(MegaSpecId::REX7), + DeclaredObserver(Observer::default()), + ); + let outcome = executor + .run_transaction(Recovered::new_unchecked(&tx, CALLER)) + .expect("a declared observer must not be refused"); + assert!(!outcome.inner.undeclared_inspector, "and must report itself declared"); + assert!(outcome.inner.inspector_ledger.is_zero(), "and must leave an empty ledger"); + let gas = executor.commit_transaction_outcome(outcome).expect("nor at commit"); + let steps = executor.evm().inspector.0.steps; + let (_, result) = executor.finish().expect("the block must finish"); + assert_eq!(result.receipts.len(), 1, "the observed block still has its receipt"); + (gas, steps) + } else { + let mut executor = + factory.create_executor(&mut state, block_ctx(), evm_env(MegaSpecId::REX7)); + let outcome = executor + .run_transaction(Recovered::new_unchecked(&tx, CALLER)) + .expect("the reference run must be admitted"); + let gas = executor.commit_transaction_outcome(outcome).expect("and committed"); + let (_, result) = executor.finish().expect("the block must finish"); + assert_eq!(result.receipts.len(), 1); + (gas, 0) + }; + (gas_used, steps) + }; + + let (observed_gas, steps) = build(true); + let (plain_gas, _) = build(false); + assert!(steps > 0, "the fixture must actually have observed something"); + assert_eq!(observed_gas, plain_gas, "observation must not move a single unit of gas"); +} + +/// The inspector that observes nothing at all is declared, so the trivial configuration passes. +/// +/// `NoOpInspector` is the one inspector this crate can declare for itself, and it is the shape a +/// caller reaches for when a code path needs an inspector-typed EVM without wanting one. +#[test] +fn test_the_no_op_inspector_is_admitted() { + let mut db = build_db(); + let mut state = State::builder().with_database(&mut db).build(); + let mut executor = executor_factory(MegaSpecId::REX7).create_executor_with_trusted_inspector( + &mut state, + block_ctx(), + evm_env(MegaSpecId::REX7), + NoOpInspector, + ); + + let tx = envelope(0); + let outcome = executor + .run_transaction(Recovered::new_unchecked(&tx, CALLER)) + .expect("a declared inspector must not be refused"); + assert!(outcome.result.is_success(), "fixture check: {:?}", outcome.result); + executor.commit_transaction_outcome(outcome).expect("nor at commit"); + let (_, result) = executor.finish().expect("the block must finish"); + assert_eq!(result.receipts.len(), 1); +} + +/// The real tracer that `mega-evme replay` attaches to this exact path is admitted through its +/// declaration, and the block it observes is the one built without it. +/// +/// The observer above is a fixture; this is the production shape, wrapper and all. +/// `TracingInspector` receives every callback the shim measures — including the ones handed a live +/// interpreter and the ones handed a frame's inputs — so if observation could move a lane by +/// accident, it would move one here. Run rather than reasoned about: the refusal's blast radius is +/// only acceptable if the inspectors that exist today have a way through it. +#[test] +fn test_the_production_tracer_is_admitted() { + use revm_inspectors::tracing::{TracingInspector, TracingInspectorConfig}; + + let mut db = build_db(); + let mut state = State::builder().with_database(&mut db).build(); + let mut executor = executor_factory(MegaSpecId::REX7).create_executor_with_trusted_inspector( + &mut state, + block_ctx(), + evm_env(MegaSpecId::REX7), + DeclaredObserver(TracingInspector::new(TracingInspectorConfig::all())), + ); + + let tx = envelope(0); + let outcome = executor + .run_transaction(Recovered::new_unchecked(&tx, CALLER)) + .expect("the tracer every replay uses must not be refused"); + + assert!(outcome.result.is_success(), "fixture check: {:?}", outcome.result); + assert!( + outcome.inner.inspector_ledger.is_zero(), + "a tracer must leave every lane untouched; got {:?}", + outcome.inner.inspector_ledger, + ); + executor.commit_transaction_outcome(outcome).expect("nor at commit"); + + assert!( + executor.evm().inspector.0.traces().nodes().len() >= 2, + "fixture check: the tracer must have recorded the nested frame it was given", + ); + let (_, result) = executor.finish().expect("the block must finish"); + assert_eq!(result.receipts.len(), 1); +} + +/// The bare `TracingInspector`, without the wrapper, is refused — which is what makes the wrapper +/// load-bearing rather than decorative. +#[test] +fn test_the_production_tracer_without_its_declaration_is_refused() { + use revm_inspectors::tracing::{TracingInspector, TracingInspectorConfig}; + + let mut db = build_db(); + let mut state = State::builder().with_database(&mut db).build(); + let factory = executor_factory(MegaSpecId::REX7); + let evm = factory + .evm_factory() + .create_evm(&mut state, evm_env(MegaSpecId::REX7)) + .with_inspector(TracingInspector::new(TracingInspectorConfig::all())); + let mut executor = as BlockExecutorFactory>::create_executor( + &factory, + evm, + block_ctx(), + ); + + let tx = envelope(0); + let err = executor + .run_transaction(Recovered::new_unchecked(&tx, CALLER)) + .expect_err("an undeclared tracer is an undeclared inspector"); + expect_undeclared(&err, *tx.hash()); +} + +/// A pre- or post-block system call never runs the inspector, so it is not an entry the guard has +/// to cover. +/// +/// Two independent reasons, and this pins the one that is not visible from the block executor's +/// own signatures. Structurally, a system call produces a `ResultAndState` rather than a +/// `MegaTransactionOutcome`, and the ledger is reset at the start of every transaction, so nothing +/// a system call booked could reach a transaction's outcome anyway. Underneath that, the system +/// call path takes revm's plain frame loop rather than the inspecting one — which is what this +/// runs to find out, rather than reading it off upstream's source. +#[test] +fn test_a_system_call_does_not_run_the_inspector() { + use revm::SystemCallEvm as _; + + let mut db = build_db(); + let mut state = State::builder().with_database(&mut db).build(); + let factory = executor_factory(MegaSpecId::REX7); + let evm = factory + .evm_factory() + .create_evm(&mut state, evm_env(MegaSpecId::REX7)) + .with_inspector(GasInjector::default()); + let mut executor = as BlockExecutorFactory>::create_executor( + &factory, + evm, + block_ctx(), + ); + + let result = executor + .evm_mut() + .system_call(CONTRACT, Bytes::new()) + .expect("the system call must not surface an EVMError"); + + assert!(result.result.is_success(), "fixture check: the callee must have run, got {result:?}",); + assert!(!executor.evm().inspector.applied, "a system call must not reach the inspector at all",); +} + +/// An EVM driven off the canonical path is not covered by the refusal, however much its inspector +/// rewrites — and every rewrite it makes is still measured and reported. +/// +/// The boundary sits on the block executor's entries, not on `MegaEvm`, and this is what makes +/// that a property rather than an accident of the current call graph. It is what leaves a +/// simulation EVM — the oracle set-slot preflight the node runs before it publishes a value, and +/// anything else an embedder drives itself — free to attach a rewriting inspector: such a run +/// never produces a block, so there is nothing for two nodes to disagree about. +/// +/// Each shape below is one the ledger can see, and the four together are why the ledger is worth +/// keeping as a backstop even though admission no longer rests on it. +#[test] +fn test_an_off_path_evm_runs_a_gas_injection_to_completion() { + let outcome = run_off_path(GasInjector::default()); + assert_eq!( + outcome.inspector_ledger.gas, + Lane::once(i128::from(INJECTED)), + "the injection must be measured: {:?}", + outcome.inspector_ledger, + ); + assert!(outcome.undeclared_inspector, "and the outcome must carry what a block would refuse"); + assert!(outcome.result_and_state.result.is_success(), "and the transaction still completes"); +} + +/// A refund rewrite: every gas lane stays at zero, and the number the sender is billed moves. +#[test] +fn test_an_off_path_evm_runs_a_refund_rewrite_to_completion() { + let outcome = run_off_path(RefundWriter::default()); + assert_eq!( + outcome.inspector_ledger.refund, + Lane::once(i128::from(REFUNDED)), + "the refund must be measured: {:?}", + outcome.inspector_ledger, + ); + assert_eq!( + outcome.inspector_ledger.conjured_gas(), + 0, + "no gas moved: a gas-only criterion would not have seen this at all", + ); + assert!(outcome.undeclared_inspector); +} + +/// A classification rewrite: nothing moves, and the transaction's state is different. +#[test] +fn test_an_off_path_evm_runs_a_classification_rewrite_to_completion() { + let outcome = run_off_path(CallFailer::default()); + assert_eq!( + ( + outcome.inspector_ledger.gas, + outcome.inspector_ledger.env, + outcome.inspector_ledger.result + ), + (Lane::default(), Lane::default(), Lane::default()), + "the point of this shape is that no gas lane moves; got {:?}", + outcome.inspector_ledger, + ); + assert_eq!(outcome.inspector_ledger.interventions, 1, "the rewrite must still be booked"); + assert!(outcome.undeclared_inspector); +} + +/// A frame grown for free: the rewrite that reaches through nothing the shim is handed. +#[test] +fn test_an_off_path_evm_runs_a_free_memory_growth_to_completion() { + let outcome = run_off_path(MemoryGrower::default()); + assert_eq!( + ( + outcome.inspector_ledger.gas, + outcome.inspector_ledger.env, + outcome.inspector_ledger.result, + outcome.inspector_ledger.refund, + ), + (Lane::default(), Lane::default(), Lane::default(), Lane::default()), + "no gas lane can see this shape; got {:?}", + outcome.inspector_ledger, + ); + assert_eq!(outcome.inspector_ledger.interventions, 1, "the growth must still be booked"); + assert!(outcome.undeclared_inspector); +} + +/// The default shim is `NoOpInspector`'s own declaration, not an undeclared wrapper around it. +/// +/// `Evm::set_inspector_enabled` is a public trait method, so an EVM built with no inspector at all +/// can have its shim switched on without any constructor being reached. Everything that runs then +/// is `NoOpInspector`, which this crate declares — so the block path must admit the transaction. +/// Building the shim undeclared would refuse an EVM for observing nothing. +#[test] +fn test_an_evm_with_no_inspector_is_admitted_after_its_shim_is_switched_on() { + let mut db = build_db(); + let mut state = State::builder().with_database(&mut db).build(); + let mut executor = executor_factory(MegaSpecId::REX7).create_executor( + &mut state, + block_ctx(), + evm_env(MegaSpecId::REX7), + ); + + alloy_evm::Evm::enable_inspector(executor.evm_mut()); + assert!( + !executor.evm().has_undeclared_inspector(), + "the inspector an uninspected EVM carries is the declared one", + ); + + let tx = envelope(0); + let outcome = executor + .run_transaction(Recovered::new_unchecked(&tx, CALLER)) + .expect("an EVM observing nothing must not be refused"); + assert!(outcome.result.is_success(), "fixture check: {:?}", outcome.result); + assert!(!outcome.inner.undeclared_inspector, "and must report itself declared"); + executor.commit_transaction_outcome(outcome).expect("nor at commit"); + let (_, result) = executor.finish().expect("the block must finish"); + assert_eq!(result.receipts.len(), 1); +} + +/// Swapping an inspector in through `InspectEvm::set_inspector` drops the declaration, whatever +/// the type being swapped in. +/// +/// The constructor whose bound is `TrustedObserver` is the only route to the declared shim, and +/// `set_inspector`'s bound is plain `Inspector` — so it builds a measured one even for a type that +/// carries a declaration. That is the safe direction and it is pinned here, because it is what +/// keeps the default shim's declaration from spreading to whatever replaces it. +#[test] +fn test_swapping_an_inspector_in_drops_the_declaration() { + let mut db = build_db(); + let mut evm = mega_evm::MegaEvm::new( + mega_evm::MegaContext::new(&mut db, MegaSpecId::REX7) + .with_tx_runtime_limits(mega_evm::EvmTxRuntimeLimits::from_spec(MegaSpecId::REX7)), + ); + assert!(evm.has_trusted_inspector(), "the default shim carries `NoOpInspector`'s declaration"); + + revm::InspectEvm::set_inspector(&mut evm, NoOpInspector); + assert!( + !evm.has_trusted_inspector(), + "a swapped-in inspector is measured: the declaration belongs to the constructor, not the \ + type being handed over", + ); +} + +/// The deprecated `inspect_transaction` runs the inspecting loop whatever the runtime flag says, +/// so what it reports about the inspector cannot be read off that flag. +/// +/// `Evm::set_inspector_enabled(false)` turns off the flag `execute_transaction` picks its loop on +/// and leaves the inspector where it is. Driven through this entry the inspector still runs, so an +/// outcome saying no inspector took part would let the commit funnel admit a transaction one did. +#[test] +fn test_inspect_transaction_reports_an_inspector_the_runtime_flag_hides() { + let mut db = build_db(); + let mut state = State::builder().with_database(&mut db).build(); + let mut executor = executor_factory(MegaSpecId::REX7).create_executor( + &mut state, + block_ctx(), + evm_env(MegaSpecId::REX7), + ); + + let mut inspected_db = build_db(); + let mut evm = mega_evm::MegaEvm::new( + mega_evm::MegaContext::new(&mut inspected_db, MegaSpecId::REX7) + .with_tx_runtime_limits(mega_evm::EvmTxRuntimeLimits::from_spec(MegaSpecId::REX7)), + ) + .with_inspector(GasInjector::default()); + alloy_evm::Evm::set_inspector_enabled(&mut evm, false); + assert!( + !evm.has_undeclared_inspector(), + "fixture check: with the flag off, the entry that honours it reports no inspector", + ); + + let mut tx_env = mega_evm::MegaTransaction::new( + revm::context::tx::TxEnvBuilder::default() + .caller(CALLER) + .call(CONTRACT) + .gas_limit(1_000_000) + .build_fill(), + ); + tx_env.enveloped_tx = Some(Bytes::new()); + #[expect(deprecated, reason = "the entry under test is the deprecated one")] + let outcome = evm.inspect_transaction(tx_env).expect("the EVM supports the rewrite in full"); + + assert!(evm.inspector.applied, "fixture check: the inspector really did run"); + assert!( + outcome.undeclared_inspector, + "an entry that always inspects must report the inspector it always runs", + ); + + let tx = envelope(0); + let err = executor + .commit_tx_result(mega_evm::MegaBlockTxResult { + tx_type: tx.tx_type(), + tx_hash: *tx.hash(), + gas_limit: 1_000_000, + tx_size: 0, + da_size: 0, + depositor: None, + inner: outcome, + }) + .expect_err("and the commit funnel must refuse it"); + expect_undeclared(&err, *tx.hash()); + assert!(executor.receipts.is_empty(), "no receipt may have been pushed"); +} diff --git a/crates/mega-evm/tests/block_executor/main.rs b/crates/mega-evm/tests/block_executor/main.rs index 663e9301..401423f4 100644 --- a/crates/mega-evm/tests/block_executor/main.rs +++ b/crates/mega-evm/tests/block_executor/main.rs @@ -2,7 +2,10 @@ mod accessed_block_hashes; mod block_limits; +mod compute_gas_lanes; +mod declared_observer; mod deposit_da_exemption; mod inspector; +mod inspector_guard; mod sequencer_registry; mod trait_factory_runtime_limits; diff --git a/crates/mega-evm/tests/block_executor/trait_factory_runtime_limits.rs b/crates/mega-evm/tests/block_executor/trait_factory_runtime_limits.rs index ff775c36..f92b9878 100644 --- a/crates/mega-evm/tests/block_executor/trait_factory_runtime_limits.rs +++ b/crates/mega-evm/tests/block_executor/trait_factory_runtime_limits.rs @@ -3,8 +3,8 @@ //! //! The factory exposes two paths that produce a `MegaBlockExecutor`: //! -//! 1. The inherent `create_executor` / `create_executor_with_inspector` methods, which build the -//! EVM internally and apply `block_ctx.block_limits.to_evm_tx_runtime_limits()` before +//! 1. The inherent `create_executor` / `create_executor_with_trusted_inspector` methods, which +//! build the EVM internally and apply `block_ctx.block_limits.to_evm_tx_runtime_limits()` before //! constructing the executor. //! //! 2. The trait method `, PUSH0, MSTORE8, PUSH1 32, PUSH0, RETURN` — returns 32 bytes of runtime - /// code whose first byte is `first`. - fn initcode(first: u8) -> [u8; 8] { - [0x60, first, 0x5f, 0x53, 0x60, 0x20, 0x5f, 0xf3] + /// `PUSH1 0, PUSH0, MSTORE8, PUSH1 , PUSH0, RETURN` — returns `len` bytes of runtime + /// code. + fn initcode(len: u8) -> [u8; 8] { + [0x60, 0x00, 0x5f, 0x53, 0x60, len, 0x5f, 0xf3] } - fn creator(first: u8) -> MemoryDatabase { - let code = initcode(first); + fn creator(len: u8) -> MemoryDatabase { + let code = initcode(len); base_db( BytecodeBuilder::default() .mstore(0, code) @@ -856,10 +891,10 @@ fn test_code_deposit_recorded_only_when_deposit_occurs() { if !spec.is_enabled(MegaSpecId::MINI_REX) { continue; // Equivalence records no compute gas at all. } - let deposited = transact(spec, creator(0x00)); - let skipped = transact(spec, creator(0xef)); - // Both transactions succeed: the EIP-3541 rejection fails the CREATE (it pushes zero), - // not the transaction. A non-success outcome means the fixture itself drifted. + let deposited = transact(spec, creator(32)); + let skipped = transact(spec, creator(0)); + // Both transactions succeed: an empty deploy leaves the CREATE successful (it pushes the + // created address). A non-success outcome means the fixture itself drifted. assert_eq!(deposited.outcome, "success", "{spec_name}: depositing run should succeed"); assert_eq!(skipped.outcome, "success", "{spec_name}: skipped-deposit run should succeed"); let (deposited, skipped) = (deposited.compute_gas, skipped.compute_gas); @@ -872,7 +907,7 @@ fn test_code_deposit_recorded_only_when_deposit_occurs() { }); assert_eq!( delta, EXPECTED_DEPOSIT_GAS, - "{spec_name}: the only compute gas separating a deposit from an EIP-3541 rejection \ + "{spec_name}: the only compute gas separating a 32-byte deposit from an empty one \ must be the code-deposit charge (deposited={deposited} skipped={skipped})" ); } diff --git a/crates/mega-evm/tests/compute_gas/main.rs b/crates/mega-evm/tests/compute_gas/main.rs index 4cae4622..87e7845b 100644 --- a/crates/mega-evm/tests/compute_gas/main.rs +++ b/crates/mega-evm/tests/compute_gas/main.rs @@ -227,6 +227,25 @@ fn transact_with_limits( (result.result, usage) } +/// [`transact_with_limits`] through [`MegaEvm::execute_transaction`], so a claim can read the +/// executed / destroyed split. +pub(crate) fn transact_with_limits_outcome( + spec: MegaSpecId, + mut db: MemoryDatabase, + to: Address, + limits: EvmTxRuntimeLimits, +) -> mega_evm::MegaTransactionOutcome { + let mut context = MegaContext::new(&mut db, spec).with_tx_runtime_limits(limits); + context.modify_chain(|chain| { + chain.operator_fee_scalar = Some(U256::from(0)); + chain.operator_fee_constant = Some(U256::from(0)); + }); + let tx = TxEnvBuilder::default().caller(CALLER).call(to).gas_limit(100_000_000).build_fill(); + let mut tx = MegaTransaction(op_revm::OpTransaction::new(tx)); + tx.enveloped_tx = Some(Bytes::new()); + MegaEvm::new(context).execute_transaction(tx).expect("tx should not surface EVMError") +} + /// Runs the same transaction as [`transact`] and returns the transaction's output bytes. /// Used by the claim tests that read a value the contract computed (e.g. forwarded gas). fn transact_output(spec: MegaSpecId, mut db: MemoryDatabase) -> Bytes { @@ -849,33 +868,59 @@ fn test_compute_gas_snapshot_matches() { } } -/// Rex7 is the unstable spec and carries no behavior of its own yet: it delegates its instruction -/// table, runtime limits, and precompile set to Rex6 unchanged. +/// Rex7's checkpoint settlement is a precision-preserving change: every program that stays inside +/// its resource limits records the same compute gas, spends the same EVM gas, and ends the same +/// way as it does under Rex6. /// /// The snapshot alone does not pin this. Its rows differ by the spec-name column, so a Rex7 row /// that drifted from its Rex6 counterpart would still render as a well-formed snapshot and could be /// blessed by a regeneration. Comparing the readings directly makes the first accidental Rex7 -/// divergence a failure. When Rex7 gains its first deliberate behavior change, this test is -/// expected to fail and should be narrowed to the corpus entries that behavior does not reach. +/// divergence a failure. +/// +/// The sanctioned divergences are the exceptional-halt carve-out — a frame that halts +/// exceptionally returns none of its remaining budget, and Rex7 settles that burned remainder as +/// compute gas where per-opcode recording attributes nothing to it — and the same split applied +/// at the precompile recording site, which a precompile halt never reaches through frame-exit +/// settlement. Both move compute gas upward only; the receipt and the outcome still have to match +/// exactly. `tests/rex7/exceptional_halt.rs` and `tests/rex7/precompile_halt.rs` pin the settled +/// amounts themselves. #[test] fn test_rex7_matches_rex6_on_every_program() { for program in corpus() { let rex6 = transact(MegaSpecId::REX6, (program.build_db)()); let rex7 = transact(MegaSpecId::REX7, (program.build_db)()); assert_eq!( - (rex7.compute_gas, rex7.gas_used, &rex7.outcome), - (rex6.compute_gas, rex6.gas_used, &rex6.outcome), - "{}: Rex7 must be behaviorally identical to Rex6 \ - (Rex6: compute_gas={} gas_used={} outcome={}; \ - Rex7: compute_gas={} gas_used={} outcome={})", + (rex7.gas_used, &rex7.outcome), + (rex6.gas_used, &rex6.outcome), + "{}: Rex7 must spend the same gas and end the same way as Rex6 \ + (Rex6: gas_used={} outcome={}; Rex7: gas_used={} outcome={})", program.name, - rex6.compute_gas, rex6.gas_used, rex6.outcome, - rex7.compute_gas, rex7.gas_used, rex7.outcome, ); + // A top-level halt, or a precompile halt the caller absorbs, can raise the reported + // total without changing the receipt. The KZG invalid-input program is the corpus + // case that takes the latter path: the outer transaction succeeds, so the halt + // prefix alone would miss it. + if rex7.outcome.starts_with("halt ") || program.name == "precompile_kzg_invalid_input" { + assert!( + rex7.compute_gas >= rex6.compute_gas, + "{}: the exceptional-halt carve-out only ever moves compute gas up \ + (Rex6={} Rex7={})", + program.name, + rex6.compute_gas, + rex7.compute_gas, + ); + continue; + } + assert_eq!( + rex7.compute_gas, rex6.compute_gas, + "{}: checkpoint settlement must telescope to the per-opcode sum \ + (Rex6={} Rex7={})", + program.name, rex6.compute_gas, rex7.compute_gas, + ); } } diff --git a/crates/mega-evm/tests/compute_gas/snapshot.txt b/crates/mega-evm/tests/compute_gas/snapshot.txt index d6e976fb..ec62f2f8 100644 --- a/crates/mega-evm/tests/compute_gas/snapshot.txt +++ b/crates/mega-evm/tests/compute_gas/snapshot.txt @@ -274,7 +274,7 @@ create2_oversized_initcode Rex3 21012 100000000 halt Ba create2_oversized_initcode Rex4 21012 100000000 halt Base(Base(CreateInitCodeSizeLimit)) create2_oversized_initcode Rex5 763907 100000000 halt Base(Base(CreateInitCodeSizeLimit)) create2_oversized_initcode Rex6 21012 100000000 halt Base(Base(CreateInitCodeSizeLimit)) -create2_oversized_initcode Rex7 21012 100000000 halt Base(Base(CreateInitCodeSizeLimit)) +create2_oversized_initcode Rex7 99961000 100000000 halt Base(Base(CreateInitCodeSizeLimit)) selfdestruct_to_empty Equivalence 0 53603 success selfdestruct_to_empty MiniRex 21003 100000000 halt Base(Base(InvalidFEOpcode)) @@ -340,7 +340,7 @@ precompile_kzg_invalid_input Rex3 23633 262633 success precompile_kzg_invalid_input Rex4 23633 262633 success precompile_kzg_invalid_input Rex5 123633 262633 success precompile_kzg_invalid_input Rex6 123633 262633 success -precompile_kzg_invalid_input Rex7 123633 262633 success +precompile_kzg_invalid_input Rex7 223633 262633 success precompile_underfunded Equivalence 0 21140 success precompile_underfunded MiniRex 23639 23640 success diff --git a/crates/mega-evm/tests/equivalence/pre_mini_rex_gates.rs b/crates/mega-evm/tests/equivalence/pre_mini_rex_gates.rs index 7da5f005..27d38ae2 100644 --- a/crates/mega-evm/tests/equivalence/pre_mini_rex_gates.rs +++ b/crates/mega-evm/tests/equivalence/pre_mini_rex_gates.rs @@ -1,6 +1,6 @@ //! Boundary coverage for the `EQUIVALENCE` side of the `MINI_REX` gates in the execution face. //! -//! Two properties are pinned here: +//! Three properties are pinned here: //! //! 1. `MegaHandler::before_run` promotes a transaction sent by the runtime system address into the //! OP deposit-style path — bypassing fee accounting — and rejects one whose callee is not @@ -9,20 +9,26 @@ //! 2. The whole `AdditionalLimit` subsystem is dormant before `MINI_REX`: no reset, no intrinsic //! accounting, and revm's stock instruction table, so every metered dimension stays at zero //! however much state a transaction touches. +//! 3. That dormancy reaches the frame settlement's inspector lanes too. An edit an inspector makes +//! to a frame result's gas is booked at `AdditionalLimit::finalize_frame`, which does not run +//! before `MINI_REX`, so under `EQUIVALENCE` the edit reaches the receipt with the ledger's +//! result lane — and therefore the block guard — reading it as untouched. What the measurement +//! shim books at its own callback boundaries is unaffected, because the shim is not spec-gated. use alloy_primitives::{address, Address, Bytes, U256}; use mega_evm::{ test_utils::{BytecodeBuilder, MemoryDatabase}, EmptyExternalEnv, MegaContext, MegaEvm, MegaSpecId, MegaTransaction, MegaTransactionNew as _, - MEGA_SYSTEM_ADDRESS, ORACLE_CONTRACT_ADDRESS, + MegaTransactionOutcome, MEGA_SYSTEM_ADDRESS, ORACLE_CONTRACT_ADDRESS, }; use revm::{ bytecode::opcode::*, context::{BlockEnv, TxEnv}, handler::EvmTr, inspector::NoOpInspector, + interpreter::{CallInputs, CallOutcome, Interpreter}, primitives::TxKind, - Database as _, + Database as _, Inspector, }; /// A callee that is deliberately absent from `MEGA_SYSTEM_TX_WHITELIST`. @@ -171,3 +177,183 @@ fn test_equivalence_leaves_additional_limit_dormant() { assert_eq!(usage.kv_updates, 0, "pre-MINI_REX must not meter KV updates"); assert_eq!(usage.state_growth, 0, "pre-MINI_REX must not meter state growth"); } + +// --- the frame settlement's inspector lanes --------------------------------------------------- + +/// Sender of the two-frame transaction the inspector tests below rewrite. +const CHEAT_CALLER: Address = address!("0000000000000000000000000000000000200000"); +/// Outer contract: makes one inner call and stops. +const CHEAT_OUTER: Address = address!("0000000000000000000000000000000000200001"); +/// Inner contract: the frame whose result the inspector rewrites. +const CHEAT_INNER: Address = address!("0000000000000000000000000000000000200002"); + +/// How much gas each inspector below writes back into the EVM. +const CHEAT_AMOUNT: u64 = 1_000; + +/// Rewrites the gas of the inner frame's result at `call_end` — the last callback that can touch a +/// frame result, and the one whose edit `AdditionalLimit::finalize_frame` books. +#[derive(Default)] +struct ResultGasCheat { + rewrites: u32, +} + +impl Inspector> + for ResultGasCheat +{ + fn call_end( + &mut self, + _context: &mut MegaContext, + inputs: &CallInputs, + outcome: &mut CallOutcome, + ) { + if inputs.target_address == CHEAT_INNER { + self.rewrites += 1; + outcome.result.gas.erase_cost(CHEAT_AMOUNT); + } + } +} + +/// Writes the same amount into the interpreter's own gas counter instead, at the first `step_end`. +/// +/// The measurement shim books this one at its own callback boundary, with no help from the frame +/// settlement, which is what makes it the control for [`ResultGasCheat`]. +#[derive(Default)] +struct CounterGasCheat { + done: bool, +} + +impl Inspector> + for CounterGasCheat +{ + fn step_end( + &mut self, + interp: &mut Interpreter, + _context: &mut MegaContext, + ) { + if !self.done { + self.done = true; + interp.gas.erase_cost(CHEAT_AMOUNT); + } + } +} + +/// A database whose outer contract calls the inner one, so the transaction has a frame to settle +/// that is not the transaction's own. +fn cheat_db() -> MemoryDatabase { + let inner = BytecodeBuilder::default().stop().build(); + let outer = BytecodeBuilder::default() + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(0u64) // argsSize + .push_number(0u64) // argsOffset + .push_number(0u64) // value + .push_address(CHEAT_INNER) + .push_number(100_000u64) + .append(CALL) + .append(POP) + .append(STOP) + .build(); + MemoryDatabase::default() + .account_balance(CHEAT_CALLER, U256::from(INITIAL_BALANCE)) + .account_code(CHEAT_OUTER, outer) + .account_code(CHEAT_INNER, inner) +} + +fn cheat_tx() -> MegaTransaction { + let mut tx = MegaTransaction::new(TxEnv { + caller: CHEAT_CALLER, + kind: TxKind::Call(CHEAT_OUTER), + gas_limit: GAS_LIMIT, + gas_price: 0, + ..Default::default() + }); + tx.enveloped_tx = Some(Bytes::new()); + tx +} + +/// Runs [`cheat_tx`] over [`cheat_db`] under `spec`, optionally with an inspector attached. +fn transact_cheat(spec: MegaSpecId, inspector: Option<&mut I>) -> MegaTransactionOutcome +where + I: for<'a> Inspector>, +{ + let mut db = cheat_db(); + let mut context = MegaContext::new(&mut db, spec).with_block(BlockEnv { + beneficiary: BENEFICIARY, + number: U256::from(10), + basefee: 0, + ..Default::default() + }); + context.modify_chain(|chain| { + chain.operator_fee_scalar = Some(U256::ZERO); + chain.operator_fee_constant = Some(U256::ZERO); + }); + let outcome = match inspector { + Some(inspector) => { + MegaEvm::new(context).with_inspector(inspector).execute_transaction(cheat_tx()) + } + None => MegaEvm::new(context).execute_transaction(cheat_tx()), + }; + outcome.expect("the cheat fixture must produce a receipt") +} + +/// `AdditionalLimit::finalize_frame` is where an edit an inspector makes to a frame result's gas is +/// booked onto the ledger's result lane, and that settlement point starts at `MINI_REX` along with +/// the rest of the subsystem. Under `EQUIVALENCE` the rewrite still reaches the receipt — the +/// settlement is not what hands gas back to a caller — but nothing books it, so the lane the block +/// guard reads stays empty. +/// +/// The `MINI_REX` half of the same fixture is the contrast that gives the assertion its meaning: +/// one spec later, the identical rewrite over the identical bytecode books its full amount. +#[test] +fn test_equivalence_does_not_book_a_frame_result_gas_rewrite() { + for (spec, expected_lane) in + [(MegaSpecId::EQUIVALENCE, 0i128), (MegaSpecId::MINI_REX, CHEAT_AMOUNT as i128)] + { + let plain = transact_cheat::(spec, None); + let mut cheat = ResultGasCheat::default(); + let cheated = transact_cheat(spec, Some(&mut cheat)); + + assert_eq!( + cheat.rewrites, 1, + "{spec:?}: the fixture must reach the inner frame's call_end" + ); + assert_eq!( + cheated.result_and_state.result.tx_gas_used() + CHEAT_AMOUNT, + plain.result_and_state.result.tx_gas_used(), + "{spec:?}: the rewrite must reach the receipt on both sides of the gate", + ); + + let lane = cheated.inspector_ledger.result; + assert_eq!(lane.net(), expected_lane, "{spec:?}: result lane net"); + assert_eq!(lane.gross(), expected_lane.unsigned_abs(), "{spec:?}: result lane gross"); + assert_eq!( + cheated.inspector_ledger.is_zero(), + expected_lane == 0, + "{spec:?}: the block guard reads the ledger through is_zero, got {:?}", + cheated.inspector_ledger, + ); + } +} + +/// The other half of the same statement: it is the frame settlement that is dormant before +/// `MINI_REX`, not the measurement shim. An edit written into the interpreter's own gas counter is +/// booked at the callback boundary that measured it, so it reads the same on both specs. +#[test] +fn test_equivalence_still_books_an_interpreter_gas_rewrite() { + for spec in [MegaSpecId::EQUIVALENCE, MegaSpecId::MINI_REX] { + let mut cheat = CounterGasCheat::default(); + let cheated = transact_cheat(spec, Some(&mut cheat)); + + assert!(cheat.done, "{spec:?}: the fixture must reach step_end at least once"); + assert_eq!( + cheated.inspector_ledger.gas.net(), + CHEAT_AMOUNT as i128, + "{spec:?}: an interpreter-counter edit is booked on every spec", + ); + assert!( + !cheated.inspector_ledger.is_zero(), + "{spec:?}: the block guard must see it, got {:?}", + cheated.inspector_ledger, + ); + } +} diff --git a/crates/mega-evm/tests/mutation/block.rs b/crates/mega-evm/tests/mutation/block.rs index 727e4881..d66eb34c 100644 --- a/crates/mega-evm/tests/mutation/block.rs +++ b/crates/mega-evm/tests/mutation/block.rs @@ -425,10 +425,10 @@ fn test_pre_execution_check_block_da_size_boundary() { fn test_post_execution_update_raw_da_gated_by_deposit_flag() { let mut limiter = BlockLimiter::new(BlockLimits::no_limits()); - limiter.post_execution_update_raw(0, 0, 1_234, 0, 0, 0, 0, false); + limiter.post_execution_update_raw(0, 0, 1_234, 0, 0, 0, 0, 0, false); assert_eq!(limiter.block_da_size_used, 1_234, "a non-deposit call must accumulate da_size"); - limiter.post_execution_update_raw(0, 0, 5_000, 0, 0, 0, 0, true); + limiter.post_execution_update_raw(0, 0, 5_000, 0, 0, 0, 0, 0, true); assert_eq!( limiter.block_da_size_used, 1_234, "a deposit call must leave the da counter untouched" @@ -457,6 +457,7 @@ fn test_is_block_limit_reached_all_below_is_false() { limiter.block_data_used = 9; limiter.block_kv_updates_used = 9; limiter.block_compute_gas_used = 9; + limiter.block_compute_gas_enforced = 9; limiter.block_state_growth_used = 9; assert!( @@ -487,6 +488,7 @@ macro_rules! only_dimension_at_limit { limiter.block_data_used = 0; limiter.block_kv_updates_used = 0; limiter.block_compute_gas_used = 0; + limiter.block_compute_gas_enforced = 0; limiter.block_state_growth_used = 0; // ...except the one under test, which sits exactly at its (5) limit. limiter.$used_field = 5; @@ -524,10 +526,21 @@ fn test_is_block_limit_reached_kv_updates_dimension() { assert!(limiter.is_block_limit_reached(), "kv updates at limit ⇒ block full"); } +/// Compute gas is the one dimension whose clause reads a counter other than the `*_used` one: +/// admission is evaluated against the enforced counter, so the reported total sitting at the +/// limit must leave the block open. #[test] fn test_is_block_limit_reached_compute_gas_dimension() { - let limiter = only_dimension_at_limit!(block_compute_gas_limit, block_compute_gas_used); - assert!(limiter.is_block_limit_reached(), "compute gas at limit ⇒ block full"); + let limiter = only_dimension_at_limit!(block_compute_gas_limit, block_compute_gas_enforced); + assert!(limiter.is_block_limit_reached(), "enforced compute gas at limit ⇒ block full"); + + let mut reported_only = + only_dimension_at_limit!(block_compute_gas_limit, block_compute_gas_used); + reported_only.block_compute_gas_enforced = 0; + assert!( + !reported_only.is_block_limit_reached(), + "a reported total at the limit with nothing enforced must leave the block open" + ); } #[test] diff --git a/crates/mega-evm/tests/rex7/burn_split.rs b/crates/mega-evm/tests/rex7/burn_split.rs new file mode 100644 index 00000000..1550cbaa --- /dev/null +++ b/crates/mega-evm/tests/rex7/burn_split.rs @@ -0,0 +1,658 @@ +//! REX7 splits an exceptionally halted frame into the work it performed and the budget it +//! destroyed. +//! +//! The two halves are accounted differently, and both halves have to be right: +//! +//! - **Executed** — everything the frame ran before it failed. It settles through the ordinary +//! enforcing path, so it shrinks the parent frame's budget, the transaction's compute budget, the +//! reading `MegaLimitControl.remainingComputeGas` returns, and the base a detention cap is built +//! on. A parent frame keeps executing after it absorbs a failed child; if the child's work left +//! enforcement, the code that follows could spend the same headroom a second time. +//! - **Destroyed** — the budget the frame never gets to spend, and never hands back. It is reported +//! and block-accounted but never enforced: halting on it would turn an ordinary EVM halt into a +//! resource-limit failure with the gas rescued, which is the receipt change the exceptional-halt +//! carve-out forbids. +//! +//! Two boundaries decide what belongs to which half, and both are exercised here: +//! +//! - the **storage gas** a checkpoint body charged before aborting is neither — it is storage gas, +//! and the body never reached the recording that would have subtracted it; +//! - the classification is only final **after action processing**, because revm's create-return can +//! still turn a successful constructor into a code-deposit out-of-gas, an EIP-3541 reject or a +//! runtime code-size reject. +//! +//! [`exceptional_halt`](crate::exceptional_halt) covers the reported totals; this module covers +//! which side of the enforcing boundary each part lands on. + +use crate::common::{ + base_db, drive, keyless_tx_bytes, plain_filler, transact, transact_default, transact_tx, + transact_with_bucket_capacity, transact_with_gas_limit, zero_operator_fee, Outcome, CALLEE, + CALLER, CONTRACT, DEFAULT_TX_GAS_LIMIT, ONE_ETH, +}; +use alloy_primitives::{address, Address, Bytes, U256}; +use alloy_sol_types::SolCall as _; +use mega_evm::{ + alloy_op_evm::OpTxError, + constants::mini_rex::{CODEDEPOSIT_STORAGE_GAS, LOG_DATA_STORAGE_GAS, MAX_CONTRACT_SIZE}, + test_utils::{BytecodeBuilder, MemoryDatabase}, + EVMError, EvmTxRuntimeLimits, IKeylessDeploy, IMegaLimitControl, MegaContext, MegaEvm, + MegaHaltReason, MegaSpecId, MegaTransaction, MegaTransactionNew as _, KEYLESS_DEPLOY_ADDRESS, + LIMIT_CONTROL_ADDRESS, +}; +use revm::{ + bytecode::opcode::{ + ADD, CALL, CREATE, LOG0, MLOAD, MSTORE8, POP, RETURN, SSTORE, STATICCALL, STOP, TIMESTAMP, + }, + context::{result::ResultAndState, tx::TxEnvBuilder, CfgEnv}, + handler::EvmTr, + inspector::NoOpInspector, +}; +use std::{convert::Infallible, vec::Vec}; + +/// Relayer that sends the keyless-deploy transactions. +const KEYLESS_RELAYER: Address = address!("0000000000000000000000000000000000340004"); + +/// Storage slot the caller writes its `remainingComputeGas` readings to. +const BEFORE_SLOT: u64 = 0xb0; +/// Second `remainingComputeGas` reading slot. +const AFTER_SLOT: u64 = 0xb1; + +/// Plain-opcode pairs the failing child runs before it underflows. Chosen large enough that the +/// work it performs dominates every other term in these fixtures. +const CHILD_PAIRS: usize = 1_000; +/// Compute gas one `PUSH1 1; POP` pair costs: `PUSH1` is 3, `POP` is 2. +const PAIR_GAS: u64 = 5; + +/// A callee that performs [`CHILD_PAIRS`] pairs of real work and then ends its frame with a stack +/// underflow — an exceptional halt that is not a gas shortage, so the interpreter keeps its +/// counter and nothing about the failure is a resource-limit exceed. +fn working_then_underflowing_callee() -> Bytes { + plain_filler(BytecodeBuilder::default(), CHILD_PAIRS).append(ADD).append(STOP).build() +} + +/// A CALL into [`CALLEE`] forwarding `gas`, with the success flag popped so the caller survives +/// whatever the callee did. +fn call_callee(builder: BytecodeBuilder, gas: u64) -> BytecodeBuilder { + builder + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(0u64) // argsSize + .push_number(0u64) // argsOffset + .push_number(0u64) // value + .push_address(CALLEE) + .push_number(gas) + .append(CALL) + .append(POP) +} + +/// A STATICCALL into `MegaLimitControl.remainingComputeGas()` whose returned word is written to +/// `slot`. The selector is written to memory first; the reading comes back into offset 0 as well. +fn store_remaining_compute_gas(builder: BytecodeBuilder, slot: u64) -> BytecodeBuilder { + builder + .mstore(0, IMegaLimitControl::remainingComputeGasCall::SELECTOR) + .push_number(32u64) // retSize + .push_number(0u64) // retOffset + .push_number(4u64) // argsSize + .push_number(0u64) // argsOffset + .push_address(LIMIT_CONTROL_ADDRESS) + .push_number(1_000_000u64) + .append(STATICCALL) + .append(POP) + .push_number(0u64) + .append(MLOAD) + .push_u256(U256::from(slot)) + .append(SSTORE) +} + +/// The caller shape every blocker-A case shares: work, an exceptional child, then the same amount +/// of work again. Whether the transaction survives the second half is what the child's executed +/// work decides. +fn work_call_work(child_gas: u64, tail_pairs: usize) -> Bytes { + let builder = plain_filler(BytecodeBuilder::default(), 10); + let builder = call_callee(builder, child_gas); + plain_filler(builder, tail_pairs).append(STOP).build() +} + +fn caller_db(caller_code: Bytes) -> MemoryDatabase { + base_db(caller_code).account_code(CALLEE, working_then_underflowing_callee()) +} + +/// The work an exceptionally halted child performed still binds the transaction's compute limit. +/// +/// This is the shape a fail-open shows up in: the child runs [`CHILD_PAIRS`] pairs of plain +/// opcodes and then underflows, the caller absorbs the failure and runs the same amount of work +/// again. Per-opcode accounting charges the child's work as it happens, so REX6's total is the +/// calibration point — set the limit one below it and the transaction must not finish. REX7 has to +/// stop as well: the child's executed work is real work, whatever the frame did afterwards. +#[test] +fn test_executed_work_of_an_exceptional_child_still_binds_the_tx_limit() { + let code = work_call_work(1_000_000, CHILD_PAIRS); + + // Calibrate against REX6 running the same program with nothing in its way. + let unconstrained = transact_default(MegaSpecId::REX6, caller_db(code.clone())); + assert!( + unconstrained.is_success(), + "the calibration run must succeed: {:?}", + unconstrained.result + ); + let limit = unconstrained.compute_gas - 1; + assert!( + limit > u64::try_from(CHILD_PAIRS).unwrap() * PAIR_GAS, + "the fixture must do more work than the child alone, or the limit proves nothing", + ); + + let limits = |spec| EvmTxRuntimeLimits::from_spec(spec).with_tx_compute_gas_limit(limit); + let r6 = transact(MegaSpecId::REX6, caller_db(code.clone()), limits(MegaSpecId::REX6)); + let r7 = transact(MegaSpecId::REX7, caller_db(code), limits(MegaSpecId::REX7)); + + assert!(!r6.is_success(), "REX6 must stop at the limit: {:?}", r6.result); + assert!( + !r7.is_success(), + "REX7 must stop at the same limit — the child's executed work is enforced even though its \ + frame ended in an exceptional halt; got {:?} with compute={}", + r7.result, + r7.compute_gas, + ); +} + +/// The same shape from the caller's own point of view: `MegaLimitControl.remainingComputeGas()` +/// reports the minimum of the caller's per-frame budget and the transaction-level remaining, so +/// one reading pins both. The drop across the failing child must cover the work the child did. +#[test] +fn test_exceptional_child_shrinks_the_callers_remaining_compute_budget() { + let builder = store_remaining_compute_gas(BytecodeBuilder::default(), BEFORE_SLOT); + let builder = call_callee(builder, 1_000_000); + let code = store_remaining_compute_gas(builder, AFTER_SLOT).append(STOP).build(); + + let child_work = u64::try_from(CHILD_PAIRS).unwrap() * PAIR_GAS; + let mut drops = Vec::new(); + for spec in [MegaSpecId::REX6, MegaSpecId::REX7] { + let outcome = transact_default(spec, caller_db(code.clone())); + assert!( + outcome.is_success(), + "{spec:?}: the caller must survive its child: {:?}", + outcome.result, + ); + let before: u64 = outcome + .storage_value(CONTRACT, U256::from(BEFORE_SLOT)) + .try_into() + .expect("a compute-gas reading fits in u64"); + let after: u64 = outcome + .storage_value(CONTRACT, U256::from(AFTER_SLOT)) + .try_into() + .expect("a compute-gas reading fits in u64"); + assert!(before > after, "{spec:?}: the reading must fall across the child"); + let drop = before - after; + assert!( + drop >= child_work, + "{spec:?}: the caller's remaining budget must fall by at least the child's executed \ + work; drop={drop} child work={child_work}", + ); + drops.push(drop); + } + // Both models see the same child work; the only slack is which opcodes each attributes to the + // failing frame, so the two readings must agree to within one opcode's static gas. + let (r6, r7) = (drops[0], drops[1]); + assert!( + r7.abs_diff(r6) <= 32, + "the two models must charge the caller the same for a failed child; REX6={r6} REX7={r7}", + ); +} + +/// A detention cap is built relative to the usage already enforced at the access point +/// (`usage + cap`), so a fail-open on an exceptional child does not just widen the compute limit — +/// it widens every cap installed afterwards. Reading the post-transaction detained limit back is a +/// direct check on the base the cap was built from. +#[test] +fn test_detention_cap_after_an_exceptional_child_counts_its_executed_work() { + let builder = plain_filler(BytecodeBuilder::default(), 10); + let builder = call_callee(builder, 1_000_000); + let code = builder.append(TIMESTAMP).append(POP).append(STOP).build(); + + let limits = |spec| { + let mut limits = EvmTxRuntimeLimits::from_spec(spec); + limits.block_env_access_compute_gas_limit = 1_000_000; + limits + }; + let r6 = transact(MegaSpecId::REX6, caller_db(code.clone()), limits(MegaSpecId::REX6)); + let r7 = transact(MegaSpecId::REX7, caller_db(code), limits(MegaSpecId::REX7)); + + assert!(r6.is_success(), "REX6 must succeed: {:?}", r6.result); + assert!(r7.is_success(), "REX7 must succeed: {:?}", r7.result); + assert!( + r7.detained_compute_gas_limit.abs_diff(r6.detained_compute_gas_limit) <= 32, + "the cap must be built on the same enforced usage under both models; REX6={} REX7={}", + r6.detained_compute_gas_limit, + r7.detained_compute_gas_limit, + ); +} + +/// The transaction-wide identity the storage-exclusion cases assert: every EVM gas a transaction +/// spends is either compute gas or `MegaETH` storage gas, so +/// `compute_gas == gas_used − storage gas`. Measuring the transaction-intrinsic part from a bare +/// `STOP` keeps it exact rather than pinned to a constant. +fn intrinsic_storage_gas(spec: MegaSpecId) -> u64 { + let outcome = transact_with_gas_limit( + spec, + base_db(BytecodeBuilder::default().append(STOP).build()), + EvmTxRuntimeLimits::from_spec(spec), + 1_000_000, + ); + outcome.gas_used - outcome.compute_gas +} + +/// A checkpoint body charges its storage gas before running the raw opcode, and subtracts it back +/// out when it records its own compute window. A body that halts in between never reaches that +/// subtraction — so the charge has to leave the open segment as it is made, or the frame-exit +/// settlement reports storage gas as compute gas. +/// +/// `LOG0` in a static frame is the shape that isolates it: the storage surcharge is a flat +/// per-byte rate that is already paid when revm rejects the state change. +#[test] +fn test_aborted_log_checkpoint_does_not_report_its_storage_charge_as_compute() { + const LOG_BYTES: u64 = 32; + let callee = BytecodeBuilder::default() + .push_number(LOG_BYTES) // len + .push_number(0u64) // offset + .append(LOG0) + .append(STOP) + .build(); + let code = BytecodeBuilder::default() + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(0u64) // argsSize + .push_number(0u64) // argsOffset + .push_address(CALLEE) + .push_number(77_777u64) + .append(STATICCALL) + .append(POP) + .append(STOP) + .build(); + + let db = || base_db(code.clone()).account_code(CALLEE, callee.clone()); + let r7 = transact_default(MegaSpecId::REX7, db()); + assert!( + r7.is_success(), + "the caller must survive the static-context rejection: {:?}", + r7.result, + ); + + let log_storage_gas = LOG_DATA_STORAGE_GAS * LOG_BYTES; + assert_eq!( + r7.compute_gas, + r7.gas_used - intrinsic_storage_gas(MegaSpecId::REX7) - log_storage_gas, + "the LOG storage surcharge is storage gas on the halting path too; compute={} gas_used={}", + r7.compute_gas, + r7.gas_used, + ); +} + +/// The same exclusion for the other storage-charging checkpoint family that can abort after +/// charging: `SSTORE`. Its surcharge is SALT-scaled, so it is only non-zero above the minimum +/// bucket size — the elevated capacity is what makes this case exist at all. +/// +/// The surcharge is measured from a control run that performs the same write outside a static +/// frame, so the assertion states the amount rather than assuming a constant: the aborted body +/// pays exactly that much storage gas, and none of it may reach the compute total. +#[test] +fn test_aborted_sstore_checkpoint_does_not_report_its_storage_charge_as_compute() { + /// Twice the minimum bucket size, so the SALT multiplier makes the `SSTORE` set charge + /// non-zero. + const BUCKET_CAPACITY: u64 = 2 * mega_evm::MIN_BUCKET_SIZE as u64; + + let callee = BytecodeBuilder::default().sstore(U256::from(9), U256::from(0x77)).build(); + let caller = |call_opcode: u8| { + let mut builder = BytecodeBuilder::default() + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(0u64) // argsSize + .push_number(0u64); // argsOffset + if call_opcode == CALL { + builder = builder.push_number(0u64); // value + } + builder + .push_address(CALLEE) + .push_number(10_000_000u64) + .append(call_opcode) + .append(POP) + .append(STOP) + .build() + }; + + let run = |call_opcode| { + transact_with_bucket_capacity( + MegaSpecId::REX7, + base_db(caller(call_opcode)).account_code(CALLEE, callee.clone()), + EvmTxRuntimeLimits::from_spec(MegaSpecId::REX7), + BUCKET_CAPACITY, + ) + }; + let intrinsic_storage = intrinsic_storage_gas(MegaSpecId::REX7); + + // Control: the same write in a frame that is allowed to make it. Its non-intrinsic storage gas + // is the surcharge the aborted run below also pays, before revm rejects the state change. + let committed = run(CALL); + assert!(committed.is_success(), "the control write must succeed: {:?}", committed.result); + let surcharge = committed.gas_used - committed.compute_gas - intrinsic_storage; + assert!( + surcharge > 0, + "the elevated bucket capacity must make the SSTORE set charge non-zero; gas_used={} \ + compute={}", + committed.gas_used, + committed.compute_gas, + ); + + let aborted = run(STATICCALL); + assert!( + aborted.is_success(), + "the caller must survive the static-context rejection: {:?}", + aborted.result, + ); + assert_eq!( + aborted.compute_gas, + aborted.gas_used - intrinsic_storage - surcharge, + "the SSTORE surcharge stays storage gas when the body it paid for never runs; compute={} \ + gas_used={} surcharge={surcharge}", + aborted.compute_gas, + aborted.gas_used, + ); +} + +/// Init code returning `len` bytes of runtime code whose first byte is `first`. +fn deploying_initcode(first: u8, len: u64) -> Bytes { + BytecodeBuilder::default() + .push_number(u64::from(first)) + .push_number(0u64) + .append(MSTORE8) + .push_number(len) + .push_number(0u64) + .append(RETURN) + .build() +} + +/// A contract whose body CREATEs `initcode` with all the gas it has, then stops. +fn creator_code(initcode: &Bytes) -> Bytes { + BytecodeBuilder::default() + .mstore(0, initcode.as_ref()) + .push_number(initcode.len() as u64) // length + .push_number(0u64) // offset + .push_number(0u64) // value + .append(CREATE) + .append(POP) + .append(STOP) + .build() +} + +/// Runs a REX7 transaction into [`CONTRACT`] with an explicit gas limit and, optionally, a lowered +/// `limit_contract_code_size`. +/// +/// The shared helpers in [`crate::common`] all take the context's default configuration, which +/// pins the contract-size limit to `MegaETH`'s 512 KiB. Reaching revm's size reject needs a +/// smaller one, so this builds the context itself. +fn transact_create_reject( + mut db: MemoryDatabase, + gas_limit: u64, + code_size_limit: Option, +) -> Outcome { + let mut cfg = CfgEnv::default(); + cfg.spec = MegaSpecId::REX7; + cfg.limit_contract_code_size = code_size_limit.or(Some(MAX_CONTRACT_SIZE)); + let context = zero_operator_fee( + MegaContext::new(&mut db, MegaSpecId::REX7) + .with_cfg(cfg) + .with_tx_runtime_limits(EvmTxRuntimeLimits::from_spec(MegaSpecId::REX7)), + ); + let tx = + TxEnvBuilder::default().caller(CALLER).call(CONTRACT).gas_limit(gas_limit).build_fill(); + let mut tx = MegaTransaction::new(tx); + tx.enveloped_tx = Some(Bytes::new()); + let mut evm = MegaEvm::new(context); + drive(MegaSpecId::REX7, &mut evm, tx) +} + +/// Runtime length the CREATE cases deploy — small enough that the per-byte code-deposit storage +/// charge stays affordable at the gas limits below. +const RUNTIME_LEN: u64 = 100; + +/// revm's per-byte code-deposit gas (`revm::interpreter::gas::CODEDEPOSIT`). +const CANONICAL_CODE_DEPOSIT_GAS: u64 = 200; + +/// The transaction-wide gas identity for the CREATE fixtures: the receipt's EVM gas is compute gas +/// plus `MegaETH` storage gas, and the only storage gas beyond the transaction intrinsic is the +/// per-byte code-deposit charge the execution layer takes before revm's create-return runs. +/// +/// It holds whether or not the deposit is ultimately rejected — which is the point. A reject +/// destroys the CREATE frame's whole remainder, and that destroyed budget is EVM gas the receipt +/// charges, so it has to appear in the compute total like any other exceptionally halted frame's. +fn assert_create_gas_identity(label: &str, outcome: &Outcome, intrinsic_storage: u64) { + let code_deposit_storage = CODEDEPOSIT_STORAGE_GAS * RUNTIME_LEN; + assert!( + outcome.is_success(), + "{label}: the creator must survive the CREATE: {:?}", + outcome.result, + ); + assert!( + outcome.gas_used > intrinsic_storage + code_deposit_storage, + "{label}: the fixture must reach the create-return, not run out paying the code-deposit \ + storage charge; gas_used={}", + outcome.gas_used, + ); + assert_eq!( + outcome.compute_gas, + outcome.gas_used - intrinsic_storage - code_deposit_storage, + "{label}: every EVM gas the transaction spent must be compute gas or storage gas; \ + compute={} gas_used={} code-deposit storage={code_deposit_storage}", + outcome.compute_gas, + outcome.gas_used, + ); +} + +/// revm's create-return rejects a successful constructor's runtime code **after** action +/// processing. EIP-3541 and the code-size limit are the two rejects that need no gas pressure at +/// all: the constructor returned normally, the frame's result was `Return` when the frame-exit +/// settlement ran, and only the create-return turned it into a halt that destroys the frame's +/// whole remainder. +/// +/// The code-size case runs against a lowered `limit_contract_code_size`. `MegaETH`'s own per-byte +/// code-deposit storage charge is 10,000 gas, so a runtime code long enough to pass the 512 KiB +/// consensus limit would need billions of gas to reach the reject and would run out paying that +/// charge first — reaching revm's size check at all needs a configured limit, not a longer +/// contract. +#[test] +fn test_create_rejected_after_action_processing_settles_its_destroyed_remainder() { + let intrinsic_storage = intrinsic_storage_gas(MegaSpecId::REX7); + let deployed = transact_create_reject( + base_db(creator_code(&deploying_initcode(0x00, RUNTIME_LEN))), + DEFAULT_TX_GAS_LIMIT, + None, + ); + assert_create_gas_identity("successful deposit", &deployed, intrinsic_storage); + + for (label, first, code_size_limit) in [ + // Runtime code starting with 0xEF: EIP-3541 rejects the deposit. + ("EIP-3541", 0xefu8, None), + // Runtime code past a configured contract-size limit. + ("code size", 0x00, Some(RUNTIME_LEN as usize - 1)), + ] { + let rejected = transact_create_reject( + base_db(creator_code(&deploying_initcode(first, RUNTIME_LEN))), + DEFAULT_TX_GAS_LIMIT, + code_size_limit, + ); + assert_create_gas_identity(label, &rejected, intrinsic_storage); + assert!( + rejected.gas_used > deployed.gas_used, + "{label}: the reject must destroy the CREATE frame's remainder, so it costs strictly \ + more than the deposit it replaced; rejected={} deployed={}", + rejected.gas_used, + deployed.gas_used, + ); + } +} + +/// The third post-action reject is the canonical code-deposit charge itself running out of gas — +/// the one that exists only inside a narrow gas window: too little gas and the frame fails earlier, +/// paying `MegaETH`'s own per-byte code-deposit storage charge; too much and the deposit goes +/// through. +/// +/// Sweeping across that window covers it without pinning the boundary. What every point has to +/// satisfy is that no EVM gas goes missing: the receipt's gas is compute gas plus storage gas, and +/// the only storage gas a run can carry beyond the transaction intrinsic is the per-byte +/// code-deposit charge — all of it, or none of it, depending on whether the frame could afford it. +/// A destroyed CREATE remainder that never reached the compute total would show up here as a third +/// value. +#[test] +fn test_create_code_deposit_out_of_gas_settles_its_destroyed_remainder() { + let intrinsic_storage = intrinsic_storage_gas(MegaSpecId::REX7); + let code_deposit_storage = CODEDEPOSIT_STORAGE_GAS * RUNTIME_LEN; + let initcode = deploying_initcode(0x00, RUNTIME_LEN); + let run = |gas_limit| transact_create_reject(base_db(creator_code(&initcode)), gas_limit, None); + + let deployed = run(DEFAULT_TX_GAS_LIMIT); + assert_create_gas_identity("successful deposit", &deployed, intrinsic_storage); + + // A successful deposit costs a fixed amount, and the frame that pays it keeps back the 2% the + // creator retained — so the window where the canonical charge alone is unaffordable sits just + // above that fixed cost, sized by the charge itself. + let canonical_deposit = CANONICAL_CODE_DEPOSIT_GAS * RUNTIME_LEN; + let mut code_deposit_oog_points = 0; + for step in 0..=canonical_deposit / 1_000 { + let gas_limit = deployed.gas_used + step * 1_000; + let outcome = run(gas_limit); + assert!( + outcome.is_success(), + "gas_limit={gas_limit}: the creator must survive the CREATE: {:?}", + outcome.result, + ); + let storage = outcome + .gas_used + .checked_sub(outcome.compute_gas) + .and_then(|total| total.checked_sub(intrinsic_storage)) + .unwrap_or_else(|| { + panic!( + "gas_limit={gas_limit}: compute gas exceeds the receipt's non-intrinsic gas; \ + compute={} gas_used={}", + outcome.compute_gas, outcome.gas_used, + ) + }); + assert!( + storage == 0 || storage == code_deposit_storage, + "gas_limit={gas_limit}: the only storage gas past the intrinsic is the per-byte \ + code-deposit charge, taken in full or not at all — anything else is EVM gas missing \ + from the compute total; storage={storage} compute={} gas_used={}", + outcome.compute_gas, + outcome.gas_used, + ); + // The charge was affordable but the deposit still did not happen: revm's create-return + // rejected it, after action processing, for the canonical code-deposit gas. + if storage == code_deposit_storage && outcome.gas_used != deployed.gas_used { + code_deposit_oog_points += 1; + } + } + assert!( + code_deposit_oog_points > 0, + "the sweep must contain at least one canonical code-deposit out-of-gas; a successful \ + deposit costs the same {} gas at every limit above the window", + deployed.gas_used, + ); +} + +/// Runs the blocker-A shape through `inspect_frame_run` instead of `frame_run`. +/// +/// The two loops are hand-maintained copies of the same body, and the split is settled across both +/// of their hooks — the executed tail before action processing, the destroyed remainder after. A +/// drop of either on the inspected copy alone would silently re-open the fail-open for any node +/// running with a tracer attached. +fn transact_inspected(mut db: MemoryDatabase, limits: EvmTxRuntimeLimits, inspected: bool) -> u64 { + let mut context = MegaContext::new(&mut db, MegaSpecId::REX7).with_tx_runtime_limits(limits); + context.modify_chain(|chain| { + chain.operator_fee_scalar = Some(U256::from(0)); + chain.operator_fee_constant = Some(U256::from(0)); + }); + let tx = TxEnvBuilder::default() + .caller(CALLER) + .call(CONTRACT) + .gas_limit(DEFAULT_TX_GAS_LIMIT) + .build_fill(); + let mut tx = MegaTransaction::new(tx); + tx.enveloped_tx = Some(Bytes::new()); + // Both arms must produce the same `MegaEvm` type, so build the inspected one by toggling the + // inspector flag rather than by changing the inspector type. + let mut evm = MegaEvm::new(context).with_inspector(NoOpInspector); + if !inspected { + alloy_evm::Evm::set_inspector_enabled(&mut evm, false); + } + let result: Result, EVMError> = + alloy_evm::Evm::transact_raw(&mut evm, tx); + result.expect("tx should not surface EVMError"); + let usage = EvmTr::ctx_ref(&evm).additional_limit.borrow().get_usage(); + usage.compute_gas +} + +/// The inspected execution loop must split an exceptional frame exactly like the plain one. +#[test] +fn test_the_split_is_the_same_under_an_inspector() { + let db = || caller_db(work_call_work(1_000_000, CHILD_PAIRS)); + let limits = EvmTxRuntimeLimits::from_spec(MegaSpecId::REX7); + assert_eq!( + transact_inspected(db(), limits, false), + transact_inspected(db(), limits, true), + "an attached inspector must not move the executed / destroyed split", + ); +} + +/// The `KeylessDeploy` sandbox runs a whole nested transaction with its own tracker and merges the +/// usage back, so the executed / destroyed split has to survive that boundary. A sandbox whose +/// constructor halts exceptionally reports its destroyed remainder like any other frame; if the +/// merge dropped the classification, the parent would enforce it — and a constructor's ordinary EVM +/// halt would rewrite the outer transaction into a compute-limit exceed with the gas rescued. +/// +/// The parent's compute limit is set well below the sandbox's gas override so the destroyed +/// remainder alone would be enough to trip it. +#[test] +fn test_sandbox_destroyed_remainder_stays_non_enforcing_across_the_merge() { + // `ADD` on an empty stack: the constructor halts immediately, so almost the whole sandbox + // envelope is destroyed rather than performed. + let init_code = BytecodeBuilder::default().append(ADD).append(STOP).build(); + let call_data = IKeylessDeploy::keylessDeployCall { + keylessDeploymentTransaction: keyless_tx_bytes(init_code, 200_000), + gasLimitOverride: U256::from(1_000_000u64), + } + .abi_encode(); + + let build_tx = || { + TxEnvBuilder::default() + .caller(KEYLESS_RELAYER) + .call(KEYLESS_DEPLOY_ADDRESS) + .gas_limit(30_000_000) + .chain_id(Some(1)) + .data(Bytes::from(call_data.clone())) + .build_fill() + }; + let limits = |spec| EvmTxRuntimeLimits::from_spec(spec).with_tx_compute_gas_limit(300_000); + let run = |spec| { + let db = + MemoryDatabase::default().account_balance(KEYLESS_RELAYER, U256::from(10 * ONE_ETH)); + transact_tx(spec, db, limits(spec), build_tx(), &crate::common::default_envs()) + }; + + let r6 = run(MegaSpecId::REX6); + let r7 = run(MegaSpecId::REX7); + + assert!( + r6.is_success(), + "REX6 returns the constructor failure through the keyless-deploy wire contract: {:?}", + r6.result, + ); + assert_eq!( + format!("{:?}", r6.result), + format!("{:?}", r7.result), + "the outer transaction must keep the wire contract REX6 defines — the sandbox's destroyed \ + budget is reported, never enforced, on either side of the merge", + ); + assert!( + r7.compute_gas > 300_000, + "the sandbox's destroyed remainder must still be reported past the limit; compute={}", + r7.compute_gas, + ); +} diff --git a/crates/mega-evm/tests/rex7/call_body_halt_charges.rs b/crates/mega-evm/tests/rex7/call_body_halt_charges.rs new file mode 100644 index 00000000..3e4aa100 --- /dev/null +++ b/crates/mega-evm/tests/rex7/call_body_halt_charges.rs @@ -0,0 +1,210 @@ +//! REX7: what a CALL-family body already charged when it halts stays in the open segment. +//! +//! revm's CALL body charges before it can fail. It expands memory for the argument and return +//! ranges, then takes the value-transfer fee, and only afterwards loads the target account and +//! charges the gas it forwards — either of which can run out of gas. A body that halts there has +//! really spent the earlier charges, and it never reaches the recording window that would have +//! settled them. +//! +//! The CALL-family wrapper is the one that carries such a failure to its tail rather than +//! returning at the inner call, because the detention cap has to be applied on every path out. Its +//! tail must not re-open the settlement window on that path: the frame-exit settlement is what +//! records the charges, and re-opening the window at the current counter would drop them from +//! every lane at once — neither enforced as work nor booked as destroyed, so the transaction's +//! reported total would no longer cover the envelope it burnt. +//! +//! Each test is a differential: the same halt, at the same instruction, with the same gas left +//! over, reached once with the charge under test and once without it and with the child budget +//! reduced by exactly that charge. The difference between the two transactions' enforced compute +//! gas is what the charge contributed, and it must be the charge itself. + +use crate::common::{transact_default, Outcome, CALLEE, CALLER, CONTRACT, ONE_ETH}; +use alloy_primitives::{address, Address, Bytes, U256}; +use mega_evm::{ + test_utils::{BytecodeBuilder, MemoryDatabase}, + MegaSpecId, +}; +use revm::bytecode::opcode::{CALL, CALLCODE, DELEGATECALL, POP, STATICCALL, STOP}; + +/// The account the inner frame's CALL targets. Never touched before that CALL, so the cold-access +/// surcharge is what the inner frame runs out of gas on. +const TARGET: Address = address!("0000000000000000000000000000000000350001"); + +/// Gas one `PUSH` costs, whatever its width. +const PUSH_GAS: u64 = 3; +/// The CALL-family static entry the interpreter charges before the handler is entered. +const CALL_STATIC_GAS: u64 = 100; +/// The EVM's value-transfer surcharge, charged first inside the body. +const VALUE_TRANSFER_GAS: u64 = 9_000; +/// Bytes of return range the memory-expansion shapes ask for: two words. +const RETURN_RANGE_BYTES: u64 = 64; +/// Memory gas two words cost from an untouched memory: `3 * 2 + 2 * 2 / 512`. +const RETURN_RANGE_MEMORY_GAS: u64 = 6; +/// Gas the inner frame still holds when it reaches the charge it cannot afford. Any value below +/// the cold-account surcharge puts the halt on that charge. +const SLACK_GAS: u64 = 100; + +/// Appends a CALL-family opcode targeting `target` with `gas` forwarded. +/// +/// `value` is `None` for the schemes that take no value operand. `ret_size` is the return range +/// the opcode asks for, which is what makes the body expand memory before it charges anything +/// else. +fn append_call( + builder: BytecodeBuilder, + opcode: u8, + target: Address, + gas: u64, + value: Option, + ret_size: u64, +) -> BytecodeBuilder { + let builder = + builder.push_number(ret_size).push_number(0_u64).push_number(0_u64).push_number(0_u64); + let builder = match value { + Some(value) => builder.push_number(value), + None => builder, + }; + builder.push_address(target).push_number(gas).append(opcode) +} + +/// How many stack operands a CALL-family opcode takes, which is what its pushes cost. +fn operand_count(opcode: u8) -> u64 { + match opcode { + CALL | CALLCODE => 7, + _ => 6, + } +} + +/// The inner frame: one CALL-family opcode into [`TARGET`] that cannot afford to finish. +fn inner_code(opcode: u8, value: Option, ret_size: u64) -> Bytes { + append_call(BytecodeBuilder::default(), opcode, TARGET, 0, value, ret_size) + .append(POP) + .append(STOP) + .build() +} + +/// The transaction's target: a plain CALL into [`CALLEE`] forwarding exactly `budget`, whose +/// result is discarded so the outer frame ends normally whatever the inner frame did. +fn outer_code(budget: u64) -> Bytes { + append_call(BytecodeBuilder::default(), CALL, CALLEE, budget, Some(0), 0) + .append(POP) + .append(STOP) + .build() +} + +fn db(opcode: u8, value: Option, ret_size: u64, budget: u64) -> MemoryDatabase { + MemoryDatabase::default() + .account_balance(CALLER, U256::from(10 * ONE_ETH)) + .account_code(CONTRACT, outer_code(budget)) + .account_code(CALLEE, inner_code(opcode, value, ret_size)) + .account_balance(CALLEE, U256::from(ONE_ETH)) + .account_code(TARGET, BytecodeBuilder::default().append(STOP).build()) +} + +/// Runs one arm of a differential and checks the properties both arms share: the inner frame +/// halted on gas, so its whole budget was spent as work and nothing was destroyed. +fn run_arm(opcode: u8, value: Option, ret_size: u64, budget: u64) -> Outcome { + let outcome = transact_default(MegaSpecId::REX7, db(opcode, value, ret_size, budget)); + assert!(outcome.is_success(), "the outer frame absorbs the inner halt and stops normally"); + assert_eq!( + outcome.destroyed, 0, + "an out-of-gas frame's counter is zeroed by the interpreter, so it destroys nothing \ + the frame-exit delta cannot already see as work", + ); + assert_eq!(outcome.booked_destroyed(), 0, "and no site books a destroyed remainder for it"); + outcome +} + +/// The budget an inner frame needs to reach the cold-account surcharge with [`SLACK_GAS`] left, +/// having paid `extra` inside the body first. +fn budget_for(opcode: u8, extra: u64) -> u64 { + operand_count(opcode) * PUSH_GAS + CALL_STATIC_GAS + extra + SLACK_GAS +} + +/// Asserts that `extra` gas charged inside a halting body reaches the enforced lane. +/// +/// Both arms halt on the same charge with the same gas left over, and differ only by `extra`: +/// the charge itself, and the child budget that funds it. +fn assert_body_charge_is_enforced( + opcode: u8, + with: (Option, u64), + without: (Option, u64), + extra: u64, +) { + let (with_value, with_ret_size) = with; + let (without_value, without_ret_size) = without; + let charged = run_arm(opcode, with_value, with_ret_size, budget_for(opcode, extra)); + let control = run_arm(opcode, without_value, without_ret_size, budget_for(opcode, 0)); + assert_eq!( + charged.total_gas_spent - control.total_gas_spent, + extra, + "the two arms must differ by the charge alone", + ); + assert_eq!( + charged.enforced() - control.enforced(), + extra, + "a charge the body took before halting is work the transaction performed, so it must \ + reach the lane every compute limit is evaluated against", + ); +} + +/// `CALLCODE` with a value transfer: the 9,000 surcharge is charged, and then the cold-account +/// load the frame cannot afford halts it. +#[test] +fn test_rex7_value_callcode_halt_enforces_the_transfer_fee() { + assert_body_charge_is_enforced(CALLCODE, (Some(1), 0), (Some(0), 0), VALUE_TRANSFER_GAS); +} + +/// The same shape through `CALL`, which resolves a different target account and so reaches the +/// surcharge by a different route. +#[test] +fn test_rex7_value_call_halt_enforces_the_transfer_fee() { + assert_body_charge_is_enforced(CALL, (Some(1), 0), (Some(0), 0), VALUE_TRANSFER_GAS); +} + +/// `STATICCALL` asking for a return range: the memory expansion is charged ahead of everything +/// else in the body, including the load that halts the frame. +#[test] +fn test_rex7_staticcall_halt_enforces_the_return_range_memory() { + assert_body_charge_is_enforced( + STATICCALL, + (None, RETURN_RANGE_BYTES), + (None, 0), + RETURN_RANGE_MEMORY_GAS, + ); +} + +/// `DELEGATECALL` covers the fourth instantiation of the shared wrapper. +#[test] +fn test_rex7_delegatecall_halt_enforces_the_return_range_memory() { + assert_body_charge_is_enforced( + DELEGATECALL, + (None, RETURN_RANGE_BYTES), + (None, 0), + RETURN_RANGE_MEMORY_GAS, + ); +} + +/// The receipt does not move. Checkpoint accounting changes how a halting frame's budget is +/// reported — REX7 settles it as compute gas, REX6 never records it at all — but not what the EVM +/// charged, so the gas the transaction burns is the same under both. +#[test] +fn test_rex6_and_rex7_burn_the_same_gas_on_a_halting_call_body() { + for (opcode, value, ret_size, extra) in [ + (CALLCODE, Some(1), 0, VALUE_TRANSFER_GAS), + (CALL, Some(1), 0, VALUE_TRANSFER_GAS), + (STATICCALL, None, RETURN_RANGE_BYTES, RETURN_RANGE_MEMORY_GAS), + (DELEGATECALL, None, RETURN_RANGE_BYTES, RETURN_RANGE_MEMORY_GAS), + ] { + let budget = budget_for(opcode, extra); + let rex6 = transact_default(MegaSpecId::REX6, db(opcode, value, ret_size, budget)); + let rex7 = transact_default(MegaSpecId::REX7, db(opcode, value, ret_size, budget)); + assert_eq!( + rex6.gas_used, rex7.gas_used, + "opcode 0x{opcode:02x}: the receipt must not depend on how compute gas is settled", + ); + assert_eq!( + rex6.total_gas_spent, rex7.total_gas_spent, + "opcode 0x{opcode:02x}: nor may the envelope the receipt is built from", + ); + } +} diff --git a/crates/mega-evm/tests/rex7/charge_on_reject.rs b/crates/mega-evm/tests/rex7/charge_on_reject.rs new file mode 100644 index 00000000..d5e638b0 --- /dev/null +++ b/crates/mega-evm/tests/rex7/charge_on_reject.rs @@ -0,0 +1,695 @@ +//! REX7 charge-on-reject for `disableVolatileDataAccess` guards. +//! +//! Through REX6 a volatile guard that rejects its opcode charges nothing: the static gas table +//! zeroes the entry so the interpreter cannot pre-empt the guard, and the handler charges only +//! after the check declines. REX7 keeps the zeroed table — the guard must still be reachable — +//! but charges the static entry before rejecting, so the revert keeps the fee and segment +//! settlement records it as compute. +//! +//! Each test runs the same rejected shape on both specs. REX6 remaining and compute stay at the +//! historical zero-charge amounts; REX7 remaining drops by exactly the opcode's static entry and +//! compute / receipt `gas_used` rise by that same amount. +//! +//! A second set of tests pins the unaffordable-static-fee branch: the guarded frame is given +//! just enough gas to reach the opcode and not enough to pay the entry, so the result is +//! `OutOfGas` rather than a `VolatileDataAccessDisabled` revert. +//! +//! A third set pins the conjunction XOR corners: `disableVolatileDataAccess` is on, but the +//! target is not the oracle / beneficiary, so `SLOAD` / `SELFBALANCE` must still execute. + +use std::convert::Infallible; + +use alloy_primitives::{address, Address, Bytes, U256}; +use alloy_sol_types::{SolCall, SolError}; +use mega_evm::{ + test_utils::{BytecodeBuilder, MemoryDatabase}, + IMegaAccessControl, MegaContext, MegaEvm, MegaSpecId, MegaTransaction, MegaTransactionNew as _, + TestExternalEnvs, VolatileDataAccessType, ACCESS_CONTROL_ADDRESS, ORACLE_CONTRACT_ADDRESS, +}; +use revm::{ + bytecode::opcode::{ + BALANCE, BLOCKHASH, CALL, POP, SELFBALANCE, SELFDESTRUCT, SLOAD, STATICCALL, TIMESTAMP, + }, + context::{tx::TxEnvBuilder, BlockEnv, ContextTr, TxEnv}, + handler::EvmTr, + inspector::Inspector, + interpreter::{ + interpreter_types::{InputsTr, Jumps}, + CallInputs, CallOutcome, InstructionResult, Interpreter, InterpreterTypes, + }, +}; + +const CALLER: Address = address!("0000000000000000000000000000000000310000"); +const PARENT: Address = address!("0000000000000000000000000000000000310001"); +const CHILD: Address = address!("0000000000000000000000000000000000310002"); +const BENEFICIARY: Address = address!("0000000000000000000000000000000000310099"); + +const DISABLE_SELECTOR: [u8; 4] = IMegaAccessControl::disableVolatileDataAccessCall::SELECTOR; + +const TIMESTAMP_STATIC_GAS: u64 = 2; +const BLOCKHASH_STATIC_GAS: u64 = 20; +const WARM_ACCESS_STATIC_GAS: u64 = 100; +const SELFBALANCE_STATIC_GAS: u64 = 5; +const SELFDESTRUCT_STATIC_GAS: u64 = 5_000; + +struct GuardedFrameOutcome { + remaining: u64, + result: InstructionResult, + output: Bytes, +} + +/// Records the gas the guarded frame held on reaching `opcode`, and the outcome that frame +/// returned. `step` runs before the handler, so `remaining_before` is the pre-charge budget. +struct GuardedFrameGasInspector { + frame: Address, + opcode: u8, + remaining_before: Option, + outcome: Option, +} + +impl GuardedFrameGasInspector { + fn new(frame: Address, opcode: u8) -> Self { + Self { frame, opcode, remaining_before: None, outcome: None } + } +} + +impl Inspector for GuardedFrameGasInspector { + fn step(&mut self, interp: &mut Interpreter, _context: &mut CTX) { + if interp.input.target_address() == self.frame && interp.bytecode.opcode() == self.opcode { + self.remaining_before = Some(interp.gas.remaining()); + } + } + + fn call_end(&mut self, _context: &mut CTX, inputs: &CallInputs, outcome: &mut CallOutcome) { + if inputs.target_address == self.frame { + self.outcome = Some(GuardedFrameOutcome { + remaining: outcome.result.gas.remaining(), + result: outcome.result.result, + output: outcome.result.output.clone(), + }); + } + } +} + +struct RejectedGuard { + remaining_before: u64, + remaining_after: u64, + compute_gas: u64, + gas_used: u64, + output: Bytes, +} + +fn call_disable(builder: BytecodeBuilder) -> BytecodeBuilder { + builder + .mstore(0x0, DISABLE_SELECTOR) + .push_number(0_u64) + .push_number(0_u64) + .push_number(4_u64) + .push_number(0_u64) + .push_number(0_u64) + .push_address(ACCESS_CONTROL_ADDRESS) + .push_number(100_000_u64) + .append(CALL) + .append(POP) +} + +fn append_call(builder: BytecodeBuilder, target: Address, gas: u64) -> BytecodeBuilder { + builder + .push_number(0_u64) + .push_number(0_u64) + .push_number(0_u64) + .push_number(0_u64) + .push_number(0_u64) + .push_address(target) + .push_number(gas) + .append(CALL) +} + +fn transact_rejected( + spec: MegaSpecId, + db: &mut MemoryDatabase, + tx: TxEnv, + inspector: &mut GuardedFrameGasInspector, +) -> (bool, u64, u64) { + let external_envs = TestExternalEnvs::::new() + .with_oracle_storage(U256::from(0), U256::from(0x1234)); + let mut context = MegaContext::new(db, spec) + .with_block(BlockEnv { beneficiary: BENEFICIARY, ..Default::default() }) + .with_external_envs((&external_envs).into()); + context.modify_chain(|chain| { + chain.operator_fee_scalar = Some(U256::from(0)); + chain.operator_fee_constant = Some(U256::from(0)); + }); + let mut evm = MegaEvm::new(context).with_inspector(inspector); + let mut tx = MegaTransaction::new(tx); + tx.enveloped_tx = Some(Bytes::new()); + let result = alloy_evm::Evm::transact_raw(&mut evm, tx).expect("tx must execute"); + let compute_gas = evm.ctx_ref().additional_limit.borrow().get_usage().compute_gas; + (result.result.is_success(), compute_gas, result.result.tx_gas_used()) +} + +fn run_child_reject( + spec: MegaSpecId, + opcode: u8, + child: Address, + child_code: Bytes, +) -> RejectedGuard { + let parent_code = append_call(call_disable(BytecodeBuilder::default()), child, 50_000_000) + .append(POP) + .stop() + .build(); + let mut db = MemoryDatabase::default() + .account_balance(CALLER, U256::from(1_000_000)) + .account_code(PARENT, parent_code) + .account_code(child, child_code); + let mut inspector = GuardedFrameGasInspector::new(child, opcode); + let (success, compute_gas, gas_used) = transact_rejected( + spec, + &mut db, + TxEnvBuilder::default().caller(CALLER).call(PARENT).gas_limit(100_000_000).build_fill(), + &mut inspector, + ); + assert!(success, "{spec}: only the guarded child should revert"); + take_rejected(spec, inspector, compute_gas, gas_used) +} + +fn run_selfbalance_reject(spec: MegaSpecId) -> RejectedGuard { + let code = + call_disable(BytecodeBuilder::default()).append(SELFBALANCE).append(POP).stop().build(); + let mut db = MemoryDatabase::default() + .account_balance(CALLER, U256::from(1_000_000)) + .account_code(BENEFICIARY, code); + let mut inspector = GuardedFrameGasInspector::new(BENEFICIARY, SELFBALANCE); + let (_success, compute_gas, gas_used) = transact_rejected( + spec, + &mut db, + TxEnvBuilder::default() + .caller(CALLER) + .call(BENEFICIARY) + .gas_limit(100_000_000) + .build_fill(), + &mut inspector, + ); + take_rejected(spec, inspector, compute_gas, gas_used) +} + +fn take_rejected( + spec: MegaSpecId, + inspector: GuardedFrameGasInspector, + compute_gas: u64, + gas_used: u64, +) -> RejectedGuard { + let remaining_before = inspector + .remaining_before + .unwrap_or_else(|| panic!("{spec}: guarded opcode was never reached")); + let outcome = inspector.outcome.unwrap_or_else(|| panic!("{spec}: guarded frame never ended")); + assert_eq!( + outcome.result, + InstructionResult::Revert, + "{spec}: guard must revert, got {:?}", + outcome.result + ); + RejectedGuard { + remaining_before, + remaining_after: outcome.remaining, + compute_gas, + gas_used, + output: outcome.output, + } +} + +fn assert_charge_on_reject( + label: &str, + static_gas: u64, + access_type: VolatileDataAccessType, + r6: RejectedGuard, + r7: RejectedGuard, +) { + assert_charge_on_reject_inner(label, static_gas, access_type, r6, r7, true) +} + +/// Same as [`assert_charge_on_reject`], but skips the in-frame remaining-before check. +/// +/// Needed when the guarded frame is the transaction recipient and that recipient is the +/// beneficiary: REX7 clamps interpreter-visible gas to the detained headroom, so +/// `remaining_before` is the clamped counter and is not the amount the revert hands back. +fn assert_charge_on_reject_after_restore( + label: &str, + static_gas: u64, + access_type: VolatileDataAccessType, + r6: RejectedGuard, + r7: RejectedGuard, +) { + assert_charge_on_reject_inner(label, static_gas, access_type, r6, r7, false) +} + +fn assert_charge_on_reject_inner( + label: &str, + static_gas: u64, + access_type: VolatileDataAccessType, + r6: RejectedGuard, + r7: RejectedGuard, + check_in_frame_debit: bool, +) { + assert_eq!( + r6.remaining_after, r6.remaining_before, + "{label}: REX6 must reject without charging; held {} returned {}", + r6.remaining_before, r6.remaining_after + ); + if check_in_frame_debit { + assert_eq!( + r7.remaining_after, + r7.remaining_before - static_gas, + "{label}: REX7 must charge exactly {static_gas}; held {} returned {}", + r7.remaining_before, + r7.remaining_after + ); + } + assert_eq!( + r7.remaining_after, + r6.remaining_after - static_gas, + "{label}: REX7 true remaining must be REX6's minus {static_gas}; REX6={} REX7={}", + r6.remaining_after, + r7.remaining_after + ); + assert_eq!( + r7.compute_gas, + r6.compute_gas + static_gas, + "{label}: REX7 compute must include the rejected static fee; REX6={} REX7={}", + r6.compute_gas, + r7.compute_gas + ); + assert_eq!( + r7.gas_used, + r6.gas_used + static_gas, + "{label}: REX7 gas_used must include the rejected static fee; REX6={} REX7={}", + r6.gas_used, + r7.gas_used + ); + + let expected = IMegaAccessControl::VolatileDataAccessDisabled { accessType: access_type }; + let encoded = expected.abi_encode(); + assert_eq!(r6.output.as_ref(), encoded.as_slice(), "{label}: REX6 revert data"); + assert_eq!(r7.output.as_ref(), encoded.as_slice(), "{label}: REX7 revert data"); +} + +/// Unconditional block-env family: `TIMESTAMP` (static 2). +#[test] +fn test_rejected_timestamp_charges_static_gas_only_on_rex7() { + let code = BytecodeBuilder::default().append(TIMESTAMP).append(POP).stop().build(); + let r6 = run_child_reject(MegaSpecId::REX6, TIMESTAMP, CHILD, code.clone()); + let r7 = run_child_reject(MegaSpecId::REX7, TIMESTAMP, CHILD, code); + assert_charge_on_reject( + "TIMESTAMP", + TIMESTAMP_STATIC_GAS, + VolatileDataAccessType::Timestamp, + r6, + r7, + ); +} + +/// Same family, different static entry, so a shared constant would fail here. +#[test] +fn test_rejected_blockhash_charges_static_gas_only_on_rex7() { + let code = + BytecodeBuilder::default().push_number(0_u64).append(BLOCKHASH).append(POP).stop().build(); + let r6 = run_child_reject(MegaSpecId::REX6, BLOCKHASH, CHILD, code.clone()); + let r7 = run_child_reject(MegaSpecId::REX7, BLOCKHASH, CHILD, code); + assert_charge_on_reject( + "BLOCKHASH", + BLOCKHASH_STATIC_GAS, + VolatileDataAccessType::BlockHash, + r6, + r7, + ); +} + +/// Beneficiary-conditional family: `BALANCE(beneficiary)` (static 100). +#[test] +fn test_rejected_balance_charges_static_gas_only_on_rex7() { + let code = BytecodeBuilder::default() + .push_address(BENEFICIARY) + .append(BALANCE) + .append(POP) + .stop() + .build(); + let r6 = run_child_reject(MegaSpecId::REX6, BALANCE, CHILD, code.clone()); + let r7 = run_child_reject(MegaSpecId::REX7, BALANCE, CHILD, code); + assert_charge_on_reject( + "BALANCE", + WARM_ACCESS_STATIC_GAS, + VolatileDataAccessType::Beneficiary, + r6, + r7, + ); +} + +/// CALL family: `CALL(beneficiary)` (static 100, the zeroed-table entry). +#[test] +fn test_rejected_call_charges_static_gas_only_on_rex7() { + let code = + append_call(BytecodeBuilder::default(), BENEFICIARY, 100_000).append(POP).stop().build(); + let r6 = run_child_reject(MegaSpecId::REX6, CALL, CHILD, code.clone()); + let r7 = run_child_reject(MegaSpecId::REX7, CALL, CHILD, code); + assert_charge_on_reject( + "CALL", + WARM_ACCESS_STATIC_GAS, + VolatileDataAccessType::Beneficiary, + r6, + r7, + ); +} + +/// CALL family, different arity: `STATICCALL(beneficiary)`. +#[test] +fn test_rejected_staticcall_charges_static_gas_only_on_rex7() { + let code = BytecodeBuilder::default() + .push_number(0_u64) + .push_number(0_u64) + .push_number(0_u64) + .push_number(0_u64) + .push_address(BENEFICIARY) + .push_number(100_000_u64) + .append(STATICCALL) + .append(POP) + .stop() + .build(); + let r6 = run_child_reject(MegaSpecId::REX6, STATICCALL, CHILD, code.clone()); + let r7 = run_child_reject(MegaSpecId::REX7, STATICCALL, CHILD, code); + assert_charge_on_reject( + "STATICCALL", + WARM_ACCESS_STATIC_GAS, + VolatileDataAccessType::Beneficiary, + r6, + r7, + ); +} + +/// Oracle-conditional `SLOAD` (static 100). +#[test] +fn test_rejected_oracle_sload_charges_static_gas_only_on_rex7() { + let code = + BytecodeBuilder::default().push_number(0_u64).append(SLOAD).append(POP).stop().build(); + let r6 = run_child_reject(MegaSpecId::REX6, SLOAD, ORACLE_CONTRACT_ADDRESS, code.clone()); + let r7 = run_child_reject(MegaSpecId::REX7, SLOAD, ORACLE_CONTRACT_ADDRESS, code); + assert_charge_on_reject( + "oracle SLOAD", + WARM_ACCESS_STATIC_GAS, + VolatileDataAccessType::Oracle, + r6, + r7, + ); +} + +/// `SELFBALANCE` in the beneficiary's own frame (static 5). A CALL into the beneficiary would +/// itself be rejected, so the transaction targets the beneficiary directly. +#[test] +fn test_rejected_selfbalance_charges_static_gas_only_on_rex7() { + let r6 = run_selfbalance_reject(MegaSpecId::REX6); + let r7 = run_selfbalance_reject(MegaSpecId::REX7); + assert_charge_on_reject_after_restore( + "SELFBALANCE", + SELFBALANCE_STATIC_GAS, + VolatileDataAccessType::Beneficiary, + r6, + r7, + ); +} + +/// A child that reaches `opcode` holding less than its static fee must OOG rather than +/// produce the disable revert. `forward_gas` is the CALL stipend the parent hands the child +/// — enough to reach the opcode, not enough to pay the entry. +fn run_child_underfunded( + opcode: u8, + child: Address, + child_code: Bytes, + forward_gas: u64, +) -> (u64, GuardedFrameOutcome) { + let parent_code = append_call(call_disable(BytecodeBuilder::default()), child, forward_gas) + .append(POP) + .stop() + .build(); + let mut db = MemoryDatabase::default() + .account_balance(CALLER, U256::from(1_000_000)) + .account_code(PARENT, parent_code) + .account_code(child, child_code); + let mut inspector = GuardedFrameGasInspector::new(child, opcode); + let (success, _compute_gas, _gas_used) = transact_rejected( + MegaSpecId::REX7, + &mut db, + TxEnvBuilder::default().caller(CALLER).call(PARENT).gas_limit(100_000_000).build_fill(), + &mut inspector, + ); + assert!(success, "only the underfunded child should fail"); + let remaining_before = inspector + .remaining_before + .unwrap_or_else(|| panic!("underfunded opcode {opcode} was never reached")); + let outcome = inspector.outcome.unwrap_or_else(|| panic!("underfunded frame never ended")); + (remaining_before, outcome) +} + +fn assert_oog_not_disable_revert( + label: &str, + static_gas: u64, + remaining_before: u64, + outcome: &GuardedFrameOutcome, +) { + assert!( + remaining_before < static_gas, + "{label}: remaining_before={remaining_before} must be below static {static_gas}" + ); + assert_eq!( + outcome.result, + InstructionResult::OutOfGas, + "{label}: unaffordable static fee must OOG, got {:?}", + outcome.result + ); + assert!( + outcome.output.is_empty(), + "{label}: OOG must not carry a disable-revert payload, got {:?}", + outcome.output + ); +} + +/// Unconditional family: child holds 1 gas, `TIMESTAMP` costs 2. +#[test] +fn test_rejected_timestamp_oog_when_static_gas_unaffordable() { + let code = BytecodeBuilder::default().append(TIMESTAMP).append(POP).stop().build(); + let (remaining_before, outcome) = run_child_underfunded(TIMESTAMP, CHILD, code, 1); + assert_oog_not_disable_revert("TIMESTAMP", TIMESTAMP_STATIC_GAS, remaining_before, &outcome); +} + +/// Same family, larger static entry: `PUSH1 0` (3) then `BLOCKHASH` (20), forwarded 4. +#[test] +fn test_rejected_blockhash_oog_when_static_gas_unaffordable() { + let code = + BytecodeBuilder::default().push_number(0_u64).append(BLOCKHASH).append(POP).stop().build(); + let (remaining_before, outcome) = run_child_underfunded(BLOCKHASH, CHILD, code, 4); + assert_oog_not_disable_revert("BLOCKHASH", BLOCKHASH_STATIC_GAS, remaining_before, &outcome); +} + +/// Conditional account-read family: `PUSH20 beneficiary` (3) then `BALANCE` (100), forwarded 4. +#[test] +fn test_rejected_balance_oog_when_static_gas_unaffordable() { + let code = BytecodeBuilder::default() + .push_address(BENEFICIARY) + .append(BALANCE) + .append(POP) + .stop() + .build(); + let (remaining_before, outcome) = run_child_underfunded(BALANCE, CHILD, code, 4); + assert_oog_not_disable_revert("BALANCE", WARM_ACCESS_STATIC_GAS, remaining_before, &outcome); +} + +/// CALL family: setup is five `PUSH1 0` + `PUSH20` + `PUSH3` (21), then `CALL` (100). +#[test] +fn test_rejected_call_oog_when_static_gas_unaffordable() { + let code = + append_call(BytecodeBuilder::default(), BENEFICIARY, 100_000).append(POP).stop().build(); + let (remaining_before, outcome) = + run_child_underfunded(CALL, CHILD, code, 21 + WARM_ACCESS_STATIC_GAS - 1); + assert_oog_not_disable_revert("CALL", WARM_ACCESS_STATIC_GAS, remaining_before, &outcome); +} + +/// Oracle `SLOAD`: `PUSH1 0` (3) then `SLOAD` (100), forwarded 4. +#[test] +fn test_rejected_oracle_sload_oog_when_static_gas_unaffordable() { + let code = + BytecodeBuilder::default().push_number(0_u64).append(SLOAD).append(POP).stop().build(); + let (remaining_before, outcome) = + run_child_underfunded(SLOAD, ORACLE_CONTRACT_ADDRESS, code, 4); + assert_oog_not_disable_revert( + "oracle SLOAD", + WARM_ACCESS_STATIC_GAS, + remaining_before, + &outcome, + ); +} + +/// `SELFDESTRUCT`: `PUSH20` (3) then static `5000`, forwarded 4. +#[test] +fn test_rejected_selfdestruct_oog_when_static_gas_unaffordable() { + let code = BytecodeBuilder::default().push_address(BENEFICIARY).append(SELFDESTRUCT).build(); + let (remaining_before, outcome) = run_child_underfunded(SELFDESTRUCT, CHILD, code, 4); + assert_oog_not_disable_revert( + "SELFDESTRUCT", + SELFDESTRUCT_STATIC_GAS, + remaining_before, + &outcome, + ); +} + +/// `SELFBALANCE` is only rejected in the beneficiary's own frame. `GAS` after `disable` +/// stores the true remaining; the transaction gas limit is then cut so the frame holds +/// `static − 1` at `SELFBALANCE`. +#[test] +fn test_rejected_selfbalance_oog_when_static_gas_unaffordable() { + use revm::bytecode::opcode::{GAS, SSTORE}; + const HIGH_LIMIT: u64 = 100_000_000; + const GAS_SLOT: u64 = 0x67; + + let calibrate_code = call_disable(BytecodeBuilder::default()) + .append(GAS) + .push_u256(U256::from(GAS_SLOT)) + .append(SSTORE) + .stop() + .build(); + let mut db = MemoryDatabase::default() + .account_balance(CALLER, U256::from(1_000_000)) + .account_code(BENEFICIARY, calibrate_code); + let external_envs = TestExternalEnvs::::new(); + let mut context = MegaContext::new(&mut db, MegaSpecId::REX7) + .with_block(BlockEnv { beneficiary: BENEFICIARY, ..Default::default() }) + .with_external_envs((&external_envs).into()); + context.modify_chain(|chain| { + chain.operator_fee_scalar = Some(U256::from(0)); + chain.operator_fee_constant = Some(U256::from(0)); + }); + let tx = + TxEnvBuilder::default().caller(CALLER).call(BENEFICIARY).gas_limit(HIGH_LIMIT).build_fill(); + let mut tx = MegaTransaction::new(tx); + tx.enveloped_tx = Some(Bytes::new()); + let mut evm = MegaEvm::new(context); + let result = alloy_evm::Evm::transact_raw(&mut evm, tx).expect("calibration must execute"); + assert!(result.result.is_success(), "calibration run must succeed: {:?}", result.result); + let true_after_gas: u64 = result + .state + .get(&BENEFICIARY) + .and_then(|account| account.storage.get(&U256::from(GAS_SLOT))) + .map(|slot| slot.present_value()) + .expect("calibration SSTORE must land") + .try_into() + .expect("remaining fits in u64"); + // `GAS` charges 2 and then pushes the post-charge remaining. `SELFBALANCE` sits where + // `GAS` sat, so the true remaining there is that reading plus 2. + let true_at_selfbalance = true_after_gas + 2; + let gas_limit = HIGH_LIMIT - true_at_selfbalance + (SELFBALANCE_STATIC_GAS - 1); + + let code = + call_disable(BytecodeBuilder::default()).append(SELFBALANCE).append(POP).stop().build(); + let mut db = MemoryDatabase::default() + .account_balance(CALLER, U256::from(1_000_000)) + .account_code(BENEFICIARY, code); + let mut inspector = GuardedFrameGasInspector::new(BENEFICIARY, SELFBALANCE); + let _ = transact_rejected( + MegaSpecId::REX7, + &mut db, + TxEnvBuilder::default().caller(CALLER).call(BENEFICIARY).gas_limit(gas_limit).build_fill(), + &mut inspector, + ); + let remaining_before = inspector.remaining_before.expect("SELFBALANCE must be reached"); + let outcome = inspector.outcome.expect("beneficiary frame must end"); + assert_oog_not_disable_revert( + "SELFBALANCE", + SELFBALANCE_STATIC_GAS, + remaining_before, + &outcome, + ); +} + +/// `SELFDESTRUCT(beneficiary)` (static 5,000) — shared REX5+ wrapper, runtime-gated on REX7. +#[test] +fn test_rejected_selfdestruct_charges_static_gas_only_on_rex7() { + let code = BytecodeBuilder::default().push_address(BENEFICIARY).append(SELFDESTRUCT).build(); + let r6 = run_child_reject(MegaSpecId::REX6, SELFDESTRUCT, CHILD, code.clone()); + let r7 = run_child_reject(MegaSpecId::REX7, SELFDESTRUCT, CHILD, code); + assert_charge_on_reject( + "SELFDESTRUCT", + SELFDESTRUCT_STATIC_GAS, + VolatileDataAccessType::Beneficiary, + r6, + r7, + ); +} + +/// Runs `child_code` after the parent disables volatile access, and returns the child's +/// frame outcome. The child is a non-oracle, non-beneficiary address, so a conjunction +/// guard (`target == oracle/beneficiary && disabled`) must let the opcode execute. +fn run_child_executes( + spec: MegaSpecId, + opcode: u8, + child: Address, + child_code: Bytes, +) -> GuardedFrameOutcome { + let parent_code = append_call(call_disable(BytecodeBuilder::default()), child, 50_000_000) + .append(POP) + .stop() + .build(); + let mut db = MemoryDatabase::default() + .account_balance(CALLER, U256::from(1_000_000)) + .account_code(PARENT, parent_code) + .account_code(child, child_code); + let mut inspector = GuardedFrameGasInspector::new(child, opcode); + let (success, _compute_gas, _gas_used) = transact_rejected( + spec, + &mut db, + TxEnvBuilder::default().caller(CALLER).call(PARENT).gas_limit(100_000_000).build_fill(), + &mut inspector, + ); + assert!(success, "{spec}: the parent must succeed when only the child is under test"); + inspector.outcome.unwrap_or_else(|| panic!("{spec}: child frame never ended")) +} + +fn assert_not_disable_revert(label: &str, spec: MegaSpecId, outcome: &GuardedFrameOutcome) { + assert_ne!( + outcome.result, + InstructionResult::Revert, + "{label}/{spec}: disabled but off-target must execute, not revert; output={:?}", + outcome.output + ); + let selector = IMegaAccessControl::VolatileDataAccessDisabled::SELECTOR; + assert!( + outcome.output.len() < 4 || outcome.output[..4] != selector, + "{label}/{spec}: must not revert with VolatileDataAccessDisabled; output={:?}", + outcome.output + ); +} + +/// XOR corner: `disableVolatileDataAccess` is on, but the `SLOAD` target is not the +/// oracle. The conjunction must stay false, so the load executes. +/// +/// Kills `&&` → `||` in `sload_checkpoint`: the `||` would reject on the disabled arm +/// alone. +#[test] +fn test_sload_at_non_oracle_with_access_disabled_executes() { + let code = + BytecodeBuilder::default().push_number(0_u64).append(SLOAD).append(POP).stop().build(); + let r6 = run_child_executes(MegaSpecId::REX6, SLOAD, CHILD, code.clone()); + let r7 = run_child_executes(MegaSpecId::REX7, SLOAD, CHILD, code); + assert_not_disable_revert("non-oracle SLOAD", MegaSpecId::REX6, &r6); + assert_not_disable_revert("non-oracle SLOAD", MegaSpecId::REX7, &r7); +} + +/// XOR corner: `disableVolatileDataAccess` is on, but the frame is not the beneficiary. +/// The conjunction must stay false, so `SELFBALANCE` executes. +/// +/// Kills `&&` → `||` in `selfbalance_checkpoint`: the `||` would reject on the disabled +/// arm alone. +#[test] +fn test_selfbalance_at_non_beneficiary_with_access_disabled_executes() { + let code = BytecodeBuilder::default().append(SELFBALANCE).append(POP).stop().build(); + let r6 = run_child_executes(MegaSpecId::REX6, SELFBALANCE, CHILD, code.clone()); + let r7 = run_child_executes(MegaSpecId::REX7, SELFBALANCE, CHILD, code); + assert_not_disable_revert("non-beneficiary SELFBALANCE", MegaSpecId::REX6, &r6); + assert_not_disable_revert("non-beneficiary SELFBALANCE", MegaSpecId::REX7, &r7); +} diff --git a/crates/mega-evm/tests/rex7/checkpoint_families.rs b/crates/mega-evm/tests/rex7/checkpoint_families.rs new file mode 100644 index 00000000..3d5535f3 --- /dev/null +++ b/crates/mega-evm/tests/rex7/checkpoint_families.rs @@ -0,0 +1,192 @@ +//! REX7: one parity case per checkpoint opcode the REX7 table wires. +//! +//! `checkpoint_settlement` covers the checkpoint families through representative members — one +//! LOG, one SLOAD, the CALL family, CREATE / CREATE2, SELFDESTRUCT, a few volatile opcodes. This +//! file closes the set: every opcode the REX7 instruction table replaces with a checkpoint handler +//! gets its own parity case, so a handler wired with the wrong settlement macro — or left out of a +//! future table edit — fails here rather than only in whichever downstream test happened to use it. +//! +//! Each opcode is run twice: once plainly, and once with a detention cap engaged before it so a +//! clamp is outstanding when its prologue runs. Both runs must be indistinguishable from REX6. + +use crate::common::{ + assert_outcomes_identical, base_db as common_base_db, plain_filler, transact, CALLEE, +}; +use alloy_primitives::{Bytes, U256}; +use mega_evm::{ + test_utils::{BytecodeBuilder, MemoryDatabase}, + EvmTxRuntimeLimits, MegaSpecId, +}; +use revm::bytecode::opcode::{ + BALANCE, BASEFEE, BLOBBASEFEE, BLOBHASH, BLOCKHASH, COINBASE, DIFFICULTY, EXTCODECOPY, + EXTCODEHASH, EXTCODESIZE, GAS, GASLIMIT, LOG0, LOG1, LOG2, LOG3, LOG4, NUMBER, POP, + SELFBALANCE, SLOAD, STOP, TIMESTAMP, +}; + +fn base_db(code: Bytes) -> MemoryDatabase { + common_base_db(code).account_code(CALLEE, BytecodeBuilder::default().append(STOP).build()) +} + +/// Wraps `snippet` in plain segments on both sides, optionally engaging a detention cap first, so +/// the checkpoint under test has an open segment to settle and a clamp to restore. +fn program(snippet: impl Fn(BytecodeBuilder) -> BytecodeBuilder, volatile_prologue: bool) -> Bytes { + let mut builder = BytecodeBuilder::default(); + if volatile_prologue { + builder = builder.append(TIMESTAMP).append(POP); + } + let builder = snippet(plain_filler(builder, 5)); + plain_filler(builder, 5).append(STOP).build() +} + +/// Runs one checkpoint opcode under both specs, plainly and with a clamp outstanding. +fn assert_checkpoint_parity(label: &str, snippet: impl Fn(BytecodeBuilder) -> BytecodeBuilder) { + for (arm, volatile_prologue, cap) in + [("plain", false, u64::MAX), ("under a clamp", true, 1_000_000)] + { + let code = program(&snippet, volatile_prologue); + let limits = |spec| { + let mut limits = EvmTxRuntimeLimits::from_spec(spec); + if cap != u64::MAX { + limits.block_env_access_compute_gas_limit = cap; + } + limits + }; + let r6 = transact(MegaSpecId::REX6, base_db(code.clone()), limits(MegaSpecId::REX6)); + let r7 = transact(MegaSpecId::REX7, base_db(code), limits(MegaSpecId::REX7)); + let label = format!("{label} ({arm})"); + assert!(r6.is_success(), "{label}: REX6 must succeed: {:?}", r6.result); + assert!(r7.is_success(), "{label}: REX7 must succeed: {:?}", r7.result); + assert_outcomes_identical(&label, &r6, &r7); + } +} + +/// Pushes `n` LOG topics, then the payload length and offset, in the order the opcode pops them. +fn log_operands(builder: BytecodeBuilder, topics: usize) -> BytecodeBuilder { + let mut builder = builder.mstore(0, [0x44u8; 32]); + for topic in (0..topics).rev() { + builder = builder.push_number(0xabc0u64 + topic as u64); + } + builder.push_number(32u64).push_number(0u64) +} + +/// The block-environment opcodes: each marks volatile access, settles its segment, then installs +/// the detention cap. They take no operands and push one word. +#[test] +fn test_block_env_checkpoints_match_per_opcode() { + for (label, opcode) in [ + ("COINBASE", COINBASE), + ("TIMESTAMP", TIMESTAMP), + ("NUMBER", NUMBER), + ("DIFFICULTY", DIFFICULTY), + ("GASLIMIT", GASLIMIT), + ("BASEFEE", BASEFEE), + ("BLOBBASEFEE", BLOBBASEFEE), + ("SELFBALANCE", SELFBALANCE), + ] { + assert_checkpoint_parity(label, |builder| builder.append(opcode).append(POP)); + } +} + +/// The operand-taking volatile checkpoints. +#[test] +fn test_operand_taking_volatile_checkpoints_match_per_opcode() { + assert_checkpoint_parity("BLOCKHASH", |builder| { + builder.push_number(0u64).append(BLOCKHASH).append(POP) + }); + assert_checkpoint_parity("BLOBHASH", |builder| { + builder.push_number(0u64).append(BLOBHASH).append(POP) + }); + assert_checkpoint_parity("BALANCE", |builder| { + builder.push_address(CALLEE).append(BALANCE).append(POP) + }); + assert_checkpoint_parity("EXTCODESIZE", |builder| { + builder.push_address(CALLEE).append(EXTCODESIZE).append(POP) + }); + assert_checkpoint_parity("EXTCODEHASH", |builder| { + builder.push_address(CALLEE).append(EXTCODEHASH).append(POP) + }); + assert_checkpoint_parity("EXTCODECOPY", |builder| { + builder + .push_number(32u64) // length + .push_number(0u64) // offset + .push_number(0u64) // destOffset + .push_address(CALLEE) + .append(EXTCODECOPY) + }); + assert_checkpoint_parity("SLOAD", |builder| { + builder.push_u256(U256::from(3)).append(SLOAD).append(POP) + }); +} + +/// `GAS` is a checkpoint only because of the clamp: it has to restore the hidden gas before revm's +/// instruction reads the counter. +#[test] +fn test_gas_checkpoint_matches_per_opcode() { + assert_checkpoint_parity("GAS", |builder| builder.append(GAS).append(POP)); +} + +/// Every LOG arity: the storage-gas surcharge scales with the topic count, and each arity is a +/// separate table entry. +#[test] +fn test_every_log_arity_matches_per_opcode() { + for (label, opcode, topics) in [ + ("LOG0", LOG0, 0), + ("LOG1", LOG1, 1), + ("LOG2", LOG2, 2), + ("LOG3", LOG3, 3), + ("LOG4", LOG4, 4), + ] { + assert_checkpoint_parity(label, move |builder| { + log_operands(builder, topics).append(opcode) + }); + } +} + +/// SSTORE across the three write shapes its storage-gas charge distinguishes: a first write to a +/// fresh slot, an overwrite of that slot, and a write back to zero. +#[test] +fn test_sstore_write_shapes_match_per_opcode() { + assert_checkpoint_parity("SSTORE zero -> non-zero", |builder| { + builder.sstore(U256::from(0x50), U256::from(0x11)) + }); + assert_checkpoint_parity("SSTORE non-zero -> non-zero", |builder| { + builder + .sstore(U256::from(0x50), U256::from(0x11)) + .sstore(U256::from(0x50), U256::from(0x22)) + }); + assert_checkpoint_parity("SSTORE non-zero -> zero", |builder| { + builder.sstore(U256::from(0x50), U256::from(0x11)).sstore(U256::from(0x50), U256::ZERO) + }); + assert_checkpoint_parity("SSTORE then SLOAD of the same slot", |builder| { + builder + .sstore(U256::from(0x50), U256::from(0x11)) + .push_u256(U256::from(0x50)) + .append(SLOAD) + .append(POP) + }); + assert_checkpoint_parity("two SSTOREs with a plain gap", |builder| { + let builder = builder.sstore(U256::from(0x50), U256::from(0x11)); + plain_filler(builder, 10).sstore(U256::from(0x51), U256::from(0x22)) + }); +} + +/// Back-to-back checkpoints with no plain opcode between them: the settlement window has to open +/// and close on a zero-length segment without billing anything twice. +#[test] +fn test_adjacent_checkpoints_match_per_opcode() { + assert_checkpoint_parity("TIMESTAMP NUMBER COINBASE", |builder| { + builder + .append(TIMESTAMP) + .append(POP) + .append(NUMBER) + .append(POP) + .append(COINBASE) + .append(POP) + }); + assert_checkpoint_parity("GAS GAS", |builder| { + builder.append(GAS).append(POP).append(GAS).append(POP) + }); + assert_checkpoint_parity("SSTORE SSTORE", |builder| { + builder.sstore(U256::from(0x60), U256::from(1)).sstore(U256::from(0x61), U256::from(2)) + }); +} diff --git a/crates/mega-evm/tests/rex7/checkpoint_settlement.rs b/crates/mega-evm/tests/rex7/checkpoint_settlement.rs new file mode 100644 index 00000000..4730887d --- /dev/null +++ b/crates/mega-evm/tests/rex7/checkpoint_settlement.rs @@ -0,0 +1,509 @@ +//! REX7 checkpoint compute-gas settlement. +//! +//! Under checkpoint accounting the plain opcodes run revm's raw instructions with no per-opcode +//! recording; compute gas settles as an interpreter-gas delta at each checkpoint — the storage-gas +//! opcodes, the CALL / CREATE family, the volatile opcodes, and frame entry / resume / exit. +//! +//! The property these tests pin is the **precision invariant**: for a transaction that stays +//! inside every per-tx limit, the settled totals are bit-identical to per-opcode recording, so +//! REX6 and REX7 produce the same compute gas, the same four-dimension usage, the same receipt +//! `gas_used`, and the same execution result. The interpreter's gas counter meters every opcode +//! anyway, so summing it by segment reproduces the per-opcode sum exactly. +//! +//! The two places where the models are *not* identical are pinned at the bottom of this file: +//! a limit crossing inside a plain-opcode segment halts *before* the crossing opcode rather than +//! after it, and a frame that halts out of gas settles its burned remainder as compute gas. The +//! enforcement mechanism behind the first — the gas clamp — has its own suite in `gas_clamp`. + +use crate::common::{ + base_db, countdown_loop_code, plain_filler, transact, transact_default, + transact_with_bucket_capacity, Outcome, CALLEE, CONTRACT, EMPTY_TARGET, +}; +use alloy_primitives::{address, Address, Bytes, U256}; +use mega_evm::{ + test_utils::{BytecodeBuilder, MemoryDatabase}, + EvmTxRuntimeLimits, MegaSpecId, +}; +use revm::bytecode::opcode::{ + ADD, BALANCE, CALL, CALLCODE, CREATE, CREATE2, DELEGATECALL, EXTCODEHASH, EXTCODESIZE, GAS, + LOG1, MUL, POP, SELFDESTRUCT, SLOAD, SSTORE, STATICCALL, STOP, TIMESTAMP, +}; + +/// A third contract, so a CALL chain can reach depth 2. +const INNER: Address = address!("0000000000000000000000000000000000300004"); + +/// A SALT bucket capacity four times the minimum, so every SALT-scaled storage-gas charge +/// (`SSTORE` set, new account, contract creation) is non-zero and the settlement sites that have +/// to exclude those charges from the compute window are actually exercised. +const SCALED_BUCKET_CAPACITY: u64 = 4 * mega_evm::MIN_BUCKET_SIZE as u64; + +/// Asserts that `build_db()` executes identically under REX6 (per-opcode recording) and REX7 +/// (checkpoint settlement): same success, same result, same four-dimension usage, same `gas_used`. +/// +/// Every case is run twice — once with minimum SALT buckets and once with +/// [`SCALED_BUCKET_CAPACITY`] — so the storage-gas exclusions are checked with a non-zero charge +/// as well. The returned outcomes are the minimum-bucket ones. +fn assert_settlement_parity( + label: &str, + expect_success: bool, + build_db: impl Fn() -> MemoryDatabase, +) -> (Outcome, Outcome) { + assert_settlement_parity_with_limits( + label, + expect_success, + build_db, + EvmTxRuntimeLimits::from_spec, + ) +} + +/// [`assert_settlement_parity`] with per-spec runtime limits. +fn assert_settlement_parity_with_limits( + label: &str, + expect_success: bool, + build_db: impl Fn() -> MemoryDatabase, + limits: impl Fn(MegaSpecId) -> EvmTxRuntimeLimits, +) -> (Outcome, Outcome) { + let r6 = transact(MegaSpecId::REX6, build_db(), limits(MegaSpecId::REX6)); + let r7 = transact(MegaSpecId::REX7, build_db(), limits(MegaSpecId::REX7)); + assert_outcomes_match(label, expect_success, &r6, &r7); + + let scaled_label = alloc_scaled_label(label); + let s6 = transact_with_bucket_capacity( + MegaSpecId::REX6, + build_db(), + limits(MegaSpecId::REX6), + SCALED_BUCKET_CAPACITY, + ); + let s7 = transact_with_bucket_capacity( + MegaSpecId::REX7, + build_db(), + limits(MegaSpecId::REX7), + SCALED_BUCKET_CAPACITY, + ); + assert_outcomes_match(&scaled_label, expect_success, &s6, &s7); + + (r6, r7) +} + +fn alloc_scaled_label(label: &str) -> String { + format!("{label} (scaled SALT buckets)") +} + +fn assert_outcomes_match(label: &str, expect_success: bool, r6: &Outcome, r7: &Outcome) { + assert_eq!( + r6.is_success(), + expect_success, + "{label}: REX6 success expectation mismatch; got {:?}", + r6.result + ); + assert_eq!( + format!("{:?}", r6.result), + format!("{:?}", r7.result), + "{label}: execution result must be identical; REX6={:?} REX7={:?}", + r6.result, + r7.result + ); + assert_eq!( + r6.compute_gas, r7.compute_gas, + "{label}: checkpoint settlement must telescope to the per-opcode compute-gas sum; \ + REX6={} REX7={}", + r6.compute_gas, r7.compute_gas + ); + assert_eq!( + r6.gas_used, r7.gas_used, + "{label}: receipt gas_used must be unchanged; REX6={} REX7={}", + r6.gas_used, r7.gas_used + ); + assert_eq!( + (r6.data_size, r6.kv_updates, r6.state_growth), + (r7.data_size, r7.kv_updates, r7.state_growth), + "{label}: the non-compute dimensions must be unchanged", + ); +} + +/// A pure arithmetic loop settles only at the frame-exit checkpoint, and that single settlement +/// must equal the sum the per-opcode wrappers would have recorded opcode by opcode. +#[test] +fn test_plain_arithmetic_loop_settles_to_the_per_opcode_sum() { + let code = countdown_loop_code(&[], 500); + let (r6, _) = assert_settlement_parity("plain loop", true, || base_db(code.clone())); + assert!(r6.compute_gas > 10_000, "the loop must be substantial; compute={}", r6.compute_gas); +} + +/// A segment that runs plain opcodes, hits a mid-code checkpoint, then runs more plain opcodes +/// before the frame exits: the two settlements must partition the frame's gas exactly. +#[test] +fn test_plain_segments_around_a_mid_code_checkpoint() { + let code = plain_filler(BytecodeBuilder::default(), 40) + .push_u256(U256::from(99u64)) + .push_u256(U256::from(7u64)) + .append(SSTORE); + let code = plain_filler(code, 40).append(STOP).build(); + assert_settlement_parity("plain | SSTORE | plain", true, || base_db(code.clone())); +} + +/// SSTORE and LOG both charge storage gas inside their compute window and subtract it back out. +/// Interleaving them with plain opcodes checks that the subtraction stays exact once the window +/// also spans the plain opcodes before them. +#[test] +fn test_sstore_and_log_mixed_with_plain_opcodes() { + let code = plain_filler(BytecodeBuilder::default(), 20) + .sstore(U256::from(1), U256::from(0x11)) + .mstore(0, [0x22u8; 32]) + .push_number(0xabcu64) // topic0 + .push_number(32u64) // len + .push_number(0u64) // offset + .append(LOG1); + let code = plain_filler(code, 20).sstore(U256::from(2), U256::from(0x33)).append(STOP).build(); + let (r6, _) = assert_settlement_parity("SSTORE + LOG mix", true, || base_db(code.clone())); + assert!(r6.data_size > 0, "the log and stores must register data size"); + assert!(r6.kv_updates > 0, "the stores must register KV updates"); +} + +/// A cold then warm SLOAD, each a checkpoint, with plain opcodes between them. +#[test] +fn test_sload_checkpoints_with_plain_opcodes_between() { + let code = plain_filler(BytecodeBuilder::default(), 10) + .push_u256(U256::from(3)) + .append(SLOAD) + .append(POP); + let code = plain_filler(code, 10) + .push_u256(U256::from(3)) + .append(SLOAD) + .append(POP) + .append(STOP) + .build(); + assert_settlement_parity("SLOAD checkpoints", true, || base_db(code.clone())); +} + +/// Bytecode for a CALL to `target` forwarding `gas_limit` and `value`. +fn call_code(target: Address, value: u64, gas_limit: u64) -> BytecodeBuilder { + BytecodeBuilder::default() + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(0u64) // argsSize + .push_number(0u64) // argsOffset + .push_number(value) + .push_address(target) + .push_number(gas_limit) + .append(CALL) + .append(POP) +} + +/// A CALL sub-frame that succeeds: the caller's segment settles at the CALL checkpoint (before +/// `frame_init`), the callee settles its own segments, and the caller's window re-opens at the +/// resume with the callee's returned gas already merged back. +#[test] +fn test_call_subframe_success() { + let callee = plain_filler(BytecodeBuilder::default(), 15) + .sstore(U256::from(5), U256::from(0x77)) + .append(STOP) + .build(); + let code = plain_filler(call_code(CALLEE, 0, 1_000_000), 15).append(STOP).build(); + assert_settlement_parity("CALL success", true, || { + base_db(code.clone()).account_code(CALLEE, callee.clone()) + }); +} + +/// A CALL sub-frame that reverts: the callee's segments still settle (compute gas is persistent +/// even when the frame's state changes are dropped), and the returned gas re-opens the caller's +/// window at the resume. +#[test] +fn test_call_subframe_revert() { + let callee = plain_filler(BytecodeBuilder::default(), 15) + .sstore(U256::from(5), U256::from(0x77)) + .revert() + .build(); + let code = plain_filler(call_code(CALLEE, 0, 1_000_000), 15).append(STOP).build(); + let (r6, _) = assert_settlement_parity("CALL revert", true, || { + base_db(code.clone()).account_code(CALLEE, callee.clone()) + }); + assert!(r6.compute_gas > 0); +} + +/// A value-transferring CALL to an empty account: the new-account storage gas is charged inside +/// the CALL's compute window and subtracted back out, with the window now also spanning the plain +/// opcodes ahead of it. +#[test] +fn test_call_value_transfer_to_empty_account() { + let code = plain_filler(call_code(EMPTY_TARGET, 1, 1_000_000), 10).append(STOP).build(); + assert_settlement_parity("CALL value transfer", true, || base_db(code.clone())); +} + +/// Two nested CALL frames, so the resume path runs at two depths. +#[test] +fn test_nested_call_frames() { + let inner = plain_filler(BytecodeBuilder::default(), 10) + .sstore(U256::from(9), U256::from(0x99)) + .append(STOP) + .build(); + let callee = plain_filler(call_code(INNER, 0, 500_000), 10).append(STOP).build(); + let code = plain_filler(call_code(CALLEE, 0, 2_000_000), 10).append(STOP).build(); + assert_settlement_parity("nested CALL", true, || { + base_db(code.clone()) + .account_code(CALLEE, callee.clone()) + .account_code(INNER, inner.clone()) + }); +} + +/// DELEGATECALL, STATICCALL and CALLCODE all reach the same checkpoint chain as CALL. +#[test] +fn test_delegatecall_staticcall_callcode_frames() { + let callee = plain_filler(BytecodeBuilder::default(), 10).append(STOP).build(); + for (label, opcode, value_operand) in [ + ("DELEGATECALL", DELEGATECALL, false), + ("STATICCALL", STATICCALL, false), + ("CALLCODE", CALLCODE, true), + ] { + let mut builder = BytecodeBuilder::default() + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(0u64) // argsSize + .push_number(0u64); // argsOffset + if value_operand { + builder = builder.push_number(0u64); + } + let code = plain_filler( + builder.push_address(CALLEE).push_number(500_000u64).append(opcode).append(POP), + 10, + ) + .append(STOP) + .build(); + assert_settlement_parity(label, true, || { + base_db(code.clone()).account_code(CALLEE, callee.clone()) + }); + } +} + +/// Initcode that deploys `runtime` as the created contract's code. +fn deploying_initcode(runtime: &[u8]) -> Vec { + BytecodeBuilder::default().return_with_data(runtime).build_vec() +} + +/// A CREATE whose child frame really runs initcode: the create frame gets its own window at entry +/// and its own tail settlement at exit, and the code-deposit accounting runs on top of both. +#[test] +fn test_create_child_frame() { + let runtime = plain_filler(BytecodeBuilder::default(), 4).append(STOP).build_vec(); + let initcode = deploying_initcode(&runtime); + let len = initcode.len() as u64; + let code = plain_filler(BytecodeBuilder::default(), 10) + .mstore(0, &initcode) + .push_number(len) // length + .push_number(0u64) // offset + .push_number(0u64) // value + .append(CREATE) + .append(POP); + let code = plain_filler(code, 10).append(STOP).build(); + assert_settlement_parity("CREATE", true, || base_db(code.clone())); +} + +/// CREATE2 folds its memory-expansion gas into the same single window, which under checkpoint +/// accounting also spans the plain opcodes before it. +#[test] +fn test_create2_child_frame() { + let runtime = plain_filler(BytecodeBuilder::default(), 4).append(STOP).build_vec(); + let initcode = deploying_initcode(&runtime); + let len = initcode.len() as u64; + let code = plain_filler(BytecodeBuilder::default(), 10) + .mstore(0, &initcode) + .push_number(0x5a5au64) // salt + .push_number(len) // length + .push_number(0u64) // offset + .push_number(0u64) // value + .append(CREATE2) + .append(POP); + let code = plain_filler(code, 10).append(STOP).build(); + assert_settlement_parity("CREATE2", true, || base_db(code.clone())); +} + +/// SELFDESTRUCT to an empty beneficiary charges new-account storage gas from a site that is not +/// the settlement site, so the open window's baseline has to be lowered by exactly that charge. +#[test] +fn test_selfdestruct_new_beneficiary_excludes_its_storage_gas() { + let code = plain_filler(BytecodeBuilder::default(), 10) + .push_address(EMPTY_TARGET) + .append(SELFDESTRUCT) + .build(); + assert_settlement_parity("SELFDESTRUCT new beneficiary", true, || base_db(code.clone())); +} + +/// SELFDESTRUCT to an existing beneficiary takes the other arm (no storage-gas charge, REX6+ +/// account-write accounting only). +#[test] +fn test_selfdestruct_existing_beneficiary() { + let code = plain_filler(BytecodeBuilder::default(), 10) + .push_address(CALLEE) + .append(SELFDESTRUCT) + .build(); + let callee = BytecodeBuilder::default().append(STOP).build(); + assert_settlement_parity("SELFDESTRUCT existing beneficiary", true, || { + base_db(code.clone()).account_code(CALLEE, callee.clone()) + }); +} + +/// TIMESTAMP marks block-environment access and lowers the compute-gas limit to +/// `usage_at_access + cap`. With the cap comfortably above what the rest of the transaction +/// spends, detention is engaged but never binding — and the detention cap must be derived from +/// fully settled usage, so REX6 and REX7 must still agree bit for bit. +#[test] +fn test_block_env_detention_below_the_cap() { + let code = countdown_loop_code(&[TIMESTAMP, POP], 200); + let limits = |spec| { + let mut limits = EvmTxRuntimeLimits::from_spec(spec); + limits.block_env_access_compute_gas_limit = 1_000_000; + limits + }; + assert_settlement_parity_with_limits( + "TIMESTAMP detention", + true, + || base_db(code.clone()), + limits, + ); +} + +/// The beneficiary-conditional volatile checkpoints (BALANCE / EXTCODESIZE / EXTCODEHASH) charge +/// their static gas after the raw instruction, which under checkpoint accounting lands inside the +/// settled segment rather than being added back. +#[test] +fn test_conditional_volatile_checkpoints() { + let code = plain_filler(BytecodeBuilder::default(), 10) + .push_address(CALLEE) + .append(BALANCE) + .append(POP) + .push_address(CALLEE) + .append(EXTCODESIZE) + .append(POP) + .push_address(CALLEE) + .append(EXTCODEHASH) + .append(POP); + let code = plain_filler(code, 10).append(STOP).build(); + let callee = BytecodeBuilder::default().append(STOP).build(); + assert_settlement_parity("conditional volatile", true, || { + base_db(code.clone()).account_code(CALLEE, callee.clone()) + }); +} + +/// `GAS` reads the interpreter's own counter, so neither the settlement nor the gas clamp may +/// perturb what it observes: a contract that stores its `GAS` reading must store the same value +/// under both specs. +#[test] +fn test_gas_opcode_reads_the_same_remaining_gas() { + let code = plain_filler(BytecodeBuilder::default(), 10) + .append(GAS) + .push_u256(U256::from(4)) + .append(SSTORE) + .append(STOP) + .build(); + let (r6, r7) = assert_settlement_parity("GAS reading", true, || base_db(code.clone())); + let slot = U256::from(4); + assert_eq!( + r6.storage_value(CONTRACT, slot), + r7.storage_value(CONTRACT, slot), + "GAS must observe the same interpreter gas under both accounting models", + ); + assert!(!r6.storage_value(CONTRACT, slot).is_zero(), "the GAS reading must be non-zero"); +} + +/// A long straight run of arithmetic with no checkpoint at all, so the whole frame is one segment +/// settled once at the frame-exit checkpoint. +#[test] +fn test_single_segment_frame() { + let mut builder = BytecodeBuilder::default().push_number(7u64); + for _ in 0..200 { + builder = builder.push_number(3u64).append(ADD).push_number(2u64).append(MUL); + } + let code = builder.append(POP).append(STOP).build(); + assert_settlement_parity("single segment", true, || base_db(code.clone())); +} + +/// Pushes the operands of an `SSTORE(slot=7, value=99)` and stops before the SSTORE byte, so a run +/// measures the compute gas accumulated up to the opcode under test. +fn plain_run_then_sstore_code(pairs: usize, include_sstore: bool) -> Bytes { + let builder = plain_filler(BytecodeBuilder::default(), pairs) + .push_u256(U256::from(99u64)) + .push_u256(U256::from(7u64)); + let builder = if include_sstore { builder.append(SSTORE) } else { builder }; + builder.append(STOP).build() +} + +/// The one enforcement difference this model has: a compute-gas crossing inside a plain-opcode +/// segment is not caught *at* the crossing opcode — nothing is metered there — but *before* it, by +/// the gas clamp, which leaves the interpreter only as much visible gas as the compute headroom +/// allows. Both specs halt, and both halt in the middle of the plain run without ever reaching the +/// SSTORE checkpoint downstream; REX6 executes the crossing opcode and records it, so its usage +/// ends up over the limit, while REX7 stops one opcode earlier and its usage stays at the limit. +#[test] +fn test_compute_limit_crossing_halts_before_the_crossing_opcode() { + let code = plain_run_then_sstore_code(200, true); + let usage_before_sstore = + transact_default(MegaSpecId::REX7, base_db(plain_run_then_sstore_code(200, false))) + .compute_gas; + // Trip the limit partway through the plain run, well before the SSTORE checkpoint. + let intrinsic = + transact_default(MegaSpecId::REX7, base_db(plain_run_then_sstore_code(0, false))) + .compute_gas; + let compute_limit = intrinsic + (usage_before_sstore - intrinsic) / 2; + let limits = + |spec| EvmTxRuntimeLimits::from_spec(spec).with_tx_compute_gas_limit(compute_limit); + + let r6 = transact(MegaSpecId::REX6, base_db(code.clone()), limits(MegaSpecId::REX6)); + let r7 = transact(MegaSpecId::REX7, base_db(code), limits(MegaSpecId::REX7)); + + assert!(!r6.is_success(), "REX6 must halt on the tight compute limit; got {:?}", r6.result); + assert!(!r7.is_success(), "REX7 must halt on the tight compute limit; got {:?}", r7.result); + assert!( + r6.compute_gas > compute_limit, + "REX6 records the crossing opcode it just executed; compute={} limit={compute_limit}", + r6.compute_gas + ); + assert_eq!( + r7.compute_gas, compute_limit, + "REX7 stops at the clamp boundary, with the crossing opcode's cost excluded", + ); + assert!( + r7.compute_gas < r6.compute_gas, + "clamp enforcement must be at least as tight as per-opcode enforcement; REX6={} REX7={}", + r6.compute_gas, + r7.compute_gas + ); + let slot = U256::from(7u64); + for (label, r) in [("REX6", &r6), ("REX7", &r7)] { + assert!( + r.storage_value(CONTRACT, slot).is_zero(), + "{label}: the halt lands inside the plain run, so the SSTORE downstream never executes", + ); + } +} + +/// The second difference: a frame that halts out of EVM gas has its remaining budget zeroed by the +/// interpreter before the frame-exit settlement reads the counter, so the burned remainder settles +/// as compute gas. Per-opcode recording attributes nothing to the failing opcode and nothing to +/// the burn, so REX7 reports strictly more compute gas for such a frame. +/// +/// The direction is the safe one — compute usage is over-reported, never under-reported — and the +/// halt itself is identical. +#[test] +fn test_out_of_gas_frame_settles_its_burned_gas_as_compute() { + // A callee that runs out of the gas its caller forwarded. + let callee = countdown_loop_code(&[], 10_000); + let code = call_code(CALLEE, 0, 5_000).append(STOP).build(); + let build_db = || base_db(code.clone()).account_code(CALLEE, callee.clone()); + + let r6 = transact_default(MegaSpecId::REX6, build_db()); + let r7 = transact_default(MegaSpecId::REX7, build_db()); + + assert!(r6.is_success(), "the outer transaction survives the callee's OOG: {:?}", r6.result); + assert_eq!( + format!("{:?}", r6.result), + format!("{:?}", r7.result), + "the halt itself is unchanged", + ); + assert_eq!(r6.gas_used, r7.gas_used, "receipt gas is unchanged"); + assert!( + r7.compute_gas > r6.compute_gas, + "the burned remainder settles as compute gas under REX7; REX6={} REX7={}", + r6.compute_gas, + r7.compute_gas + ); +} diff --git a/crates/mega-evm/tests/rex7/checkpoint_static_fee_edges.rs b/crates/mega-evm/tests/rex7/checkpoint_static_fee_edges.rs new file mode 100644 index 00000000..111c39f4 --- /dev/null +++ b/crates/mega-evm/tests/rex7/checkpoint_static_fee_edges.rs @@ -0,0 +1,286 @@ +//! Table-prepaid checkpoint static-fee edges under REX7 gas-clamp enforcement. +//! +//! revm's `step()` pre-charges an opcode's gas-table entry before the handler runs. Under REX7 the +//! zeroed set is only the volatile-guarded family, so `GAS` (2) and `LOG0`–`LOG4` (375) keep a +//! non-zero entry. When the clamp's visible remainder is below that entry and true EVM gas is +//! still sufficient, the inherited per-opcode check stops the opcode before the body — a +//! plain-segment crossing: Halt(`ComputeGasLimitExceeded`), the fee never enters compute usage, +//! and the body has no observable effect. +//! +//! `CREATE` / `CREATE2` are the contrast: their table entry is 0 (revm charges 32,000 inside the +//! body, after the checkpoint restores the true counter). A compute headroom of `32_000 − 1` does +//! not stop them before the body; the body runs, the fee is recorded, and a top-frame exceed +//! surfaces as `Revert(MegaLimitExceeded)` with the created account discarded. +//! +//! Each family has two top-frame edges, calibrated so the named headroom is the remaining compute +//! at the opcode itself (prefix `PUSH` opcodes are measured out first). + +use crate::common::{base_db, rex7_compute_limit, stop_only, transact, Outcome, CONTRACT}; +use alloy_primitives::{Address, Bytes}; +use alloy_sol_types::SolError; +use mega_evm::{ + constants::mini_rex::LOG_TOPIC_STORAGE_GAS, test_utils::BytecodeBuilder, EvmTxRuntimeLimits, + LimitKind, MegaHaltReason, MegaLimitExceeded, MegaSpecId, +}; +use revm::{ + bytecode::opcode::{CREATE, GAS, LOG1, STOP}, + context::result::ExecutionResult, +}; + +/// `GAS` static gas — prepaid by the inherited table. +const GAS_STATIC_GAS: u64 = 2; + +/// `LOG0`–`LOG4` table entry (base LOG cost). Per-topic and per-byte costs are charged in the body. +const LOG_STATIC_GAS: u64 = 375; + +/// `LOG1` with empty data: table 375 + one topic 375. +const LOG1_BODY_COMPUTE: u64 = 750; + +/// `CREATE` body fee. The REX7 table entry is 0; revm charges this inside the body. +const CREATE_BODY_GAS: u64 = 32_000; + +fn run(code: Bytes, limit: u64) -> Outcome { + transact(MegaSpecId::REX7, base_db(code), rex7_compute_limit(limit)) +} + +fn unconstrained(code: Bytes) -> Outcome { + transact(MegaSpecId::REX7, base_db(code), EvmTxRuntimeLimits::from_spec(MegaSpecId::REX7)) +} + +fn account_nonce(outcome: &Outcome, address: Address) -> u64 { + outcome.state.get(&address).map(|account| account.info.nonce).unwrap_or(0) +} + +fn created_addresses(outcome: &Outcome) -> Vec
{ + outcome + .state + .iter() + .filter(|(_, account)| account.is_created()) + .map(|(address, _)| *address) + .collect() +} + +/// Transaction-level clamp crossing: Halt, usage stops at the limit, the opcode's fee is not in +/// compute, receipt gas is compute plus the intrinsic storage component. +fn assert_tx_level_crossing(label: &str, outcome: &Outcome, limit: u64, storage_overhead: u64) { + match outcome.halt_reason(label) { + MegaHaltReason::ComputeGasLimitExceeded { limit: reported, actual } => { + assert_eq!(*reported, limit, "{label}: reported limit is the TX compute limit"); + assert_eq!( + *actual, outcome.compute_gas, + "{label}: reported actual is the transaction's final compute usage" + ); + } + other => panic!("{label}: expected ComputeGasLimitExceeded, got {other:?}"), + } + assert_eq!( + outcome.compute_gas, limit, + "{label}: crossing usage stops at the limit; the opcode's fee must not be recorded" + ); + assert_eq!( + outcome.gas_used, + outcome.compute_gas + storage_overhead, + "{label}: receipt gas is compute plus the intrinsic storage component" + ); +} + +// --------------------------------------------------------------------------------------------- +// GAS (table static = 2) +// --------------------------------------------------------------------------------------------- + +/// `GAS; STOP` with compute headroom `static − 1`. +/// +/// The table pre-charges 2, so the clamp stops `GAS` before the handler. A charge after the +/// prologue would let the body run and record 2. +#[test] +fn test_gas_one_below_static_is_a_plain_segment_crossing() { + let intrinsic = unconstrained(stop_only()); + assert_eq!(intrinsic.compute_gas, 21_000, "intrinsic compute is 21_000"); + let limit = intrinsic.compute_gas + GAS_STATIC_GAS - 1; + assert_eq!(limit, 21_001); + + let outcome = run(BytecodeBuilder::default().append(GAS).append(STOP).build(), limit); + + assert_tx_level_crossing( + "GAS headroom=static-1", + &outcome, + limit, + intrinsic.storage_overhead(), + ); +} + +/// Neighbouring edge: headroom equals the static fee, so `GAS` itself can finish. +#[test] +fn test_gas_at_static_executes_the_body() { + let intrinsic = unconstrained(stop_only()); + let limit = intrinsic.compute_gas + GAS_STATIC_GAS; + assert_eq!(limit, 21_002); + + let outcome = run(BytecodeBuilder::default().append(GAS).append(STOP).build(), limit); + + assert!( + outcome.is_success(), + "headroom=static_gas must let GAS finish; got {:?}", + outcome.result + ); + assert_eq!(outcome.compute_gas, intrinsic.compute_gas + GAS_STATIC_GAS); + assert_eq!(outcome.gas_used, outcome.compute_gas + intrinsic.storage_overhead()); +} + +// --------------------------------------------------------------------------------------------- +// LOG1 (table static = 375; empty-data body adds 375 for the topic) +// --------------------------------------------------------------------------------------------- + +fn log1_operands(builder: BytecodeBuilder) -> BytecodeBuilder { + builder.push_number(0xabu64).push_number(0u64).push_number(0u64) +} + +/// `LOG1; STOP` with compute headroom `table_static − 1` at the opcode. +/// +/// The three operand `PUSH` opcodes are measured out first so the named headroom is the remainder +/// the clamp shows `LOG1`. The table pre-charges 375, so 374 stops the opcode before the body: no +/// log, no topic storage gas. +#[test] +fn test_log1_one_below_static_is_a_plain_segment_crossing() { + let intrinsic = unconstrained(stop_only()); + let before = unconstrained(log1_operands(BytecodeBuilder::default()).append(STOP).build()); + let limit = before.compute_gas + LOG_STATIC_GAS - 1; + + let outcome = + run(log1_operands(BytecodeBuilder::default()).append(LOG1).append(STOP).build(), limit); + + assert_tx_level_crossing( + "LOG1 headroom=static-1", + &outcome, + limit, + intrinsic.storage_overhead(), + ); + assert!( + outcome.result.logs().is_empty(), + "LOG1 body must not run; logs={:?}", + outcome.result.logs() + ); +} + +/// Neighbouring edge: headroom equals the full `LOG1` body cost, so the log is emitted. +/// +/// Table static is 375; the topic adds another 375. Headroom of 375 would enter the handler and +/// then overshoot on the topic, reverting the log. The executing edge is the full body cost. +#[test] +fn test_log1_at_full_body_cost_emits_the_log() { + let intrinsic = unconstrained(stop_only()); + let before = unconstrained(log1_operands(BytecodeBuilder::default()).append(STOP).build()); + let full = + unconstrained(log1_operands(BytecodeBuilder::default()).append(LOG1).append(STOP).build()); + assert_eq!( + full.compute_gas, + before.compute_gas + LOG1_BODY_COMPUTE, + "empty-data LOG1 compute is table 375 plus one topic 375" + ); + let limit = before.compute_gas + LOG1_BODY_COMPUTE; + + let outcome = + run(log1_operands(BytecodeBuilder::default()).append(LOG1).append(STOP).build(), limit); + + assert!( + outcome.is_success(), + "headroom=full LOG1 body must emit the log; got {:?}", + outcome.result + ); + assert_eq!(outcome.compute_gas, full.compute_gas); + assert_eq!(outcome.result.logs().len(), 1, "LOG1 body must emit exactly one log"); + assert_eq!( + outcome.gas_used, + outcome.compute_gas + intrinsic.storage_overhead() + LOG_TOPIC_STORAGE_GAS, + "receipt gas includes the LOG topic storage component" + ); +} + +// --------------------------------------------------------------------------------------------- +// CREATE (table static = 0; body charges 32,000 after the true counter is restored) +// --------------------------------------------------------------------------------------------- + +fn create_operands(builder: BytecodeBuilder) -> BytecodeBuilder { + builder.push_number(0u64).push_number(0u64).push_number(0u64) +} + +fn decode_top_revert(label: &str, outcome: &Outcome) -> MegaLimitExceeded { + match &outcome.result { + ExecutionResult::Revert { output, .. } => MegaLimitExceeded::abi_decode(output) + .unwrap_or_else(|e| panic!("{label}: revert is not MegaLimitExceeded: {e}")), + ExecutionResult::Halt { reason, .. } => panic!( + "{label}: CREATE table entry is 0, so headroom below 32_000 must not be a clamp \ + Halt (that would mean the 32_000 was prepaid); got {reason:?} compute={}", + outcome.compute_gas + ), + other => panic!("{label}: expected Revert(MegaLimitExceeded), got {other:?}"), + } +} + +/// `CREATE; STOP` with compute headroom `32_000 − 1` at the opcode. +/// +/// The table does not pre-charge CREATE, so the body runs on the restored counter, records +/// 32,000, and the top-frame per-opcode exceed reverts. The created account is discarded; the +/// fee stays in compute. A table entry of 32,000 would have made this a clamp Halt instead. +#[test] +fn test_create_one_below_body_fee_runs_then_reverts() { + let intrinsic = unconstrained(stop_only()); + let before = unconstrained(create_operands(BytecodeBuilder::default()).append(STOP).build()); + let full = unconstrained( + create_operands(BytecodeBuilder::default()).append(CREATE).append(STOP).build(), + ); + assert_eq!( + full.compute_gas, + before.compute_gas + CREATE_BODY_GAS, + "empty-initcode CREATE compute is the 32_000 body fee" + ); + let limit = before.compute_gas + CREATE_BODY_GAS - 1; + + let outcome = + run(create_operands(BytecodeBuilder::default()).append(CREATE).append(STOP).build(), limit); + + let decoded = decode_top_revert("CREATE headroom=32000-1", &outcome); + assert_eq!(decoded.kind, LimitKind::ComputeGas.as_u8()); + assert_eq!( + outcome.compute_gas, full.compute_gas, + "the 32_000 body fee is recorded; a table-prepaid crossing would stop at {limit}" + ); + assert!( + created_addresses(&outcome).is_empty(), + "the reverted CREATE must not leave a created account; created={:?}", + created_addresses(&outcome) + ); + assert_eq!(account_nonce(&outcome, CONTRACT), 0, "the creator nonce must not advance"); + assert_eq!( + outcome.gas_used, + outcome.compute_gas + intrinsic.storage_overhead(), + "empty-initcode CREATE adds no storage gas at minimum bucket capacity" + ); +} + +/// Neighbouring edge: headroom equals the 32,000 body fee, so CREATE finishes and the account +/// remains. +#[test] +fn test_create_at_body_fee_creates_the_account() { + let intrinsic = unconstrained(stop_only()); + let before = unconstrained(create_operands(BytecodeBuilder::default()).append(STOP).build()); + let limit = before.compute_gas + CREATE_BODY_GAS; + + let outcome = + run(create_operands(BytecodeBuilder::default()).append(CREATE).append(STOP).build(), limit); + + assert!( + outcome.is_success(), + "headroom=32000 must let CREATE finish; got {:?}", + outcome.result + ); + assert_eq!(outcome.compute_gas, before.compute_gas + CREATE_BODY_GAS); + assert_eq!(account_nonce(&outcome, CONTRACT), 1, "CREATE must advance the creator nonce"); + assert_eq!( + created_addresses(&outcome).len(), + 1, + "CREATE must leave one created account; created={:?}", + created_addresses(&outcome) + ); + assert_eq!(outcome.gas_used, outcome.compute_gas + intrinsic.storage_overhead()); +} diff --git a/crates/mega-evm/tests/rex7/clamp_classification.rs b/crates/mega-evm/tests/rex7/clamp_classification.rs new file mode 100644 index 00000000..156f80ae --- /dev/null +++ b/crates/mega-evm/tests/rex7/clamp_classification.rs @@ -0,0 +1,370 @@ +//! REX7 gas-clamp classification and the payload a clamp-induced exceed reports. +//! +//! The clamp is a lifecycle, not an amount: it is applied at a checkpoint, it binds the segment +//! that follows to one specific constraint, and it is consumed at the next checkpoint or at frame +//! exit. Whether the interpreter's true remaining happened to sit *above* the compute headroom or +//! exactly *on* it changes how much gets hidden — zero in the second case — but not whether the +//! clamp is in force. Both are the compute limit doing the stopping, and both must be reported as +//! such; only a frame whose own EVM gas runs out first is an ordinary out-of-gas. +//! +//! The payload has to match too. A frame-local binding reverts with +//! `MegaLimitExceeded(uint8 kind, uint64 limit)`, which the caller can decode and branch on, so its +//! `limit` must be the sub-frame budget that actually bound the clamp rather than the +//! transaction-level limit. A transaction-level binding halts with `ComputeGasLimitExceeded`, whose +//! `actual` must be the compute usage the transaction ends with — including the frame-exit +//! settlement that runs after the exceed is latched. + +use crate::common::{ + base_db, compute_limit, countdown_loop_code, plain_filler as common_plain_filler, transact, + transact_default, transact_with_gas_limit, Outcome, CALLEE, +}; +use alloy_primitives::{Bytes, U256}; +use alloy_sol_types::SolError; +use mega_evm::{ + test_utils::BytecodeBuilder, LimitKind, MegaHaltReason, MegaLimitExceeded, MegaSpecId, +}; +use revm::{ + bytecode::opcode::{CALL, EXP, MSTORE, POP, RETURN, RETURNDATACOPY, RETURNDATASIZE, STOP}, + context::result::ExecutionResult, +}; + +fn plain_filler(pairs: usize) -> Vec { + common_plain_filler(BytecodeBuilder::default(), pairs).build_vec() +} + +// --------------------------------------------------------------------------------------------- +// The equal-value clamp: hidden == 0 and the clamp still binds. +// --------------------------------------------------------------------------------------------- + +/// Memory offset the calibrated `MSTORE` writes at — 32 KiB, so its expansion cost is thousands of +/// gas and the knife edge is not sensitive to a one-gas miscount anywhere else. +const MSTORE_OFFSET: u64 = 0x8000; + +/// The two shapes the knife-edge calibration needs: everything up to the `MSTORE`'s operands, and +/// the same thing with the `MSTORE` itself. +fn knife_edge_shapes() -> (Bytes, Bytes) { + let operands = |mut code: Vec| { + let mut builder = BytecodeBuilder::default(); + builder = builder.push_number(0u64).push_number(MSTORE_OFFSET); + code.extend_from_slice(&builder.build_vec()); + code + }; + let mut before = operands(plain_filler(20)); + before.push(STOP); + let mut full = operands(plain_filler(20)); + full.push(MSTORE); + full.push(STOP); + (Bytes::from(before), Bytes::from(full)) +} + +/// The calibrated knife edge: the exact transaction gas limit and compute gas limit that leave the +/// crossing `MSTORE` one gas short on *both* budgets at once. +struct KnifeEdge { + code: Bytes, + gas_limit: u64, + compute_limit: u64, + /// Compute gas the transaction has recorded when the `MSTORE` is reached. + compute_before: u64, +} + +fn calibrate_knife_edge() -> KnifeEdge { + let (before_code, full_code) = knife_edge_shapes(); + let before = transact_default(MegaSpecId::REX7, base_db(before_code)); + let full = transact_default(MegaSpecId::REX7, base_db(full_code.clone())); + assert!(before.is_success(), "calibration run must succeed: {:?}", before.result); + assert!(full.is_success(), "calibration run must succeed: {:?}", full.result); + + let mstore_cost = full.compute_gas - before.compute_gas; + assert!(mstore_cost > 1, "the MSTORE must have a real expansion cost, got {mstore_cost}"); + KnifeEdge { + code: full_code, + // One gas short of the MSTORE on the EVM's own counter... + gas_limit: before.gas_used + mstore_cost - 1, + // ...and one gas short of it on the compute headroom, so the two coincide exactly and the + // clamp hides nothing at all. + compute_limit: before.compute_gas + mstore_cost - 1, + compute_before: before.compute_gas, + } +} + +/// An exact-value clamp — true remaining equal to the compute headroom, nothing hidden — is still +/// the compute limit doing the stopping, and must be reported as a compute exceed rather than as +/// an ordinary EVM out-of-gas. +/// +/// This is the double-exceed preference at its knife edge: the crossing opcode exhausts both +/// budgets at the same gas, and the compute classification is the one that keeps the sender's +/// remaining gas refundable. +#[test] +fn test_exact_value_clamp_is_still_a_compute_exceed() { + let edge = calibrate_knife_edge(); + let r7 = transact_with_gas_limit( + MegaSpecId::REX7, + base_db(edge.code.clone()), + compute_limit(edge.compute_limit)(MegaSpecId::REX7), + edge.gas_limit, + ); + + match r7.halt_reason("REX7") { + MegaHaltReason::ComputeGasLimitExceeded { limit, actual } => { + assert_eq!(*limit, edge.compute_limit, "the reported limit is the TX compute limit"); + assert_eq!( + *actual, r7.compute_gas, + "the reported usage must be the transaction's final compute usage", + ); + } + other => panic!( + "an equal-value clamp must classify as a compute exceed, not an ordinary \ + out-of-gas; got {other:?}", + ), + } +} + +/// The neighbouring points on either side of the knife edge classify the way the equal point does +/// or the way an ordinary out-of-gas does, and nothing in between. +/// +/// One gas more of transaction gas puts the true remaining strictly above the headroom, so the +/// clamp hides one gas — the case that already worked. One gas more of compute limit puts the +/// headroom strictly above the true remaining, so the frame's own gas is what runs out and the +/// halt is an ordinary out-of-gas with no compute attribution. +#[test] +fn test_knife_edge_neighbours_classify_by_which_budget_binds() { + let edge = calibrate_knife_edge(); + + let hidden_one = transact_with_gas_limit( + MegaSpecId::REX7, + base_db(edge.code.clone()), + compute_limit(edge.compute_limit)(MegaSpecId::REX7), + edge.gas_limit + 1, + ); + assert!( + matches!( + hidden_one.halt_reason("hidden=1"), + MegaHaltReason::ComputeGasLimitExceeded { .. } + ), + "one gas above the edge the clamp hides one gas and binds; got {:?}", + hidden_one.result + ); + + let gas_bound = transact_with_gas_limit( + MegaSpecId::REX7, + base_db(edge.code.clone()), + compute_limit(edge.compute_limit + 1)(MegaSpecId::REX7), + edge.gas_limit, + ); + assert!( + !matches!( + gas_bound.halt_reason("gas-bound"), + MegaHaltReason::ComputeGasLimitExceeded { .. } + ), + "one gas of headroom above the true remaining makes this the EVM's own out-of-gas; \ + got {:?}", + gas_bound.result + ); + assert!( + gas_bound.compute_gas > edge.compute_before, + "the EVM out-of-gas burns the frame's remainder, which settles as compute", + ); +} + +/// The two shapes the spend-all knife edge needs: everything up to the `EXP`'s operands, and +/// the same thing with the `EXP` itself. A full-width exponent is charged through the plain +/// `gas!` macro, so a shortage is `InstructionResult::OutOfGas` — the variant +/// `Interpreter::halt` spends all remaining gas for. +fn spend_all_knife_edge_shapes() -> (Bytes, Bytes) { + let operands = |mut code: Vec| { + let mut builder = BytecodeBuilder::default(); + builder = builder.push_u256(U256::MAX).push_number(2u64); + code.extend_from_slice(&builder.build_vec()); + code + }; + let mut before = operands(plain_filler(20)); + before.push(STOP); + let mut full = operands(plain_filler(20)); + full.push(EXP); + full.push(STOP); + (Bytes::from(before), Bytes::from(full)) +} + +fn calibrate_spend_all_knife_edge() -> KnifeEdge { + let (before_code, full_code) = spend_all_knife_edge_shapes(); + let before = transact_default(MegaSpecId::REX7, base_db(before_code)); + let full = transact_default(MegaSpecId::REX7, base_db(full_code.clone())); + assert!(before.is_success(), "calibration run must succeed: {:?}", before.result); + assert!(full.is_success(), "calibration run must succeed: {:?}", full.result); + // The receipt carries compute gas plus MegaETH storage gas; only the compute half is what + // the compute limit bounds. Both calibration shapes must carry the same storage gas, or the + // two budgets cannot be lined up from these readings. + assert_eq!( + before.gas_used - before.compute_gas, + full.gas_used - full.compute_gas, + "the two calibration shapes must carry the same storage gas", + ); + + let crossing_cost = full.compute_gas - before.compute_gas; + assert!(crossing_cost > 1, "the EXP must have a real cost, got {crossing_cost}"); + KnifeEdge { + code: full_code, + // One gas short of the EXP on the EVM's own counter... + gas_limit: before.gas_used + crossing_cost - 1, + // ...and one gas short of it on the compute headroom, so the two coincide exactly. + compute_limit: before.compute_gas + crossing_cost - 1, + compute_before: before.compute_gas, + } +} + +/// An equal-value clamp on a spend-all out-of-gas charges the sender the same as REX6 at the +/// same point. The halt reason moves to a compute exceed; the receipt `gas_used` does not. +/// +/// The existing equal-value case above is a `MemoryOOG` (`MSTORE`), which does not spend all +/// and is already pinned for classification. This is the `OutOfGas` neighbour, where revm +/// zeroes the counter before the clamp restore, so rescue has nothing to hand back. +#[test] +fn test_equal_value_clamp_on_a_spend_all_out_of_gas_matches_rex6_gas_used() { + let edge = calibrate_spend_all_knife_edge(); + + let r7 = transact_with_gas_limit( + MegaSpecId::REX7, + base_db(edge.code.clone()), + compute_limit(edge.compute_limit)(MegaSpecId::REX7), + edge.gas_limit, + ); + let r6 = transact_with_gas_limit( + MegaSpecId::REX6, + base_db(edge.code.clone()), + compute_limit(edge.compute_limit)(MegaSpecId::REX6), + edge.gas_limit, + ); + + assert_eq!( + r7.gas_used, edge.gas_limit, + "an equal-value clamp on a spend-all out-of-gas returns nothing to the sender", + ); + assert_eq!( + r7.gas_used, r6.gas_used, + "REX6 and REX7 charge the sender identically at the equal-value spend-all edge", + ); + assert!( + matches!(r7.halt_reason("REX7"), MegaHaltReason::ComputeGasLimitExceeded { .. }), + "the equal-value clamp still classifies as a compute exceed; got {:?}", + r7.result, + ); + assert!( + !matches!(r6.halt_reason("REX6"), MegaHaltReason::ComputeGasLimitExceeded { .. }), + "REX6 reports the EVM's own out-of-gas, not a compute exceed; got {:?}", + r6.result, + ); +} + +// --------------------------------------------------------------------------------------------- +// The payload a clamp-induced exceed reports. +// --------------------------------------------------------------------------------------------- + +/// A caller that CALLs [`CALLEE`] and returns the sub-frame's return data verbatim, so the +/// `MegaLimitExceeded` payload the sub-frame reverted with is observable from the receipt. +fn call_and_return_revert_data(gas: u64) -> Bytes { + BytecodeBuilder::default() + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(0u64) // argsSize + .push_number(0u64) // argsOffset + .push_number(0u64) // value + .push_address(CALLEE) + .push_number(gas) + .append(CALL) + .append(POP) + .append(RETURNDATASIZE) + .push_number(0u64) // dataOffset + .push_number(0u64) // destOffset + .append(RETURNDATACOPY) + .append(RETURNDATASIZE) + .push_number(0u64) // offset + .append(RETURN) + .build() +} + +/// Decodes the `MegaLimitExceeded` payload a successful transaction returned. +fn decode_limit_exceeded(label: &str, outcome: &Outcome) -> MegaLimitExceeded { + let output = match &outcome.result { + ExecutionResult::Success { output, .. } => output.data().clone(), + other => panic!("{label}: expected success carrying the sub-frame payload, got {other:?}"), + }; + MegaLimitExceeded::abi_decode(&output) + .unwrap_or_else(|e| panic!("{label}: return data is not MegaLimitExceeded: {e}")) +} + +/// A frame-local clamp exceed must report the sub-frame budget that bound it, byte for byte the +/// same payload per-opcode enforcement produces. +/// +/// The revert data is visible to the calling contract, which can decode `limit` and branch on it, +/// so a transaction-level value here is not a diagnostic difference — it is a different observable +/// return value for the same execution. +#[test] +fn test_frame_local_clamp_exceed_reports_the_sub_frame_budget() { + // Enough iterations to outrun the sub-frame's 98/100 share of a one-million compute budget. + let callee = countdown_loop_code(&[], 40_000); + let code = call_and_return_revert_data(50_000_000); + let build_db = || base_db(code.clone()).account_code(CALLEE, callee.clone()); + let limits = compute_limit(1_000_000); + + let r6 = transact(MegaSpecId::REX6, build_db(), limits(MegaSpecId::REX6)); + let r7 = transact(MegaSpecId::REX7, build_db(), limits(MegaSpecId::REX7)); + + let d6 = decode_limit_exceeded("REX6", &r6); + let d7 = decode_limit_exceeded("REX7", &r7); + + assert_eq!(d6.kind, LimitKind::ComputeGas.as_u8(), "REX6 must blame compute gas"); + assert_eq!(d7.kind, d6.kind, "REX7 must blame the same dimension"); + assert_eq!( + d7.limit, d6.limit, + "the ABI-visible limit must be the sub-frame budget on both specs; REX6={} REX7={}", + d6.limit, d7.limit + ); + assert!( + d7.limit < 1_000_000, + "the sub-frame budget is a fraction of the TX limit, not the TX limit itself; got {}", + d7.limit + ); +} + +/// A transaction-level clamp exceed must report the usage the transaction actually ends with. +/// +/// The exceed is latched at the frame's final result, but the frame-exit settlement that closes +/// the partial plain segment runs after that. A halt reason frozen at latch time reports a usage +/// the transaction never had. +#[test] +fn test_tx_level_clamp_halt_reports_the_final_usage() { + let mut code = plain_filler(200); + code.push(STOP); + let code = Bytes::from(code); + + // The unconstrained run tells us both ends of the plain segment; putting the limit in the + // middle of it guarantees the halt lands with a partial segment still unsettled. + let free = transact_default(MegaSpecId::REX7, base_db(code.clone())); + assert!(free.is_success(), "the unconstrained run must succeed: {:?}", free.result); + let intrinsic = transact_default(MegaSpecId::REX7, base_db(Bytes::from(vec![STOP]))); + let midpoint = (intrinsic.compute_gas + free.compute_gas) / 2; + + let r7 = transact(MegaSpecId::REX7, base_db(code), compute_limit(midpoint)(MegaSpecId::REX7)); + + let (limit, actual) = match r7.halt_reason("REX7") { + MegaHaltReason::ComputeGasLimitExceeded { limit, actual } => (*limit, *actual), + other => panic!("expected a compute-gas halt, got {other:?}"), + }; + assert_eq!(limit, midpoint, "the reported limit is the configured TX compute limit"); + assert_eq!( + r7.compute_gas, midpoint, + "clamp enforcement stops the crossing opcode, so usage lands exactly on the limit", + ); + assert_eq!( + actual, r7.compute_gas, + "the reported usage must be the tracker's final reading, not a pre-settlement snapshot; \ + reported={actual} tracker={}", + r7.compute_gas + ); + assert_eq!( + r7.gas_used, + r7.compute_gas + (intrinsic.gas_used - intrinsic.compute_gas), + "the receipt must charge exactly the compute the transaction was allowed plus the \ + intrinsic storage gas", + ); +} diff --git a/crates/mega-evm/tests/rex7/common.rs b/crates/mega-evm/tests/rex7/common.rs new file mode 100644 index 00000000..d1c6dd17 --- /dev/null +++ b/crates/mega-evm/tests/rex7/common.rs @@ -0,0 +1,666 @@ +//! Shared helpers for the REX7 test suite. + +use alloy_primitives::{address, hex, Address, Bytes, Signature, TxKind, B256, U256}; +use mega_evm::{ + alloy_consensus::{Signed, TxLegacy}, + test_utils::{BytecodeBuilder, MemoryDatabase}, + ConservationTerms, EvmTxRuntimeLimits, ExternalEnvTypes, InspectorLedger, MegaContext, MegaEvm, + MegaHaltReason, MegaSpecId, MegaTransaction, MegaTransactionNew as _, TestExternalEnvs, +}; +use revm::{ + bytecode::opcode::{DUP1, JUMPDEST, JUMPI, POP, STOP, SUB, SWAP1}, + context::{result::ExecutionResult, tx::TxEnvBuilder, TxEnv}, + handler::EvmTr, + state::EvmState, + Inspector, +}; +use std::{collections::BTreeMap, string::String, vec::Vec}; + +/// Transaction sender. +pub(crate) const CALLER: Address = address!("0000000000000000000000000000000000300000"); +/// Contract invoked by the transaction; its code exercises the opcodes under test. +pub(crate) const CONTRACT: Address = address!("0000000000000000000000000000000000300001"); +/// A second contract, used as the target of internal CALL-family frames. +pub(crate) const CALLEE: Address = address!("0000000000000000000000000000000000300002"); +/// A spare empty address used as a value-transfer / SELFDESTRUCT target. +pub(crate) const EMPTY_TARGET: Address = address!("0000000000000000000000000000000000300003"); + +/// One ether, in wei. +pub(crate) const ONE_ETH: u128 = 1_000_000_000_000_000_000; + +/// The transaction gas limit [`transact`] runs with — high enough that EVM gas is never the +/// binding constraint. +pub(crate) const DEFAULT_TX_GAS_LIMIT: u64 = 100_000_000; + +/// The standard fixture: a funded [`CALLER`], `code` at [`CONTRACT`], and a balance there for the +/// value transfers and SELFDESTRUCTs the fixtures make. +pub(crate) fn base_db(code: Bytes) -> MemoryDatabase { + MemoryDatabase::default() + .account_balance(CALLER, U256::from(10 * ONE_ETH)) + .account_code(CONTRACT, code) + .account_balance(CONTRACT, U256::from(ONE_ETH)) +} + +/// `pairs` PUSH/POP pairs: plain opcodes that touch nothing, for padding a segment out to a known +/// compute cost. +pub(crate) fn plain_filler(builder: BytecodeBuilder, pairs: usize) -> BytecodeBuilder { + let mut builder = builder; + for _ in 0..pairs { + builder = builder.push_number(1u64).append(POP); + } + builder +} + +/// A countdown loop of plain opcodes with no checkpoint anywhere in the body, after `prefix`, so +/// the run is one settlement segment and the gas clamp is the only thing enforcing the compute +/// limit inside it. +pub(crate) fn countdown_loop_code(prefix: &[u8], iterations: u16) -> Bytes { + let mut code = prefix.to_vec(); + code.push(0x61); // PUSH2 + code.extend_from_slice(&iterations.to_be_bytes()); + let loop_target = u8::try_from(code.len()).expect("loop target must fit in a PUSH1"); + code.push(JUMPDEST); + code.extend_from_slice(&[0x60, 0x01]); // PUSH1 1 + code.push(SWAP1); + code.push(SUB); + code.push(DUP1); + code.extend_from_slice(&[0x60, loop_target]); // PUSH1 loop + code.push(JUMPI); + code.push(STOP); + Bytes::from(code) +} + +/// A contract that does nothing, for measuring what a transaction costs before its code runs. +pub(crate) fn stop_only() -> Bytes { + BytecodeBuilder::default().append(STOP).build() +} + +/// The spec's default runtime limits with the per-transaction compute budget lowered to `limit`. +pub(crate) fn compute_limit(limit: u64) -> impl Fn(MegaSpecId) -> EvmTxRuntimeLimits { + move |spec| EvmTxRuntimeLimits::from_spec(spec).with_tx_compute_gas_limit(limit) +} + +/// [`compute_limit`] under REX7, for the files that never run a second spec. +pub(crate) fn rex7_compute_limit(limit: u64) -> EvmTxRuntimeLimits { + compute_limit(limit)(MegaSpecId::REX7) +} + +/// The spec's default runtime limits with the block-env detention cap lowered to `cap`. +pub(crate) fn detention_cap(cap: u64) -> impl Fn(MegaSpecId) -> EvmTxRuntimeLimits { + move |spec| { + let mut limits = EvmTxRuntimeLimits::from_spec(spec); + limits.block_env_access_compute_gas_limit = cap; + limits + } +} + +/// A deterministic pre-EIP-155 keyless deployment transaction, RLP-encoded. +/// +/// The signature is Nick's Method's: an unrecoverable `r == s` that no key produced, which is what +/// makes the sender deterministic and the deployment address the same on every chain. +pub(crate) fn keyless_tx_bytes(init_code: Bytes, gas_limit: u64) -> Bytes { + let tx = TxLegacy { + nonce: 0, + gas_price: 100_000_000_000, + gas_limit, + to: TxKind::Create, + value: U256::ZERO, + input: init_code, + chain_id: None, + }; + let word = U256::from_be_bytes(hex!( + "3333333333333333333333333333333333333333333333333333333333333333" + )); + let signed = Signed::new_unchecked(tx, Signature::new(word, word, false), B256::ZERO); + let mut buf = Vec::new(); + signed.rlp_encode(&mut buf); + Bytes::from(buf) +} + +/// The post-transaction readings compared across specs. +pub(crate) struct Outcome { + pub(crate) result: ExecutionResult, + /// Post-tx compute-gas tracker reading (`get_usage().compute_gas`). + pub(crate) compute_gas: u64, + /// Post-tx data-size tracker reading (`get_usage().data_size`). + pub(crate) data_size: u64, + /// Post-tx KV-update tracker reading (`get_usage().kv_updates`). + pub(crate) kv_updates: u64, + /// Post-tx state-growth tracker reading (`get_usage().state_growth`). + pub(crate) state_growth: u64, + /// Receipt `gas_used` (combined compute + storage EVM gas). + pub(crate) gas_used: u64, + /// The part of [`compute_gas`](Self::compute_gas) an exceptionally halted frame destroyed + /// rather than performed (REX7+, else 0). + pub(crate) destroyed: u64, + /// Post-tx enforced compute gas — the part of [`compute_gas`](Self::compute_gas) every limit + /// comparison and the block's admission counter run against. + enforced_lane: u64, + /// Receipt envelope before the EIP-3529 refund and the EIP-7623 floor: exactly the number + /// settlement derives the destroyed total from. + pub(crate) total_gas_spent: u64, + /// Post-tx detained compute gas limit — equal to the configured TX limit unless volatile + /// access lowered it. + pub(crate) detained_compute_gas_limit: u64, + /// The conservation law's terms, as the tracker held them when the transaction ended. + pub(crate) terms: ConservationTerms, + /// What the measurement shim booked for this transaction, as the outcome reports it. + pub(crate) inspector_ledger: InspectorLedger, + /// The state the transaction produced. + pub(crate) state: EvmState, +} + +impl Outcome { + pub(crate) fn is_success(&self) -> bool { + self.result.is_success() + } + + /// The halt reason, or a panic with `label` when the transaction did not halt. + pub(crate) fn halt_reason(&self, label: &str) -> &MegaHaltReason { + match &self.result { + ExecutionResult::Halt { reason, .. } => reason, + other => panic!("{label}: expected a halt, got {other:?}"), + } + } + + /// The part of the reported compute total that a resource limit is evaluated against. + /// + /// Read from the tracker's own lane rather than subtracted from the reported total; the two + /// agree because [`assert_terminal_identity`] checks that they do on every transaction the + /// helpers in this module run. + pub(crate) fn enforced(&self) -> u64 { + self.enforced_lane + } + + /// `S` — `MegaETH` storage gas plus the sandbox boundary residue. Signed. + pub(crate) fn non_compute_gas(&self) -> i128 { + self.terms.non_compute_gas + } + + /// `K` — the `CALL_STIPEND` total minted into child frames by value-transferring calls. + pub(crate) fn minted_call_stipend(&self) -> u64 { + self.terms.minted_call_stipend + } + + /// The sum of the per-site destroyed bookings — the second opinion the derived + /// [`destroyed`](Self::destroyed) is cross-checked against, never the reported number. + pub(crate) fn booked_destroyed(&self) -> u64 { + self.terms.booked_destroyed_compute_gas + } + + /// `I` — the net gas an inspector conjured. Zero for every transaction that ran without one, + /// and for every observation-only inspector. + pub(crate) fn inspector_conjured_gas(&self) -> i128 { + self.terms.inspector_conjured_gas + } + + /// The receipt's raw EIP-3529 refund, before the cap that decides how much of it applies. + pub(crate) fn refunded(&self) -> u64 { + self.result.gas().inner_refunded() + } + + /// The receipt's final EIP-8037 state-gas spend. + pub(crate) fn state_gas_spent(&self) -> u64 { + self.result.gas().state_gas_spent_final() + } + + /// The non-compute part of what this transaction's receipt reports — the `MegaETH` storage + /// gas and intrinsic share a compute-gas figure does not cover. + pub(crate) fn storage_overhead(&self) -> u64 { + self.gas_used - self.compute_gas + } + + /// Reads a storage slot out of the produced state, defaulting to zero when the transaction + /// never touched it. + pub(crate) fn storage_value(&self, address: Address, slot: U256) -> U256 { + self.state + .get(&address) + .and_then(|account| account.storage.get(&slot)) + .map(|value| value.present_value()) + .unwrap_or_default() + } +} + +/// The transaction every helper here runs unless a test supplies its own: a plain call from +/// [`CALLER`] into [`CONTRACT`]. +pub(crate) fn call_contract_tx(gas_limit: u64) -> MegaTransaction { + let tx = + TxEnvBuilder::default().caller(CALLER).call(CONTRACT).gas_limit(gas_limit).build_fill(); + let mut tx = MegaTransaction::new(tx); + tx.enveloped_tx = Some(Bytes::new()); + tx +} + +/// Zeroes the operator fee, which otherwise adds an L1 charge to every receipt the suite reads. +pub(crate) fn zero_operator_fee( + mut context: MegaContext<&mut MemoryDatabase, EXT>, +) -> MegaContext<&mut MemoryDatabase, EXT> { + context.modify_chain(|chain| { + chain.operator_fee_scalar = Some(U256::ZERO); + chain.operator_fee_constant = Some(U256::ZERO); + }); + context +} + +/// The context every helper here runs on: `spec`, `limits`, no operator fee, no external +/// environment. +pub(crate) fn context( + db: &mut MemoryDatabase, + spec: MegaSpecId, + limits: EvmTxRuntimeLimits, +) -> MegaContext<&mut MemoryDatabase, mega_evm::EmptyExternalEnv> { + zero_operator_fee(MegaContext::new(db, spec).with_tx_runtime_limits(limits)) +} + +/// Runs `tx` on `evm` and assembles the [`Outcome`], checking the terminal identity before handing +/// it back. +/// +/// Every helper in this module funnels through here, and so does every test that builds its own +/// EVM — so every REX7 transaction the suite runs, not just the ones written to look at gas, is a +/// check that the tracker lanes reconcile with the receipt the transaction produced. +pub(crate) fn drive<'db, INSP, EXT>( + spec: MegaSpecId, + evm: &mut MegaEvm<&'db mut MemoryDatabase, INSP, EXT>, + tx: MegaTransaction, +) -> Outcome +where + INSP: Inspector>, + EXT: ExternalEnvTypes, +{ + try_drive(spec, evm, tx) + .unwrap_or_else(|refusal| panic!("tx should not surface EVMError, got {}", refusal.error)) +} + +/// [`drive`] for a run the shim may refuse. +/// +/// A refusal produces no receipt at all, so there is no [`Outcome`] to read and the two numbers a +/// [`Refusal`] carries are the whole of what such a run leaves behind. +pub(crate) fn try_drive<'db, INSP, EXT>( + spec: MegaSpecId, + evm: &mut MegaEvm<&'db mut MemoryDatabase, INSP, EXT>, + tx: MegaTransaction, +) -> Result +where + INSP: Inspector>, + EXT: ExternalEnvTypes, +{ + let executed = evm.execute_transaction(tx); + let (detained_compute_gas_limit, terms, tracker_ledger) = { + let additional_limit = EvmTr::ctx_ref(evm).additional_limit.borrow(); + ( + additional_limit.detained_compute_gas_limit(), + additional_limit.conservation_terms(), + additional_limit.inspector_ledger(), + ) + }; + let outcome = match executed { + Ok(outcome) => outcome, + Err(e) => { + return Err(Refusal { + error: std::format!("{e:?}"), + rejected_rewrites: tracker_ledger.rejected_rewrites, + }) + } + }; + assert_eq!( + outcome.inspector_ledger, tracker_ledger, + "the outcome must report the ledger the shim booked, unchanged", + ); + let gas_used = outcome.result_and_state.result.tx_gas_used(); + let total_gas_spent = outcome.result_and_state.result.gas().total_gas_spent(); + let outcome = Outcome { + result: outcome.result_and_state.result, + compute_gas: outcome.compute_gas_used, + data_size: outcome.data_size, + kv_updates: outcome.kv_updates, + state_growth: outcome.state_growth_used, + gas_used, + destroyed: outcome.compute_gas_destroyed, + enforced_lane: outcome.compute_gas_enforced, + total_gas_spent, + detained_compute_gas_limit, + terms, + inspector_ledger: outcome.inspector_ledger, + state: outcome.result_and_state.state, + }; + assert_terminal_identity(spec, &outcome); + Ok(outcome) +} + +/// Runs a single transaction that calls [`CONTRACT`] under `spec` with the given DB and runtime +/// limits, returning the execution result plus the post-tx tracker readings and `gas_used`. +pub(crate) fn transact( + spec: MegaSpecId, + db: MemoryDatabase, + limits: EvmTxRuntimeLimits, +) -> Outcome { + transact_with_gas_limit(spec, db, limits, DEFAULT_TX_GAS_LIMIT) +} + +/// [`transact`] with an explicit transaction gas limit, for cases that need EVM gas itself to run +/// out. +pub(crate) fn transact_with_gas_limit( + spec: MegaSpecId, + mut db: MemoryDatabase, + limits: EvmTxRuntimeLimits, + gas_limit: u64, +) -> Outcome { + let mut evm = MegaEvm::new(context(&mut db, spec, limits)); + drive(spec, &mut evm, call_contract_tx(gas_limit)) +} + +/// Runs [`transact`] with the spec's default runtime limits. +pub(crate) fn transact_default(spec: MegaSpecId, db: MemoryDatabase) -> Outcome { + transact(spec, db, EvmTxRuntimeLimits::from_spec(spec)) +} + +/// [`transact`] with an inspector attached, borrowed so the caller can read it back afterwards. +/// +/// Runs the same fixture through the inspected frame loops. The identity every other helper here +/// checks holds on this path too, with the inspector's own term in it — which is the point: a +/// rewriting inspector must leave the transaction's numbers accountable, not merely plausible. +pub(crate) fn transact_inspected( + spec: MegaSpecId, + mut db: MemoryDatabase, + limits: EvmTxRuntimeLimits, + inspector: &mut I, +) -> Outcome +where + I: for<'a> Inspector>, +{ + let mut evm = MegaEvm::new(context(&mut db, spec, limits)).with_inspector(inspector); + drive(spec, &mut evm, call_contract_tx(DEFAULT_TX_GAS_LIMIT)) +} + +/// What a transaction the shim refused reports: the error it surfaced and the refusals counted. +/// +/// A refused rewrite produces no receipt at all, so there is no [`Outcome`] to read — these two +/// numbers are the whole of what such a run leaves behind. +pub(crate) struct Refusal { + /// The `EVMError` the refusal surfaced, rendered. + pub(crate) error: String, + /// How many rewrites the shim refused over the transaction. + pub(crate) rejected_rewrites: u32, +} + +/// [`transact_inspected`] for a run the shim is expected to refuse. +/// +/// Panics when the transaction produced a receipt, so a fixture that stops reaching the refused +/// shape fails rather than passing as a run that was never refused. +pub(crate) fn transact_inspected_refused( + spec: MegaSpecId, + mut db: MemoryDatabase, + limits: EvmTxRuntimeLimits, + inspector: &mut I, +) -> Refusal +where + I: for<'a> Inspector>, +{ + let mut evm = MegaEvm::new(context(&mut db, spec, limits)).with_inspector(inspector); + match try_drive(spec, &mut evm, call_contract_tx(DEFAULT_TX_GAS_LIMIT)) { + Ok(outcome) => { + panic!("the run was expected to be refused, but produced {:?}", outcome.result) + } + Err(refusal) => refusal, + } +} + +/// The external environment [`transact_tx`] runs with when a test does not need SALT buckets or +/// oracle storage of its own. Equivalent to the empty environment the other helpers use: every +/// bucket reports the minimum capacity and the oracle has no data. +pub(crate) fn default_envs() -> TestExternalEnvs { + TestExternalEnvs::new() +} + +/// The general entry point: an explicit transaction and an explicit external environment. +/// +/// The other helpers in this module fix the transaction to a plain call into [`CONTRACT`]; the +/// shapes that need a different one — EIP-7702 authorizations, system-originated callers, direct +/// calls into a system contract — build their own `TxEnv` and come through here. `envs` is borrowed +/// so a test can read back what execution recorded into it (oracle hints, for instance). +pub(crate) fn transact_tx( + spec: MegaSpecId, + db: MemoryDatabase, + limits: EvmTxRuntimeLimits, + tx: TxEnv, + envs: &TestExternalEnvs, +) -> Outcome { + let mut tx = MegaTransaction::new(tx); + tx.enveloped_tx = Some(Bytes::new()); + transact_mega_tx(spec, db, limits, tx, envs) +} + +/// [`transact_tx`] for the shapes that need the `MegaETH` transaction itself, not just its +/// `TxEnv` — a deposit's `source_hash` and `mint` live on the outer type. +pub(crate) fn transact_mega_tx( + spec: MegaSpecId, + mut db: MemoryDatabase, + limits: EvmTxRuntimeLimits, + tx: MegaTransaction, + envs: &TestExternalEnvs, +) -> Outcome { + let context = zero_operator_fee( + MegaContext::new(&mut db, spec) + .with_external_envs(envs.into()) + .with_tx_runtime_limits(limits), + ); + let mut evm = MegaEvm::new(context); + drive(spec, &mut evm, tx) +} + +/// [`transact`] with every SALT bucket reporting `bucket_capacity`. +/// +/// The SALT-scaled storage-gas charges (`SSTORE` set, new account, contract creation) are +/// `base × (capacity / MIN_BUCKET_SIZE − 1)`, so only a capacity above +/// [`mega_evm::MIN_BUCKET_SIZE`] makes them non-zero and exercises the paths that have to +/// exclude them from the compute-gas window. +pub(crate) fn transact_with_bucket_capacity( + spec: MegaSpecId, + db: MemoryDatabase, + limits: EvmTxRuntimeLimits, + bucket_capacity: u64, +) -> Outcome { + let envs = TestExternalEnvs::default().with_default_bucket_capacity(bucket_capacity); + transact_tx( + spec, + db, + limits, + TxEnvBuilder::default() + .caller(CALLER) + .call(CONTRACT) + .gas_limit(DEFAULT_TX_GAS_LIMIT) + .build_fill(), + &envs, + ) +} + +/// The identity every REX7 transaction that produces a receipt must satisfy, connecting what the +/// trackers hold to the number the receipt reports. +/// +/// # The identity +/// +/// For one transaction, write +/// +/// ```text +/// C = compute_gas reported compute total +/// E = enforced_lane the part limits and block admission compare against +/// D = destroyed the part that is reported and accounted but never enforced +/// N = non_compute_gas MegaETH storage gas plus the sandbox boundary residue (signed) +/// M = minted_call_stipend CALL_STIPEND minted into child frames and never debited from a caller +/// I = inspector_conjured_gas gas an inspector wrote into the execution that nothing debited +/// S = total_gas_spent the receipt envelope, before the refund and the floor +/// R = the receipt's raw refund +/// F = the receipt's EIP-7623 floor gas +/// ``` +/// +/// then +/// +/// ```text +/// (1) C = E + D +/// (2) E + N + D − M − I = S +/// (3) I = the ledger's own net +/// (4) receipt gas_used = max(S − R, F) +/// ``` +/// +/// (1) is the split of the reported total. (2) is `ConservationTerms::envelope_for`, the law +/// solved for the envelope; substituting (1) gives the equivalent receipt-facing form +/// `C + N − M − I = S`. (3) pins the law's inspector term to the ledger it is read from, so a +/// lane the shim books but the law never sees cannot pass. (4) is how a receipt's gas number is +/// built from its envelope. +/// +/// `I` is zero for every transaction that runs without an inspector and for every +/// observation-only one, so (2) is the plain two-term identity on all but the handful of runs +/// that attach a rewriting inspector — which is exactly where it earns its keep. +/// +/// # Why (2) needs no refund or floor correction +/// +/// The EIP-3529 refund and the EIP-7623 floor both move the number the receipt reports without +/// anyone having burnt the difference. Both are applied strictly after the envelope is final, and +/// both are carried on the result as their own fields rather than folded into the envelope, so +/// anchoring on `S` — the same value settlement reads — keeps them out of the identity entirely. +/// Substituting (2) into (4) gives the receipt-level form, which is what a reader normally wants: +/// +/// ```text +/// receipt gas_used = max(C + N − M − R, F) +/// ``` +/// +/// # What it catches +/// +/// (2) fails whenever a transaction's envelope moves without a `MegaETH` site accounting for it — +/// a settlement that never ran, a result rewritten after settlement, an upstream subsidy nobody +/// records. (1) fails when the reported split disagrees with the per-site bookings, which is what +/// the block's admission counter reads. Pre-REX7 specs have neither a destroyed lane nor a +/// non-compute lane, so (1) and (2) are REX7-only by construction; (3) is not, because the shim +/// is not spec-gated. +fn assert_terminal_identity(spec: MegaSpecId, outcome: &Outcome) { + assert_eq!( + outcome.terms.inspector_conjured_gas, + outcome.inspector_ledger.conjured_gas(), + "the law's `I` term is the ledger's net, and nothing else", + ); + if !spec.is_enabled(MegaSpecId::REX7) { + return; + } + assert_eq!( + outcome.compute_gas, + outcome.enforced_lane + outcome.destroyed, + "reported compute gas must split into enforced + destroyed; \ + compute={} enforced={} destroyed={} result={:?}", + outcome.compute_gas, + outcome.enforced_lane, + outcome.destroyed, + outcome.result, + ); + assert_eq!( + outcome.terms.envelope_for(outcome.destroyed), + i128::from(outcome.total_gas_spent), + "the tracker lanes must account for the whole receipt envelope; \ + reported compute={} destroyed={} envelope={} (receipt gas_used={}) result={:?} ({})", + outcome.compute_gas, + outcome.destroyed, + outcome.total_gas_spent, + outcome.gas_used, + outcome.result, + outcome.terms, + ); +} + +/// The part of an account a transaction's state actually asserts. +/// +/// Raw [`EvmState`] cannot be compared directly: `Account::transaction_id` and each storage slot's +/// `is_cold` are journal bookkeeping with no consensus meaning, and two runs that produce identical +/// state can still differ there. This keeps the account info, the deployed code, the status flags +/// that decide how the account is applied, and every slot's original/present pair. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct AccountView { + balance: U256, + nonce: u64, + code_hash: B256, + code: Bytes, + touched: bool, + created: bool, + selfdestructed: bool, + loaded_as_not_existing: bool, + storage: BTreeMap, +} + +/// Normalises an [`EvmState`] into a stable, order-independent view. +pub(crate) fn state_view(state: &EvmState) -> BTreeMap { + state + .iter() + .map(|(address, account)| { + let view = AccountView { + balance: account.info.balance, + nonce: account.info.nonce, + code_hash: account.info.code_hash, + code: account + .info + .code + .as_ref() + .map(|code| code.original_bytes()) + .unwrap_or_default(), + touched: account.is_touched(), + created: account.is_created(), + selfdestructed: account.is_selfdestructed(), + loaded_as_not_existing: account.is_loaded_as_not_existing(), + storage: account + .storage + .iter() + .map(|(slot, value)| (*slot, (value.original_value, value.present_value))) + .collect(), + }; + (*address, view) + }) + .collect() +} + +/// Asserts that two outcomes are indistinguishable: same execution result, same four-dimension +/// usage, same receipt `gas_used`, the same detained compute-gas limit, and the same state. +/// +/// This is the precision invariant in assertion form — what a transaction that stays inside every +/// per-tx limit must produce under both accounting models. +pub(crate) fn assert_outcomes_identical(label: &str, r6: &Outcome, r7: &Outcome) { + assert_eq!( + format!("{:?}", r6.result), + format!("{:?}", r7.result), + "{label}: execution result must be identical; REX6={:?} REX7={:?}", + r6.result, + r7.result + ); + assert_eq!( + r6.compute_gas, r7.compute_gas, + "{label}: compute gas must be identical; REX6={} REX7={}", + r6.compute_gas, r7.compute_gas + ); + assert_eq!( + r6.gas_used, r7.gas_used, + "{label}: receipt gas_used must be identical; REX6={} REX7={}", + r6.gas_used, r7.gas_used + ); + assert_eq!( + (r6.data_size, r6.kv_updates, r6.state_growth), + (r7.data_size, r7.kv_updates, r7.state_growth), + "{label}: the non-compute dimensions must be identical", + ); + assert_eq!( + r6.detained_compute_gas_limit, r7.detained_compute_gas_limit, + "{label}: the detained compute-gas limit must be identical; REX6={} REX7={}", + r6.detained_compute_gas_limit, r7.detained_compute_gas_limit + ); + let (s6, s7) = (state_view(&r6.state), state_view(&r7.state)); + if s6 != s7 { + // Report the first address the two disagree on; dumping both whole states buries it. + let mut addresses: Vec<&Address> = s6.keys().chain(s7.keys()).collect(); + addresses.sort_unstable(); + addresses.dedup(); + let culprit = addresses + .into_iter() + .find(|address| s6.get(*address) != s7.get(*address)) + .expect("the maps differ, so some address must"); + panic!( + "{label}: the produced state must be identical; {culprit} is\n REX6: {:?}\n REX7: {:?}", + s6.get(culprit), + s7.get(culprit), + ); + } +} diff --git a/crates/mega-evm/tests/rex7/conservation_terms.rs b/crates/mega-evm/tests/rex7/conservation_terms.rs new file mode 100644 index 00000000..90a96742 --- /dev/null +++ b/crates/mega-evm/tests/rex7/conservation_terms.rs @@ -0,0 +1,405 @@ +//! REX7: the terms of the destroyed-remainder conservation law, in combination. +//! +//! A REX7 transaction's destroyed compute gas is not summed from the sites that destroyed it. It +//! is derived once, when the envelope is final, from what the transaction spent: +//! +//! ```text +//! destroyed = spent + minted call stipends − non-compute gas − enforced compute gas +//! ``` +//! +//! The rest of the suite exercises the derivation one term at a time — a halted frame, a failing +//! precompile, a sandbox merge. This file exercises the terms **together**, because a term that is +//! individually right can still be wrong in company: a minted stipend that leaked into the +//! destroyed lane, or a second stipend that overwrote the first, would be invisible to any fixture +//! that produces only one of them. +//! +//! Two terms of the law are deliberately not covered here: +//! +//! - **The EIP-8037 reservoir.** The derivation reads `total_gas_spent`, which nets the state-gas +//! reservoir out of the envelope. Every `MegaEVM` transaction pins the reservoir at zero — the +//! flag is forced off at configuration time and re-forced inside the transaction, pinned by +//! `evm::factory`'s `test_embedder_cannot_enable_amsterdam_eip8037` — so a non-zero reservoir is +//! not a state this spec can reach, and it is not constructed here. If that pin is ever lifted, +//! the derivation needs revisiting before the reservoir can carry gas. +//! - **A negative derivation.** Reaching it end-to-end would require breaking the conservation law +//! first, so the guard is driven directly at the seam, in `limit::limit`'s +//! `test_negative_derivation_is_clamped_to_zero`. + +use crate::common::{ + default_envs, keyless_tx_bytes, transact_default, transact_tx, Outcome, CALLEE, CALLER, + CONTRACT, EMPTY_TARGET, ONE_ETH, +}; +use alloy_primitives::{address, Address, Bytes, U256}; +use alloy_sol_types::SolCall as _; +use mega_evm::{ + test_utils::{BytecodeBuilder, MemoryDatabase}, + EvmTxRuntimeLimits, IKeylessDeploy, MegaSpecId, KEYLESS_DEPLOY_ADDRESS, +}; +use revm::{ + bytecode::opcode::{ADD, CALL, MSTORE8, POP, STOP}, + context::tx::TxEnvBuilder, +}; + +/// Empty accounts the value-transferring calls pay into. Each one is fresh, so every call really +/// transfers value and really mints a stipend. +const VALUE_TARGETS: [Address; 3] = [ + EMPTY_TARGET, + address!("0000000000000000000000000000000000300010"), + address!("0000000000000000000000000000000000300011"), +]; + +/// Gas operand the envelope-destroying call forwards. Far below 63/64 of what the caller holds, so +/// the child's budget is exactly this number and the destroyed remainder is exactly computable. +const DESTROYING_CHILD_GAS: u64 = 1_000_000; + +/// `VERYLOW`, the gas `ADD` charges before it discovers the stack is empty — the only work the +/// destroying child performs. +const ADD_GAS: u64 = 3; + +/// What the destroying child leaves behind: its whole budget less the one opcode it paid for. +const EXPECTED_DESTROYED: u64 = DESTROYING_CHILD_GAS - ADD_GAS; + +/// `CALL_STIPEND`: what revm mints into a value-transferring call's child frame without debiting +/// the caller. +const CALL_STIPEND: u64 = 2_300; + +/// Relayer that sends the keyless-deploy transactions. +const KEYLESS_RELAYER: Address = address!("0000000000000000000000000000000000340004"); + +fn call_code(builder: BytecodeBuilder, target: Address, value: u64, gas: u64) -> BytecodeBuilder { + builder + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(0u64) // argsSize + .push_number(0u64) // argsOffset + .push_number(value) + .push_address(target) + .push_number(gas) + .append(CALL) + .append(POP) +} + +/// A caller that makes `value_transfers` one-wei calls into fresh empty accounts and then, +/// optionally, one call into a callee that destroys its whole forwarded envelope. +/// +/// The caller pops every success flag, so it survives the failing child and returns normally — +/// which keeps the destroyed remainder attributable to the child alone. +fn run_fixture(value_transfers: usize, destroy: bool) -> Outcome { + let mut builder = BytecodeBuilder::default(); + for target in VALUE_TARGETS.iter().take(value_transfers) { + builder = call_code(builder, *target, 1, 100_000); + } + if destroy { + builder = call_code(builder, CALLEE, 0, DESTROYING_CHILD_GAS); + } + let db = MemoryDatabase::default() + .account_balance(CALLER, U256::from(10 * ONE_ETH)) + .account_code(CONTRACT, builder.append(STOP).build()) + .account_balance(CONTRACT, U256::from(ONE_ETH)) + // A bare `ADD`: the child charges `VERYLOW`, finds the stack empty, and halts with its + // budget intact. A stack underflow is not a gas shortage, so the interpreter keeps its + // counter and the whole remainder is destroyed rather than already zero. + .account_code(CALLEE, Bytes::from_static(&[ADD])); + transact_default(MegaSpecId::REX7, db) +} + +/// A transaction that both mints a stipend and destroys an envelope must report exactly the +/// envelope it destroyed. +/// +/// The two terms pull the derivation in opposite directions — the mint raises recorded work above +/// what the envelope funded, the halt leaves envelope unspent — and they meet for the first time +/// here. If the mint leaked into the destroyed lane the reported remainder would be one stipend +/// too high; if it were dropped, one stipend too low. +#[test] +fn test_minted_stipend_and_destroyed_envelope_in_one_transaction() { + let alone = run_fixture(0, true); + let together = run_fixture(1, true); + + assert!(alone.is_success(), "the caller must survive its failing child: {:?}", alone.result); + assert!( + together.is_success(), + "the caller must survive its failing child: {:?}", + together.result, + ); + + // Both terms are live, and each is live only where it should be. + assert_eq!(alone.minted_call_stipend(), 0, "no value transfer, no mint"); + assert_eq!( + together.minted_call_stipend(), + CALL_STIPEND, + "one value transfer mints one stipend" + ); + assert!(together.gas_used > alone.gas_used, "the value transfer must really have happened"); + + assert_eq!( + together.destroyed, EXPECTED_DESTROYED, + "the destroyed remainder is the child's forwarded budget less the one opcode it paid for", + ); + assert_eq!( + together.destroyed, alone.destroyed, + "a minted stipend must not move the destroyed remainder in either direction", + ); + assert_eq!( + together.destroyed, + together.booked_destroyed(), + "the derived remainder and the per-site bookings must agree", + ); +} + +/// A minted stipend on its own destroys nothing. +/// +/// This is the other half of the composition: the mint makes recorded compute exceed the envelope, +/// and a derivation that took that overshoot for unspent budget would report it as destroyed. +#[test] +fn test_minted_stipend_alone_destroys_nothing() { + for transfers in 1..=VALUE_TARGETS.len() { + let outcome = run_fixture(transfers, false); + assert!(outcome.is_success(), "{transfers} transfers: {:?}", outcome.result); + assert_eq!( + outcome.destroyed, 0, + "{transfers} transfers: a transaction that never halts destroys nothing", + ); + } +} + +/// Several value transfers in one transaction each mint their own stipend, and the derivation has +/// to account for all of them. +/// +/// A term that latched the first mint instead of accumulating would leave the derived remainder +/// short by one stipend per extra call — which the invariance below is exactly sensitive to. +#[test] +fn test_several_minted_stipends_in_one_transaction() { + for transfers in 1..=VALUE_TARGETS.len() { + let outcome = run_fixture(transfers, true); + + assert!(outcome.is_success(), "{transfers} transfers: {:?}", outcome.result); + assert_eq!( + outcome.minted_call_stipend(), + CALL_STIPEND * transfers as u64, + "{transfers} transfers: every value-transferring call mints its own stipend", + ); + assert_eq!( + outcome.destroyed, EXPECTED_DESTROYED, + "{transfers} transfers: the destroyed remainder must not drift with the mint count", + ); + assert_eq!( + outcome.destroyed, + outcome.booked_destroyed(), + "{transfers} transfers: the derived remainder and the per-site bookings must agree", + ); + } +} + +/// A caller with no balance at all, so its value-transferring call cannot be funded. +/// +/// Everything else about the fixture matches [`run_fixture`]: the caller pops the call's success +/// flag and returns normally, so the transaction succeeds and the only destroyed remainder is the +/// one the destroying child leaves behind. +fn run_unfunded_fixture(destroy: bool) -> Outcome { + let mut builder = call_code(BytecodeBuilder::default(), EMPTY_TARGET, 1, 100_000); + if destroy { + builder = call_code(builder, CALLEE, 0, DESTROYING_CHILD_GAS); + } + let db = MemoryDatabase::default() + .account_balance(CALLER, U256::from(10 * ONE_ETH)) + .account_code(CONTRACT, builder.append(STOP).build()) + // No balance for CONTRACT: revm turns the value call away at frame init. + .account_code(CALLEE, Bytes::from_static(&[ADD])); + transact_default(MegaSpecId::REX7, db) +} + +/// A value-transferring call whose child frame never runs still mints a stipend, and the law needs +/// it booked. +/// +/// The stipend is created by the CALL opcode, before the callee is entered: the inherited EVM adds +/// it to the child's budget without debiting the caller. A call that is then turned away at frame +/// init — here for want of balance, equally for exceeding the call depth — hands that whole budget +/// back to the caller, mint included, so the envelope shrinks against recorded work by exactly one +/// stipend, the same way a child that ran and returned it would. +/// +/// Booking the mint on "the child frame ran" instead would leave the term short by 2,300 on this +/// shape, which any contract can produce, and the derived remainder short by the same amount: +/// [`EXPECTED_DESTROYED`] − [`CALL_STIPEND`] rather than [`EXPECTED_DESTROYED`]. +#[test] +fn test_stipend_is_minted_by_a_value_call_whose_child_frame_never_runs() { + let alone = run_unfunded_fixture(false); + let with_destroyed_envelope = run_unfunded_fixture(true); + + assert!(alone.is_success(), "the caller must survive its failed call: {:?}", alone.result); + assert!( + with_destroyed_envelope.is_success(), + "the caller must survive both children: {:?}", + with_destroyed_envelope.result, + ); + + // The child frame really never ran: an unfunded value call materialises nothing at the target. + assert!( + alone.state.get(&EMPTY_TARGET).is_none_or(|account| account.is_empty()), + "the value call must have been turned away before the target was touched", + ); + + assert_eq!( + alone.minted_call_stipend(), + CALL_STIPEND, + "the mint is created by the CALL opcode, not by the child frame", + ); + assert_eq!( + alone.destroyed, 0, + "a refunded child budget is not a destroyed one: nothing here was thrown away", + ); + + assert_eq!( + with_destroyed_envelope.minted_call_stipend(), + CALL_STIPEND, + "the failed value call still mints, with a destroying sibling alongside it", + ); + assert_eq!( + with_destroyed_envelope.destroyed, + EXPECTED_DESTROYED, + "only the destroying child's remainder is destroyed; dropping the mint would report {}", + EXPECTED_DESTROYED - CALL_STIPEND, + ); + assert_eq!( + with_destroyed_envelope.destroyed, + with_destroyed_envelope.booked_destroyed(), + "the derived remainder and the per-site bookings must agree", + ); +} + +/// Runs one `KeylessDeploy` whose sandbox executes `init_code` with `gas_limit`. +/// +/// [`CALLEE`] is preloaded with a bare `ADD` so a constructor can open a sub-frame that halts +/// exceptionally without failing the constructor itself. +fn keyless_deploy(init_code: Bytes, gas_limit: u64) -> Outcome { + let call_data = IKeylessDeploy::keylessDeployCall { + keylessDeploymentTransaction: keyless_tx_bytes(init_code, gas_limit), + gasLimitOverride: U256::from(gas_limit), + } + .abi_encode(); + let tx = TxEnvBuilder::default() + .caller(KEYLESS_RELAYER) + .call(KEYLESS_DEPLOY_ADDRESS) + .gas_limit(30_000_000) + .chain_id(Some(1)) + .data(Bytes::from(call_data)) + .build_fill(); + let db = MemoryDatabase::default() + .account_balance(KEYLESS_RELAYER, U256::from(10 * ONE_ETH)) + .account_code(CALLEE, Bytes::from_static(&[ADD])); + transact_tx( + MegaSpecId::REX7, + db, + EvmTxRuntimeLimits::from_spec(MegaSpecId::REX7), + tx, + &default_envs(), + ) +} + +/// Set/clear `SSTORE` pairs a constructor runs to earn the EIP-3529 refund. +/// +/// Twenty pairs is what it takes to outgrow the sandbox's own storage gas by a clear margin: each +/// pair returns 19,900, and the sandbox pays a flat transaction storage-gas intrinsic plus the +/// per-token storage gas on the initcode it carries. +const REFUND_PAIRS: u64 = 20; + +/// Memory offset the refunding constructors touch. The expansion cost is quadratic, so one +/// `MSTORE8` this far out buys millions of gas from a handful of bytecode bytes — enough for +/// EIP-3529's one-fifth-of-gas-spent cap to stop binding on the refunds above. +const REFUND_MEM_OFFSET: u64 = 1_500_000; + +/// Sandbox gas limit the refunding deployments run with: above what the memory expansion costs, so +/// the constructor reaches its own end rather than running out. +const REFUND_SANDBOX_GAS: u64 = 8_000_000; + +/// The refund-earning prologue: `pairs` set/clear `SSTORE` pairs, then the memory expansion that +/// lifts the refund cap. +fn refunding_prologue(pairs: u64) -> BytecodeBuilder { + let mut builder = BytecodeBuilder::default(); + for slot in 1..=pairs { + builder = builder.sstore(U256::from(slot), U256::from(1)); + builder = builder.sstore(U256::from(slot), U256::ZERO); + } + builder.push_number(1u64).push_number(REFUND_MEM_OFFSET).append(MSTORE8) +} + +/// A constructor that earns `pairs` refunds and then returns, deploying empty code — so no +/// per-byte code-deposit storage gas offsets the refund. +fn refunding_initcode(pairs: u64) -> Bytes { + refunding_prologue(pairs).return_empty().build() +} + +/// The non-compute lane is signed, and this is the shape that needs it. +/// +/// The lane is the transaction's `MegaETH` storage gas, except at the `KeylessDeploy` sandbox +/// boundary, where the parent books a **difference**: what the sandbox cost its gas counter, less +/// what the sandbox recorded as compute work. A sandbox whose own EIP-3529 refund outgrows its own +/// storage gas hands that difference over negative, and a large enough refund drives the whole +/// lane below zero. A lane that saturated at zero would then over-report the destroyed remainder +/// by the entire overshoot. +/// +/// The control run is the same deployment without the refunds, which leaves the lane positive. +#[test] +fn test_sandbox_refund_drives_the_non_compute_lane_negative() { + let control = keyless_deploy(refunding_initcode(0), REFUND_SANDBOX_GAS); + let refunding = keyless_deploy(refunding_initcode(REFUND_PAIRS), REFUND_SANDBOX_GAS); + + assert!(control.is_success(), "the control deployment must run: {:?}", control.result); + assert!(refunding.is_success(), "the refunding deployment must run: {:?}", refunding.result); + assert!( + control.non_compute_gas() > 0, + "the control must leave the lane positive, or it proves nothing; got {}", + control.non_compute_gas(), + ); + + assert!( + refunding.non_compute_gas() < 0, + "the sandbox's refund must drive the lane negative; got {}", + refunding.non_compute_gas(), + ); + // The signature of a negative lane: the transaction records more compute work than its + // envelope ever paid for. + assert!( + refunding.compute_gas > refunding.gas_used, + "recorded compute {} must exceed the envelope {}", + refunding.compute_gas, + refunding.gas_used, + ); + assert_eq!( + refunding.destroyed, + refunding.booked_destroyed(), + "the derivation must stay exact across the sign change", + ); +} + +/// The negative lane must compose with a destroyed envelope too. +/// +/// The constructor earns its refunds, opens a sub-frame that halts exceptionally, absorbs the +/// failure and returns — so one transaction carries a negative non-compute lane *and* a destroyed +/// remainder that the lane's sign is part of deriving. The constructor has to survive: EIP-3529 +/// refunds only reach the receipt of a transaction that succeeds, so a constructor that halted +/// would take the refund — and the negative lane — down with it. +#[test] +fn test_negative_non_compute_lane_composes_with_a_destroyed_envelope() { + let init_code = call_code(refunding_prologue(REFUND_PAIRS), CALLEE, 0, DESTROYING_CHILD_GAS) + .return_empty() + .build(); + + let outcome = keyless_deploy(init_code, REFUND_SANDBOX_GAS); + + assert!(outcome.is_success(), "the deployment must succeed: {:?}", outcome.result); + assert_eq!( + outcome.destroyed, EXPECTED_DESTROYED, + "the halted sub-frame's whole forwarded budget, less the one opcode it paid for, must \ + cross the sandbox boundary as destroyed", + ); + assert!( + outcome.non_compute_gas() < 0, + "the refunds must still drive the lane negative; got {}", + outcome.non_compute_gas(), + ); + assert_eq!( + outcome.destroyed, + outcome.booked_destroyed(), + "the derivation must stay exact with both terms live", + ); +} diff --git a/crates/mega-evm/tests/rex7/create_code_deposit_charge.rs b/crates/mega-evm/tests/rex7/create_code_deposit_charge.rs new file mode 100644 index 00000000..f84eb13d --- /dev/null +++ b/crates/mega-evm/tests/rex7/create_code_deposit_charge.rs @@ -0,0 +1,503 @@ +//! REX7: the canonical code-deposit compute gas of a CREATE frame is recorded only when the +//! deposit actually happens. +//! +//! revm charges a successful CREATE the active gas schedule's per-byte code-deposit rate when it +//! processes the frame's action, and only then — a frame whose result is no longer successful at +//! that point pays nothing and deposits nothing. `MegaETH` has to decide the charge one step +//! earlier, before the action is processed, because a compute-limit exceed discovered after the +//! CREATE checkpoint is committed would leave the frame's state changes in the journal under a +//! reverted result. +//! +//! Deciding early is not the same as charging early. REX5 and REX6 record the charge and then let +//! the latched exceed mark the result, which leaves the tracker holding compute gas for a deposit +//! that never happened. REX7 asks first: the charge is weighed against the frame's completed usage +//! and recorded only on the answer that lets the deposit go through. The other two answers stop the +//! frame — a frame-local exceed reverts it, a TX-level exceed halts the transaction — and record +//! nothing, which is what keeps the reported compute total equal to the gas the transaction spent. +//! +//! These tests pin the four rows of that decision, the frozen REX4-REX6 shapes they must not +//! disturb, and the journal consistency that the early decision exists for in the first place. +//! +//! One shape that used to be pinned here is gone: a knife-edge CREATE holding exactly revm's +//! built-in per-byte charge under a schedule that charged more, which separated a predicate +//! reading the active schedule from one reading the constant. That fork needed a configuration +//! carrying a gas schedule other than its spec's, and such a configuration is now rejected at +//! every entry point into the EVM rather than run, so the two readings can no longer disagree on +//! any input. What survives here is the pair that is still decidable: installing the built-in +//! rate explicitly changes nothing, and installing anything else is turned away. + +use crate::common::{ + default_envs, drive, transact_tx, zero_operator_fee, Outcome, CALLER, ONE_ETH, +}; +use alloy_primitives::{Address, Bytes, TxKind, U256}; +use alloy_sol_types::SolError as _; +use mega_evm::{ + test_utils::{BytecodeBuilder, MemoryDatabase}, + EvmTxRuntimeLimits, LimitKind, MegaContext, MegaEvm, MegaHaltReason, MegaLimitExceeded, + MegaSpecId, MegaTransaction, MegaTransactionNew as _, +}; +use revm::{ + bytecode::opcode::{MSTORE, POP, RETURN, TIMESTAMP}, + context::{result::ExecutionResult, tx::TxEnvBuilder, CfgEnv}, + context_interface::cfg::GasId, + state::EvmState, +}; + +/// revm's per-byte code-deposit gas (`revm::interpreter::gas::CODEDEPOSIT`). +const CODEDEPOSIT: u64 = 200; + +/// Runtime code size the constructor returns. Small enough that the `MegaETH` code-deposit storage +/// charge (10,000 per byte) stays well inside the transaction gas limit. +const RUNTIME_LEN: u64 = 100; + +/// The canonical code-deposit compute gas for [`RUNTIME_LEN`] bytes — the charge under test. +const CODE_DEPOSIT_GAS: u64 = RUNTIME_LEN * CODEDEPOSIT; + +/// Transaction gas limit: covers the constructor, the 1,000,000 `MegaETH` code-deposit storage +/// charge and the canonical charge many times over. +const TX_GAS_LIMIT: u64 = 10_000_000; + +/// Init code that returns `len` zero bytes from memory, and nothing else. +fn return_zeros_initcode(len: u64) -> Bytes { + BytecodeBuilder::default().push_number(len).push_number(0u64).append(RETURN).build() +} + +/// The address the first CREATE from [`CALLER`] deploys to. +fn deployed_address() -> Address { + CALLER.create(0) +} + +/// Whether the produced state actually carries deployed code at `address`. +/// +/// Reads the state delta rather than the journal: it is what the transaction reports as committed, +/// so it answers the question a caller of the CREATE would ask. +fn has_deployed_code(state: &EvmState, address: Address) -> bool { + state + .get(&address) + .map(|account| { + account.info.code.as_ref().is_some_and(|code| !code.is_empty()) || + account.info.code_hash != revm::primitives::KECCAK_EMPTY + }) + .unwrap_or(false) +} + +/// Runs `init_code` as a creation transaction under `spec` with `limits`. +fn create(spec: MegaSpecId, limits: EvmTxRuntimeLimits, init_code: Bytes) -> Outcome { + let db = MemoryDatabase::default().account_balance(CALLER, U256::from(10 * ONE_ETH)); + let tx = TxEnvBuilder::default() + .caller(CALLER) + .kind(TxKind::Create) + .gas_limit(TX_GAS_LIMIT) + .gas_price(0) + .data(init_code) + .build_fill(); + transact_tx(spec, db, limits, tx, &default_envs()) +} + +/// The unconstrained run of the standard fixture: a CREATE that deposits [`RUNTIME_LEN`] bytes with +/// no resource limit in the way. Used to calibrate the limits the constrained runs sit against. +fn calibrate(spec: MegaSpecId) -> Outcome { + let outcome = create(spec, EvmTxRuntimeLimits::no_limits(), return_zeros_initcode(RUNTIME_LEN)); + assert!( + outcome.is_success(), + "{spec:?}: the calibration run must deploy: {:?}", + outcome.result + ); + assert!( + has_deployed_code(&outcome.state, deployed_address()), + "{spec:?}: the calibration run must leave code behind", + ); + outcome +} + +/// The revert payload of an absorbed frame-local limit exceed. +fn revert_payload(label: &str, outcome: &Outcome) -> Bytes { + match &outcome.result { + ExecutionResult::Revert { output, .. } => output.clone(), + other => panic!("{label}: expected a revert, got {other:?}"), + } +} + +/// A frame-local compute exceed produced by the code-deposit charge stops the deposit, and REX7 +/// records nothing for it. +/// +/// The compute limit is set one gas below what the whole transaction needs, so the constructor runs +/// to a successful RETURN and only the code-deposit charge is unaffordable. REX6 is run beside it: +/// the two must produce the same failure, the same revert payload and the same receipt, and differ +/// only in the compute total they report — REX6 by exactly the charge it recorded and then did not +/// spend. +#[test] +fn test_create_frame_local_code_deposit_exceed_records_nothing() { + let deployed = calibrate(MegaSpecId::REX7); + let full_compute = deployed.compute_gas; + assert_eq!( + calibrate(MegaSpecId::REX6).compute_gas, + full_compute, + "the specs must agree on a successful CREATE's compute total, or the shared limit below \ + would not mean the same thing to both", + ); + + let limits = EvmTxRuntimeLimits::no_limits().with_tx_compute_gas_limit(full_compute - 1); + let rex6 = create(MegaSpecId::REX6, limits, return_zeros_initcode(RUNTIME_LEN)); + let rex7 = create(MegaSpecId::REX7, limits, return_zeros_initcode(RUNTIME_LEN)); + + // The frame fails, and its journal fails with it: no code is deployed under either spec. + for (label, outcome) in [("REX6", &rex6), ("REX7", &rex7)] { + assert!( + matches!(outcome.result, ExecutionResult::Revert { .. }), + "{label}: the frame-local exceed must be absorbed into a revert: {:?}", + outcome.result, + ); + assert!( + !has_deployed_code(&outcome.state, deployed_address()), + "{label}: a reverted CREATE must leave no code behind", + ); + } + + // The parent's view is bit-identical: same payload, same receipt. + assert_eq!( + revert_payload("REX7", &rex7), + revert_payload("REX6", &rex6), + "the revert payload must not change", + ); + assert_eq!( + MegaLimitExceeded::abi_decode(&revert_payload("REX7", &rex7)) + .expect("the payload must be a MegaLimitExceeded") + .kind, + LimitKind::ComputeGas.as_u8(), + "the revert must blame compute gas", + ); + assert_eq!(rex7.gas_used, rex6.gas_used, "the receipt's gas must not change"); + + // What does change is the compute total: REX6 holds a charge nobody spent, REX7 does not. + assert_eq!( + rex6.compute_gas, + rex7.compute_gas + CODE_DEPOSIT_GAS, + "REX6 must keep recording the unspent code-deposit charge (rex6={}, rex7={})", + rex6.compute_gas, + rex7.compute_gas, + ); + assert_eq!( + rex7.compute_gas, + full_compute - CODE_DEPOSIT_GAS, + "REX7's total must be the successful run's minus exactly the charge that never happened", + ); + + // Nothing was destroyed and nothing was latched: with the charge not made, the transaction is + // within its limits and the reverted frame is an ordinary revert. + assert_eq!(rex7.destroyed, 0, "a reverted frame keeps its gas; nothing is destroyed"); + assert_eq!(rex7.booked_destroyed(), 0, "no site may book a destroyed remainder here"); + assert_eq!( + rex7.enforced(), + rex7.compute_gas, + "the whole reported total enforces when nothing is destroyed", + ); +} + +/// The knife edge of the same decision: the compute limit that exactly affords the charge deploys, +/// one gas less reverts. Both sides stay accounted for. +#[test] +fn test_create_code_deposit_charge_knife_edge() { + let full_compute = calibrate(MegaSpecId::REX7).compute_gas; + + let exact = create( + MegaSpecId::REX7, + EvmTxRuntimeLimits::no_limits().with_tx_compute_gas_limit(full_compute), + return_zeros_initcode(RUNTIME_LEN), + ); + assert!( + exact.is_success(), + "a limit exactly equal to the transaction's compute total must deploy: {:?}", + exact.result, + ); + assert!( + has_deployed_code(&exact.state, deployed_address()), + "the exactly-affordable CREATE must leave code behind", + ); + assert_eq!( + exact.compute_gas, full_compute, + "the affordable charge is recorded like any other work", + ); + + let short = create( + MegaSpecId::REX7, + EvmTxRuntimeLimits::no_limits().with_tx_compute_gas_limit(full_compute - 1), + return_zeros_initcode(RUNTIME_LEN), + ); + assert!( + matches!(short.result, ExecutionResult::Revert { .. }), + "one gas short must revert: {:?}", + short.result, + ); + assert!( + !has_deployed_code(&short.state, deployed_address()), + "one gas short must leave no code behind", + ); + assert_eq!( + short.compute_gas, + full_compute - CODE_DEPOSIT_GAS, + "one gas short must record the frame's work and none of the charge", + ); +} + +/// The same decision reached through a dimension that is not compute gas. +/// +/// The data-size tracker records the deployed code's size as the frame ends, one step before the +/// code-deposit charge is settled. When that record puts the frame over its data-size budget the +/// frame reverts, so revm never charges the deposit — and REX7 must not have recorded it either. +#[test] +fn test_create_data_size_exceed_at_frame_exit_records_no_code_deposit() { + let deployed = calibrate(MegaSpecId::REX7); + let full_compute = deployed.compute_gas; + let full_data_size = deployed.data_size; + + let limits = EvmTxRuntimeLimits::no_limits().with_tx_data_size_limit(full_data_size - 1); + let rex6 = create(MegaSpecId::REX6, limits, return_zeros_initcode(RUNTIME_LEN)); + let rex7 = create(MegaSpecId::REX7, limits, return_zeros_initcode(RUNTIME_LEN)); + + for (label, outcome) in [("REX6", &rex6), ("REX7", &rex7)] { + assert!( + matches!(outcome.result, ExecutionResult::Revert { .. }), + "{label}: the data-size exceed must be absorbed into a revert: {:?}", + outcome.result, + ); + assert!( + !has_deployed_code(&outcome.state, deployed_address()), + "{label}: a reverted CREATE must leave no code behind", + ); + } + assert_eq!( + MegaLimitExceeded::abi_decode(&revert_payload("REX7", &rex7)) + .expect("the payload must be a MegaLimitExceeded") + .kind, + LimitKind::DataSize.as_u8(), + "the revert must blame data size", + ); + assert_eq!(rex7.gas_used, rex6.gas_used, "the receipt's gas must not change"); + assert_eq!( + rex7.compute_gas, + full_compute - CODE_DEPOSIT_GAS, + "a frame that failed on another dimension must not be charged for a deposit it never made", + ); + assert_eq!( + rex6.compute_gas, full_compute, + "REX6 keeps recording the charge whatever the frame's fate", + ); +} + +/// The reverted CREATE's other tracked usage goes with it: the state growth of an account that was +/// never deployed is discarded when the frame pops. +#[test] +fn test_create_frame_local_exceed_discards_state_growth() { + let deployed = calibrate(MegaSpecId::REX7); + assert!( + deployed.state_growth > 0, + "the successful CREATE must record state growth for the new account", + ); + + let short = create( + MegaSpecId::REX7, + EvmTxRuntimeLimits::no_limits().with_tx_compute_gas_limit(deployed.compute_gas - 1), + return_zeros_initcode(RUNTIME_LEN), + ); + assert!(matches!(short.result, ExecutionResult::Revert { .. }), "{:?}", short.result); + assert_eq!( + short.state_growth, 0, + "the reverted frame's state growth must be discarded with the frame", + ); +} + +/// Init code that detains the transaction, burns most of the detained budget in one memory +/// expansion, and then returns `len` bytes of runtime code. +/// +/// `TIMESTAMP` caps the transaction's remaining compute gas relative to usage at that point; the +/// `MSTORE` then spends nearly all of that cap. The frame's own budget is untouched by detention, +/// so what the code-deposit charge runs into afterwards is the transaction limit alone. +fn detained_burn_initcode(mstore_offset: u64, len: u64) -> Bytes { + BytecodeBuilder::default() + .append(TIMESTAMP) + .append(POP) + .push_number(0u64) + .push_number(mstore_offset) + .append(MSTORE) + .push_number(len) + .push_number(0u64) + .append(RETURN) + .build() +} + +/// A TX-level exceed produced by the code-deposit charge halts the transaction and rescues its +/// remaining gas, and REX7 records nothing for the charge that caused it. +/// +/// Detention is what separates the two budgets: it lowers the transaction's compute limit without +/// touching the frame's, so the charge can be unaffordable for the transaction while the frame +/// still has room. The halt must keep blaming detention, which it can no longer read off usage — +/// the charge that crossed the limit was never recorded. +#[test] +fn test_create_tx_level_code_deposit_exceed_halts_and_blames_detention() { + // Memory offset chosen so the expansion costs ~19.9M of the 20M detention cap, leaving less + // than the 200,000-gas code-deposit charge but more than zero. + const BURN_OFFSET: u64 = 3_205_568; + const DETAINED_RUNTIME_LEN: u64 = 1_000; + const DETAINED_TX_GAS_LIMIT: u64 = 50_000_000; + + let init_code = detained_burn_initcode(BURN_OFFSET, DETAINED_RUNTIME_LEN); + let run = |spec: MegaSpecId| { + let db = MemoryDatabase::default().account_balance(CALLER, U256::from(10 * ONE_ETH)); + let tx = TxEnvBuilder::default() + .caller(CALLER) + .kind(TxKind::Create) + .gas_limit(DETAINED_TX_GAS_LIMIT) + .gas_price(0) + .data(init_code.clone()) + .build_fill(); + transact_tx(spec, db, EvmTxRuntimeLimits::from_spec(spec), tx, &default_envs()) + }; + + let rex6 = run(MegaSpecId::REX6); + let rex7 = run(MegaSpecId::REX7); + + for (label, outcome) in [("REX6", &rex6), ("REX7", &rex7)] { + assert!( + matches!(outcome.halt_reason(label), MegaHaltReason::VolatileDataAccessOutOfGas { .. }), + "{label}: the halt must blame detention: {:?}", + outcome.result, + ); + assert!( + !has_deployed_code(&outcome.state, deployed_address()), + "{label}: a halted CREATE must leave no code behind", + ); + } + + assert_eq!( + rex7.gas_used, rex6.gas_used, + "the rescued receipt must not change (rex6={}, rex7={})", + rex6.gas_used, rex7.gas_used, + ); + + // What proves the halt came from the charge rather than from usage crossing on its own: REX7's + // recorded usage never reaches the detained limit, so the only thing left that can classify + // this halt as detention is the flag the charge's settlement set. + assert!( + rex7.enforced() <= rex7.detained_compute_gas_limit, + "REX7's usage must stay within the detained limit (usage={}, limit={})", + rex7.enforced(), + rex7.detained_compute_gas_limit, + ); + assert!( + rex6.enforced() > rex6.detained_compute_gas_limit, + "REX6's usage crosses the limit because it recorded the charge (usage={}, limit={})", + rex6.enforced(), + rex6.detained_compute_gas_limit, + ); + assert_eq!( + rex6.compute_gas, + rex7.compute_gas + DETAINED_RUNTIME_LEN * CODEDEPOSIT, + "REX6 records the charge that halted it, REX7 does not (rex6={}, rex7={})", + rex6.compute_gas, + rex7.compute_gas, + ); +} + +/// The frozen shapes this decision sits on top of. +/// +/// Deciding the charge before the action is processed is what keeps a CREATE's journal and its +/// reported result in agreement, and that has been true since REX5 — REX4 is the last spec that +/// reports a revert over a committed deployment. REX7 changes what is recorded, not this. +#[test] +fn test_frozen_specs_keep_their_create_journal_shapes() { + let full_compute = calibrate(MegaSpecId::REX5).compute_gas; + let limits = EvmTxRuntimeLimits::no_limits().with_tx_compute_gas_limit(full_compute - 1); + + let rex4 = create(MegaSpecId::REX4, limits, return_zeros_initcode(RUNTIME_LEN)); + assert!( + matches!(rex4.result, ExecutionResult::Revert { .. }), + "REX4 reports a revert: {:?}", + rex4.result, + ); + assert!( + has_deployed_code(&rex4.state, deployed_address()), + "REX4 keeps its split outcome: the deployment stands under the reverted result", + ); + + for spec in [MegaSpecId::REX5, MegaSpecId::REX6, MegaSpecId::REX7] { + let outcome = create(spec, limits, return_zeros_initcode(RUNTIME_LEN)); + assert!( + matches!(outcome.result, ExecutionResult::Revert { .. }), + "{spec:?} reports a revert: {:?}", + outcome.result, + ); + assert!( + !has_deployed_code(&outcome.state, deployed_address()), + "{spec:?} must roll the deployment back with the result", + ); + } +} + +/// A per-byte code-deposit rate one gas above revm's built-in [`CODEDEPOSIT`] — the smallest +/// deviation from the schedule `REX7` defines, and one no configuration may carry. +const OVERRIDDEN_CODEDEPOSIT: u64 = CODEDEPOSIT + 1; + +/// Runs [`return_zeros_initcode`] as a REX7 creation transaction with `gas_limit`, under a +/// configuration whose gas schedule charges `rate` gas per deployed byte. +/// +/// The shared helpers all take the context's default configuration, so this builds the context +/// itself — everything else about the transaction matches [`create`]. A `rate` other than the +/// one `REX7`'s schedule defines makes the configuration inadmissible, and `with_cfg` panics +/// before any transaction runs. +fn create_at_rate(rate: u64, gas_limit: u64) -> Outcome { + let mut db = MemoryDatabase::default().account_balance(CALLER, U256::from(10 * ONE_ETH)); + let mut cfg = CfgEnv::new_with_spec(MegaSpecId::REX7); + cfg.gas_params.override_gas([(GasId::code_deposit_cost(), rate)]); + let context = zero_operator_fee( + MegaContext::new(&mut db, MegaSpecId::REX7) + .with_cfg(cfg) + .with_tx_runtime_limits(EvmTxRuntimeLimits::no_limits()), + ); + let tx = TxEnvBuilder::default() + .caller(CALLER) + .kind(TxKind::Create) + .gas_limit(gas_limit) + .gas_price(0) + .data(return_zeros_initcode(RUNTIME_LEN)) + .build_fill(); + let mut tx = MegaTransaction::new(tx); + tx.enveloped_tx = Some(Bytes::new()); + let mut evm = MegaEvm::new(context); + drive(MegaSpecId::REX7, &mut evm, tx) +} + +/// Installing a rate explicitly is not itself a deviation: a schedule that names revm's built-in +/// per-byte rate is the schedule `REX7` defines, is admitted, and measures exactly what the +/// shared helper measures on the default configuration. +#[test] +fn test_installing_the_built_in_code_deposit_rate_changes_nothing() { + let explicit = create_at_rate(CODEDEPOSIT, TX_GAS_LIMIT); + + assert!(explicit.is_success(), "the explicit-rate run must deploy: {:?}", explicit.result); + assert!( + has_deployed_code(&explicit.state, deployed_address()), + "the explicit-rate run must leave code behind", + ); + assert_eq!( + explicit.compute_gas, + calibrate(MegaSpecId::REX7).compute_gas, + "naming the built-in rate must not change what the shared helper measures", + ); + assert_eq!(explicit.destroyed, 0, "a successful CREATE destroys nothing"); + assert_eq!( + explicit.enforced(), + explicit.compute_gas, + "the whole reported total enforces when nothing is destroyed", + ); +} + +/// A schedule that charges a different per-byte rate never runs. `MegaETH`'s gas schedule is +/// defined by the spec, and a configuration carrying any other one is rejected where it enters +/// rather than executed — which is what keeps the charge revm debits at the create-return equal +/// to the one the tracker weighed and recorded a step earlier. +#[test] +#[should_panic(expected = "gas params differ from the spec-defined schedule")] +fn test_a_code_deposit_rate_off_the_spec_schedule_is_rejected() { + let _ = create_at_rate(OVERRIDDEN_CODEDEPOSIT, TX_GAS_LIMIT); +} diff --git a/crates/mega-evm/tests/rex7/deposit_receipt_rewrite.rs b/crates/mega-evm/tests/rex7/deposit_receipt_rewrite.rs new file mode 100644 index 00000000..0589a7d5 --- /dev/null +++ b/crates/mega-evm/tests/rex7/deposit_receipt_rewrite.rs @@ -0,0 +1,460 @@ +//! A failed OP deposit's receipt is rewritten to report the whole gas limit, after every `MegaETH` +//! settlement has already run. +//! +//! An OP deposit is not allowed to fail, so when one does, its receipt is rebuilt at the outermost +//! error boundary: the journal is rolled back to nothing but the nonce bump and the mint, and the +//! reported gas becomes the transaction's whole `gas_limit`. Two shapes arrive there, and they +//! start from opposite accounting positions: +//! +//! - a validation reject, which never reached a settlement at all, so its lanes hold only the +//! intrinsic compute gas `validate` recorded before returning the error; +//! - an execution halt, which settled correctly against the envelope it burnt and is then raised +//! back to `gas_limit`, re-taking whatever the resource-limit rescue had handed back. +//! +//! Both end at the same place: a receipt burning an envelope for which nothing was executed. The +//! boundary books the difference as destroyed compute gas, so the reported total covers the +//! receipt while the enforced total — what the per-tx limits and the block's admission counter +//! read — stays exactly the work the transaction performed. +//! +//! What is deliberately not affected: pre-REX7 specs, which have no destroyed lane and whose lane +//! values here must stay what they always were; and the keyless-deploy sandbox, whose own rejected +//! transactions never settle a derivation, because the law is stated over an outer transaction's +//! final envelope. + +use crate::common::{keyless_tx_bytes, transact_mega_tx, transact_tx, Outcome, ONE_ETH}; +use alloy_primitives::{address, Address, Bytes, B256, U256}; +use alloy_sol_types::{SolCall as _, SolError as _}; +use mega_evm::{ + constants::rex::TX_INTRINSIC_STORAGE_GAS, + test_utils::{BytecodeBuilder, MemoryDatabase}, + EvmTxRuntimeLimits, IKeylessDeploy, MegaContext, MegaEvm, MegaSpecId, MegaTransaction, + MegaTransactionNew as _, TestExternalEnvs, KEYLESS_DEPLOY_ADDRESS, + MEGA_SYSTEM_TRANSACTION_SOURCE_HASH, +}; +use revm::{ + bytecode::opcode::{INVALID, JUMP, JUMPDEST}, + context::{result::ExecutionResult, tx::TxEnvBuilder}, + inspector::NoOpInspector, +}; +use std::vec::Vec; + +/// Sender of the deposit transactions. +const DEPOSIT_CALLER: Address = address!("0000000000000000000000000000000000350000"); +/// Callee of the deposit transactions. +const TARGET: Address = address!("0000000000000000000000000000000000350001"); +/// Relayer that sends the keyless-deploy transaction. +const RELAYER: Address = address!("0000000000000000000000000000000000350002"); + +/// Standard EVM intrinsic gas for a plain call with no calldata and no access list — the whole of +/// what `validate` records as compute gas before the first frame opens. +const BASE_INTRINSIC_GAS: u64 = 21_000; + +/// What a plain deposit call must supply before a frame can open: the standard EVM intrinsic plus +/// `MegaETH`'s flat intrinsic storage gas, which is charged to the envelope but is not compute. +const INTRINSIC_REQUIREMENT: u64 = BASE_INTRINSIC_GAS + TX_INTRINSIC_STORAGE_GAS; + +/// A source hash no `MegaETH` component produces, so the deposit is an ordinary user deposit +/// rather than a system-originated one. +fn user_source_hash() -> B256 { + B256::repeat_byte(0x42) +} + +/// A deposit transaction calling [`TARGET`], with the given source hash and gas limit. +fn deposit_tx(source_hash: B256, gas_limit: u64) -> MegaTransaction { + let mut tx = MegaTransaction::new( + TxEnvBuilder::default() + .caller(DEPOSIT_CALLER) + .call(TARGET) + .gas_limit(gas_limit) + .gas_price(0) + .build_fill(), + ); + tx.deposit.source_hash = source_hash; + tx.enveloped_tx = Some(Bytes::new()); + tx +} + +/// The same call as [`deposit_tx`], as an ordinary (non-deposit) transaction — the control that +/// shows what the receipt would have reported without the rewrite. +fn plain_tx(gas_limit: u64) -> MegaTransaction { + let mut tx = MegaTransaction::new( + TxEnvBuilder::default() + .caller(DEPOSIT_CALLER) + .call(TARGET) + .gas_limit(gas_limit) + .gas_price(0) + .build_fill(), + ); + tx.enveloped_tx = Some(Bytes::new()); + tx +} + +/// A funded sender, so the deposit never owes caller-materialization storage gas, plus whatever +/// code [`TARGET`] needs for the shape under test. +fn db(target_code: Option) -> MemoryDatabase { + let db = MemoryDatabase::default().account_balance(DEPOSIT_CALLER, U256::from(ONE_ETH)); + match target_code { + Some(code) => db.account_code(TARGET, code), + None => db, + } +} + +fn run(spec: MegaSpecId, target_code: Option, tx: MegaTransaction) -> Outcome { + transact_mega_tx( + spec, + db(target_code), + EvmTxRuntimeLimits::from_spec(spec), + tx, + &TestExternalEnvs::default(), + ) +} + +fn run_with_compute_limit( + spec: MegaSpecId, + target_code: Option, + tx: MegaTransaction, + tx_compute_gas_limit: u64, +) -> Outcome { + transact_mega_tx( + spec, + db(target_code), + EvmTxRuntimeLimits::from_spec(spec).with_tx_compute_gas_limit(tx_compute_gas_limit), + tx, + &TestExternalEnvs::default(), + ) +} + +/// Asserts the shape every rewritten receipt has: a `FailedDeposit` halt reporting the whole gas +/// limit. +fn assert_failed_deposit(outcome: &Outcome, gas_limit: u64, label: &str) { + let rendered = std::format!("{:?}", outcome.halt_reason(label)); + assert!( + rendered.contains("FailedDeposit"), + "{label}: a failed deposit must be reported as FailedDeposit, got {rendered}", + ); + assert_eq!( + outcome.gas_used, gas_limit, + "{label}: a failed deposit's receipt reports the whole gas limit", + ); +} + +/// Runtime code that loops forever, so the transaction stops on a budget rather than on its own. +fn spin_forever() -> Bytes { + BytecodeBuilder::default().append(JUMPDEST).push_number(0u8).append(JUMP).build() +} + +/// A deposit one gas short of its own intrinsic requirement never reaches execution: `validate` +/// rejects it after recording the standard EVM intrinsic as compute and before booking the +/// `MegaETH` share as non-compute. The receipt still reports the whole gas limit, so everything +/// past the intrinsic is an envelope that nothing was executed for. +#[test] +fn test_underfunded_deposit_reject_settles_the_rewritten_envelope() { + let gas_limit = INTRINSIC_REQUIREMENT - 1; + let outcome = run(MegaSpecId::REX7, None, deposit_tx(user_source_hash(), gas_limit)); + + assert_failed_deposit(&outcome, gas_limit, "underfunded deposit"); + assert_eq!( + outcome.enforced(), + BASE_INTRINSIC_GAS, + "only the intrinsic validate recorded may enforce — the transaction executed nothing", + ); + assert_eq!( + outcome.destroyed, + gas_limit - BASE_INTRINSIC_GAS, + "the rest of the rewritten envelope is destroyed", + ); + assert_eq!( + outcome.compute_gas, gas_limit, + "the reported total must cover the receipt: the reject books no MegaETH storage gas, so \ + the whole envelope is compute", + ); + assert_eq!( + outcome.non_compute_gas(), + 0, + "the reject returns before the MegaETH share of intrinsic gas is booked", + ); + assert_eq!( + outcome.booked_destroyed(), + outcome.destroyed, + "the per-site booking and the derived total must agree", + ); +} + +/// A deposit that halts inside execution has already settled correctly against the envelope it +/// burnt, and that envelope is the whole gas limit — an exceptional halt keeps everything. The +/// rewrite reports the same number, so this shape needs no correction and must not receive one. +#[test] +fn test_deposit_runtime_halt_keeps_its_settlement() { + const GAS_LIMIT: u64 = 200_000; + let code = BytecodeBuilder::default().append(INVALID).build(); + let outcome = run(MegaSpecId::REX7, Some(code), deposit_tx(user_source_hash(), GAS_LIMIT)); + + assert_failed_deposit(&outcome, GAS_LIMIT, "halting deposit"); + assert_eq!( + outcome.enforced(), + BASE_INTRINSIC_GAS, + "INVALID performs no work, so the intrinsic is the whole of what enforces", + ); + assert_eq!( + outcome.destroyed, + GAS_LIMIT - INTRINSIC_REQUIREMENT, + "the frame's whole budget is destroyed", + ); + assert_eq!( + outcome.non_compute_gas(), + i128::from(TX_INTRINSIC_STORAGE_GAS), + "the MegaETH share of intrinsic gas is booked as non-compute", + ); + assert_eq!( + outcome.compute_gas, + GAS_LIMIT - TX_INTRINSIC_STORAGE_GAS, + "the reported total plus the storage gas must cover the receipt", + ); +} + +/// A deposit stopped by a per-transaction resource limit is the shape where the rewrite actually +/// takes gas back. The limit halt rescues the frame's remaining gas for the sender, which shrinks +/// the envelope settlement reads; the rewrite then raises the receipt back to the gas limit. The +/// rescued amount is exactly what the boundary has to destroy, which is asserted against an +/// identical non-deposit transaction rather than against a constant. +#[test] +fn test_deposit_resource_limit_halt_destroys_what_the_rescue_returned() { + const GAS_LIMIT: u64 = 5_000_000; + const COMPUTE_LIMIT: u64 = 100_000; + + let plain = run_with_compute_limit( + MegaSpecId::REX7, + Some(spin_forever()), + plain_tx(GAS_LIMIT), + COMPUTE_LIMIT, + ); + let rescued = GAS_LIMIT - plain.total_gas_spent; + assert!( + rescued > 0, + "the control must actually rescue gas, otherwise the shape proves nothing; \ + spent={} limit={GAS_LIMIT}", + plain.total_gas_spent, + ); + + let deposit = run_with_compute_limit( + MegaSpecId::REX7, + Some(spin_forever()), + deposit_tx(user_source_hash(), GAS_LIMIT), + COMPUTE_LIMIT, + ); + + assert_failed_deposit(&deposit, GAS_LIMIT, "resource-limited deposit"); + assert_eq!( + deposit.enforced(), + plain.enforced(), + "the rewrite must not change what enforces — the same work was performed either way", + ); + assert_eq!( + deposit.enforced(), + COMPUTE_LIMIT, + "the transaction ran until the compute limit bound it", + ); + assert_eq!( + deposit.destroyed, + plain.destroyed + rescued, + "the rewrite destroys exactly the gas the rescue had returned to the sender", + ); + assert_eq!( + deposit.compute_gas, + plain.compute_gas + rescued, + "the reported total grows by the same amount, so it covers the rewritten receipt", + ); + assert_eq!( + deposit.non_compute_gas(), + plain.non_compute_gas(), + "the storage-gas lane is untouched by the rewrite", + ); +} + +/// Inspection is a separate entry into the handler, and the boundary settlement has to be on both. +/// The same resource-limited deposit is run with a no-op inspector attached and must report +/// exactly what the uninspected run reports — an inspector changes what is observed, never what is +/// accounted. +#[test] +fn test_inspected_deposit_failure_settles_the_same_way() { + const GAS_LIMIT: u64 = 5_000_000; + const COMPUTE_LIMIT: u64 = 100_000; + + let uninspected = run_with_compute_limit( + MegaSpecId::REX7, + Some(spin_forever()), + deposit_tx(user_source_hash(), GAS_LIMIT), + COMPUTE_LIMIT, + ); + + let mut database = db(Some(spin_forever())); + let mut context = MegaContext::new(&mut database, MegaSpecId::REX7).with_tx_runtime_limits( + EvmTxRuntimeLimits::from_spec(MegaSpecId::REX7).with_tx_compute_gas_limit(COMPUTE_LIMIT), + ); + context.modify_chain(|chain| { + chain.operator_fee_scalar = Some(U256::ZERO); + chain.operator_fee_constant = Some(U256::ZERO); + }); + let mut evm = MegaEvm::new(context).with_inspector(NoOpInspector); + let executed = evm + .execute_transaction(deposit_tx(user_source_hash(), GAS_LIMIT)) + .expect("tx should not surface EVMError"); + + assert_eq!( + ( + executed.result_and_state.result.tx_gas_used(), + executed.compute_gas_used, + executed.compute_gas_enforced, + executed.compute_gas_destroyed, + ), + ( + uninspected.gas_used, + uninspected.compute_gas, + uninspected.enforced(), + uninspected.destroyed, + ), + "the inspected run must account for the rewritten envelope exactly as the plain run does", + ); + assert_eq!( + executed.compute_gas_destroyed, + GAS_LIMIT - TX_INTRINSIC_STORAGE_GAS - COMPUTE_LIMIT, + "the destroyed total is the envelope less the storage gas and the work performed", + ); +} + +/// A system-originated deposit is exempt from `MegaETH`'s per-transaction resource limits, which +/// is a statement about enforcement, not about recording. Its lanes must still account for the +/// rewritten envelope exactly as a user deposit's do. +#[test] +fn test_exempt_deposit_reject_still_accounts_for_the_envelope() { + let gas_limit = INTRINSIC_REQUIREMENT - 1; + let exempt = + run(MegaSpecId::REX7, None, deposit_tx(MEGA_SYSTEM_TRANSACTION_SOURCE_HASH, gas_limit)); + let user = run(MegaSpecId::REX7, None, deposit_tx(user_source_hash(), gas_limit)); + + assert_failed_deposit(&exempt, gas_limit, "exempt deposit"); + assert_eq!( + exempt.enforced(), + BASE_INTRINSIC_GAS, + "the exempt deposit enforces only the intrinsic validate recorded", + ); + assert_eq!( + exempt.destroyed, + gas_limit - BASE_INTRINSIC_GAS, + "the rest of its rewritten envelope is destroyed, exemption or not", + ); + assert_eq!( + (exempt.compute_gas, exempt.enforced(), exempt.destroyed, exempt.non_compute_gas()), + (user.compute_gas, user.enforced(), user.destroyed, user.non_compute_gas()), + "an exemption suppresses limit enforcement, not accounting", + ); +} + +/// The inner keyless transaction's own gas limit, set below what `MegaETH`'s intrinsic +/// requirement for a create transaction comes to, so the sandbox transaction is rejected in +/// validation rather than running. +const SANDBOX_REJECT_INNER_GAS_LIMIT: u64 = INTRINSIC_REQUIREMENT; + +/// A keyless-deploy sandbox transaction that fails validation is rewritten into a failed deposit +/// too — inside the sandbox, where no settlement of its own belongs. Its usage is discarded and +/// the interceptor hands the whole reservation back, so the outer transaction sees only the +/// upfront charges. Pinned across both specs: the boundary settlement must not reach in here and +/// change what the outer transaction reports. +#[test] +fn test_keyless_sandbox_reject_leaves_the_outer_transaction_alone() { + const OUTER_GAS_LIMIT: u64 = 1_000_000; + let call_data = IKeylessDeploy::keylessDeployCall { + keylessDeploymentTransaction: keyless_tx_bytes( + BytecodeBuilder::default().append(INVALID).build(), + SANDBOX_REJECT_INNER_GAS_LIMIT, + ), + gasLimitOverride: U256::from(SANDBOX_REJECT_INNER_GAS_LIMIT), + } + .abi_encode(); + + let outcomes: Vec = [MegaSpecId::REX6, MegaSpecId::REX7] + .into_iter() + .map(|spec| { + let tx = TxEnvBuilder::default() + .caller(RELAYER) + .call(KEYLESS_DEPLOY_ADDRESS) + .gas_limit(OUTER_GAS_LIMIT) + .chain_id(Some(1)) + .data(Bytes::from(call_data.clone())) + .build_fill(); + transact_tx( + spec, + MemoryDatabase::default().account_balance(RELAYER, U256::from(10 * ONE_ETH)), + EvmTxRuntimeLimits::from_spec(spec), + tx, + &TestExternalEnvs::default(), + ) + }) + .collect(); + let (rex6, rex7) = (&outcomes[0], &outcomes[1]); + + let ExecutionResult::Revert { output, .. } = &rex7.result else { + panic!("a sandbox reject must revert the outer call, got {:?}", rex7.result); + }; + IKeylessDeploy::InvalidTransaction::abi_decode(output).expect( + "the sandbox transaction must be rejected in validation — that is the shape whose inner \ + deposit is rewritten inside the sandbox", + ); + assert_eq!( + rex7.gas_used, rex6.gas_used, + "a sandbox reject must cost the outer transaction the same on both specs", + ); + assert_eq!( + rex7.compute_gas, rex6.compute_gas, + "the sandbox's own rejected envelope must not reach the outer transaction's lanes", + ); + assert_eq!( + rex7.destroyed, 0, + "nothing the outer transaction did was destroyed: the reservation came back in full", + ); +} + +/// Pre-REX7 specs have no destroyed lane, and the boundary settlement must leave them alone. Both +/// rewritten shapes are run under REX6 and pinned to the accounting they have always produced: +/// the receipt reports the whole gas limit, nothing is destroyed, and the reported compute total +/// stays exactly what REX7 now enforces. +#[test] +fn test_rex6_deposit_failures_keep_their_frozen_accounting() { + let underfunded_gas_limit = INTRINSIC_REQUIREMENT - 1; + let rex6_reject = + run(MegaSpecId::REX6, None, deposit_tx(user_source_hash(), underfunded_gas_limit)); + let rex7_reject = + run(MegaSpecId::REX7, None, deposit_tx(user_source_hash(), underfunded_gas_limit)); + + assert_failed_deposit(&rex6_reject, underfunded_gas_limit, "REX6 underfunded deposit"); + assert_eq!(rex6_reject.destroyed, 0, "REX6 has no destroyed lane"); + assert_eq!( + rex6_reject.compute_gas, BASE_INTRINSIC_GAS, + "REX6 reports only the intrinsic validate recorded before the reject", + ); + assert_eq!( + rex7_reject.enforced(), + rex6_reject.compute_gas, + "REX7 must enforce exactly what REX6 recorded — the destroyed lane is an addition to the \ + reported total, never a change to the enforced one", + ); + + const HALT_GAS_LIMIT: u64 = 200_000; + let code = BytecodeBuilder::default().append(INVALID).build(); + let rex6_halt = + run(MegaSpecId::REX6, Some(code.clone()), deposit_tx(user_source_hash(), HALT_GAS_LIMIT)); + let rex7_halt = + run(MegaSpecId::REX7, Some(code), deposit_tx(user_source_hash(), HALT_GAS_LIMIT)); + + assert_failed_deposit(&rex6_halt, HALT_GAS_LIMIT, "REX6 halting deposit"); + assert_eq!(rex6_halt.destroyed, 0, "REX6 has no destroyed lane"); + assert_eq!( + rex7_halt.enforced(), + rex6_halt.compute_gas, + "REX7 must enforce exactly what REX6 recorded for the halting shape too", + ); + assert_eq!( + rex6_halt.gas_used, rex7_halt.gas_used, + "the receipt is op-revm's, identical on both specs", + ); +} diff --git a/crates/mega-evm/tests/rex7/detention_window.rs b/crates/mega-evm/tests/rex7/detention_window.rs new file mode 100644 index 00000000..3645bf54 --- /dev/null +++ b/crates/mega-evm/tests/rex7/detention_window.rs @@ -0,0 +1,105 @@ +//! REX7 detention-mark timing: a frame that cannot afford the pre-load fees does not mark. +//! +//! revm 40 charges CALL-family static / value fees (and EXTCODECOPY's copy cost) before the +//! target account is loaded. Through REX6 that order is a frozen replay window: historical +//! revm 27 executions marked first, and `debug_check_frozen_detention_window` panics when a +//! debug replay hits the window (`tests/rex4/frozen_window_tripwire.rs`). REX7 has no history +//! and specifies the new order: the mark is produced when the target account is loaded, so +//! these shapes must stay unmarked and the tripwire — now spec-gated — must stay silent. + +use alloy_primitives::{address, Address, Bytes, U256}; +use mega_evm::{ + test_utils::{BytecodeBuilder, MemoryDatabase}, + MegaContext, MegaEvm, MegaSpecId, MegaTransaction, MegaTransactionNew as _, +}; +use revm::{ + bytecode::opcode::{CALL, EXTCODECOPY}, + context::{tx::TxEnvBuilder, BlockEnv, TxEnv}, + handler::EvmTr, +}; + +const CALLER: Address = address!("0000000000000000000000000000000000320000"); +const OUTER: Address = address!("0000000000000000000000000000000000320001"); +const INNER: Address = address!("0000000000000000000000000000000000320002"); +const BENEFICIARY: Address = address!("0000000000000000000000000000000000320099"); + +fn append_call(builder: BytecodeBuilder, target: Address, gas: u64, value: u64) -> BytecodeBuilder { + builder + .push_number(0_u64) + .push_number(0_u64) + .push_number(0_u64) + .push_number(0_u64) + .push_number(value) + .push_address(target) + .push_number(gas) + .append(CALL) +} + +fn inner_window_call(target: Address, value: u64) -> Bytes { + append_call(BytecodeBuilder::default(), target, 0, value).build() +} + +fn inner_window_extcodecopy(target: Address) -> Bytes { + BytecodeBuilder::default() + .push_number(0x8000_u64) + .push_number(0_u64) + .push_number(0_u64) + .push_address(target) + .append(EXTCODECOPY) + .build() +} + +fn window_db(inner_code: Bytes, inner_budget: u64) -> MemoryDatabase { + MemoryDatabase::default() + .account_balance(CALLER, U256::from(1_000_000_000_u64)) + .account_code( + OUTER, + append_call(BytecodeBuilder::default(), INNER, inner_budget, 0).build(), + ) + .account_code(INNER, inner_code) +} + +fn transact_rex7(db: &mut MemoryDatabase, tx: TxEnv) -> bool { + let block = BlockEnv { beneficiary: BENEFICIARY, ..Default::default() }; + let mut context = MegaContext::new(db, MegaSpecId::REX7).with_block(block); + context.modify_chain(|chain| { + chain.operator_fee_scalar = Some(U256::from(0)); + chain.operator_fee_constant = Some(U256::from(0)); + }); + let mut evm = MegaEvm::new(context); + let mut tx = MegaTransaction::new(tx); + tx.enveloped_tx = Some(Bytes::new()); + alloy_evm::Evm::transact_raw(&mut evm, tx).expect("tx must execute"); + let tracker = evm.ctx_ref().volatile_data_tracker.borrow(); + let marked = tracker.has_accessed_beneficiary_balance(); + drop(tracker); + marked +} + +fn default_tx() -> TxEnv { + TxEnvBuilder::default().caller(CALLER).call(OUTER).gas_limit(1_000_000).build_fill() +} + +/// Static-charge window: INNER reaches CALL holding fewer than 100 gas, so the load never runs. +#[test] +fn test_rex7_underfunded_call_to_beneficiary_does_not_mark() { + let mut db = window_db(inner_window_call(BENEFICIARY, 0), 80); + let marked = transact_rex7(&mut db, default_tx()); + assert!(!marked, "REX7 specifies charge-before-load: an underfunded CALL must not mark"); +} + +/// Value-transfer window: the frame affords the 100 static charge but not the 9,000 transfer cost. +#[test] +fn test_rex7_underfunded_value_call_to_beneficiary_does_not_mark() { + let mut db = window_db(inner_window_call(BENEFICIARY, 1), 800); + let marked = transact_rex7(&mut db, default_tx()); + assert!(!marked, "REX7 specifies charge-before-load: an underfunded value CALL must not mark"); +} + +/// EXTCODECOPY window: the copy cost sits before the load, so a poor frame never marks. +#[test] +fn test_rex7_underfunded_extcodecopy_of_beneficiary_does_not_mark() { + let mut db = window_db(inner_window_extcodecopy(BENEFICIARY), 100); + let marked = transact_rex7(&mut db, default_tx()); + assert!(!marked, "REX7 specifies charge-before-load: an underfunded EXTCODECOPY must not mark"); +} diff --git a/crates/mega-evm/tests/rex7/double_exceed_corner.rs b/crates/mega-evm/tests/rex7/double_exceed_corner.rs new file mode 100644 index 00000000..9e2c1184 --- /dev/null +++ b/crates/mega-evm/tests/rex7/double_exceed_corner.rs @@ -0,0 +1,333 @@ +//! REX7: the double-exceed corner, swept one gas at a time across the knife edge. +//! +//! The corner is the single opcode whose cost outruns *both* the true EVM remaining and the compute +//! headroom. The adjudication is that the compute classification wins: the transaction reports the +//! resource limit and the sender keeps the remaining gas, instead of revm's out-of-gas burning the +//! frame. The reason it can be adjudicated at all is that the two are indistinguishable at the +//! frame boundary — an out-of-gas carries no opcode cost — so there is nothing to tell them apart +//! with. +//! +//! A single case at the corner cannot show that the rule is *stable*: pick the transaction gas +//! limit one gas differently and the crossing opcode may become affordable in true gas while still +//! crossing the compute headroom. These tests calibrate the exact gas limit at which the crossing +//! opcode becomes affordable and sweep ±3 gas around it, asserting that +//! +//! - REX7 reports the same classification on every point of the sweep, and rescues the same amount +//! at every point — the receipt does not notice the edge at all; +//! - the window really does straddle the edge, which the REX6 arm shows by flipping from a burned +//! out-of-gas to a resource stop partway through. +//! +//! The calibration runs a probe truncated just before the crossing opcode and reads two numbers off +//! it: the compute gas recorded there (which fixes where to put the compute limit) and the +//! receipt's `gas_used` (the EVM gas spent there, which fixes the transaction gas limit at which +//! the crossing opcode is exactly affordable). The two are not the same number — `MegaETH`'s +//! intrinsic transaction gas is larger than the intrinsic compute it records — so both have to be +//! measured. + +use crate::common::{ + base_db, plain_filler, transact, transact_default, transact_with_gas_limit, Outcome, +}; +use alloy_primitives::{Bytes, U256}; +use mega_evm::{ + test_utils::{BytecodeBuilder, MemoryDatabase}, + EvmTxRuntimeLimits, MegaHaltReason, MegaSpecId, +}; +use revm::bytecode::opcode::{CALL, MSTORE, POP, RETURN, STOP, TIMESTAMP}; + +/// Memory offset the crossing MSTORE writes to. Far enough out that the expansion dominates the +/// opcode's cost, close enough that the cost stays in the hundreds of gas. +const CROSSING_OFFSET: u64 = 0x2000; + +/// How far either side of the knife edge to sweep. +const SWEEP: i64 = 3; + +/// The run leading up to the crossing MSTORE: an optional volatile access, a plain segment, and the +/// MSTORE's two stack operands. Everything here is cheap and fully paid for in every sweep point. +fn approach(volatile: bool) -> BytecodeBuilder { + let mut builder = BytecodeBuilder::default(); + if volatile { + builder = builder.append(TIMESTAMP).append(POP); + } + plain_filler(builder, 20).push_number(0u64).push_number(CROSSING_OFFSET) +} + +/// Runs `db` with nothing constraining it, for calibration. +fn unconstrained_db(db: MemoryDatabase) -> Outcome { + let outcome = transact_default(MegaSpecId::REX7, db); + assert!(outcome.is_success(), "the calibration run must succeed: {:?}", outcome.result); + outcome +} + +/// Runs `code` against the default database with nothing constraining it, for calibration. +fn unconstrained(code: Bytes) -> Outcome { + unconstrained_db(base_db(code)) +} + +/// The compute gas `code` records when neither EVM gas nor any resource limit constrains it. +fn unconstrained_compute_gas(code: Bytes) -> u64 { + unconstrained(code).compute_gas +} + +/// The calibration a sweep runs against. +struct KnifeEdge { + /// Compute gas recorded up to (not including) the crossing opcode. + compute_before: u64, + /// The crossing opcode's own cost. + cost: u64, + /// The transaction gas limit at which the crossing opcode is exactly affordable. + gas_limit_at_edge: u64, +} + +fn calibrate(volatile: bool) -> KnifeEdge { + let before = unconstrained(approach(volatile).append(STOP).build()); + let after = approach(volatile).append(MSTORE).append(STOP).build(); + let cost = unconstrained_compute_gas(after) - before.compute_gas; + assert!( + cost > 100, + "the crossing opcode must be expensive enough to sweep around; cost={cost}" + ); + KnifeEdge { + compute_before: before.compute_gas, + cost, + gas_limit_at_edge: before.gas_used + cost, + } +} + +/// The TX-level corner: the compute headroom at the MSTORE is half its cost, so the clamp always +/// stops it, while the transaction gas limit sweeps from one gas short of affording it to two gas +/// more than enough. +#[test] +fn test_tx_level_double_exceed_classification_is_stable_across_the_knife_edge() { + let edge = calibrate(false); + let code = approach(false).append(MSTORE).append(STOP).build(); + // Headroom strictly between zero and the opcode's cost: the clamp is outstanding at the MSTORE + // on every sweep point, and the MSTORE never fits inside it. + let limit = edge.compute_before + edge.cost / 2; + let limits = |spec| EvmTxRuntimeLimits::from_spec(spec).with_tx_compute_gas_limit(limit); + + let mut rex7_gas_used = Vec::new(); + let mut rex6_burned = Vec::new(); + for delta in -SWEEP..=SWEEP { + let gas_limit = (edge.gas_limit_at_edge as i64 + delta) as u64; + let label = format!("edge{delta:+}"); + let r6 = transact_with_gas_limit( + MegaSpecId::REX6, + base_db(code.clone()), + limits(MegaSpecId::REX6), + gas_limit, + ); + let r7 = transact_with_gas_limit( + MegaSpecId::REX7, + base_db(code.clone()), + limits(MegaSpecId::REX7), + gas_limit, + ); + + assert!(!r7.is_success(), "{label}/REX7 must stop: {:?}", r7.result); + assert!( + matches!(r7.halt_reason(&label), MegaHaltReason::ComputeGasLimitExceeded { .. }), + "{label}: REX7 must classify the corner as a compute exceed on every sweep point; got \ + {:?}", + r7.halt_reason(&label), + ); + // The crossing opcode never ran, so its cost is not in the usage: the recorded total sits + // at or under the limit, and within one crossing-opcode cost of it — the headroom the + // opcode could not pay for is what the frame leaves unspent. + assert!( + r7.compute_gas <= limit && r7.compute_gas + edge.cost > limit, + "{label}: REX7 must stop at the clamp boundary; compute={} limit={limit} cost={}", + r7.compute_gas, + edge.cost, + ); + assert!( + r7.gas_used < gas_limit, + "{label}: REX7 must rescue rather than burn; gas_used={} gas_limit={gas_limit}", + r7.gas_used + ); + rex7_gas_used.push(r7.gas_used); + + assert!(!r6.is_success(), "{label}/REX6 must stop: {:?}", r6.result); + rex6_burned.push(r6.gas_used == gas_limit); + } + + let first = rex7_gas_used[0]; + assert!( + rex7_gas_used.iter().all(|&used| used == first), + "the rescued amount must not notice the edge; gas_used across the sweep = {rex7_gas_used:?}", + ); + // The sweep has to actually straddle the edge, or the stability claim is vacuous: per-opcode + // accounting burns the frame below the edge and stops on the resource limit above it. + assert!( + rex6_burned.contains(&true) && rex6_burned.contains(&false), + "the sweep must straddle the knife edge; REX6 burn pattern = {rex6_burned:?}", + ); +} + +/// The same sweep with gas detention as the binding constraint: the classification that has to stay +/// stable is `VolatileDataAccessOutOfGas`, which the clamp reconstructs from what bound it rather +/// than from usage having crossed the detained limit. +#[test] +fn test_detained_double_exceed_classification_is_stable_across_the_knife_edge() { + let edge = calibrate(true); + let code = approach(true).append(MSTORE).append(STOP).build(); + // The cap is relative to usage at the access, which happens two opcodes in. Sizing it as + // "everything between the access and the MSTORE, plus half the MSTORE" puts the detained + // headroom at the MSTORE at half the opcode's cost, exactly as in the TX-level case. + let at_access = unconstrained_compute_gas( + BytecodeBuilder::default().append(TIMESTAMP).append(STOP).build(), + ); + let cap = edge.compute_before - at_access + edge.cost / 2; + let limits = move |spec| { + let mut limits = EvmTxRuntimeLimits::from_spec(spec); + limits.block_env_access_compute_gas_limit = cap; + limits + }; + + let mut rex7_gas_used = Vec::new(); + let mut rex6_burned = Vec::new(); + for delta in -SWEEP..=SWEEP { + let gas_limit = (edge.gas_limit_at_edge as i64 + delta) as u64; + let label = format!("edge{delta:+}"); + let r6 = transact_with_gas_limit( + MegaSpecId::REX6, + base_db(code.clone()), + limits(MegaSpecId::REX6), + gas_limit, + ); + let r7 = transact_with_gas_limit( + MegaSpecId::REX7, + base_db(code.clone()), + limits(MegaSpecId::REX7), + gas_limit, + ); + + assert!(!r7.is_success(), "{label}/REX7 must stop: {:?}", r7.result); + assert!( + matches!(r7.halt_reason(&label), MegaHaltReason::VolatileDataAccessOutOfGas { .. }), + "{label}: a detained corner must keep the volatile attribution on every sweep point; \ + got {:?}", + r7.halt_reason(&label), + ); + assert!( + r7.gas_used < gas_limit, + "{label}: REX7 must rescue rather than burn; gas_used={} gas_limit={gas_limit}", + r7.gas_used + ); + rex7_gas_used.push(r7.gas_used); + + assert!(!r6.is_success(), "{label}/REX6 must stop: {:?}", r6.result); + rex6_burned.push(r6.gas_used == gas_limit); + } + + let first = rex7_gas_used[0]; + assert!( + rex7_gas_used.iter().all(|&used| used == first), + "the rescued amount must not notice the edge; gas_used across the sweep = {rex7_gas_used:?}", + ); + assert!( + rex6_burned.contains(&true) && rex6_burned.contains(&false), + "the sweep must straddle the knife edge; REX6 burn pattern = {rex6_burned:?}", + ); +} + +/// The corner one frame down, where the clamp is bound frame-locally rather than TX-level. +/// +/// A nested frame's compute budget is always strictly tighter than the TX-level remaining (98/100 +/// of its parent's), so the clamp inside a sub-frame is always bound frame-locally — and a +/// frame-local exceed is absorbed into a revert rather than halting the transaction. With the +/// compute limit set so the child's headroom runs out inside the crossing opcode, that absorption +/// has to hold on every sweep point, including where the child's *true* forwarded gas flips from +/// too little to enough. +/// +/// The control arm — the same sweep with nothing constraining compute — is what shows the window +/// straddles a real edge: there the sub-frame's outcome does flip. +#[test] +fn test_frame_local_double_exceed_classification_is_stable_across_the_knife_edge() { + let callee_code = approach(false).append(MSTORE).append(STOP).build(); + let callee_before = approach(false).append(STOP).build(); + // Calibrating the callee as a standalone transaction gives its work up to the MSTORE once the + // intrinsic part — which a sub-frame does not pay again — is taken back out. + let callee_intrinsic = + unconstrained_compute_gas(BytecodeBuilder::default().append(STOP).build()); + let callee_before_compute = unconstrained_compute_gas(callee_before); + let before_in_frame = callee_before_compute - callee_intrinsic; + let cost = unconstrained_compute_gas(callee_code.clone()) - callee_before_compute; + let forwarded_at_edge = before_in_frame + cost; + + let caller = |forwarded: u64| { + BytecodeBuilder::default() + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(0u64) // argsSize + .push_number(0u64) // argsOffset + .push_number(0u64) // value + .push_address(crate::common::CALLEE) + .push_number(forwarded) + .append(CALL) + .push_number(0u64) + .append(MSTORE) + .push_number(32u64) + .push_number(0u64) + .append(RETURN) + .build() + }; + let build_db = |code: &Bytes| { + base_db(code.clone()).account_code(crate::common::CALLEE, callee_code.clone()) + }; + let call_succeeded = + |r: &Outcome| r.result.output().map(|o| U256::from_be_slice(o)) == Some(U256::from(1u64)); + + // A compute limit half a crossing-opcode short of what the whole transaction needs when the + // sub-frame completes: the child's own budget is what runs out, and it runs out inside the + // MSTORE. + let generous = caller(forwarded_at_edge + SWEEP as u64); + let whole_tx = unconstrained_db(build_db(&generous)); + assert!(call_succeeded(&whole_tx), "the calibration run's sub-frame must complete"); + let limit = whole_tx.compute_gas - cost / 2; + let limits = |spec| EvmTxRuntimeLimits::from_spec(spec).with_tx_compute_gas_limit(limit); + + let mut control_outcomes = Vec::new(); + for delta in -SWEEP..=SWEEP { + let forwarded = (forwarded_at_edge as i64 + delta) as u64; + let label = format!("edge{delta:+}"); + let code = caller(forwarded); + + // Treatment: the child's compute headroom is what the crossing opcode cannot pay for. + let r7 = transact(MegaSpecId::REX7, build_db(&code), limits(MegaSpecId::REX7)); + assert!( + r7.is_success(), + "{label}: a frame-local exceed must be absorbed into a revert, not halt the \ + transaction: {:?}", + r7.result + ); + assert!( + !call_succeeded(&r7), + "{label}: the sub-frame must report failure on every sweep point", + ); + + // Control: nothing constrains compute, so only the forwarded gas decides. + let c6 = transact_default(MegaSpecId::REX6, build_db(&code)); + let c7 = transact_default(MegaSpecId::REX7, build_db(&code)); + for (spec, r) in [("REX6", &c6), ("REX7", &c7)] { + assert!( + r.is_success(), + "{label}/{spec}: a sub-frame running out of gas must not stop the transaction: \ + {:?}", + r.result + ); + } + assert_eq!( + call_succeeded(&c6), + call_succeeded(&c7), + "{label}: both models must agree on whether the sub-frame survived", + ); + control_outcomes.push(call_succeeded(&c7)); + } + // The window straddles the point where the forwarded gas starts covering the crossing opcode, + // so the stability asserted above is a statement about a real edge. + assert!( + control_outcomes.contains(&true) && control_outcomes.contains(&false), + "the sweep must straddle the sub-frame's knife edge; control outcomes = \ + {control_outcomes:?}", + ); +} diff --git a/crates/mega-evm/tests/rex7/exceptional_halt.rs b/crates/mega-evm/tests/rex7/exceptional_halt.rs new file mode 100644 index 00000000..ad1cbf75 --- /dev/null +++ b/crates/mega-evm/tests/rex7/exceptional_halt.rs @@ -0,0 +1,270 @@ +//! REX7 exceptional-halt frame settlement. +//! +//! A frame that ends in an exceptional halt never returns its remaining budget: the top-level +//! frame's whole envelope is spent by the transaction's final gas accounting, and an inner frame's +//! remainder is simply not handed back to its caller. REX7 settles that burned remainder as compute +//! gas at frame exit, so the recorded compute total covers the entire budget the sender's gas paid +//! for. Per-opcode recording through REX6 attributes neither the failing opcode nor the burn, so it +//! reports strictly less. +//! +//! The interpreter only zeroes its own counter for a plain `OutOfGas`; every other exceptional halt +//! keeps the loop-exit reading and has its remainder burned later, by the frame-return rules. The +//! settlement therefore cannot be read off the counter — it has to be driven by the halt +//! classification, which is what these tests sweep. +//! +//! The invariant each case pins is the same one in both frame positions. These transactions spend +//! EVM gas on exactly two things: compute, and the transaction-intrinsic storage gas that is +//! excluded from compute accounting by definition. So "the whole burned budget settled as compute" +//! is `compute_gas == gas_used − intrinsic storage gas`. At the top level that covers the entire +//! transaction envelope; in the nested shape it covers the caller's own consumption plus the whole +//! budget it forwarded, because the halting callee returns none of it. +//! +//! `MemoryLimitOOG` is not in the sweep: it needs revm's `memory_limit` cfg, which this workspace +//! does not enable, so no bytecode can reach it. + +use crate::common::{base_db, transact, transact_with_gas_limit, Outcome, CALLEE}; +use alloy_primitives::Bytes; +use mega_evm::{test_utils::BytecodeBuilder, EvmTxRuntimeLimits, MegaSpecId}; +use revm::bytecode::opcode::{ + ADD, CALL, DUP1, JUMP, JUMPDEST, JUMPI, MSTORE, POP, STOP, SUB, SWAP1, +}; + +/// The two transaction-intrinsic readings every case below calibrates against, measured from a +/// transaction that runs a single `STOP`: the total EVM gas the receipt charges before the frame +/// does anything (`gas`), and the part of it that counts as compute (`compute`). +/// +/// The difference is the intrinsic storage gas — charged to EVM gas but excluded from compute +/// accounting. Measuring it keeps the budget identity exact rather than pinned to a constant. +struct Intrinsic { + gas: u64, + compute: u64, +} + +impl Intrinsic { + fn measure(spec: MegaSpecId) -> Self { + let code = BytecodeBuilder::default().append(STOP).build(); + let outcome = transact_with_gas_limit( + spec, + base_db(code), + EvmTxRuntimeLimits::from_spec(spec), + 1_000_000, + ); + Self { gas: outcome.gas_used, compute: outcome.compute_gas } + } + + /// The storage gas the receipt carries that compute accounting never sees. + fn storage_gas(&self) -> u64 { + self.gas - self.compute + } +} + +/// A countdown loop of cheap plain opcodes, sized to outrun any budget below its own cost. +fn countdown_loop_code(iterations: u16) -> Vec { + let mut code = vec![0x61]; // PUSH2 + code.extend_from_slice(&iterations.to_be_bytes()); + let loop_target = u8::try_from(code.len()).expect("loop target must fit in a PUSH1"); + code.push(JUMPDEST); + code.extend_from_slice(&[0x60, 0x01]); // PUSH1 1 + code.push(SWAP1); + code.push(SUB); + code.push(DUP1); + code.extend_from_slice(&[0x60, loop_target]); // PUSH1 loop + code.push(JUMPI); + code.push(STOP); + code +} + +/// One exceptional-halt shape. +struct HaltCase { + /// Case name, used in assertion messages. + name: &'static str, + /// Bytecode that ends the frame it runs in with an exceptional halt. + code: Vec, + /// The budget the halting frame is given — large enough for the shape to reach its halt with + /// gas to spare, so the burned remainder is substantial. + frame_gas: u64, +} + +/// Every exceptional-halt classification a plain-opcode segment can produce, other than the +/// `memory_limit` cfg-gated one. +fn halt_cases() -> Vec { + vec![ + // Plain out-of-gas: the interpreter zeroes its own counter here, so this is the one shape + // that already settled its burn before the classification-driven settlement existed. + HaltCase { name: "plain OOG", code: countdown_loop_code(10_000), frame_gas: 60_000 }, + // Memory out-of-gas: expanding to one MiB costs ~2.2M gas, far past the budget. + HaltCase { + name: "memory OOG", + code: BytecodeBuilder::default() + .push_number(0u64) + .push_number(0x10_0000u64) + .append(MSTORE) + .append(STOP) + .build_vec(), + frame_gas: 60_000, + }, + // Stack underflow: ADD with nothing on the stack. + HaltCase { + name: "stack underflow", + code: BytecodeBuilder::default().append(ADD).append(STOP).build_vec(), + frame_gas: 60_000, + }, + // Stack overflow: each iteration pushes one word and jumps back, so the stack passes 1024 + // long before the budget runs out. + HaltCase { + name: "stack overflow", + code: vec![JUMPDEST, 0x60, 0x01, 0x60, 0x00, JUMP], + frame_gas: 60_000, + }, + // Invalid jump: a destination that is not a JUMPDEST. + HaltCase { + name: "invalid jump", + code: BytecodeBuilder::default().push_number(0xffu64).append(JUMP).build_vec(), + frame_gas: 60_000, + }, + // Unknown opcode: 0x0c is unassigned on every spec this table covers. + HaltCase { name: "unknown opcode", code: vec![0x0c], frame_gas: 60_000 }, + ] +} + +/// Runs `code` as the transaction's direct target, with exactly `frame_gas` beyond intrinsic. +fn top_level(spec: MegaSpecId, code: &[u8], frame_gas: u64) -> Outcome { + transact_with_gas_limit( + spec, + base_db(Bytes::copy_from_slice(code)), + EvmTxRuntimeLimits::from_spec(spec), + Intrinsic::measure(spec).gas + frame_gas, + ) +} + +/// Runs `code` in an inner frame that [`CONTRACT`] calls with exactly `frame_gas` forwarded, then +/// pops the failure flag and stops — so the caller survives its callee's halt. +fn nested(spec: MegaSpecId, code: &[u8], frame_gas: u64) -> Outcome { + let caller = BytecodeBuilder::default() + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(0u64) // argsSize + .push_number(0u64) // argsOffset + .push_number(0u64) // value + .push_address(CALLEE) + .push_number(frame_gas) + .append(CALL) + .append(POP) + .append(STOP) + .build(); + let db = base_db(caller).account_code(CALLEE, Bytes::copy_from_slice(code)); + transact(spec, db, EvmTxRuntimeLimits::from_spec(spec)) +} + +/// The frame's entire budget must settle as compute gas, in both frame positions, for every +/// exceptional-halt classification. +#[test] +fn test_every_exceptional_halt_settles_its_burned_budget_as_compute() { + /// Runs one halt shape in one frame position. + type Runner = fn(MegaSpecId, &[u8], u64) -> Outcome; + + let storage_gas = Intrinsic::measure(MegaSpecId::REX7).storage_gas(); + for case in halt_cases() { + for (position, run) in [("top-level", top_level as Runner), ("nested", nested as Runner)] { + let label = format!("{} ({position})", case.name); + let r6 = run(MegaSpecId::REX6, &case.code, case.frame_gas); + let r7 = run(MegaSpecId::REX7, &case.code, case.frame_gas); + + assert_eq!( + format!("{:?}", r6.result), + format!("{:?}", r7.result), + "{label}: the halt itself must be unchanged", + ); + assert_eq!( + r6.gas_used, r7.gas_used, + "{label}: receipt gas_used must be unchanged; REX6={} REX7={}", + r6.gas_used, r7.gas_used + ); + assert_eq!( + r7.compute_gas, + r7.gas_used - storage_gas, + "{label}: REX7 must settle the whole burned budget as compute; \ + compute={} gas_used={} intrinsic storage gas={storage_gas}", + r7.compute_gas, + r7.gas_used, + ); + assert!( + r6.compute_gas < r7.compute_gas, + "{label}: per-opcode recording attributes neither the failing opcode nor the \ + burn, so it must report strictly less; REX6={} REX7={}", + r6.compute_gas, + r7.compute_gas + ); + } + } +} + +/// A frame that halts exceptionally while a non-zero gas clamp is outstanding burns the hidden gas +/// too, so the settlement has to cover the true remainder, not just the visible one. +/// +/// A tight compute limit keeps a large amount hidden; the frame then hits a stack underflow, which +/// is not a gas shortage at all and must not be reclassified into a compute exceed. +#[test] +fn test_exceptional_halt_under_an_active_clamp_settles_the_hidden_gas_too() { + let code = BytecodeBuilder::default().append(ADD).append(STOP).build(); + let intrinsic = Intrinsic::measure(MegaSpecId::REX7); + let limits = |spec| { + EvmTxRuntimeLimits::from_spec(spec).with_tx_compute_gas_limit(intrinsic.compute + 1_000) + }; + let gas_limit = intrinsic.gas + 500_000; + + let r6 = transact_with_gas_limit( + MegaSpecId::REX6, + base_db(code.clone()), + limits(MegaSpecId::REX6), + gas_limit, + ); + let r7 = transact_with_gas_limit( + MegaSpecId::REX7, + base_db(code), + limits(MegaSpecId::REX7), + gas_limit, + ); + + assert_eq!( + format!("{:?}", r6.result), + format!("{:?}", r7.result), + "the stack underflow must not be reclassified as a resource-limit exceed", + ); + assert_eq!(r6.gas_used, r7.gas_used, "receipt gas_used must be unchanged"); + assert_eq!( + r7.compute_gas, + intrinsic.compute + 500_000, + "the clamp hides most of the frame's budget, and all of it is still burned", + ); +} + +/// The burn settlement must not retroactively fail the transaction. +/// +/// The burned budget is whatever the sender's gas envelope allowed, not what the compute limit +/// allowed, so the settlement can push the recorded total past the compute limit. Turning that into +/// a compute-limit halt would rescue gas the EVM already burned and change the receipt, which the +/// exceptional-halt carve-out must not do. +#[test] +fn test_burn_settlement_does_not_retroactively_halt_on_the_compute_limit() { + let code = BytecodeBuilder::default().append(ADD).append(STOP).build(); + let intrinsic = Intrinsic::measure(MegaSpecId::REX7); + let compute_limit = intrinsic.compute + 1_000; + let limits = + EvmTxRuntimeLimits::from_spec(MegaSpecId::REX7).with_tx_compute_gas_limit(compute_limit); + + let r7 = + transact_with_gas_limit(MegaSpecId::REX7, base_db(code), limits, intrinsic.gas + 500_000); + + let reason = format!("{:?}", r7.halt_reason("REX7")); + assert!( + reason.contains("StackUnderflow"), + "the halt must stay the EVM's own, not become a compute-limit exceed; got {reason}", + ); + assert!( + r7.compute_gas > compute_limit, + "the settled burn is expected to exceed the compute limit here; compute={} limit={}", + r7.compute_gas, + compute_limit + ); +} diff --git a/crates/mega-evm/tests/rex7/frame_init_reject_burn.rs b/crates/mega-evm/tests/rex7/frame_init_reject_burn.rs new file mode 100644 index 00000000..da9776a5 --- /dev/null +++ b/crates/mega-evm/tests/rex7/frame_init_reject_burn.rs @@ -0,0 +1,692 @@ +//! A frame init that refuses to build a frame still decides the fate of a whole child budget. +//! +//! `frame_init` hands back a result instead of a frame on several shapes, and the result carries +//! the entire child budget as `remaining`. What happens to that budget is decided by the result's +//! classification alone: a success or a revert is erased back into the caller's gas counter, an +//! exceptional halt is not. The child never runs, so the frame-exit settlement that splits an +//! ordinary exceptional halt never sees the halting shapes — REX7 books them here instead. +//! +//! The classification is what separates the rows, not the reason: +//! +//! | shape | result | class | destroyed | +//! | --------------------------------- | ---------------- | ------ | --------- | +//! | CREATE past the call-stack limit | `CallTooDeep` | revert | 0 | +//! | CREATE whose value exceeds balance| `OutOfFunds` | revert | 0 | +//! | CREATE from a `u64::MAX` nonce | `Return` | ok | 0 | +//! | CREATE onto an occupied address | `CreateCollision`| halt | whole | +//! | CALL into an account with no code | `Stop` | ok | 0 | +//! | CALL past the call-stack limit | `CallTooDeep` | revert | 0 | +//! | CALL that dispatches a precompile | precompile's own | either | booked at the precompile site | +//! +//! The precompile row is the one that has to be excluded rather than classified. A precompile is +//! dispatched inside the same frame init and comes back as a result too, but it has already booked +//! both halves of its own split — against the forwarded envelope rather than the capped budget the +//! result carries — so booking it again here would report the same gas twice. +//! +//! Pre-REX7 specs have no destroyed lane, so every row books nothing and the receipts are +//! unchanged. +//! +//! The halting row is then followed through the two boundaries that run after the booking: the +//! failed-deposit receipt rewrite, which settles again against a larger envelope, and the +//! `KeylessDeploy` sandbox merge, which carries a nested execution's split into its parent. + +use crate::common::{ + default_envs, transact, transact_default, transact_mega_tx, transact_tx, CALLER, CONTRACT, + ONE_ETH, +}; +use alloy_primitives::{address, hex, Address, Bytes, Signature, TxKind, B256, U256}; +use alloy_sol_types::SolCall as _; +use mega_evm::{ + alloy_consensus::{Signed, TxLegacy}, + constants::rex::TX_INTRINSIC_STORAGE_GAS, + test_utils::{BytecodeBuilder, MemoryDatabase}, + EvmTxRuntimeLimits, IKeylessDeploy, MegaContext, MegaEvm, MegaSpecId, MegaTransaction, + MegaTransactionNew as _, TestExternalEnvs, KEYLESS_DEPLOY_ADDRESS, +}; +use revm::{ + bytecode::opcode::{CREATE, CREATE2, POP, STOP}, + context::tx::TxEnvBuilder, + handler::{EvmTr, ItemOrResult}, + interpreter::{ + interpreter::SharedMemory, interpreter_action::FrameInit, CallInput, CallInputs, + CallScheme, CallValue, CreateInputs, CreateScheme, FrameInput, InstructionResult, + }, + primitives::CALL_STACK_LIMIT, +}; + +/// The budget every synthetic frame init below forwards to the child it asks for. +const FRAME_GAS: u64 = 100_000; + +/// An address seeded with code, so a CREATE aimed at it collides and a CALL into it is not the +/// empty-code shape. +const OCCUPIED: Address = address!("0000000000000000000000000000000000310001"); + +/// An address with no code and no nonce, so a CALL into it returns `Stop` without a frame. +const VACANT: Address = address!("0000000000000000000000000000000000310002"); + +/// blake2f. Rejects any input whose length is not 213 bytes, before charging anything — a +/// precompile halt with nothing performed. +const BLAKE2F: Address = address!("0000000000000000000000000000000000000009"); + +/// The two specs every row is run under: the one with the destroyed lane, and the frozen one +/// directly beneath it. +const SPECS: [MegaSpecId; 2] = [MegaSpecId::REX6, MegaSpecId::REX7]; + +/* ------------------------------------------------------------------------------------------- * + * The state table, driven at the `frame_init` boundary. + * ------------------------------------------------------------------------------------------- */ + +/// A `frame_init` that asks for a CREATE child. +fn create_frame_init(value: U256, depth: usize) -> FrameInit { + FrameInit { + depth, + memory: SharedMemory::new(), + frame_input: FrameInput::Create(Box::new(CreateInputs::new( + CALLER, + CreateScheme::Create, + value, + Bytes::new(), + FRAME_GAS, + 0, + ))), + } +} + +/// A `frame_init` that asks for a CALL child. +fn call_frame_init(target: Address, input: Bytes, depth: usize) -> FrameInit { + FrameInit { + depth, + memory: SharedMemory::new(), + frame_input: FrameInput::Call(Box::new(CallInputs { + input: CallInput::Bytes(input), + return_memory_offset: 0..0, + gas_limit: FRAME_GAS, + bytecode_address: target, + target_address: target, + caller: CALLER, + // Apparent rather than Transfer: a zero-value transfer would still touch the target + // account, and these synthetic frame inits run against a journal that has loaded + // nothing. The rows under test are all decided before any value would move. + value: CallValue::Apparent(U256::ZERO), + scheme: CallScheme::Call, + is_static: false, + reservoir: 0, + known_bytecode: Default::default(), + charged_new_account_state_gas: false, + })), + } +} + +/// What one `frame_init` row produced: the classification it returned, the budget the result still +/// carries, and the destroyed total the tracker booked for it. +struct Row { + instruction_result: InstructionResult, + remaining: u64, + booked_destroyed: u64, +} + +/// Drives `frame_init` once against a fresh EVM and reads back the row. +fn run_frame_init(spec: MegaSpecId, mut db: MemoryDatabase, frame_init: FrameInit) -> Row { + let context = MegaContext::new(&mut db, spec); + let mut evm = MegaEvm::new(context); + let result = EvmTr::frame_init(&mut evm, frame_init).expect("frame_init must not error"); + let ItemOrResult::Result(frame_result) = result else { + panic!("{spec:?}: this shape must reject the frame, not build one"); + }; + let booked_destroyed = + evm.ctx_ref().additional_limit.borrow().conservation_terms().booked_destroyed_compute_gas; + Row { + instruction_result: frame_result.instruction_result(), + remaining: frame_result.gas().remaining(), + booked_destroyed, + } +} + +/// The database every row starts from: a funded caller, an occupied address, and blake2f reachable +/// as a precompile. +fn row_db() -> MemoryDatabase { + MemoryDatabase::default() + .account_balance(CALLER, U256::from(ONE_ETH)) + .account_code(OCCUPIED, BytecodeBuilder::default().append(STOP).build()) +} + +/// Every `frame_init` rejection classified as a success or a revert hands its budget back, so it +/// must book nothing — on both specs. +#[test] +fn test_returned_frame_init_rejections_book_nothing() { + let cases: Vec<(&str, MemoryDatabase, FrameInit, InstructionResult)> = vec![ + ( + "CREATE past the call-stack limit", + row_db(), + create_frame_init(U256::ZERO, CALL_STACK_LIMIT as usize + 1), + InstructionResult::CallTooDeep, + ), + ( + "CREATE whose value exceeds the caller's balance", + row_db(), + create_frame_init(U256::from(2 * ONE_ETH), 1), + InstructionResult::OutOfFunds, + ), + ( + "CREATE from a caller whose nonce cannot be bumped", + row_db().account_nonce(CALLER, u64::MAX), + create_frame_init(U256::ZERO, 1), + InstructionResult::Return, + ), + ( + "CALL into an account with no code", + row_db(), + call_frame_init(VACANT, Bytes::new(), 1), + InstructionResult::Stop, + ), + ( + "CALL past the call-stack limit", + row_db(), + // The REX5 depth guard covers `Call` / `StaticCall`; a `CallCode` reaches revm's own + // depth check, which is the arm under test here. + FrameInit { + depth: CALL_STACK_LIMIT as usize + 1, + memory: SharedMemory::new(), + frame_input: match call_frame_init( + OCCUPIED, + Bytes::new(), + CALL_STACK_LIMIT as usize + 1, + ) + .frame_input + { + FrameInput::Call(mut inputs) => { + inputs.scheme = CallScheme::CallCode; + FrameInput::Call(inputs) + } + other => other, + }, + }, + InstructionResult::CallTooDeep, + ), + ]; + + for (label, db, frame_init, expected) in cases { + for spec in SPECS { + let row = run_frame_init(spec, db.clone(), clone_frame_init(&frame_init)); + assert_eq!( + row.instruction_result, expected, + "{label} ({spec:?}): unexpected classification", + ); + assert!( + row.instruction_result.is_ok_or_revert(), + "{label} ({spec:?}): this row is only meaningful while the shape stays \ + non-halting", + ); + assert_eq!( + row.remaining, FRAME_GAS, + "{label} ({spec:?}): the whole budget must be handed back to the caller", + ); + assert_eq!( + row.booked_destroyed, 0, + "{label} ({spec:?}): gas that returns to the caller is not destroyed", + ); + } + } +} + +/// A CREATE onto an occupied address is the one `frame_init` rejection whose budget the caller +/// never sees again, so REX7 books the whole thing as destroyed and REX6 books nothing. +#[test] +fn test_create_collision_books_the_whole_swallowed_budget() { + for spec in SPECS { + let created = CALLER.create(0); + let db = row_db().account_code(created, BytecodeBuilder::default().append(STOP).build()); + let row = run_frame_init(spec, db, create_frame_init(U256::ZERO, 1)); + + assert_eq!( + row.instruction_result, + InstructionResult::CreateCollision, + "{spec:?}: the shape under test must be a collision", + ); + assert!( + !row.instruction_result.is_ok_or_revert(), + "{spec:?}: a collision is an exceptional halt, which is why the budget is lost", + ); + assert_eq!( + row.remaining, FRAME_GAS, + "{spec:?}: the result carries the whole child budget it is about to swallow", + ); + let expected = if spec.is_enabled(MegaSpecId::REX7) { FRAME_GAS } else { 0 }; + assert_eq!( + row.booked_destroyed, expected, + "{spec:?}: the swallowed budget must be booked exactly once on the destroyed lane", + ); + } +} + +/// A precompile comes back through the same arm on its own terms: the envelope it destroys is the +/// caller's uncapped forwarded amount rather than the budget its result carries. +/// Booking again here would double it, so the total must stay one forwarded envelope. +#[test] +fn test_precompile_result_is_not_booked_a_second_time() { + // 32 bytes: not blake2f's 213, so it is rejected before any work and halts. + let malformed = Bytes::from(vec![0xAAu8; 32]); + for spec in SPECS { + let row = run_frame_init(spec, row_db(), call_frame_init(BLAKE2F, malformed.clone(), 1)); + + assert_eq!( + row.instruction_result, + InstructionResult::PrecompileError, + "{spec:?}: the probe must reach the precompile and halt inside it", + ); + let expected = if spec.is_enabled(MegaSpecId::REX7) { FRAME_GAS } else { 0 }; + assert_eq!( + row.booked_destroyed, + expected, + "{spec:?}: the precompile's own recording site books the forwarded envelope once; \ + a second booking at the frame-init arm would report {} here", + 2 * FRAME_GAS, + ); + } +} + +/// `FrameInit` is not `Clone`, and each row is run once per spec. +fn clone_frame_init(frame_init: &FrameInit) -> FrameInit { + FrameInit { + depth: frame_init.depth, + memory: SharedMemory::new(), + frame_input: frame_init.frame_input.clone(), + } +} + +/* ------------------------------------------------------------------------------------------- * + * The same rejections reached through real transactions. + * ------------------------------------------------------------------------------------------- */ + +/// The transaction gas limit the end-to-end collision cases run with. +const TX_GAS_LIMIT: u64 = 1_000_000; + +/// Standard EVM intrinsic gas for a creation transaction with empty init code: 21,000 plus the +/// 32,000 creation surcharge. Empty init code adds neither calldata nor EIP-3860 word cost. +const CREATE_INTRINSIC_COMPUTE: u64 = 53_000; + +/// A creation transaction from [`CALLER`] with empty init code, aimed at whatever +/// `CALLER.create(0)` resolves to. +fn colliding_create_tx(gas_limit: u64) -> revm::context::TxEnv { + TxEnvBuilder::default() + .caller(CALLER) + .kind(TxKind::Create) + .gas_limit(gas_limit) + .gas_price(0) + .data(Bytes::new()) + .build_fill() +} + +/// A funded caller whose first creation address is already occupied. +fn colliding_db() -> MemoryDatabase { + MemoryDatabase::default() + .account_balance(CALLER, U256::from(ONE_ETH)) + .account_code(CALLER.create(0), BytecodeBuilder::default().append(STOP).build()) +} + +/// A transaction that is nothing but a colliding creation destroys everything past its intrinsic +/// cost, and the receipt is unchanged from the frozen spec's. +#[test] +fn test_top_level_create_collision_destroys_the_rest_of_the_envelope() { + let run = |spec| { + transact_tx( + spec, + colliding_db(), + EvmTxRuntimeLimits::from_spec(spec), + colliding_create_tx(TX_GAS_LIMIT), + &default_envs(), + ) + }; + let r6 = run(MegaSpecId::REX6); + let r7 = run(MegaSpecId::REX7); + + assert_eq!( + format!("{:?}", r6.result), + format!("{:?}", r7.result), + "the collision halt itself must be unchanged", + ); + assert_eq!(r6.gas_used, TX_GAS_LIMIT, "an exceptional halt spends the whole envelope"); + assert_eq!(r7.gas_used, r6.gas_used, "receipt gas_used must be unchanged"); + + // Everything the transaction spent is either compute or the flat intrinsic storage gas: the + // collision creates no account, so nothing else is charged. + assert_eq!( + r7.compute_gas, + TX_GAS_LIMIT - TX_INTRINSIC_STORAGE_GAS, + "REX7 must report the whole envelope less its intrinsic storage gas as compute", + ); + assert_eq!( + r7.enforced(), + CREATE_INTRINSIC_COMPUTE, + "only the intrinsic compute was ever performed, so only it may enforce", + ); + assert_eq!( + r7.destroyed, + TX_GAS_LIMIT - TX_INTRINSIC_STORAGE_GAS - CREATE_INTRINSIC_COMPUTE, + "the rest of the envelope is what the refused frame swallowed", + ); + assert_eq!( + r7.booked_destroyed(), + r7.destroyed, + "the per-site booking and the conservation law must agree", + ); + + assert_eq!( + r6.compute_gas, CREATE_INTRINSIC_COMPUTE, + "REX6 attributes nothing to a frame that never ran", + ); + assert_eq!(r6.destroyed, 0, "REX6 has no destroyed lane"); + assert_eq!( + r7.enforced(), + r6.compute_gas, + "the enforcing lane is byte-identical across the two specs", + ); +} + +/// Two CREATE2s with the same salt and the same (empty) init code: the first deploys, the second +/// collides with it. +fn colliding_create2_code() -> Bytes { + BytecodeBuilder::default() + .push_number(0u64) // salt + .push_number(0u64) // size + .push_number(0u64) // offset + .push_number(0u64) // value + .append(CREATE2) + .append(POP) + .push_number(0u64) // salt + .push_number(0u64) // size + .push_number(0u64) // offset + .push_number(0u64) // value + .append(CREATE2) + .append(POP) + .append(STOP) + .build() +} + +/// The reported repro: two CREATE2s with the same salt and the same init code, the second of which +/// collides. The caller survives it, so this also pins that a swallowed inner budget is booked +/// without the surrounding frame noticing. +#[test] +fn test_inner_create2_collision_destroys_the_forwarded_budget() { + let code = colliding_create2_code(); + let db = MemoryDatabase::default() + .account_balance(CALLER, U256::from(10 * ONE_ETH)) + .account_code(CONTRACT, code); + + let r6 = transact_default(MegaSpecId::REX6, db.clone()); + let r7 = transact_default(MegaSpecId::REX7, db); + + assert!(r7.is_success(), "the caller absorbs the failed CREATE2 and stops: {:?}", r7.result); + assert_eq!( + format!("{:?}", r6.result), + format!("{:?}", r7.result), + "the caller's own result must be unchanged", + ); + assert_eq!(r6.gas_used, r7.gas_used, "receipt gas_used must be unchanged"); + + assert!( + r7.destroyed > 0, + "the colliding CREATE2's forwarded budget is swallowed and must be booked", + ); + assert_eq!( + r7.booked_destroyed(), + r7.destroyed, + "the per-site booking and the conservation law must agree", + ); + assert_eq!( + r7.enforced(), + r6.compute_gas, + "the enforcing lane is byte-identical across the two specs", + ); + assert_eq!( + r7.compute_gas, + r6.compute_gas + r7.destroyed, + "REX7 reports exactly what REX6 reported plus the swallowed budget", + ); +} + +/// A CREATE whose value exceeds the caller's balance is a revert: its budget comes back, so +/// nothing is destroyed and the two specs report the same compute total. +#[test] +fn test_inner_create_out_of_funds_destroys_nothing() { + let code = BytecodeBuilder::default() + .push_number(0u64) // size + .push_number(0u64) // offset + .push_number(2 * ONE_ETH as u64) // value, above the contract's balance + .append(CREATE) + .append(POP) + .append(STOP) + .build(); + let db = MemoryDatabase::default() + .account_balance(CALLER, U256::from(10 * ONE_ETH)) + .account_code(CONTRACT, code) + .account_balance(CONTRACT, U256::from(ONE_ETH)); + + let r6 = transact_default(MegaSpecId::REX6, db.clone()); + let r7 = transact_default(MegaSpecId::REX7, db); + + assert!(r7.is_success(), "the caller absorbs the failed CREATE: {:?}", r7.result); + assert_eq!(r6.gas_used, r7.gas_used, "receipt gas_used must be unchanged"); + assert_eq!(r7.destroyed, 0, "an OutOfFunds create hands its budget back"); + assert_eq!(r7.booked_destroyed(), 0, "and so books nothing"); + assert_eq!( + r7.compute_gas, r6.compute_gas, + "with nothing destroyed the two specs report the same compute total", + ); +} + +/// A CREATE from an account whose nonce cannot be bumped reports success and hands its budget +/// back, so it books nothing either. +#[test] +fn test_inner_create_nonce_overflow_destroys_nothing() { + let code = BytecodeBuilder::default() + .push_number(0u64) // size + .push_number(0u64) // offset + .push_number(0u64) // value + .append(CREATE) + .append(POP) + .append(STOP) + .build(); + let db = MemoryDatabase::default() + .account_balance(CALLER, U256::from(10 * ONE_ETH)) + .account_code(CONTRACT, code) + .account_nonce(CONTRACT, u64::MAX); + + let r6 = transact_default(MegaSpecId::REX6, db.clone()); + let r7 = transact_default(MegaSpecId::REX7, db); + + assert!(r7.is_success(), "the caller survives the refused CREATE: {:?}", r7.result); + assert_eq!(r6.gas_used, r7.gas_used, "receipt gas_used must be unchanged"); + assert_eq!(r7.destroyed, 0, "a nonce-overflow create hands its budget back"); + assert_eq!(r7.booked_destroyed(), 0, "and so books nothing"); + assert_eq!( + r7.compute_gas, r6.compute_gas, + "with nothing destroyed the two specs report the same compute total", + ); +} + +/// A precompile that halts is booked once, by its own recording site. Running it alongside the +/// frame-init arm must not double the destroyed total. +#[test] +fn test_precompile_halt_stays_booked_once_end_to_end() { + let malformed = vec![0xAAu8; 32]; + let forwarded: u64 = 200_000; + let code = BytecodeBuilder::default() + .mstore(0, &malformed) + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(malformed.len() as u64) // argsSize + .push_number(0u64) // argsOffset + .push_number(0u64) // value + .push_address(BLAKE2F) + .push_number(forwarded) + .append(revm::bytecode::opcode::CALL) + .append(POP) + .append(STOP) + .build(); + let db = MemoryDatabase::default() + .account_balance(CALLER, U256::from(10 * ONE_ETH)) + .account_code(CONTRACT, code); + + let r7 = transact(MegaSpecId::REX7, db, EvmTxRuntimeLimits::from_spec(MegaSpecId::REX7)); + + assert!(r7.is_success(), "the caller absorbs the precompile failure: {:?}", r7.result); + assert_eq!( + r7.destroyed, + forwarded, + "the forwarded envelope is destroyed exactly once; a second booking at the frame-init \ + arm would report about {} here", + 2 * forwarded, + ); + assert_eq!( + r7.booked_destroyed(), + r7.destroyed, + "the per-site booking and the conservation law must agree", + ); +} + +/* ------------------------------------------------------------------------------------------- * + * The rewritten-envelope boundary. + * ------------------------------------------------------------------------------------------- */ + +/// Sender of the deposit transaction. +const DEPOSIT_CALLER: Address = address!("0000000000000000000000000000000000310003"); + +/// A colliding creation, sent as an OP deposit. +/// +/// A failed deposit's receipt is rebuilt to report the whole gas limit after every settlement has +/// run, and the boundary that rebuilds it books the difference as destroyed. This transaction +/// books at both places, so it is where a double count between them would show up: the total must +/// still be the whole envelope less the work the transaction actually performed. +#[test] +fn test_failed_deposit_whose_create_collides_books_the_envelope_once() { + let gas_limit = TX_GAS_LIMIT; + let db = MemoryDatabase::default() + .account_balance(DEPOSIT_CALLER, U256::from(ONE_ETH)) + .account_code(DEPOSIT_CALLER.create(0), BytecodeBuilder::default().append(STOP).build()); + let mut tx = MegaTransaction::new( + TxEnvBuilder::default() + .caller(DEPOSIT_CALLER) + .kind(TxKind::Create) + .gas_limit(gas_limit) + .gas_price(0) + .data(Bytes::new()) + .build_fill(), + ); + tx.deposit.source_hash = B256::repeat_byte(0x42); + tx.enveloped_tx = Some(Bytes::new()); + + let r7 = transact_mega_tx( + MegaSpecId::REX7, + db, + EvmTxRuntimeLimits::from_spec(MegaSpecId::REX7), + tx, + &TestExternalEnvs::default(), + ); + + let rendered = format!("{:?}", r7.halt_reason("deposit")); + assert!( + rendered.contains("FailedDeposit"), + "a failed deposit must be reported as FailedDeposit, got {rendered}", + ); + assert_eq!(r7.gas_used, gas_limit, "a failed deposit's receipt reports the whole gas limit"); + assert_eq!( + r7.enforced(), + CREATE_INTRINSIC_COMPUTE, + "only the intrinsic compute was performed, and the rewrite must not change that", + ); + assert_eq!( + r7.destroyed, + gas_limit - TX_INTRINSIC_STORAGE_GAS - CREATE_INTRINSIC_COMPUTE, + "the rewritten envelope, less what was performed, is destroyed exactly once", + ); + assert_eq!( + r7.booked_destroyed(), + r7.destroyed, + "the per-site bookings and the conservation law must agree after the rewrite too", + ); +} + +/* ------------------------------------------------------------------------------------------- * + * The nested-execution boundary. + * ------------------------------------------------------------------------------------------- */ + +/// The inner keyless transaction's gas limit — enough for its constructor to run both creations. +const KEYLESS_INNER_GAS: u64 = 400_000; + +/// A deterministic pre-EIP-155 creation transaction, wrapped in a `keylessDeploy` call. +/// +/// Its constructor runs [`colliding_create2_code`], so the collision happens inside the +/// `KeylessDeploy` sandbox rather than in the outer transaction's own frames. +fn keyless_deploy_calldata() -> Bytes { + let tx = TxLegacy { + nonce: 0, + gas_price: 100_000_000_000, + gas_limit: KEYLESS_INNER_GAS, + to: TxKind::Create, + value: U256::ZERO, + input: colliding_create2_code(), + chain_id: None, + }; + let word = U256::from_be_bytes(hex!( + "3333333333333333333333333333333333333333333333333333333333333333" + )); + let signed = Signed::new_unchecked(tx, Signature::new(word, word, false), B256::ZERO); + let mut buf = Vec::new(); + signed.rlp_encode(&mut buf); + Bytes::from( + IKeylessDeploy::keylessDeployCall { + keylessDeploymentTransaction: Bytes::from(buf), + gasLimitOverride: U256::from(KEYLESS_INNER_GAS), + } + .abi_encode(), + ) +} + +/// A keyless deployment whose constructor collides with itself books the swallowed budget in the +/// sandbox's own tracker, and the merge has to carry it into the outer transaction: the outer +/// transaction reports it and still enforces only the work performed. +#[test] +fn test_keyless_sandbox_create_collision_crosses_the_merge_boundary() { + let run = |spec| { + let db = MemoryDatabase::default().account_balance(CALLER, U256::from(1_000 * ONE_ETH)); + let tx = TxEnvBuilder::default() + .caller(CALLER) + .call(KEYLESS_DEPLOY_ADDRESS) + .gas_limit(2_000_000u64) + .chain_id(Some(1)) + .data(keyless_deploy_calldata()) + .build_fill(); + transact_tx(spec, db, EvmTxRuntimeLimits::from_spec(spec), tx, &default_envs()) + }; + let r6 = run(MegaSpecId::REX6); + let r7 = run(MegaSpecId::REX7); + + assert!(r7.is_success(), "the sandbox deployment must still succeed: {:?}", r7.result); + assert_eq!( + format!("{:?}", r6.result), + format!("{:?}", r7.result), + "the outer transaction's own result must be unchanged", + ); + assert_eq!(r6.gas_used, r7.gas_used, "receipt gas_used must be unchanged"); + assert!( + r7.destroyed > 0, + "the sandbox frame the collision refused swallowed its budget, and the merge must carry \ + that across", + ); + assert_eq!( + r7.booked_destroyed(), + r7.destroyed, + "the per-site booking and the conservation law must agree across the merge", + ); + assert_eq!( + r7.enforced(), + r6.compute_gas, + "the enforcing lane is byte-identical across the two specs", + ); + assert_eq!( + r7.compute_gas, + r6.compute_gas + r7.destroyed, + "the outer transaction reports exactly what REX6 reported plus the swallowed budget", + ); +} diff --git a/crates/mega-evm/tests/rex7/frame_loop_parity.rs b/crates/mega-evm/tests/rex7/frame_loop_parity.rs new file mode 100644 index 00000000..b2496544 --- /dev/null +++ b/crates/mega-evm/tests/rex7/frame_loop_parity.rs @@ -0,0 +1,697 @@ +//! The two frame loops must agree, on every shape a frame can end in. +//! +//! `frame_run` and `inspect_frame_run` are separate functions, and so are `frame_init` and +//! `inspect_frame_init`. What they share is a body: the frame's settlement point, the reading the +//! frozen post-action charge is measured against, the guards in front of interceptor dispatch, and +//! the journal decision. The inspected copies add exactly one thing to it — the callback that can +//! rewrite a frame's classification — and an observation-only inspector rewrites nothing. +//! +//! So with such an inspector attached, every quantity a transaction produces has to be identical +//! to the uninspected run: the receipt, the four resource dimensions, the enforced / destroyed +//! split, and the state. A difference means one loop reached a settlement the other did not. +//! +//! The cases below are not representative samples. They are one per branch of the frame lifecycle +//! that can end a frame: the classification arms of a contract creation (accepted, oversized, +//! `0xEF`-prefixed, unaffordable deposit, reverted constructor, occupied address), a call frame's +//! three outcomes, the frame inits that refuse to build a frame at all, a precompile, and the two +//! suspension shapes that must settle nothing. +//! +//! An observation-only inspector is the wrong tool for one question, though, and the same matrix +//! answers it with a rewriting one. Whether a frame's result came out of a frame that *ran* or out +//! of frame init decides whether a classification rewrite can be followed at all: the running +//! frame's journal decision is withheld until after the last callback, and the init-produced one's +//! was taken before the first. Each case therefore also declares which of the two its first +//! completed frame is, and [`test_a_classification_rewrite_is_followed_or_refused_by_that_alone`] +//! drives every one of them through an inspector that moves the classification across the +//! boundary. + +use crate::common::{CALLEE, CALLER, CONTRACT, EMPTY_TARGET, ONE_ETH}; +use alloy_primitives::{address, Address, Bytes, B256, U256}; +use mega_evm::{ + constants::mini_rex::MAX_CONTRACT_SIZE, + test_utils::{BytecodeBuilder, MemoryDatabase}, + EmptyExternalEnv, EvmTxRuntimeLimits, MegaContext, MegaEvm, MegaSpecId, MegaTransaction, + MegaTransactionNew as _, MegaTransactionOutcome, +}; +use revm::{ + bytecode::opcode::{CALL, CREATE, INVALID, MSTORE8, PUSH0, RETURN, REVERT, STATICCALL, STOP}, + context::{tx::TxEnvBuilder, CfgEnv, ContextTr}, + handler::{EvmTr, FrameResult}, + inspector::NoOpInspector, + interpreter::{FrameInput, InstructionResult, InterpreterTypes}, + primitives::TxKind, + state::EvmState, + Inspector, +}; +use std::{collections::BTreeMap, string::String}; + +/// High enough that EVM gas is never what binds, except where a case says otherwise. +const TX_GAS_LIMIT: u64 = 30_000_000; +/// `ecrecover`, the cheapest precompile to reach with junk input. +const ECRECOVER: Address = address!("0000000000000000000000000000000000000001"); + +/// Everything one transaction produced, in a form two runs can be compared field by field. +#[derive(Debug, PartialEq, Eq)] +struct Reading { + result: String, + compute_gas: u64, + enforced: u64, + destroyed: u64, + data_size: u64, + kv_updates: u64, + state_growth: u64, + gas_used: u64, + total_gas_spent: u64, + state: String, +} + +/// Renders the produced state in a canonical, order-independent form. +/// +/// Comparing the state is what makes these cases cover the journal decision rather than only the +/// accounting: a frame committed on one loop and reverted on the other shows up here and nowhere +/// else. +fn render_state(state: &EvmState) -> String { + let canonical: BTreeMap)> = state + .iter() + .map(|(address, account)| { + let storage = account + .storage + .iter() + .map(|(slot, value)| (*slot, value.present_value())) + .collect(); + (*address, (account.info.balance, account.info.nonce, account.info.code_hash, storage)) + }) + .collect(); + std::format!("{canonical:?}") +} + +/// Runs `case` once under `spec`, with the inspector either driving the inspected loops or +/// switched off. +/// +/// Both arms build the same `MegaEvm` type and toggle the flag, so the only thing that changes is +/// which pair of loops runs — not the inspector, not the context, not the transaction. +fn run_under(case: &Case, spec: MegaSpecId, inspected: bool) -> Reading { + let mut db = (case.db)(); + let mut cfg = CfgEnv::default(); + cfg.spec = spec; + cfg.limit_contract_code_size = Some(case.code_size_limit.unwrap_or(MAX_CONTRACT_SIZE)); + let mut context = MegaContext::new(&mut db, spec) + .with_cfg(cfg) + .with_tx_runtime_limits(EvmTxRuntimeLimits::from_spec(spec)); + context.modify_chain(|chain| { + chain.operator_fee_scalar = Some(U256::ZERO); + chain.operator_fee_constant = Some(U256::ZERO); + }); + + let mut tx = MegaTransaction::new( + TxEnvBuilder::default() + .caller(CALLER) + .kind(case.kind) + .data(case.data.clone()) + .value(case.value) + .gas_limit(case.gas_limit) + .build_fill(), + ); + tx.enveloped_tx = Some(Bytes::new()); + + let mut evm: MegaEvm<_, NoOpInspector, EmptyExternalEnv> = + MegaEvm::new(context).with_inspector(NoOpInspector); + if !inspected { + alloy_evm::Evm::set_inspector_enabled(&mut evm, false); + } + let outcome: MegaTransactionOutcome = + evm.execute_transaction(tx).expect("tx should not surface EVMError"); + + Reading { + result: std::format!("{:?}", outcome.result_and_state.result), + compute_gas: outcome.compute_gas_used, + enforced: outcome.compute_gas_enforced, + destroyed: outcome.compute_gas_destroyed, + data_size: outcome.data_size, + kv_updates: outcome.kv_updates, + state_growth: outcome.state_growth_used, + gas_used: outcome.result_and_state.result.tx_gas_used(), + total_gas_spent: outcome.result_and_state.result.gas().total_gas_spent(), + state: render_state(&outcome.result_and_state.state), + } +} + +/// One frame-lifecycle shape, and how to reach it. +struct Case { + /// What the case pins, used as the assertion label. + name: &'static str, + db: fn() -> MemoryDatabase, + kind: TxKind, + data: Bytes, + value: U256, + gas_limit: u64, + /// A lowered contract-size limit, for the case that needs revm's size reject. `MegaETH`'s own + /// 512 KiB limit is far past what a constructor can afford to return under the per-byte + /// storage gas. + code_size_limit: Option, + /// Asserted against the plain run, so a case that stops reaching its shape fails loudly + /// instead of comparing two runs of something else. + expect: fn(&Reading), + /// Where this case's first completed frame result comes from, which is the whole of what + /// decides whether a classification rewrite of it can be followed. + origin: Origin, +} + +/// Where a frame result was produced, for the rewrite the second matrix applies to it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Origin { + /// A frame ran and reached its own settlement point. Its journal decision is parked until + /// after the last callback, so a rewrite is followed and the state follows it. + FrameRan, + /// Frame init produced the result without ever building a frame — an empty-code call, a + /// precompile, a refusal upstream or `MegaETH` declined to build. The journal decision behind + /// it was taken before any callback ran, so a rewrite is refused. + FrameInit, +} + +fn base_db() -> MemoryDatabase { + MemoryDatabase::default().account_balance(CALLER, U256::from(ONE_ETH)) +} + +fn caller_db(code: Bytes) -> MemoryDatabase { + base_db().account_code(CONTRACT, code) +} + +/// Init code returning `len` bytes of runtime code, the first of them `first_byte`. +fn init_code_returning(len: u64, first_byte: u8) -> Bytes { + BytecodeBuilder::default() + .push_number(u128::from(first_byte)) + .push_number(0u64) + .append(MSTORE8) + .push_number(u128::from(len)) + .push_number(0u64) + .append(RETURN) + .build() +} + +/// A contract whose body issues one `CALL` to `target`, forwarding `gas`, then stops. +fn calls(target: Address, gas: u64, value: u128) -> Bytes { + BytecodeBuilder::default() + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(0u64) // argsSize + .push_number(0u64) // argsOffset + .push_number(value) + .push_address(target) + .push_number(u128::from(gas)) + .append(CALL) + .append(STOP) + .build() +} + +fn assert_success(r: &Reading) { + assert!(r.result.starts_with("Success"), "expected a success, got {}", r.result); +} + +fn assert_revert(r: &Reading) { + assert!(r.result.starts_with("Revert"), "expected a revert, got {}", r.result); +} + +fn assert_halt(r: &Reading) { + assert!(r.result.starts_with("Halt"), "expected a halt, got {}", r.result); +} + +/// A halt with a named reason, so a case that stops reaching its classification arm fails rather +/// than comparing two runs of a different failure. +fn assert_halt_reason(r: &Reading, reason: &str) { + assert_halt(r); + assert!(r.result.contains(reason), "expected a {reason} halt, got {}", r.result); +} + +/// The frame's remainder was destroyed rather than handed back — the shape whose booking site sits +/// on one side of the callback and whose derivation sits on the other. +fn assert_destroyed(r: &Reading) { + assert!(r.destroyed > 0, "expected a destroyed remainder, got {r:?}"); +} + +fn cases() -> Vec { + std::vec![ + Case { + name: "CALL succeeds and commits its storage write", + db: || { + caller_db(calls(CALLEE, 200_000, 0)).account_code( + CALLEE, + BytecodeBuilder::default().sstore(U256::from(1), U256::from(7)).stop().build(), + ) + }, + kind: TxKind::Call(CONTRACT), + data: Bytes::new(), + value: U256::ZERO, + code_size_limit: None, + gas_limit: TX_GAS_LIMIT, + expect: assert_success, + origin: Origin::FrameRan, + }, + Case { + name: "CALL reverts and its storage write is rolled back", + db: || { + caller_db(calls(CALLEE, 200_000, 0)).account_code( + CALLEE, + BytecodeBuilder::default() + .sstore(U256::from(1), U256::from(7)) + .revert() + .build(), + ) + }, + kind: TxKind::Call(CONTRACT), + data: Bytes::new(), + value: U256::ZERO, + code_size_limit: None, + gas_limit: TX_GAS_LIMIT, + expect: assert_success, + origin: Origin::FrameRan, + }, + Case { + name: "CALL halts on INVALID and destroys its forwarded budget", + db: || { + caller_db(calls(CALLEE, 200_000, 0)) + .account_code(CALLEE, Bytes::from_static(&[INVALID])) + }, + kind: TxKind::Call(CONTRACT), + data: Bytes::new(), + value: U256::ZERO, + code_size_limit: None, + gas_limit: TX_GAS_LIMIT, + expect: |r| { + assert_success(r); + assert_destroyed(r); + }, + origin: Origin::FrameRan, + }, + Case { + name: "CALL into empty code stops without a frame", + db: || caller_db(calls(EMPTY_TARGET, 200_000, 0)), + kind: TxKind::Call(CONTRACT), + data: Bytes::new(), + value: U256::ZERO, + code_size_limit: None, + gas_limit: TX_GAS_LIMIT, + expect: assert_success, + origin: Origin::FrameInit, + }, + Case { + name: "CALL is refused for want of balance", + db: || caller_db(calls(CALLEE, 200_000, 1)) + .account_code(CALLEE, init_code_returning(1, 0x00)), + kind: TxKind::Call(CONTRACT), + data: Bytes::new(), + value: U256::ZERO, + code_size_limit: None, + gas_limit: TX_GAS_LIMIT, + expect: assert_success, + origin: Origin::FrameInit, + }, + Case { + name: "STATICCALL reaches a precompile, which returns without a frame", + db: || { + caller_db( + BytecodeBuilder::default() + .push_number(0u64) + .push_number(0u64) + .push_number(32u64) + .push_number(0u64) + .push_address(ECRECOVER) + .push_number(100_000u64) + .append(STATICCALL) + .append(STOP) + .build(), + ) + }, + kind: TxKind::Call(CONTRACT), + data: Bytes::new(), + value: U256::ZERO, + code_size_limit: None, + gas_limit: TX_GAS_LIMIT, + expect: assert_success, + origin: Origin::FrameInit, + }, + Case { + name: "CREATE deposits its code", + db: base_db, + kind: TxKind::Create, + data: init_code_returning(64, 0x00), + value: U256::ZERO, + code_size_limit: None, + gas_limit: TX_GAS_LIMIT, + expect: assert_success, + origin: Origin::FrameRan, + }, + Case { + name: "CREATE is rejected for an oversized runtime code", + db: base_db, + kind: TxKind::Create, + data: init_code_returning(64, 0x00), + value: U256::ZERO, + code_size_limit: Some(32), + gas_limit: TX_GAS_LIMIT, + expect: |r| { + assert_halt_reason(r, "CreateContractSizeLimit"); + assert_destroyed(r); + }, + origin: Origin::FrameRan, + }, + Case { + name: "CREATE is rejected for an 0xEF-prefixed runtime code", + db: base_db, + kind: TxKind::Create, + data: init_code_returning(4, 0xEF), + value: U256::ZERO, + code_size_limit: None, + gas_limit: TX_GAS_LIMIT, + expect: |r| { + assert_halt_reason(r, "CreateContractStartingWithEF"); + assert_destroyed(r); + }, + origin: Origin::FrameRan, + }, + Case { + name: "CREATE runs out of gas paying for its own code", + db: base_db, + // 1000 bytes of runtime code cost ten million gas to store; the limit below lets the + // constructor run and leaves it unable to pay for what it returned. + data: init_code_returning(1_000, 0x00), + kind: TxKind::Create, + value: U256::ZERO, + code_size_limit: None, + gas_limit: 150_000, + expect: |r| assert_halt_reason(r, "OutOfGas"), + origin: Origin::FrameRan, + }, + Case { + name: "CREATE's constructor reverts", + db: base_db, + kind: TxKind::Create, + data: BytecodeBuilder::default().revert().build(), + value: U256::ZERO, + code_size_limit: None, + gas_limit: TX_GAS_LIMIT, + expect: assert_revert, + origin: Origin::FrameRan, + }, + Case { + name: "CREATE onto an occupied address collides", + db: || { + caller_db( + BytecodeBuilder::default() + .push_number(0u64) + .push_number(0u64) + .push_number(0u64) + .append(CREATE) + .append(STOP) + .build(), + ) + // The address CONTRACT's first CREATE derives, pre-occupied with code. + .account_code(CONTRACT.create(1), Bytes::from_static(&[STOP])) + .account_nonce(CONTRACT, 1) + }, + kind: TxKind::Call(CONTRACT), + data: Bytes::new(), + value: U256::ZERO, + code_size_limit: None, + gas_limit: TX_GAS_LIMIT, + expect: |r| { + assert_success(r); + assert_destroyed(r); + }, + origin: Origin::FrameInit, + }, + Case { + name: "a nested CALL suspends its caller without settling it", + db: || { + caller_db(calls(CALLEE, 500_000, 0)) + .account_code(CALLEE, calls(EMPTY_TARGET, 100_000, 0)) + }, + kind: TxKind::Call(CONTRACT), + data: Bytes::new(), + value: U256::ZERO, + code_size_limit: None, + gas_limit: TX_GAS_LIMIT, + expect: assert_success, + origin: Origin::FrameInit, + }, + Case { + name: "the top-level frame itself halts", + db: || caller_db(Bytes::from_static(&[PUSH0, PUSH0, REVERT])), + kind: TxKind::Call(CONTRACT), + data: Bytes::new(), + value: U256::ZERO, + code_size_limit: None, + gas_limit: TX_GAS_LIMIT, + expect: assert_revert, + origin: Origin::FrameRan, + }, + ] +} + +/// Every frame-lifecycle shape, run through both loops, compared on everything a transaction +/// produces. +/// +/// The state comparison is what covers the journal decision: the loops now decide it themselves, +/// after the callback, and a frame committed on one and reverted on the other is invisible in the +/// receipt of a transaction whose caller absorbed the difference. +#[test] +fn test_both_frame_loops_agree_on_every_frame_outcome() { + for case in cases() { + let plain = run_under(&case, MegaSpecId::REX7, false); + (case.expect)(&plain); + let inspected = run_under(&case, MegaSpecId::REX7, true); + assert_eq!( + plain, inspected, + "{}: an observation-only inspector must change nothing", + case.name, + ); + } +} + +/// Moves the classification of the first frame result it is handed across the success / revert / +/// halt boundary, once. +/// +/// It fires at the generic `frame_end`, which revm runs after the variant-specific callback over +/// the same result — so one frame receives exactly one rewrite whatever shape it has, and the +/// count below is the number of frames rewritten rather than the number of callbacks reached. +#[derive(Debug, Default)] +struct MoveTheClassification { + fired: u32, +} + +/// The class a result is moved *to*, which is any class but its own. +fn across_the_boundary(from: InstructionResult) -> InstructionResult { + if from.is_ok() { + InstructionResult::Revert + } else if from.is_revert() { + InstructionResult::OutOfGas + } else { + InstructionResult::Revert + } +} + +impl Inspector for MoveTheClassification { + fn frame_end( + &mut self, + _context: &mut CTX, + _frame_input: &FrameInput, + frame_result: &mut FrameResult, + ) { + if self.fired > 0 { + return; + } + self.fired += 1; + let result = frame_result.interpreter_result_mut(); + result.result = across_the_boundary(result.result); + } +} + +/// What one rewritten run came to. +#[derive(Debug, PartialEq, Eq)] +enum Rewritten { + /// The transaction produced a receipt, and the shim refused nothing. + Followed, + /// The shim restored the classification and failed the transaction. + Refused, +} + +/// Runs `case` under REX7 with the rewriting inspector attached, and says which way it came out. +fn run_rewritten(case: &Case) -> Rewritten { + let mut db = (case.db)(); + let mut cfg = CfgEnv::default(); + cfg.spec = MegaSpecId::REX7; + cfg.limit_contract_code_size = Some(case.code_size_limit.unwrap_or(MAX_CONTRACT_SIZE)); + let mut context = MegaContext::new(&mut db, MegaSpecId::REX7) + .with_cfg(cfg) + .with_tx_runtime_limits(EvmTxRuntimeLimits::from_spec(MegaSpecId::REX7)); + context.modify_chain(|chain| { + chain.operator_fee_scalar = Some(U256::ZERO); + chain.operator_fee_constant = Some(U256::ZERO); + }); + + let mut tx = MegaTransaction::new( + TxEnvBuilder::default() + .caller(CALLER) + .kind(case.kind) + .data(case.data.clone()) + .value(case.value) + .gas_limit(case.gas_limit) + .build_fill(), + ); + tx.enveloped_tx = Some(Bytes::new()); + + let mut inspector = MoveTheClassification::default(); + let (outcome, ledger) = { + let mut evm = MegaEvm::new(context).with_inspector(&mut inspector); + let outcome: Result = evm.execute_transaction(tx); + let ledger = evm.ctx_ref().additional_limit.borrow().inspector_ledger(); + (outcome, ledger) + }; + assert_eq!( + inspector.fired, 1, + "{}: the rewriting inspector must reach exactly one frame result", + case.name, + ); + match outcome { + Ok(_) => { + assert_eq!( + ledger.rejected_rewrites, 0, + "{}: a followed rewrite refuses nothing", + case.name, + ); + assert!( + ledger.interventions > 0, + "{}: a followed rewrite must still be booked as an intervention", + case.name, + ); + Rewritten::Followed + } + Err(_) => { + assert_eq!( + ledger.rejected_rewrites, 1, + "{}: a refused rewrite is counted exactly once", + case.name, + ); + Rewritten::Refused + } + } +} + +/// Whether a classification rewrite is followed or refused is decided by where the result came +/// from, and by nothing else. +/// +/// This is the case the observation-only matrix above cannot reach: an inspector that changes +/// nothing cannot tell a result the frame loops settled from one frame init produced, because both +/// arrive at the same callback holding the same type. The difference is in what stands behind +/// them, and only a rewrite makes it visible — a running frame's journal decision is still +/// outstanding and follows the rewrite, while an init-produced result's was taken inside +/// `make_call_frame` or inside an interceptor before the callback existed. +/// +/// Every case of the matrix runs here, so the split is stated over the whole frame lifecycle +/// rather than over the three shapes that happen to have their own fixtures. +#[test] +fn test_a_classification_rewrite_is_followed_or_refused_by_that_alone() { + for case in cases() { + let expected = match case.origin { + Origin::FrameRan => Rewritten::Followed, + Origin::FrameInit => Rewritten::Refused, + }; + assert_eq!( + run_rewritten(&case), + expected, + "{}: a {:?} result must be {expected:?}", + case.name, + case.origin, + ); + } +} + +/// The matrix covers both origins, so the test above is a comparison rather than a restatement of +/// one verdict. +#[test] +fn test_the_matrix_reaches_both_frame_origins() { + let cases = cases(); + for origin in [Origin::FrameRan, Origin::FrameInit] { + assert!( + cases.iter().any(|case| case.origin == origin), + "the matrix must contain a {origin:?} case", + ); + } +} + +/// The two-loop comparison for the rewriting inspector: with the inspected loops switched off, the +/// same inspector is handed no callback and the run is the uninspected one, bit for bit. +/// +/// This is what says the refusal and the marker that drives it live entirely inside the inspected +/// path — that neither the window `inspect_frame_init` opens nor the comparison the shim makes in +/// it can move a transaction the plain loops ran. +#[test] +fn test_a_rewriting_inspector_with_the_loops_switched_off_changes_nothing() { + for case in cases() { + let plain = run_under(&case, MegaSpecId::REX7, false); + let mut db = (case.db)(); + let mut cfg = CfgEnv::default(); + cfg.spec = MegaSpecId::REX7; + cfg.limit_contract_code_size = Some(case.code_size_limit.unwrap_or(MAX_CONTRACT_SIZE)); + let mut context = MegaContext::new(&mut db, MegaSpecId::REX7) + .with_cfg(cfg) + .with_tx_runtime_limits(EvmTxRuntimeLimits::from_spec(MegaSpecId::REX7)); + context.modify_chain(|chain| { + chain.operator_fee_scalar = Some(U256::ZERO); + chain.operator_fee_constant = Some(U256::ZERO); + }); + let mut tx = MegaTransaction::new( + TxEnvBuilder::default() + .caller(CALLER) + .kind(case.kind) + .data(case.data.clone()) + .value(case.value) + .gas_limit(case.gas_limit) + .build_fill(), + ); + tx.enveloped_tx = Some(Bytes::new()); + + let mut inspector = MoveTheClassification::default(); + let outcome: MegaTransactionOutcome = { + let mut evm = MegaEvm::new(context).with_inspector(&mut inspector); + alloy_evm::Evm::set_inspector_enabled(&mut evm, false); + evm.execute_transaction(tx).expect("tx should not surface EVMError") + }; + assert_eq!(inspector.fired, 0, "{}: no callback may run", case.name); + let switched_off = Reading { + result: std::format!("{:?}", outcome.result_and_state.result), + compute_gas: outcome.compute_gas_used, + enforced: outcome.compute_gas_enforced, + destroyed: outcome.compute_gas_destroyed, + data_size: outcome.data_size, + kv_updates: outcome.kv_updates, + state_growth: outcome.state_growth_used, + gas_used: outcome.result_and_state.result.tx_gas_used(), + total_gas_spent: outcome.result_and_state.result.gas().total_gas_spent(), + state: render_state(&outcome.result_and_state.state), + }; + assert_eq!( + plain, switched_off, + "{}: a rewriting inspector with no callbacks must change nothing", + case.name, + ); + } +} + +/// The same matrix under the frozen spec the REX7 loops share their body with. +/// +/// The loops are not spec-gated — only where they take the journal decision is — so a settlement +/// that reaches one loop and not the other would show up here too, on a spec whose behaviour is +/// closed. +#[test] +fn test_both_frame_loops_agree_on_every_frame_outcome_under_rex6() { + for case in cases() { + let plain = run_under(&case, MegaSpecId::REX6, false); + let inspected = run_under(&case, MegaSpecId::REX6, true); + assert_eq!( + plain, inspected, + "{}: an observation-only inspector must change nothing under REX6", + case.name, + ); + } +} diff --git a/crates/mega-evm/tests/rex7/gas_clamp.rs b/crates/mega-evm/tests/rex7/gas_clamp.rs new file mode 100644 index 00000000..422271e8 --- /dev/null +++ b/crates/mega-evm/tests/rex7/gas_clamp.rs @@ -0,0 +1,503 @@ +//! REX7 gas-clamp enforcement. +//! +//! Plain opcodes under checkpoint accounting record nothing, so nothing checks a limit while a +//! plain segment runs. Enforcement instead comes from the interpreter itself: at every checkpoint +//! and frame entry / resume the visible remaining gas is clamped down to the compute headroom — the +//! tighter of the frame-local budget and the TX-level (possibly detained) limit — and the hidden +//! remainder is remembered along with the constraint that bound it. revm's own per-opcode gas check +//! then stops a crossing opcode at the clamp boundary *before it executes*, and the frame's final +//! result restores the hidden gas and reclassifies the out-of-gas as the limit exceed it stands +//! for. +//! +//! These tests pin the three sides of that mechanism: +//! +//! - **Unobservable while within limits**: `GAS` reads the true counter even under an active clamp, +//! so a transaction that never exceeds a limit is bit-identical to per-opcode accounting. +//! - **Exact enforcement**: the crossing opcode never runs, its cost never enters the recorded +//! usage, and usage therefore stops at or below the limit — including inside a checkpoint-free +//! arithmetic loop, where deferring to the next checkpoint would overshoot by the whole loop. +//! - **Faithful reclassification**: frame-local exceeds revert to the parent, TX-level exceeds halt +//! the transaction with the remaining gas rescued, and a detention exceed keeps reporting +//! `VolatileDataAccessOutOfGas`. + +use crate::common::{ + base_db, compute_limit, countdown_loop_code, detention_cap, plain_filler, transact, + transact_default, transact_with_gas_limit, Outcome, CALLEE, CONTRACT, +}; +use alloy_primitives::{Address, Bytes, U256}; +use mega_evm::{test_utils::BytecodeBuilder, EvmTxRuntimeLimits, MegaHaltReason, MegaSpecId}; +use revm::bytecode::opcode::{ + CALL, EXTCODECOPY, GAS, MSTORE, POP, RETURN, SSTORE, STOP, TIMESTAMP, +}; + +/// Slot the outer contract stores the CALL success flag into. +const CALL_RESULT_SLOT: u64 = 0x10; +/// Slot a callee writes to, so a reverted sub-frame can be told from a committed one. +const CALLEE_SLOT: u64 = 0x11; + +/// Per-spec runtime limits with both the TX compute gas limit and the block-environment +/// detention cap replaced. +fn compute_and_detention(compute: u64, cap: u64) -> impl Fn(MegaSpecId) -> EvmTxRuntimeLimits { + move |spec| { + EvmTxRuntimeLimits::from_spec(spec) + .with_tx_compute_gas_limit(compute) + .with_block_env_access_compute_gas_limit(cap) + } +} + +/// The compute gas a transaction running `code` uses when nothing constrains it. +fn unconstrained_compute_gas(code: Bytes) -> u64 { + transact_default(MegaSpecId::REX7, base_db(code)).compute_gas +} + +/// `GAS` must observe the true remaining gas even while a clamp is outstanding — the checkpoint +/// prologue restores the hidden gas before the raw instruction reads the counter. +/// +/// A tight detention cap keeps the clamp active for the whole post-access run while the transaction +/// itself stays far inside every limit, so the stored reading, the compute total and the receipt +/// must all match per-opcode REX6, where no clamp exists at all. +#[test] +fn test_clamp_is_unobservable_via_the_gas_opcode() { + let code = plain_filler(BytecodeBuilder::default(), 5) + .append(TIMESTAMP) + .append(POP) + .append(GAS) + .push_u256(U256::from(CALL_RESULT_SLOT)) + .append(SSTORE) + .append(STOP) + .build(); + // A cap two orders of magnitude below the frame's remaining EVM gas, so the clamp is active at + // the GAS opcode, but well above what the rest of this transaction spends, so nothing is ever + // exceeded. + let limits = detention_cap(1_000_000); + + let r6 = transact(MegaSpecId::REX6, base_db(code.clone()), limits(MegaSpecId::REX6)); + let r7 = transact(MegaSpecId::REX7, base_db(code), limits(MegaSpecId::REX7)); + + assert!(r6.is_success(), "REX6 must succeed: {:?}", r6.result); + assert!(r7.is_success(), "REX7 must succeed: {:?}", r7.result); + + let slot = U256::from(CALL_RESULT_SLOT); + let r7_reading = r7.storage_value(CONTRACT, slot); + assert!(!r7_reading.is_zero(), "the GAS reading must be non-zero"); + assert_eq!( + r6.storage_value(CONTRACT, slot), + r7_reading, + "GAS must push the true remaining gas, not the clamped value", + ); + assert_eq!(r6.compute_gas, r7.compute_gas, "compute totals must be identical"); + assert_eq!(r6.gas_used, r7.gas_used, "receipt gas must be identical"); +} + +/// The crossing opcode is stopped before it executes, so its cost never enters the recorded usage. +/// +/// The limit is placed partway through a straight plain-opcode run. REX6 executes the crossing +/// opcode and only then records it, so its usage ends up strictly over the limit; REX7 clamps the +/// visible gas to the headroom, so revm rejects the crossing opcode at the boundary and usage stops +/// exactly at the limit. +#[test] +fn test_crossing_opcode_is_stopped_before_it_executes() { + let code = plain_filler(BytecodeBuilder::default(), 200).append(STOP).build(); + let intrinsic = unconstrained_compute_gas(BytecodeBuilder::default().append(STOP).build()); + let full_run = unconstrained_compute_gas(code.clone()); + // Trip the limit halfway through the plain run. + let limit = intrinsic + (full_run - intrinsic) / 2; + let limits = compute_limit(limit); + + let r6 = transact(MegaSpecId::REX6, base_db(code.clone()), limits(MegaSpecId::REX6)); + let r7 = transact(MegaSpecId::REX7, base_db(code), limits(MegaSpecId::REX7)); + + assert!(!r6.is_success(), "REX6 must stop on the tight compute limit: {:?}", r6.result); + assert!(!r7.is_success(), "REX7 must stop on the tight compute limit: {:?}", r7.result); + + assert!( + r6.compute_gas > limit, + "REX6 records the crossing opcode before halting; compute={} limit={limit}", + r6.compute_gas + ); + assert_eq!( + r7.compute_gas, limit, + "REX7 must stop exactly at the limit: the crossing opcode never runs, and the headroom it \ + could not pay for is what the frame burns", + ); +} + +/// A TX-level crossing halts the transaction, and the gas the clamp was hiding is rescued for the +/// sender rather than burned. +/// +/// The top-level frame's compute budget equals the TX-level remaining, so the TX limit is what +/// binds and the halt must propagate. +#[test] +fn test_tx_level_clamp_exceed_halts_with_the_hidden_gas_rescued() { + // ~260k gas of plain opcodes with no checkpoint inside the loop at all. + let code = countdown_loop_code(&[], 10_000); + let intrinsic = unconstrained_compute_gas(BytecodeBuilder::default().append(STOP).build()); + let limit = intrinsic + 5_000; + + let r7 = transact(MegaSpecId::REX7, base_db(code), compute_limit(limit)(MegaSpecId::REX7)); + + assert!(!r7.is_success(), "the tight compute limit must halt the transaction: {:?}", r7.result); + assert!( + matches!(r7.halt_reason("REX7"), MegaHaltReason::ComputeGasLimitExceeded { .. }), + "a TX-level clamp exceed must report the compute-gas limit; got {:?}", + r7.halt_reason("REX7"), + ); + assert_eq!(r7.compute_gas, limit, "usage must stop at the limit, not past it"); + assert!( + r7.gas_used < 200_000, + "the clamp-hidden gas must be rescued, not burned; gas_used={}", + r7.gas_used + ); +} + +/// A frame-local crossing reverts the sub-frame and lets the caller continue. +/// +/// A nested frame's compute budget is 98/100 of its parent's remaining budget, so it is always +/// tighter than the TX-level remaining — the clamp binds frame-locally, and the clamp-induced +/// out-of-gas must be reclassified into the ordinary frame-local revert rather than a TX halt. +#[test] +fn test_frame_local_clamp_exceed_reverts_to_the_parent() { + // The callee writes a slot and then burns far more compute than its frame budget allows. The + // write is passed as the loop's prefix so the loop's jump target accounts for it. + let prologue = + BytecodeBuilder::default().sstore(U256::from(CALLEE_SLOT), U256::from(0x77)).build_vec(); + let callee = countdown_loop_code(&prologue, 10_000); + + // The caller returns the CALL's success flag. A nested frame may consume up to 98/100 of its + // parent's compute budget, so the caller's own tail has to be cheap enough to fit in the + // remainder — a storage write would push the caller over its budget too. + let code = BytecodeBuilder::default() + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(0u64) // argsSize + .push_number(0u64) // argsOffset + .push_number(0u64) // value + .push_address(CALLEE) + .push_number(50_000_000u64) // gas + .append(CALL) + .push_number(0u64) // memory offset + .append(MSTORE) + .push_number(32u64) // length + .push_number(0u64) // offset + .append(RETURN) + .build(); + + let intrinsic = unconstrained_compute_gas(BytecodeBuilder::default().append(STOP).build()); + // Enough headroom for the caller's own work and the callee's SSTORE, far short of its loop. + let limits = compute_limit(intrinsic + 100_000); + let build_db = || base_db(code.clone()).account_code(CALLEE, callee.clone()); + + let r6 = transact(MegaSpecId::REX6, build_db(), limits(MegaSpecId::REX6)); + let r7 = transact(MegaSpecId::REX7, build_db(), limits(MegaSpecId::REX7)); + + for (label, r) in [("REX6", &r6), ("REX7", &r7)] { + assert!( + r.is_success(), + "{label}: the outer transaction survives a frame-local exceed: {:?}", + r.result + ); + assert_eq!( + r.result.output().map(|o| U256::from_be_slice(o)), + Some(U256::ZERO), + "{label}: the CALL must report failure", + ); + assert!( + r.storage_value(CALLEE, U256::from(CALLEE_SLOT)).is_zero(), + "{label}: the reverted sub-frame's storage write must be discarded", + ); + } + assert!( + r7.compute_gas < r6.compute_gas, + "REX7 stops the callee before the crossing opcode, so it records less than REX6; \ + REX6={} REX7={}", + r6.compute_gas, + r7.compute_gas + ); +} + +/// A TX-level clamp exceed after a volatile access that did not tighten the detained +/// limit (`detained_limit == base_tx_limit`) must stay a compute-gas halt. +/// +/// `TIMESTAMP` marks volatile access so the halt-reason remap consults +/// `latched_detained`, but the detention cap is far above the TX compute budget, so +/// `set_detained_limit` leaves the effective limit at the base. The `<` comparison in +/// `latch_clamp_exceed` is then false; `<=` would stamp the flag and rewrite the halt +/// as `VolatileDataAccessOutOfGas`. +#[test] +fn test_tx_level_clamp_exceed_without_tightened_detention_is_compute() { + let code = countdown_loop_code(&[TIMESTAMP, POP], 10_000); + let free = transact_default(MegaSpecId::REX7, base_db(code.clone())); + assert!(free.is_success(), "the unconstrained run must succeed: {:?}", free.result); + let with_timestamp = unconstrained_compute_gas( + BytecodeBuilder::default().append(TIMESTAMP).append(POP).append(STOP).build(), + ); + let midpoint = (with_timestamp + free.compute_gas) / 2; + // Far above the TX compute budget, so usage_at_access + cap cannot undercut it. + let limits = compute_and_detention(midpoint, 50_000_000); + + let r7 = transact(MegaSpecId::REX7, base_db(code), limits(MegaSpecId::REX7)); + + assert_eq!( + r7.detained_compute_gas_limit, midpoint, + "detention must not tighten: detained={} base={midpoint}", + r7.detained_compute_gas_limit + ); + match r7.halt_reason("REX7") { + MegaHaltReason::ComputeGasLimitExceeded { limit, .. } => { + assert_eq!(*limit, midpoint, "the reported limit is the TX compute limit"); + } + other => panic!( + "a TX-level clamp with detained_limit == base must be ComputeGasLimitExceeded, \ + not {other:?}" + ), + } +} + +/// A detention crossing keeps its `VolatileDataAccessOutOfGas` attribution. +/// +/// Detention lowers the TX-level limit to `usage_at_access + cap`, so it is the TX-level constraint +/// that binds. The usual detained-exceed predicate needs usage to have crossed the detained limit, +/// which clamp enforcement never lets happen — the attribution has to survive on the clamp's own +/// record of what bound it. +#[test] +fn test_detention_clamp_exceed_keeps_the_volatile_attribution() { + let code = countdown_loop_code(&[TIMESTAMP, POP], 10_000); + let limits = detention_cap(1_000); + + let r6 = transact(MegaSpecId::REX6, base_db(code.clone()), limits(MegaSpecId::REX6)); + let r7 = transact(MegaSpecId::REX7, base_db(code), limits(MegaSpecId::REX7)); + + assert!(!r6.is_success(), "REX6 must halt on the detention cap: {:?}", r6.result); + assert!(!r7.is_success(), "REX7 must halt on the detention cap: {:?}", r7.result); + for (label, r) in [("REX6", &r6), ("REX7", &r7)] { + assert!( + matches!(r.halt_reason(label), MegaHaltReason::VolatileDataAccessOutOfGas { .. }), + "{label}: the halt must be attributed to volatile detention; got {:?}", + r.halt_reason(label), + ); + } +} + +/// The clamp bounds a detention cap inside a checkpoint-free arithmetic loop — the shape that makes +/// checkpoint-deferred enforcement unbounded, since the loop body contains no checkpoint at all and +/// the whole ~260k-gas loop would otherwise run to completion before anything checked. +#[test] +fn test_clamp_bounds_detention_inside_a_checkpoint_free_loop() { + let cap = 1_000; + let code = countdown_loop_code(&[TIMESTAMP, POP], 10_000); + let intrinsic = unconstrained_compute_gas(BytecodeBuilder::default().append(STOP).build()); + let limits = detention_cap(cap); + + let r6 = transact(MegaSpecId::REX6, base_db(code.clone()), limits(MegaSpecId::REX6)); + let r7 = transact(MegaSpecId::REX7, base_db(code.clone()), limits(MegaSpecId::REX7)); + let unconstrained = unconstrained_compute_gas(code); + + // The detained limit is `usage_at_access + cap`; the access happens two opcodes in, so + // `intrinsic + TIMESTAMP + cap` bounds it from above. + let detained_upper = intrinsic + 2 + cap; + assert!( + unconstrained > 250_000, + "the loop must be far larger than the cap to make the test meaningful; loop={unconstrained}" + ); + assert!( + r7.compute_gas <= detained_upper, + "REX7 must not overshoot the detention cap; compute={} cap≈{detained_upper}", + r7.compute_gas + ); + // Per-opcode enforcement stops within one opcode of the cap; the clamp must not stop earlier. + assert!( + r7.compute_gas + 32 >= r6.compute_gas, + "REX7 must stop at the clamp boundary, not before it; REX6={} REX7={}", + r6.compute_gas, + r7.compute_gas + ); +} + +/// The adjudicated double-exceed corner: when the crossing opcode outruns both the true EVM +/// remaining and the compute headroom, the compute classification wins. +/// +/// A memory expansion far larger than the transaction's whole gas limit is unaffordable either way. +/// REX6 reports revm's memory out-of-gas and burns the frame; REX7 reports the compute-gas limit +/// and rescues the clamp-hidden remainder for the sender. The two are indistinguishable at the +/// frame boundary — an out-of-gas carries no opcode cost — and this direction favours the sender +/// without opening anything new: a caller that wants to avoid the burn can already REVERT. +#[test] +fn test_double_exceed_prefers_the_compute_classification() { + // A ~7.5 MB memory offset: the expansion costs on the order of 10^8 gas, well past the + // transaction's gas limit below. + let code = plain_filler(BytecodeBuilder::default(), 5) + .push_number(0u64) // value + .push_number(7_500_000u64) // offset + .append(MSTORE) + .append(STOP) + .build(); + let gas_limit = 1_000_000; + let intrinsic = unconstrained_compute_gas(BytecodeBuilder::default().append(STOP).build()); + // Headroom well below the frame's true remaining, so the clamp is outstanding at the MSTORE. + let limits = compute_limit(intrinsic + 1_000); + + let r6 = transact_with_gas_limit( + MegaSpecId::REX6, + base_db(code.clone()), + limits(MegaSpecId::REX6), + gas_limit, + ); + let r7 = transact_with_gas_limit( + MegaSpecId::REX7, + base_db(code), + limits(MegaSpecId::REX7), + gas_limit, + ); + + assert!(!r6.is_success(), "REX6 must fail on the unaffordable expansion: {:?}", r6.result); + assert!(!r7.is_success(), "REX7 must fail on the unaffordable expansion: {:?}", r7.result); + assert!( + matches!(r6.halt_reason("REX6"), MegaHaltReason::Base(_)), + "REX6 reports revm's own out-of-gas; got {:?}", + r6.halt_reason("REX6"), + ); + assert!( + matches!(r7.halt_reason("REX7"), MegaHaltReason::ComputeGasLimitExceeded { .. }), + "REX7 must classify the double exceed as a compute exceed; got {:?}", + r7.halt_reason("REX7"), + ); + assert_eq!(r6.gas_used, gas_limit, "REX6 burns the whole gas limit"); + assert!( + r7.gas_used < gas_limit, + "REX7 must rescue the clamp-hidden gas; gas_used={} limit={gas_limit}", + r7.gas_used + ); +} + +/// Every checkpoint kind has to restore the clamp before its body runs and re-apply it afterwards, +/// or the segments around it would either observe clamped gas or run unbounded. Exercising them in +/// one transaction that stays inside every limit pins the round trip: any asymmetry between the +/// restore and the re-clamp shows up as a compute-gas or receipt difference against REX6. +#[test] +fn test_clamp_round_trips_through_every_checkpoint_kind() { + let callee = plain_filler(BytecodeBuilder::default(), 5).append(STOP).build(); + let code = plain_filler(BytecodeBuilder::default(), 5) + .append(TIMESTAMP) + .append(POP) + .append(GAS) + .append(POP) + .sstore(U256::from(1), U256::from(0x22)) + .push_u256(U256::from(1)) + .append(revm::bytecode::opcode::SLOAD) + .append(POP) + .push_address(CALLEE) + .append(revm::bytecode::opcode::BALANCE) + .append(POP); + let code = plain_filler(code, 5) + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(0u64) // argsSize + .push_number(0u64) // argsOffset + .push_number(0u64) // value + .push_address(CALLEE) + .push_number(500_000u64) // gas + .append(CALL) + .append(POP); + let code = plain_filler(code, 5) + .mstore(0, [0x33u8; 32]) + .push_number(0xabcu64) // topic0 + .push_number(32u64) // len + .push_number(0u64) // offset + .append(revm::bytecode::opcode::LOG1) + .append(STOP) + .build(); + + // A detention cap that engages at the TIMESTAMP but is never binding, so the clamp is + // outstanding across every later checkpoint. + let limits = detention_cap(1_000_000); + let build_db = || base_db(code.clone()).account_code(CALLEE, callee.clone()); + + let r6 = transact(MegaSpecId::REX6, build_db(), limits(MegaSpecId::REX6)); + let r7 = transact(MegaSpecId::REX7, build_db(), limits(MegaSpecId::REX7)); + + assert!(r6.is_success(), "REX6 must succeed: {:?}", r6.result); + assert!(r7.is_success(), "REX7 must succeed: {:?}", r7.result); + assert_outcomes_identical(&r6, &r7); +} + +fn assert_outcomes_identical(r6: &Outcome, r7: &Outcome) { + assert_eq!( + format!("{:?}", r6.result), + format!("{:?}", r7.result), + "execution result must be identical", + ); + assert_eq!(r6.compute_gas, r7.compute_gas, "compute gas must be identical"); + assert_eq!(r6.gas_used, r7.gas_used, "receipt gas_used must be identical"); + assert_eq!( + (r6.data_size, r6.kv_updates, r6.state_growth), + (r7.data_size, r7.kv_updates, r7.state_growth), + "the non-compute dimensions must be identical", + ); +} + +/// A volatile checkpoint whose own body crosses the compute limit must behave identically under +/// both accounting models. +/// +/// The prologue restores the clamp before the body runs, so an `EXTCODECOPY` large enough to cross +/// the limit is metered on the true counter and recorded per opcode exactly as REX6 records it — +/// the clamp plays no part. What the checkpoint form has to reproduce is the tail: the detention +/// cap is applied on a frame-local exceed (a revert the per-opcode layering carries past the cap) +/// and skipped on a TX-level exceed (an out-of-gas halt that layering short-circuits on). Pinning +/// the halt, the recorded usage and the resulting detained limit together covers both the metering +/// and that ordering. +#[test] +fn test_volatile_body_crossing_the_limit_matches_per_opcode() { + // ~1.5 MB of EXTCODECOPY against the block beneficiary: the copy plus the memory expansion cost + // millions of gas, and the account load marks beneficiary access. + let callee = BytecodeBuilder::default() + .push_number(1_500_000u64) // length + .push_number(0u64) // offset + .push_number(0u64) // destOffset + .push_address(Address::ZERO) // the default block beneficiary + .append(EXTCODECOPY) + .append(STOP) + .build(); + let code = BytecodeBuilder::default() + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(0u64) // argsSize + .push_number(0u64) // argsOffset + .push_number(0u64) // value + .push_address(CALLEE) + .push_number(50_000_000u64) // gas + .append(CALL) + .append(POP) + .append(STOP) + .build(); + let build_db = || base_db(code.clone()).account_code(CALLEE, callee.clone()); + let full = transact_default(MegaSpecId::REX7, build_db()).compute_gas; + assert!(full > 4_000_000, "the copy must dominate the transaction; compute={full}"); + + // Just under what the transaction needs, so the crossing lands inside the EXTCODECOPY body + // rather than in a plain segment. + let tx_limit = full - full / 100; + let limits = move |spec| { + let mut limits = EvmTxRuntimeLimits::from_spec(spec).with_tx_compute_gas_limit(tx_limit); + limits.block_env_access_compute_gas_limit = 1_000; + limits + }; + + let r6 = transact(MegaSpecId::REX6, build_db(), limits(MegaSpecId::REX6)); + let r7 = transact(MegaSpecId::REX7, build_db(), limits(MegaSpecId::REX7)); + + assert!(!r6.is_success(), "REX6 must stop on the tight compute limit: {:?}", r6.result); + assert_eq!( + format!("{:?}", r6.result), + format!("{:?}", r7.result), + "the halt must be identical", + ); + assert_eq!( + r6.compute_gas, r7.compute_gas, + "the body is metered on the true counter under both models; REX6={} REX7={}", + r6.compute_gas, r7.compute_gas + ); + assert_eq!( + r6.detained_compute_gas_limit, r7.detained_compute_gas_limit, + "the detention tail must fire — or not fire — at the same point under both models", + ); +} diff --git a/crates/mega-evm/tests/rex7/gas_leakage.rs b/crates/mega-evm/tests/rex7/gas_leakage.rs new file mode 100644 index 00000000..31ff733a --- /dev/null +++ b/crates/mega-evm/tests/rex7/gas_leakage.rs @@ -0,0 +1,350 @@ +//! REX7: the three gas-leakage paths, exercised with a clamp outstanding. +//! +//! Any mechanism that hides, grants or adjusts gas per frame has to be unwound on every way out of +//! a frame, or system-held gas leaks back to the parent or the sender. The gas clamp is such a +//! mechanism — it hides part of the interpreter's gas — and the three paths that have to handle it +//! are the ones the leakage checklist names: +//! +//! 1. **System contract interception** short-circuits `frame_init` and synthesizes a result with no +//! child frame. The clamp must already be restored when the CALL checkpoint publishes the frame, +//! and the caller's counter must come back whole on resume. +//! 2. **Gas rescue on a TX-level exceed** captures the frame's remaining gas for the sender. It +//! must capture the true remaining — neither the clamped view (which would burn the hidden gas) +//! nor the sum of both (which would refund it twice). +//! 3. **Frame return** hands the frame's gas back to its parent. The restore must happen before +//! anything reads or charges that gas, and identically on success, on revert and on a limit +//! exceed. +//! +//! The probes are chosen so a leak changes an observable, not just an internal: the parent's own +//! `GAS` reading after the child returns, the receipt's independence from the transaction gas +//! limit, and whether a code deposit that costs more than the clamp left visible can be paid at +//! all. + +use crate::common::{ + assert_outcomes_identical, base_db as common_base_db, compute_limit, countdown_loop_code, + detention_cap, plain_filler, transact, transact_with_gas_limit, Outcome, CALLEE, CONTRACT, +}; +use alloy_primitives::{Bytes, U256}; +use alloy_sol_types::SolCall as _; +use mega_evm::{ + test_utils::{BytecodeBuilder, MemoryDatabase}, + EvmTxRuntimeLimits, IMegaLimitControl, MegaHaltReason, MegaSpecId, LIMIT_CONTROL_ADDRESS, + LIMIT_CONTROL_CODE, +}; +use revm::bytecode::opcode::{CALL, CREATE, GAS, MSTORE, POP, RETURN, SSTORE, STOP, TIMESTAMP}; + +/// Slot the caller stores its post-return `GAS` reading into. +const GAS_READING_SLOT: u64 = 0x40; +/// Slot a callee writes, so a committed sub-frame can be told from a reverted one. +const CALLEE_SLOT: u64 = 0x41; + +fn base_db(code: Bytes) -> MemoryDatabase { + common_base_db(code).account_code(LIMIT_CONTROL_ADDRESS, LIMIT_CONTROL_CODE) +} + +/// A CALL to `target` forwarding `gas`, with no arguments and no return data, followed by the +/// caller reading `GAS` and storing it. +/// +/// The stored reading is the probe: it is the caller's own view of its counter after the child's +/// gas has been merged back, so any gas the child failed to hand back — or handed back twice — +/// shows up in it. +fn call_then_store_gas(target: revm::primitives::Address, forwarded: u64) -> Bytes { + plain_filler(BytecodeBuilder::default(), 5) + .append(TIMESTAMP) + .append(POP) + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(0u64) // argsSize + .push_number(0u64) // argsOffset + .push_number(0u64) // value + .push_address(target) + .push_number(forwarded) + .append(CALL) + .append(POP) + .append(GAS) + .push_u256(U256::from(GAS_READING_SLOT)) + .append(SSTORE) + .append(STOP) + .build() +} + +/// Runs `code` under both specs with `limits` and returns `(REX6, REX7)`. +fn run_both( + build_db: impl Fn() -> MemoryDatabase, + limits: &impl Fn(MegaSpecId) -> EvmTxRuntimeLimits, +) -> (Outcome, Outcome) { + let r6 = transact(MegaSpecId::REX6, build_db(), limits(MegaSpecId::REX6)); + let r7 = transact(MegaSpecId::REX7, build_db(), limits(MegaSpecId::REX7)); + (r6, r7) +} + +/// Asserts both arms succeeded and read back the same post-return `GAS` value. +fn assert_same_gas_reading(label: &str, r6: &Outcome, r7: &Outcome) { + assert!(r6.is_success(), "{label}/REX6 must succeed: {:?}", r6.result); + assert!(r7.is_success(), "{label}/REX7 must succeed: {:?}", r7.result); + let slot = U256::from(GAS_READING_SLOT); + let reading = r7.storage_value(CONTRACT, slot); + assert!(!reading.is_zero(), "{label}: the GAS reading must be non-zero"); + assert_eq!( + r6.storage_value(CONTRACT, slot), + reading, + "{label}: the caller's counter after the return must match per-opcode accounting", + ); + assert_outcomes_identical(label, r6, r7); +} + +/// Leakage path 1 — the interception short-circuit, probed from the caller's own counter. +/// +/// The interceptor produces a synthetic result without a child frame ever existing, so nothing on +/// that path unwinds a clamp. The clamp therefore has to be already restored when the CALL +/// checkpoint publishes the frame, and re-applied only once the caller resumes. Reading `GAS` right +/// after the CALL is the caller's direct view of that: a clamp that survived into `frame_init`, or +/// one restored twice, moves this reading. +#[test] +fn test_interception_short_circuit_leaves_the_callers_counter_whole() { + let code = plain_filler(BytecodeBuilder::default(), 5) + .append(TIMESTAMP) + .append(POP) + .mstore(0, IMegaLimitControl::remainingComputeGasCall::SELECTOR) + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(4u64) // argsSize + .push_number(0u64) // argsOffset + .push_number(0u64) // value + .push_address(LIMIT_CONTROL_ADDRESS) + .push_number(1_000_000u64) + .append(CALL) + .append(POP) + .append(GAS) + .push_u256(U256::from(GAS_READING_SLOT)) + .append(SSTORE) + .append(STOP) + .build(); + let (r6, r7) = run_both(|| base_db(code.clone()), &detention_cap(1_000_000)); + assert_same_gas_reading("interception short-circuit", &r6, &r7); +} + +/// Leakage path 2 — the TX-level rescue, probed by varying the transaction gas limit. +/// +/// A TX-level compute exceed stops at the compute limit, so how much EVM gas the transaction was +/// given cannot change what it consumed. The clamp-hidden amount, on the other hand, is exactly +/// `gas_limit − headroom` and moves one-for-one with the gas limit — so if the rescue captured the +/// clamped view (burning the hidden gas) or the true remaining plus the hidden amount (refunding it +/// twice), the receipt would track the gas limit. It must not. +#[test] +fn test_tx_level_rescue_is_independent_of_the_transaction_gas_limit() { + let code = countdown_loop_code(&[], 10_000); + let intrinsic = transact( + MegaSpecId::REX7, + base_db(BytecodeBuilder::default().append(STOP).build()), + EvmTxRuntimeLimits::from_spec(MegaSpecId::REX7), + ) + .compute_gas; + let limit = intrinsic + 5_000; + let limits = compute_limit(limit); + + let mut readings = Vec::new(); + for spec in [MegaSpecId::REX6, MegaSpecId::REX7] { + let small = transact_with_gas_limit(spec, base_db(code.clone()), limits(spec), 1_000_000); + let large = transact_with_gas_limit(spec, base_db(code.clone()), limits(spec), 50_000_000); + assert!(!small.is_success(), "{spec:?}: the tight compute limit must stop the tx"); + assert!(!large.is_success(), "{spec:?}: the tight compute limit must stop the tx"); + assert_eq!( + small.compute_gas, large.compute_gas, + "{spec:?}: the stop point must not depend on the transaction gas limit", + ); + assert_eq!( + small.gas_used, large.gas_used, + "{spec:?}: the rescued gas must be the true remaining, so the receipt cannot track the \ + transaction gas limit; 1M limit -> {} and 50M limit -> {}", + small.gas_used, large.gas_used + ); + readings.push((small.compute_gas, small.gas_used)); + } + let (r7_compute, r7_gas_used) = readings[1]; + assert_eq!(r7_compute, limit, "REX7 stops exactly at the compute limit"); + assert!( + r7_gas_used < 1_000_000, + "the clamp-hidden gas must reach the sender, not the burn; gas_used={r7_gas_used}", + ); +} + +/// The same rescue probe with gas detention as the binding constraint, so the reclassification path +/// (`VolatileDataAccessOutOfGas` on a clamp-latched detention exceed) is the one under test. +#[test] +fn test_detained_rescue_is_independent_of_the_transaction_gas_limit() { + let code = countdown_loop_code(&[TIMESTAMP, POP], 10_000); + let limits = detention_cap(1_000); + + for spec in [MegaSpecId::REX6, MegaSpecId::REX7] { + let small = transact_with_gas_limit(spec, base_db(code.clone()), limits(spec), 1_000_000); + let large = transact_with_gas_limit(spec, base_db(code.clone()), limits(spec), 50_000_000); + for (label, r) in [("1M", &small), ("50M", &large)] { + assert!(!r.is_success(), "{spec:?}/{label}: the detention cap must stop the tx"); + assert!( + matches!(r.halt_reason(label), MegaHaltReason::VolatileDataAccessOutOfGas { .. }), + "{spec:?}/{label}: the halt must keep the volatile attribution; got {:?}", + r.halt_reason(label), + ); + } + assert_eq!( + small.gas_used, large.gas_used, + "{spec:?}: a detained stop must rescue the true remaining, so the receipt cannot track \ + the transaction gas limit; 1M limit -> {} and 50M limit -> {}", + small.gas_used, large.gas_used + ); + } +} + +/// Leakage path 3 — frame return, on the success arm. +/// +/// The callee ends inside a plain segment, so its clamp is still outstanding when the frame +/// produces its result. Restoring it there is what lets the unspent remainder flow back to the +/// caller; the caller's `GAS` reading is what shows whether it did. +#[test] +fn test_frame_return_restores_the_clamp_on_success() { + let callee = plain_filler(BytecodeBuilder::default(), 20).append(STOP).build(); + let code = call_then_store_gas(CALLEE, 1_000_000); + let build_db = || base_db(code.clone()).account_code(CALLEE, callee.clone()); + let (r6, r7) = run_both(build_db, &detention_cap(1_000_000)); + assert_same_gas_reading("frame return / success", &r6, &r7); +} + +/// The same probe on the revert arm: the unwinding has to be unconditional, or one of the two exit +/// paths leaks. +#[test] +fn test_frame_return_restores_the_clamp_on_revert() { + let callee = plain_filler(BytecodeBuilder::default(), 20) + .sstore(U256::from(CALLEE_SLOT), U256::from(0x77)) + .revert() + .build(); + let code = call_then_store_gas(CALLEE, 1_000_000); + let build_db = || base_db(code.clone()).account_code(CALLEE, callee.clone()); + let (r6, r7) = run_both(build_db, &detention_cap(1_000_000)); + assert_same_gas_reading("frame return / revert", &r6, &r7); + for (label, r) in [("REX6", &r6), ("REX7", &r7)] { + assert!( + r.storage_value(CALLEE, U256::from(CALLEE_SLOT)).is_zero(), + "{label}: the reverted sub-frame's write must be discarded", + ); + } +} + +/// Frame return on the exceed arm: the callee outruns its own frame-local compute budget, so its +/// clamp turns into a fake out-of-gas that is restored and reclassified into a revert. The gas the +/// clamp was hiding still belongs to the caller. +/// +/// The two models stop the callee at different points — REX7 stops the crossing opcode before it +/// runs — so the caller resumes with different amounts and the readings are not comparable. What is +/// comparable is that the caller survives, sees a failed CALL, and is left with gas of the right +/// order rather than a burned or doubled counter. +#[test] +fn test_frame_local_exceed_returns_the_hidden_gas_to_the_parent() { + let prologue = + BytecodeBuilder::default().sstore(U256::from(CALLEE_SLOT), U256::from(0x77)).build_vec(); + let callee = countdown_loop_code(&prologue, 10_000); + let code = plain_filler(BytecodeBuilder::default(), 5) + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(0u64) // argsSize + .push_number(0u64) // argsOffset + .push_number(0u64) // value + .push_address(CALLEE) + .push_number(50_000_000u64) + .append(CALL) + .push_number(0u64) + .append(MSTORE) + .push_number(32u64) + .push_number(0u64) + .append(RETURN) + .build(); + let intrinsic = transact( + MegaSpecId::REX7, + base_db(BytecodeBuilder::default().append(STOP).build()), + EvmTxRuntimeLimits::from_spec(MegaSpecId::REX7), + ) + .compute_gas; + let build_db = || base_db(code.clone()).account_code(CALLEE, callee.clone()); + let (r6, r7) = run_both(build_db, &compute_limit(intrinsic + 100_000)); + + for (label, r) in [("REX6", &r6), ("REX7", &r7)] { + assert!( + r.is_success(), + "{label}: the caller survives a frame-local exceed: {:?}", + r.result + ); + assert_eq!( + r.result.output().map(|o| U256::from_be_slice(o)), + Some(U256::ZERO), + "{label}: the CALL must report failure", + ); + assert!( + r.storage_value(CALLEE, U256::from(CALLEE_SLOT)).is_zero(), + "{label}: the reverted sub-frame's write must be discarded", + ); + } + // The caller forwarded (63/64 of) tens of millions of gas and got a failed call back. Only the + // callee's actual work may be gone: a clamp that was not restored would have stranded the + // hidden millions in the child. + assert!( + r7.gas_used < 1_000_000, + "the gas the clamp hid inside the callee must return to the caller; gas_used={}", + r7.gas_used + ); + assert!( + r7.gas_used < r6.gas_used + 100_000, + "REX7 must not consume materially more than per-opcode accounting; REX6={} REX7={}", + r6.gas_used, + r7.gas_used + ); +} + +/// Frame return, ordering arm: the clamp must be restored *before* the code-deposit charge. +/// +/// The deposit costs `CODEDEPOSIT_STORAGE_GAS` (10,000) per byte of deployed code, while the +/// compute it records is 200 per byte. Sizing the detention cap between the two — comfortably above +/// the deposit's compute, far below its EVM gas — leaves a CREATE that can only be paid for out of +/// the counter the clamp was hiding. If the charge saw the clamped copy, this deployment would fail +/// out of gas despite the transaction being nowhere near any limit. +#[test] +fn test_create_return_restores_the_clamp_before_the_code_deposit_charge() { + let runtime = vec![STOP; 100]; + let initcode = BytecodeBuilder::default().return_with_data(&runtime).build_vec(); + let len = initcode.len() as u64; + let code = plain_filler(BytecodeBuilder::default(), 5) + .append(TIMESTAMP) + .append(POP) + .mstore(0, &initcode) + .push_number(len) // length + .push_number(0u64) // offset + .push_number(0u64) // value + .append(CREATE) + .push_number(0u64) + .append(MSTORE) + .push_number(32u64) + .push_number(0u64) + .append(RETURN) + .build(); + // 100 bytes of runtime code: 20,000 compute for the deposit, 1,000,000 EVM gas for it. + let deposit_compute = runtime.len() as u64 * 200; + let deposit_evm_gas = runtime.len() as u64 * 10_000; + let cap = 80_000; + assert!( + deposit_compute < cap && cap < deposit_evm_gas, + "the cap has to sit between the deposit's compute and its EVM gas", + ); + + let (r6, r7) = run_both(|| base_db(code.clone()), &detention_cap(cap)); + + assert!(r6.is_success(), "REX6 must succeed: {:?}", r6.result); + assert!(r7.is_success(), "REX7 must succeed: {:?}", r7.result); + let created = + r7.result.output().map(|o| U256::from_be_slice(o)).expect("CREATE must return output"); + assert!(!created.is_zero(), "the CREATE must have succeeded; a zero address means it OOG'd"); + assert_eq!( + r6.result.output().map(|o| U256::from_be_slice(o)), + Some(created), + "both models must deploy to the same address", + ); + assert_outcomes_identical("CREATE under a clamp", &r6, &r7); +} diff --git a/crates/mega-evm/tests/rex7/gas_surface.rs b/crates/mega-evm/tests/rex7/gas_surface.rs new file mode 100644 index 00000000..23400db5 --- /dev/null +++ b/crates/mega-evm/tests/rex7/gas_surface.rs @@ -0,0 +1,898 @@ +//! The closed enumeration of gas the `Inspector` trait puts within an inspector's reach. +//! +//! `inspector_cheat_matrix.rs` asks whether every callback × *rewrite shape* pair is covered. That +//! question is answered over a shape list this repository writes down, so it can only be as +//! complete as that list. This module asks the question one level below it, over a list +//! *upstream* writes down: is there a field, reachable through an argument some callback is +//! handed, that nothing in `MegaETH` has classified? +//! +//! Gas is what the question was originally about and is still what most of the verdicts are about, +//! but the table covers every field of every object rather than the numeric ones — a field that +//! carries no gas and changes what the execution *does* needs a verdict just as much, and the two +//! cannot be told apart without looking. `CallOutcome::memory_offset` is the case that settled it: +//! not gas, not bookkeeping, and for a while not in the table at all. +//! +//! The live `Interpreter` is in the table for the same reason and at some cost to the module's +//! name: it is the one argument whose own fields, rather than a field of a field, are what an +//! inspector reaches. Leaving it out is what let `Interpreter::bytecode` go unclassified while the +//! interpreter's other fields were named in prose, and an inspector could step the program counter +//! past an instruction with every lane reading zero. +//! +//! # The two levels the enumeration has +//! +//! - **Shapes.** Which objects the EVM hands a callback that carry gas at all. Every one of them +//! arrives through an enum — `InterpreterAction`, `FrameInput`, `FrameResult` — so an exhaustive +//! match with no catch-all is a compile-time pin: a variant added upstream stops the build. +//! - **Fields.** Which numbers inside those objects carry gas. Rust cannot enumerate a foreign +//! struct's fields, but a derived `Debug` renders every one of them by name, so a snapshot of +//! that name set against the classification table is a pin with the same reach: a field added +//! upstream appears in the rendering, fails to match a table row, and the test names it. +//! +//! # The lock +//! +//! A verdict of "this reaches the receipt and nothing books it" is nameable — `Coverage` has an arm +//! for it — but not keepable: [`test_the_table_carries_no_open_gap`] fails on any row that carries +//! one. Writing a gap down is how it gets closed; leaving it written down is how a table stops +//! being a statement about the code and becomes a list of things somebody meant to do. +//! +//! # What the pins cannot reach, and what covers it instead +//! +//! A callback *added* to the `Inspector` trait is not a compile error anywhere — the trait gives +//! every method a default body, so an unimplemented one silently does nothing and an unwrapped one +//! is silently unmeasured. [`test_the_callback_set_is_the_one_the_shim_wraps`] pins the set that +//! exists today by overriding all of it, which catches a rename or a removal at compile time and +//! an addition only through the upgrade obligation stated in `src/evm/AGENTS.md`. That obligation +//! is the reason the classification table lives there rather than here. +//! +//! # The other closed table +//! +//! `src/limit/destroyed.rs` closes the perpendicular axis and the two do not overlap: this file +//! enumerates the *carriers* — which field of which object carries gas, and which lane books it — +//! while that one enumerates the *endings*, the `InstructionResult` classification that decides +//! whether a carrier's remainder is handed back to the caller or swallowed. A number reaches the +//! receipt through a carrier named here and an ending named there, and `finalize_frame` composes +//! the two answers. + +use revm::{ + handler::FrameResult, + interpreter::{ + interpreter::EthInterpreter, CallInput, CallInputs, CallOutcome, CallScheme, CallValue, + CreateInputs, CreateOutcome, CreateScheme, FrameInput, Gas, InstructionResult, Interpreter, + InterpreterAction, InterpreterResult, InterpreterTypes, + }, + primitives::{Address, Bytes, Log, U256}, + Inspector, +}; +use std::{collections::BTreeSet, string::String, vec::Vec}; + +// --- the classification ------------------------------------------------------------------------ + +/// What `MegaETH` does about one field of one object an inspector can reach. +/// +/// The whole point of the enum is that there is no fifth arm and no catch-all: a field is +/// measured, or it carries no gas, or it carries gas that reaches nothing `MegaETH` reports, or it +/// is a hole with a name. "Nobody looked at it" is not one of the options. +/// +/// The fourth arm exists and is unused, which is the state +/// [`test_the_table_carries_no_open_gap`] holds the table in. A hole is nameable, so that +/// discovering one is a change to this file rather than a silence — and it is not *keepable*, so +/// that adding one means closing it in the same change or taking the decision to an owner. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Coverage { + /// Measured, and booked on the named `InspectorLedger` lane. + Lane(&'static str), + /// Not a gas quantity. + NotGas(&'static str), + /// A gas quantity that moves nothing `MegaETH` reports, with the reason it cannot. + Inert(&'static str), + /// A gas quantity that moves what `MegaETH` reports, and that no lane books. + /// + /// Carrying this verdict in the type is deliberate. A gap named in a table is a gap someone + /// can close; a gap that is merely absent from a table is one nobody knows about. + NotClosed(&'static str), +} + +/// The five numbers a `Gas`'s tracker holds. +/// +/// `remaining` is the one every lane the shim books is defined over. The other four are reachable +/// through exactly the same `&mut Gas`, on all four of the objects the table in `src/evm/AGENTS.md` +/// lists, and the verdicts here are what each was measured to do. +const GAS_TRACKER_FIELDS: [(&str, Coverage); 5] = [ + ( + "remaining", + Coverage::Lane( + "gas / env / result, by which object holds the `Gas` and how its frame ends", + ), + ), + ( + "gas_limit", + Coverage::Inert( + "op-revm normalises the top-level gas object to the transaction's own limit before \ + the settlement point, and no REX7 lane reads a frame's limit — the two that do are \ + the REX4 legacy stipend's burn and rescue caps, which REX5 mode does not take", + ), + ), + ( + "refunded", + Coverage::Lane( + "refund, at the callback boundary — nominal, because neither the EIP-3529 cap nor the \ + chain of successful frame returns an edit must survive is attributable to one \ + callback", + ), + ), + ( + "reservoir", + Coverage::Lane( + "reservoir, settled once from the figure the transaction ends with: `MegaETH` runs \ + with EIP-8037 off and produces none of it, and revm propagates it between frames by \ + replacement, so there is no difference across a callback to take", + ), + ), + ( + "state_gas_spent", + Coverage::Lane( + "state_gas, settled at the same point — the receipt reports the final figure whether \ + or not EIP-8037 is on, and a failing frame folds it into its caller's reservoir, \ + where the lane above picks it up", + ), + ), +]; + +/// The memoisation of how far a frame's memory has been paid for. +/// +/// Both rows were once excused as "editing this alone desynchronises the memo from the memory, and +/// the EVM then reads out of bounds" — which is true of each field on its own and not of the pair +/// with the memory beside it. An inspector that grows the memory *and* moves the memo leaves the +/// interpreter in a state it could have reached by paying, having paid nothing, and every later +/// expansion inside the new bound is free. The verdict stands — neither field is a budget, and +/// nothing here carries gas across the boundary — but the reason it needs no lane is now that it is +/// booked as an intervention, from the constant-time reading the shim takes off a live interpreter. +const MEMORY_GAS_FIELDS: [(&str, Coverage); 2] = [ + ( + "words_num", + Coverage::NotGas( + "a memo of how far the frame's memory has been paid for, not a budget — but one the \ + next expanding opcode compares its requirement against, so moving it together with \ + the memory skips that opcode's charge. Booked as an intervention at each of the four \ + live-interpreter callbacks, off `WorkingSet`", + ), + ), + ( + "expansion_cost", + Coverage::NotGas( + "the memo's other half, which prices the *next* expansion incrementally; booked \ + the same way and for the same reason", + ), + ), +]; + +/// The two halves of a `Gas`. +const GAS_FIELDS: [(&str, Coverage); 2] = [ + ("tracker", Coverage::NotGas("a container; its own fields are classified separately")), + ("memory", Coverage::NotGas("a container; its own fields are classified separately")), +]; + +/// Everything a call frame is built from. +const CALL_INPUTS_FIELDS: [(&str, Coverage); 12] = [ + ( + "gas_limit", + Coverage::Lane( + "env, at the callback boundary — or, when the same callback answers the frame itself, \ + the baseline the interception's own gas is settled against", + ), + ), + ( + "reservoir", + Coverage::Lane( + "reservoir, at the transaction's settlement point — the child frame is seeded from \ + this pool and hands it back, so an edit here reaches the receipt; the rewrite \ + comparison books it as an intervention as well, which is not a second reading of the \ + same edit because no lane books anything at this boundary", + ), + ), + ("input", Coverage::NotGas("what the frame does")), + ("return_memory_offset", Coverage::NotGas("what the frame does")), + ("bytecode_address", Coverage::NotGas("what the frame does")), + ("known_bytecode", Coverage::NotGas("what the frame does")), + ("target_address", Coverage::NotGas("what the frame does")), + ("caller", Coverage::NotGas("what the frame does")), + ("value", Coverage::NotGas("what the frame does")), + ("scheme", Coverage::NotGas("what the frame does")), + ("is_static", Coverage::NotGas("what the frame does")), + ( + "charged_new_account_state_gas", + Coverage::NotGas( + "an EIP-8037 refund flag rather than an amount; the rewrite comparison books it as an \ + intervention like any other semantic field", + ), + ), +]; + +/// Everything a creation frame is built from. +const CREATE_INPUTS_FIELDS: [(&str, Coverage); 8] = [ + ("gas_limit", Coverage::Lane("env, exactly as a call's")), + ("reservoir", Coverage::Lane("reservoir, exactly as a call's")), + ("caller", Coverage::NotGas("what the frame does")), + ("scheme", Coverage::NotGas("what the frame does")), + ("value", Coverage::NotGas("what the frame does")), + ("init_code", Coverage::NotGas("what the frame does")), + ( + "cached_address", + Coverage::NotGas( + "a memo of the caller, the scheme and the init code above, filled on demand through a \ + shared reference — so it is left out of the rewrite comparison, which is what \ + `CREATE_INPUTS_COMPARISON` records", + ), + ), + ( + "cached_init_code_hash", + Coverage::NotGas("a memo of the init code above, left out for the same reason"), + ), +]; + +// --- what the rewrite comparison is written over ------------------------------------------------- + +/// How one field of a frame's inputs enters the rewrite comparison the shim makes at +/// `frame_start`, `call` and `create`. +/// +/// The field set is upstream's and is pinned the same way every table here is, against the +/// struct's own `Debug` rendering. What this table adds is the split the comparison is written +/// over: a field upstream adds is a field with no verdict until someone decides which of the three +/// it is, and the decision has teeth in both directions — a `Semantic` row must have a case in +/// `shim_input_comparison.rs` that proves the shim books an edit to it, and a `Memo` row appearing +/// on a *call's* inputs breaks the derived equality those are compared by. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Comparison { + /// Compared. It says what the frame will do, so an edit to it is what "the inspector rewrote + /// this frame" means, and it is booked on the interventions lane. + Semantic, + /// Left out because it is the frame's budget. It is booked on the env lane as an amount, and + /// comparing it here as well would report one edit twice. + Envelope, + /// Left out because it is a memo of the semantic fields, filled on demand through a shared + /// reference. Filling one is a derived value being computed rather than an input being + /// changed — `created_address` is the case, and every tracer that records a deployment calls + /// it — and the frame the EVM builds afterwards is built from the same numbers either way. + Memo, +} + +/// A call's inputs, by how each field enters the comparison. +/// +/// No `Memo` row, which is exactly what licenses `call_inputs_rewritten` to be the derived +/// equality with the gas limit normalised out: every field of `CallInputs` says what the frame +/// does, so one upstream adds joins the comparison by itself. +pub(crate) const CALL_INPUTS_COMPARISON: [(&str, Comparison); 12] = [ + ("gas_limit", Comparison::Envelope), + ("input", Comparison::Semantic), + ("return_memory_offset", Comparison::Semantic), + ("reservoir", Comparison::Semantic), + ("bytecode_address", Comparison::Semantic), + ("known_bytecode", Comparison::Semantic), + ("target_address", Comparison::Semantic), + ("caller", Comparison::Semantic), + ("value", Comparison::Semantic), + ("scheme", Comparison::Semantic), + ("is_static", Comparison::Semantic), + ("charged_new_account_state_gas", Comparison::Semantic), +]; + +/// A creation's inputs, by how each field enters the comparison. +/// +/// The two `Memo` rows are why `create_inputs_rewritten` is written out field by field instead: +/// the derived equality reads a filled memo as a changed input, so an observation-only tracer that +/// asked a creation for its address booked an intervention and, under a `TrustedObserver` +/// declaration, failed the debug verification at the first `CREATE` it saw. +pub(crate) const CREATE_INPUTS_COMPARISON: [(&str, Comparison); 8] = [ + ("gas_limit", Comparison::Envelope), + ("caller", Comparison::Semantic), + ("scheme", Comparison::Semantic), + ("value", Comparison::Semantic), + ("init_code", Comparison::Semantic), + ("reservoir", Comparison::Semantic), + ("cached_address", Comparison::Memo), + ("cached_init_code_hash", Comparison::Memo), +]; + +/// The fields of a table an edit to must be booked as an intervention, in the table's own order. +pub(crate) fn semantic_fields(table: &[(&'static str, Comparison)]) -> Vec<&'static str> { + table.iter().filter(|(_, how)| *how == Comparison::Semantic).map(|(name, _)| *name).collect() +} + +/// Everything a finished call hands back besides the result inside it. +const CALL_OUTCOME_FIELDS: [(&str, Coverage); 5] = [ + ("result", Coverage::NotGas("a container; its own fields are classified separately")), + ( + "memory_offset", + Coverage::NotGas( + "the range of its caller's memory the callee's output is copied into — what the \ + caller reads next, not what the frame cost. Booked as an intervention", + ), + ), + ( + "was_precompile_called", + Coverage::NotGas("which logs the inspector is shown next; booked as an intervention"), + ), + ( + "precompile_call_logs", + Coverage::NotGas("the logs themselves, carried past a revert; booked as an intervention"), + ), + ( + "charged_new_account_state_gas", + Coverage::NotGas( + "an EIP-8037 refund flag rather than an amount, copied here from the call's inputs \ + so the caller knows whether to give the upfront charge back; booked as an \ + intervention like the inputs' own copy of it", + ), + ), +]; + +/// Everything a finished creation hands back besides the result inside it. +const CREATE_OUTCOME_FIELDS: [(&str, Coverage); 2] = [ + ("result", Coverage::NotGas("a container; its own fields are classified separately")), + ( + "address", + Coverage::NotGas( + "the address the caller's stack receives. Not gas, and not the same question as the \ + classification: the code stays deployed where the EVM put it, so a rewrite here \ + reports a contract at an address holding nothing. Booked as an intervention", + ), + ), +]; + +/// Every field of the live interpreter a callback is handed. +/// +/// The row this table exists for is `bytecode`. Before it, the interpreter's fields were named in +/// prose — "stack, memory, `return_data`, input, `runtime_flag`, extend" — and `bytecode` was +/// simply not in the sentence, so an inspector could step the program counter past an instruction +/// and delete it from the frame with every lane and every counter reading zero. A prose list is +/// only as complete as whoever wrote it; this one is checked against what upstream's `Debug` +/// renders, like every other table here. +/// +/// The verdicts are stated over *readings*, because that is what a boundary can compare. Every +/// constant-time reading of every field below is in `inspector.rs::WorkingSet` and books an +/// intervention when it moves; what is left over in each row is content-class, which is the one +/// row of the shape table with no lane. +const INTERPRETER_FIELDS: [(&str, Coverage); 8] = [ + ( + "bytecode", + Coverage::NotGas( + "the code and the position in it. Three constant-time readings — the program counter, \ + the code buffer's identity, and revm's `continue_execution` flag, which is what the \ + inspected loop breaks on and is a separate object from the pending action. Moving the \ + counter deletes an instruction from the frame, which costs the transaction the work \ + that instruction would have done; nothing meters that, because it never happens. \ + Booked as interventions, off `WorkingSet`. Two further readings are deliberately \ + absent: `Jumps::opcode` is derived from the counter and the code buffer, both of \ + which are here, and `ExtBytecode::bytecode_hash` is a cache on the concrete type \ + that a shim generic over `InterpreterTypes` cannot reach and that no execution path \ + reads back", + ), + ), + ( + "gas", + Coverage::NotGas("a container; its own fields are classified separately"), + ), + ( + "stack", + Coverage::NotGas( + "its length is a constant-time reading and is booked as an intervention; the words in \ + it are content-class", + ), + ), + ( + "return_data", + Coverage::NotGas( + "the buffer a frame's `RETURNDATASIZE` and `RETURNDATACOPY` read. Its identity is a \ + constant-time reading and is booked as an intervention, so a frame handed data no \ + call produced is visible; the bytes inside it are content-class", + ), + ), + ( + "memory", + Coverage::NotGas( + "its size and the offset of the frame's window into the shared buffer are both \ + constant-time readings and are booked as interventions — the size because moving it \ + together with the memo in `gas` skips the next expanding opcode's charge; the bytes \ + are content-class", + ), + ), + ( + "input", + Coverage::NotGas( + "what the frame is: the account its storage instructions resolve against, the code \ + address, the caller, the value, and the calldata's identity. All constant-time, all \ + booked as interventions; the calldata's bytes are content-class", + ), + ), + ( + "runtime_flag", + Coverage::NotGas( + "the static flag and the spec id, both constant-time and both booked as \ + interventions — clearing the static flag mid-frame would let a `STATICCALL` write \ + state", + ), + ), + ( + "extend", + Coverage::NotGas( + "the one field with no reading, by construction: `InterpreterTypes::Extend` carries no \ + trait bound, so a shim generic over the interpreter has nothing it can call on it. \ + `MegaETH` configures it as `()`, which holds nothing to rewrite", + ), + ), +]; + +/// Everything a finished frame hands back. +const INTERPRETER_RESULT_FIELDS: [(&str, Coverage); 3] = [ + ("gas", Coverage::NotGas("a container; its own fields are classified separately")), + ( + "result", + Coverage::NotGas("the classification, booked as an intervention rather than as gas"), + ), + ("output", Coverage::NotGas("the returned bytes, booked as an intervention")), +]; + +// --- reading a struct's field names off its `Debug` ---------------------------------------------- + +/// The field names a derived `Debug` rendering shows at the top level of the struct it renders. +/// +/// Depth-limited on purpose: a nested value's own fields belong to that value's own table row, and +/// pinning them here would make this test fail on churn in a type nothing reaches. +fn field_names(rendered: &str) -> BTreeSet { + let bytes: Vec = rendered.chars().collect(); + let mut names = BTreeSet::new(); + let mut depth = 0usize; + let mut index = 0usize; + while index < bytes.len() { + match bytes[index] { + '{' => depth += 1, + '}' => depth = depth.saturating_sub(1), + _ if depth == 1 => { + // A field name follows the opening brace or a comma, with at most one space. + let starts_a_field = (index > 0 && matches!(bytes[index - 1], '{' | ',')) || + (index > 1 && + bytes[index - 1] == ' ' && + matches!(bytes[index - 2], '{' | ',')); + if starts_a_field && (bytes[index].is_ascii_lowercase() || bytes[index] == '_') { + let mut end = index; + while end < bytes.len() && + (bytes[end].is_ascii_alphanumeric() || bytes[end] == '_') + { + end += 1; + } + if bytes.get(end) == Some(&':') { + names.insert(bytes[index..end].iter().collect()); + index = end; + continue; + } + } + } + _ => {} + } + index += 1; + } + names +} + +/// Asserts that a struct's rendered field names are exactly the ones `table` classifies. +/// +/// Both directions are checked. A field upstream added is one the table has no verdict for; a row +/// the table keeps for a field upstream removed is a verdict about nothing, and stale prose about +/// a field that no longer exists is how a table stops being evidence. +fn assert_classified(what: &str, rendered: &str, table: &[(&str, Coverage)]) { + assert_every_field_named(what, rendered, table.iter().map(|(name, _)| *name)); +} + +/// [`assert_classified`] over the comparison tables, whose verdict type is a different enum. +fn assert_compared(what: &str, rendered: &str, table: &[(&str, Comparison)]) { + assert_every_field_named(what, rendered, table.iter().map(|(name, _)| *name)); +} + +/// The body both of those share: the rendered field names are exactly the classified ones. +fn assert_every_field_named<'a>(what: &str, rendered: &str, names: impl Iterator) { + let seen = field_names(rendered); + let classified: BTreeSet = names.map(String::from).collect(); + let unclassified: Vec<&String> = seen.difference(&classified).collect(); + let vanished: Vec<&String> = classified.difference(&seen).collect(); + assert!( + unclassified.is_empty(), + "{what} has {} field(s) no verdict covers: {unclassified:?}\n rendered: {rendered}", + unclassified.len(), + ); + assert!( + vanished.is_empty(), + "{what} no longer has {} classified field(s): {vanished:?}", + vanished.len(), + ); + assert!(!seen.is_empty(), "{what}: the rendering parsed to nothing, so nothing was checked"); +} + +// --- the samples the renderings are taken from --------------------------------------------------- + +fn sample_gas() -> Gas { + Gas::new(1) +} + +fn sample_call_inputs() -> CallInputs { + CallInputs { + input: CallInput::Bytes(Bytes::new()), + return_memory_offset: 0..0, + gas_limit: 1, + reservoir: 0, + bytecode_address: Address::ZERO, + known_bytecode: Default::default(), + target_address: Address::ZERO, + caller: Address::ZERO, + value: CallValue::Transfer(U256::ZERO), + scheme: CallScheme::Call, + is_static: false, + charged_new_account_state_gas: false, + } +} + +fn sample_create_inputs() -> CreateInputs { + CreateInputs::new(Address::ZERO, CreateScheme::Create, U256::ZERO, Bytes::new(), 1, 0) +} + +fn sample_result() -> InterpreterResult { + InterpreterResult::new(InstructionResult::Stop, Bytes::new(), sample_gas()) +} + +fn sample_call_outcome() -> CallOutcome { + CallOutcome::new(sample_result(), 0..0) +} + +fn sample_create_outcome() -> CreateOutcome { + CreateOutcome::new(sample_result(), None) +} + +/// A live interpreter, for the one table whose object is not a callback argument's field but the +/// argument itself. +fn sample_interpreter() -> Interpreter { + Interpreter::default() +} + +// --- the field-level pin ------------------------------------------------------------------------- + +/// Every field of every gas-carrying object an inspector is handed has a verdict. +/// +/// This is the closure the completeness table rests on. It is not a claim that the verdicts are +/// right — the tests in `shim_lanes.rs`, `shim_settlement.rs`, `shim_blind_spots.rs` and +/// `inspector_cheat_matrix.rs` are — it is the claim that there is no field without one. +#[test] +fn test_every_field_of_every_gas_carrier_has_a_verdict() { + let gas = sample_gas(); + let renderings = [ + ("Gas", std::format!("{gas:?}")), + ("GasTracker", std::format!("{:?}", gas.tracker())), + ("MemoryGas", std::format!("{:?}", gas.memory())), + ("CallInputs", std::format!("{:?}", sample_call_inputs())), + ("CreateInputs", std::format!("{:?}", sample_create_inputs())), + ("InterpreterResult", std::format!("{:?}", sample_result())), + ("CallOutcome", std::format!("{:?}", sample_call_outcome())), + ("CreateOutcome", std::format!("{:?}", sample_create_outcome())), + ("Interpreter", std::format!("{:?}", sample_interpreter())), + ]; + // Looked up rather than listed, so the set of tables the lock walks and the set this test + // checks the renderings against cannot drift apart. + for (what, rendered) in &renderings { + let (_, table) = tables() + .into_iter() + .find(|(name, _)| name == what) + .unwrap_or_else(|| panic!("{what} has a rendering but no table in `tables()`")); + assert_classified(what, rendered, table); + } + assert_eq!( + renderings.len(), + tables().len(), + "every table must have a rendering checked against it", + ); +} + +/// ★ Every field of a frame's inputs has a verdict on how it is compared, too. +/// +/// The same closure as the table above, over the same field sets, asking the other question: not +/// "does an edit to this reach the receipt through a lane" but "is an edit to this what the shim +/// calls a rewrite". A field upstream adds needs both answers, and the second is the one that was +/// missing when `CreateInputs` grew its memo cells — they were classified as not-gas, correctly, +/// while the comparison went on reading a filled memo as a changed input. +#[test] +fn test_every_field_of_a_frames_inputs_has_a_comparison_verdict() { + assert_compared( + "CallInputs", + &std::format!("{:?}", sample_call_inputs()), + &CALL_INPUTS_COMPARISON, + ); + assert_compared( + "CreateInputs", + &std::format!("{:?}", sample_create_inputs()), + &CREATE_INPUTS_COMPARISON, + ); +} + +/// ★ A call's inputs carry no memo, which is what the derived equality rests on. +/// +/// `call_inputs_rewritten` compares the whole struct with the gas limit normalised out, so it +/// picks up a field upstream adds without anyone noticing — which is the right trade only as long +/// as every field says what the frame does. A memo added to `CallInputs` would have to be +/// classified here, and this is what turns that classification into a failure rather than a row +/// nobody reads: the comparison has to be narrowed the way a creation's was. +#[test] +fn test_a_calls_inputs_carry_no_memo_field() { + let memos: Vec<&str> = CALL_INPUTS_COMPARISON + .iter() + .filter(|(_, how)| *how == Comparison::Memo) + .map(|(name, _)| *name) + .collect(); + assert_eq!( + memos, + Vec::<&str>::new(), + "a call's inputs are compared by the derived equality, which reads a filled memo as a \ + changed input; narrow `call_inputs_rewritten` to the semantic fields first", + ); +} + +/// The parser the pin rests on reads what it is supposed to read. +/// +/// Without this, a change to `Debug`'s formatting that made the parser return nothing would turn +/// every assertion above into a tautology — and `assert_classified`'s emptiness check would be the +/// only thing standing in the way, which is one check too few for the thing the whole module is +/// built on. +#[test] +fn test_the_field_reader_reads_the_top_level_and_stops_there() { + let names = field_names( + "Outer { first: 1, nested: Inner { hidden: 2, deeper: Deepest { buried: 3 } }, \ + last: Tuple(0x00, Other { also_hidden: 4 }) }", + ); + let expected: BTreeSet = + ["first", "nested", "last"].into_iter().map(String::from).collect(); + assert_eq!(names, expected, "only the outermost struct's own fields may be read"); + assert!(field_names("NoFields").is_empty(), "a unit struct has no fields to read"); +} + +/// Every table this module classifies, by the name its rendering is checked under. +fn tables() -> [(&'static str, &'static [(&'static str, Coverage)]); 9] { + [ + ("Gas", GAS_FIELDS.as_slice()), + ("GasTracker", GAS_TRACKER_FIELDS.as_slice()), + ("MemoryGas", MEMORY_GAS_FIELDS.as_slice()), + ("CallInputs", CALL_INPUTS_FIELDS.as_slice()), + ("CreateInputs", CREATE_INPUTS_FIELDS.as_slice()), + ("InterpreterResult", INTERPRETER_RESULT_FIELDS.as_slice()), + ("CallOutcome", CALL_OUTCOME_FIELDS.as_slice()), + ("CreateOutcome", CREATE_OUTCOME_FIELDS.as_slice()), + ("Interpreter", INTERPRETER_FIELDS.as_slice()), + ] +} + +/// The `Owner::Field` names a set of tables leaves with no lane, in sorted order. +fn open_gaps(tables: &[(&'static str, &'static [(&'static str, Coverage)])]) -> Vec { + let mut open = Vec::new(); + for (what, table) in tables { + for (field, coverage) in *table { + if matches!(coverage, Coverage::NotClosed(_)) { + open.push(std::format!("{what}::{field}")); + } + } + } + open.sort(); + open +} + +/// ★ The table carries no open gap, and cannot be left carrying one. +/// +/// Every earlier version of this test named the gaps that were open, which made a hole something a +/// change could add as long as it also added a line here. There are none left, so the pin becomes +/// structural: a field that reaches what `MegaETH` reports and that no lane books fails this test +/// the moment it is written down. +/// +/// That is deliberately awkward. Closing a surface is work, and a test that merely *records* an +/// open one lets the work be deferred indefinitely while the table still reads as complete. With +/// this pin the two options are to close the gap in the same change or to take the decision +/// somewhere a person owns it — and either way somebody has looked. +/// +/// It does not, and cannot, stop a hole from being *mis*classified as `Inert` or `NotGas`. Nothing +/// mechanical can: those verdicts are claims about what the EVM does with a number, and what backs +/// them is the measurement each one was written from. There are two cautionary cases, and they +/// failed differently. `state_gas_spent` sat under `Inert` on the strength of "EIP-8037 is off", +/// which is true and which the receipt does not care about — a wrong verdict. `MemoryGas`'s two +/// fields had the right verdict and the wrong reason: "editing this desynchronises it from the +/// memory and the EVM reads out of bounds" is true of each field alone and false of the pair moved +/// together with the memory, which is a rewrite that leaves every interpreter invariant intact and +/// is charged for nothing. A reason that only covers half its own input space is the harder of the +/// two to see, because the row reads as considered. +#[test] +fn test_the_table_carries_no_open_gap() { + assert_eq!( + open_gaps(&tables()), + Vec::::new(), + "a gas surface with no lane cannot be left in the table; close it, or take the decision \ + to an owner and record it there", + ); +} + +/// The lock detects what it claims to detect. +/// +/// Without this, the assertion above would pass just as happily against a predicate that never +/// matched anything — which is the failure mode of every test whose expected value is empty. +#[test] +fn test_the_lock_names_an_open_gap_when_there_is_one() { + const PROBE: [(&str, Coverage); 2] = [ + ("measured", Coverage::Lane("somewhere")), + ("unmeasured", Coverage::NotClosed("moves the receipt, and no lane books it")), + ]; + assert_eq!(open_gaps(&[("Probe", PROBE.as_slice())]), ["Probe::unmeasured"]); +} + +// --- the shape-level pin ------------------------------------------------------------------------- + +/// Which object a gas-carrying shape puts within reach. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Carrier { + /// A `Gas`, whose fields the tables above classify. + Gas, + /// A frame's `gas_limit`, a bare `u64`. + Envelope, + /// Nothing that carries gas. + None, +} + +/// The gas a pending action carries, by variant — no catch-all, so a variant revm adds stops the +/// build here and the shim's own `held` has to grow an arm with it. +const fn action_carrier(action: &InterpreterAction) -> Carrier { + match action { + InterpreterAction::Return(_) => Carrier::Gas, + InterpreterAction::NewFrame(input) => frame_input_carrier(input), + } +} + +/// The gas a frame input carries, by variant. +const fn frame_input_carrier(input: &FrameInput) -> Carrier { + match input { + FrameInput::Call(_) | FrameInput::Create(_) => Carrier::Envelope, + FrameInput::Empty => Carrier::None, + } +} + +/// The gas a frame result carries, by variant. +const fn frame_result_carrier(result: &FrameResult) -> Carrier { + match result { + FrameResult::Call(_) | FrameResult::Create(_) => Carrier::Gas, + } +} + +/// Every gas-carrying shape the EVM hands a callback is reached through an enum this module +/// matches exhaustively. +/// +/// The assertions are the small half; the compile is the large one. A variant added to any of the +/// three enums is a build failure here, which is the only mechanical warning `MegaETH` gets that +/// upstream grew a new way to carry gas across a callback boundary. +#[test] +fn test_every_gas_carrying_shape_is_matched_without_a_catch_all() { + let call = FrameInput::Call(std::boxed::Box::new(sample_call_inputs())); + let create = FrameInput::Create(std::boxed::Box::new(sample_create_inputs())); + + assert_eq!(frame_input_carrier(&call), Carrier::Envelope); + assert_eq!(frame_input_carrier(&create), Carrier::Envelope); + assert_eq!(frame_input_carrier(&FrameInput::Empty), Carrier::None); + + assert_eq!(action_carrier(&InterpreterAction::Return(sample_result())), Carrier::Gas); + assert_eq!(action_carrier(&InterpreterAction::NewFrame(call)), Carrier::Envelope); + + let call_result = FrameResult::Call(sample_call_outcome()); + let create_result = FrameResult::Create(sample_create_outcome()); + assert_eq!(frame_result_carrier(&call_result), Carrier::Gas); + assert_eq!(frame_result_carrier(&create_result), Carrier::Gas); +} + +// --- the callback-set snapshot ------------------------------------------------------------------- + +/// Every callback the `Inspector` trait has today, in the order the trait declares them. +/// +/// The same twelve rows `inspector_cheat_matrix.rs` runs its shapes over, restated here because +/// the two pins answer different questions: that one asks whether each row is exercised, this one +/// asks whether the row set is still the trait's. +const CALLBACKS: [&str; 12] = [ + "initialize_interp", + "step", + "step_end", + "log", + "log_full", + "frame_start", + "frame_end", + "call", + "call_end", + "create", + "create_end", + "selfdestruct", +]; + +/// Overrides every callback, so that the set is pinned by the compiler rather than by the list. +struct EveryCallback { + seen: Vec<&'static str>, +} + +impl Inspector for EveryCallback { + fn initialize_interp(&mut self, _interp: &mut Interpreter, _context: &mut CTX) { + self.seen.push("initialize_interp"); + } + + fn step(&mut self, _interp: &mut Interpreter, _context: &mut CTX) { + self.seen.push("step"); + } + + fn step_end(&mut self, _interp: &mut Interpreter, _context: &mut CTX) { + self.seen.push("step_end"); + } + + fn log(&mut self, _context: &mut CTX, _log: Log) { + self.seen.push("log"); + } + + fn log_full(&mut self, _interp: &mut Interpreter, _context: &mut CTX, _log: Log) { + self.seen.push("log_full"); + } + + fn frame_start( + &mut self, + _context: &mut CTX, + _frame_input: &mut FrameInput, + ) -> Option { + self.seen.push("frame_start"); + None + } + + fn frame_end( + &mut self, + _context: &mut CTX, + _frame_input: &FrameInput, + _frame_result: &mut FrameResult, + ) { + self.seen.push("frame_end"); + } + + fn call(&mut self, _context: &mut CTX, _inputs: &mut CallInputs) -> Option { + self.seen.push("call"); + None + } + + fn call_end(&mut self, _context: &mut CTX, _inputs: &CallInputs, _outcome: &mut CallOutcome) { + self.seen.push("call_end"); + } + + fn create(&mut self, _context: &mut CTX, _inputs: &mut CreateInputs) -> Option { + self.seen.push("create"); + None + } + + fn create_end( + &mut self, + _context: &mut CTX, + _inputs: &CreateInputs, + _outcome: &mut CreateOutcome, + ) { + self.seen.push("create_end"); + } + + fn selfdestruct(&mut self, _contract: Address, _target: Address, _value: U256) { + self.seen.push("selfdestruct"); + } +} + +/// The callback set the shim wraps is the trait's, and it is the set the tables are written over. +/// +/// A callback upstream renames or removes stops [`EveryCallback`] from compiling. A callback +/// upstream *adds* does not — the trait's default bodies see to that — and there is no compile-time +/// construct that would, which is why the upgrade obligation in `src/evm/AGENTS.md` is what covers +/// that direction, and why this list is written out rather than derived. +#[test] +fn test_the_callback_set_is_the_one_the_shim_wraps() { + let mut probe = EveryCallback { seen: Vec::new() }; + let inspector: &mut dyn Inspector<(), EthInterpreter> = &mut probe; + inspector.selfdestruct(Address::ZERO, Address::ZERO, U256::ZERO); + assert_eq!(probe.seen, ["selfdestruct"], "the override must be the one that runs"); + + assert_eq!(CALLBACKS.len(), 12, "the trait's callback count is part of the snapshot"); + let unique: BTreeSet<&str> = CALLBACKS.into_iter().collect(); + assert_eq!(unique.len(), CALLBACKS.len(), "no callback may be listed twice"); +} diff --git a/crates/mega-evm/tests/rex7/guard_pass_static_gas.rs b/crates/mega-evm/tests/rex7/guard_pass_static_gas.rs new file mode 100644 index 00000000..1dc04727 --- /dev/null +++ b/crates/mega-evm/tests/rex7/guard_pass_static_gas.rs @@ -0,0 +1,405 @@ +//! Guard-pass static-gas position under REX7 checkpoint accounting. +//! +//! Charge-on-reject debits a disabled opcode's static entry on the reject arm only. A passing +//! guard must charge at the same position the baseline REX7 handler used. For the +//! checkpoint-wrapped families that is after [`checkpoint_prologue!`] restores the true +//! counter, so a compute headroom of `static_gas − 1` lets the body run and reports a +//! frame-local `MegaLimitExceeded` rather than a clamp halt that never reads the host. +//! +//! `headroom = static_gas − 1` and `headroom = static_gas` are the two edges: the first +//! overshoots after the body, the second can afford the charge. `TIMESTAMP` is exercised in +//! the top-level frame and in a nested child; `SELFBALANCE` covers the dedicated checkpoint +//! handler at the top-level edges. `remainingComputeGas` is the value +//! `MegaLimitControl.remainingComputeGas` would return after the transaction: the TX-level +//! remaining, which is what the interceptor reads once the frames have been popped. + +use crate::common::{ + base_db, context, drive, rex7_compute_limit, stop_only, transact, Outcome, CALLEE, CALLER, + CONTRACT, DEFAULT_TX_GAS_LIMIT, +}; +use alloy_primitives::{Bytes, U256}; +use alloy_sol_types::{SolCall as _, SolError}; +use mega_evm::{ + test_utils::{BytecodeBuilder, MemoryDatabase}, + EvmTxRuntimeLimits, IMegaLimitControl, LimitKind, MegaEvm, MegaLimitExceeded, MegaSpecId, + MegaTransaction, MegaTransactionNew as _, VolatileDataAccess, LIMIT_CONTROL_ADDRESS, +}; +use revm::{ + bytecode::opcode::{CALL, MLOAD, POP, SELFBALANCE, SSTORE, STATICCALL, STOP, TIMESTAMP}, + context::{result::ExecutionResult, tx::TxEnvBuilder}, + handler::EvmTr, +}; + +/// `TIMESTAMP` static gas — the unconditional-family representative. +const TIMESTAMP_STATIC_GAS: u64 = 2; + +/// `SELFBALANCE` static gas (`LOW`) — the dedicated-checkpoint representative. +const SELFBALANCE_STATIC_GAS: u64 = 5; + +/// Slot the nested caller stores its `remainingComputeGas` reading into. +const REMAINING_SLOT: u64 = 0xc0; + +/// Codex / knife-edge program: `TIMESTAMP; POP; STOP`. +fn timestamp_pop_stop() -> Bytes { + BytecodeBuilder::default().append(TIMESTAMP).append(POP).stop().build() +} + +/// The same checkpoint with nothing after it, so `headroom = static_gas` can finish. +fn timestamp_stop() -> Bytes { + BytecodeBuilder::default().append(TIMESTAMP).stop().build() +} + +/// Dedicated-checkpoint sibling of [`timestamp_pop_stop`]: `SELFBALANCE; POP; STOP`. +fn selfbalance_pop_stop() -> Bytes { + BytecodeBuilder::default().append(SELFBALANCE).append(POP).stop().build() +} + +/// Dedicated-checkpoint sibling of [`timestamp_stop`]: `SELFBALANCE; STOP`. +fn selfbalance_stop() -> Bytes { + BytecodeBuilder::default().append(SELFBALANCE).stop().build() +} + +struct GuardPassRun { + outcome: Outcome, + accessed: VolatileDataAccess, + /// Post-tx TX-level remaining — what `remainingComputeGas` returns with no frame on the stack. + remaining_compute_gas: u64, +} + +fn run(code: Bytes, limits: EvmTxRuntimeLimits) -> GuardPassRun { + run_db(base_db(code), limits) +} + +fn run_db(mut db: MemoryDatabase, limits: EvmTxRuntimeLimits) -> GuardPassRun { + let mut evm = MegaEvm::new(context(&mut db, MegaSpecId::REX7, limits)); + let tx = TxEnvBuilder::default() + .caller(CALLER) + .call(CONTRACT) + .gas_limit(DEFAULT_TX_GAS_LIMIT) + .build_fill(); + let mut tx = MegaTransaction::new(tx); + tx.enveloped_tx = Some(Bytes::new()); + let outcome = drive(MegaSpecId::REX7, &mut evm, tx); + let remaining_compute_gas = + evm.ctx_ref().additional_limit.borrow().current_call_remaining_compute_gas(); + let accessed = evm.ctx_ref().volatile_data_tracker.borrow().get_volatile_data_accessed(); + GuardPassRun { outcome, accessed, remaining_compute_gas } +} + +fn intrinsic_stop() -> Outcome { + transact( + MegaSpecId::REX7, + base_db(stop_only()), + EvmTxRuntimeLimits::from_spec(MegaSpecId::REX7), + ) +} + +fn assert_timestamp_marked(label: &str, run: &GuardPassRun) { + assert!( + run.accessed.contains(VolatileDataAccess::TIMESTAMP), + "{label}: TIMESTAMP body must run and mark detention; accessed={:?}", + run.accessed + ); +} + +fn assert_timestamp_unmarked(label: &str, run: &GuardPassRun) { + assert!( + !run.accessed.contains(VolatileDataAccess::TIMESTAMP), + "{label}: TIMESTAMP must not have marked; accessed={:?}", + run.accessed + ); +} + +fn decode_top_revert(label: &str, outcome: &Outcome) -> MegaLimitExceeded { + match &outcome.result { + ExecutionResult::Revert { output, .. } => MegaLimitExceeded::abi_decode(output) + .unwrap_or_else(|e| panic!("{label}: revert is not MegaLimitExceeded: {e}")), + other => panic!("{label}: expected Revert(MegaLimitExceeded), got {other:?}"), + } +} + +fn call_callee(builder: BytecodeBuilder, gas: u64) -> BytecodeBuilder { + builder + .push_number(0u64) + .push_number(0u64) + .push_number(0u64) + .push_number(0u64) + .push_number(0u64) + .push_address(CALLEE) + .push_number(gas) + .append(CALL) +} + +fn store_remaining_compute_gas(builder: BytecodeBuilder, slot: u64) -> BytecodeBuilder { + builder + .mstore(0, IMegaLimitControl::remainingComputeGasCall::SELECTOR) + .push_number(32u64) + .push_number(0u64) + .push_number(4u64) + .push_number(0u64) + .push_address(LIMIT_CONTROL_ADDRESS) + .push_number(1_000_000u64) + .append(STATICCALL) + .append(POP) + .push_number(0u64) + .append(MLOAD) + .push_u256(U256::from(slot)) + .append(SSTORE) +} + +/// Parent that only calls the child and stops — used to place the compute knife edge on +/// the child's first opcode rather than on a later `remainingComputeGas` CALL. +fn nested_db(child: Bytes) -> MemoryDatabase { + let parent = call_callee(BytecodeBuilder::default(), 50_000_000).append(STOP).build(); + base_db(parent).account_code(CALLEE, child) +} + +/// Same parent, then a `remainingComputeGas` read. Only used when the TX compute limit is +/// unconstrained, so the CALL itself is not the binding opcode. +fn nested_db_with_remaining_read(child: Bytes) -> MemoryDatabase { + let parent = store_remaining_compute_gas( + call_callee(BytecodeBuilder::default(), 50_000_000), + REMAINING_SLOT, + ) + .append(STOP) + .build(); + base_db(parent).account_code(CALLEE, child) +} + +/// Codex reproduction: `TIMESTAMP; POP; STOP` with compute limit `intrinsic + 1`. +/// +/// Headroom at `TIMESTAMP` is `static_gas − 1`. Charging after the prologue records the body +/// and reverts `MegaLimitExceeded` with compute `intrinsic + 2`. Charging before the prologue +/// lets the clamp stop the opcode: a TX-level `Halt(ComputeGasLimitExceeded)` with compute +/// `intrinsic + 1` and no detention mark. +#[test] +fn test_top_frame_timestamp_one_below_static_gas_reverts_after_the_body() { + let intrinsic = intrinsic_stop(); + assert_eq!(intrinsic.compute_gas, 21_000, "intrinsic compute is 21_000"); + let limit = intrinsic.compute_gas + TIMESTAMP_STATIC_GAS - 1; + assert_eq!(limit, 21_001); + + let run = run(timestamp_pop_stop(), rex7_compute_limit(limit)); + + let decoded = decode_top_revert("headroom=static-1", &run.outcome); + assert_eq!(decoded.kind, LimitKind::ComputeGas.as_u8()); + // Top-frame remaining after intrinsic equals the headroom (`static_gas − 1`). The + // per-opcode record path classifies that as frame-local, so the payload names the + // frame budget (1), not the TX compute limit (21001). + assert_eq!( + decoded.limit, + TIMESTAMP_STATIC_GAS - 1, + "the payload names the top-frame budget that the body overshot" + ); + assert_eq!( + run.outcome.compute_gas, + intrinsic.compute_gas + TIMESTAMP_STATIC_GAS, + "the body charge is recorded; compute must be 21002, not the clamp's 21001" + ); + assert_eq!(run.outcome.compute_gas, 21_002); + assert_eq!( + run.outcome.gas_used, + run.outcome.compute_gas + intrinsic.storage_overhead(), + "receipt gas is compute plus the intrinsic storage component" + ); + assert_eq!(run.outcome.gas_used, 60_002); + assert_timestamp_marked("headroom=static-1", &run); + assert_eq!( + run.remaining_compute_gas, 0, + "usage is past the limit, so remainingComputeGas is zero" + ); +} + +/// Neighbouring edge: headroom equals the static fee, so `TIMESTAMP` itself can finish. +/// +/// Nothing follows the checkpoint (`TIMESTAMP; STOP`) so the transaction continues rather +/// than walking into a zero-headroom clamp on `POP`. +#[test] +fn test_top_frame_timestamp_at_static_gas_continues() { + let intrinsic = intrinsic_stop(); + let limit = intrinsic.compute_gas + TIMESTAMP_STATIC_GAS; + assert_eq!(limit, 21_002); + + let run = run(timestamp_stop(), rex7_compute_limit(limit)); + + assert!( + run.outcome.is_success(), + "headroom=static_gas must let TIMESTAMP finish; got {:?}", + run.outcome.result + ); + assert_eq!(run.outcome.compute_gas, intrinsic.compute_gas + TIMESTAMP_STATIC_GAS); + assert_eq!(run.outcome.gas_used, run.outcome.compute_gas + intrinsic.storage_overhead(),); + assert_timestamp_marked("headroom=static_gas", &run); + assert_eq!(run.remaining_compute_gas, 0); +} + +/// Nested sibling of the Codex edge: the child's first opcode is `TIMESTAMP`, and the TX +/// compute limit leaves `static_gas − 1` of headroom when that opcode is reached. +#[test] +fn test_nested_timestamp_one_below_static_gas_reverts_after_the_body() { + let empty = run_db(nested_db(stop_only()), EvmTxRuntimeLimits::from_spec(MegaSpecId::REX7)); + assert!( + empty.outcome.is_success(), + "calibration child must succeed: {:?}", + empty.outcome.result + ); + let before = empty.outcome.compute_gas; + let limit = before + TIMESTAMP_STATIC_GAS - 1; + + let run = run_db(nested_db(timestamp_stop()), rex7_compute_limit(limit)); + + assert_timestamp_marked("nested headroom=static-1", &run); + assert_eq!( + run.outcome.compute_gas, + before + TIMESTAMP_STATIC_GAS, + "the child body must be recorded; clamp-before-prologue would stop at {limit}" + ); + match &run.outcome.result { + ExecutionResult::Revert { output, .. } => { + let decoded = MegaLimitExceeded::abi_decode(output) + .unwrap_or_else(|e| panic!("nested revert is not MegaLimitExceeded: {e}")); + assert_eq!(decoded.kind, LimitKind::ComputeGas.as_u8()); + } + ExecutionResult::Success { .. } => { + // Frame-local child revert absorbed by the parent. The TX remaining is what + // `remainingComputeGas` would report; the body overshot, so it is zero. + } + ExecutionResult::Halt { reason, .. } => { + panic!( + "nested headroom=static-1 must not be a clamp Halt (the e4dfbca shape); \ + got {reason:?} compute={} marked={}", + run.outcome.compute_gas, + run.accessed.contains(VolatileDataAccess::TIMESTAMP), + ); + } + } + assert_eq!( + run.remaining_compute_gas, 0, + "remainingComputeGas after the body overshoot is zero" + ); +} + +/// Nested sibling of the exact-fee edge: the child can afford `TIMESTAMP` and returns, so +/// the parent continues. +#[test] +fn test_nested_timestamp_at_static_gas_continues() { + let empty = run_db(nested_db(stop_only()), EvmTxRuntimeLimits::from_spec(MegaSpecId::REX7)); + let before = empty.outcome.compute_gas; + let limit = before + TIMESTAMP_STATIC_GAS; + + let run = run_db(nested_db(timestamp_stop()), rex7_compute_limit(limit)); + + assert_timestamp_marked("nested headroom=static_gas", &run); + assert_eq!(run.outcome.compute_gas, before + TIMESTAMP_STATIC_GAS); + assert!( + run.outcome.is_success(), + "headroom=static_gas must let the child TIMESTAMP finish and the parent continue; got {:?}", + run.outcome.result + ); + assert_eq!(run.remaining_compute_gas, 0); +} + +/// Unconstrained nested read: after a child `TIMESTAMP` the parent's `remainingComputeGas` +/// is the detained remaining, which is how the mark is observed on chain. +#[test] +fn test_nested_timestamp_remaining_compute_gas_is_detained() { + let empty = run_db( + nested_db_with_remaining_read(stop_only()), + EvmTxRuntimeLimits::from_spec(MegaSpecId::REX7), + ); + let with_ts = run_db( + nested_db_with_remaining_read(timestamp_stop()), + EvmTxRuntimeLimits::from_spec(MegaSpecId::REX7), + ); + assert!(empty.outcome.is_success(), "empty child must succeed: {:?}", empty.outcome.result); + assert!( + with_ts.outcome.is_success(), + "TIMESTAMP child must succeed: {:?}", + with_ts.outcome.result + ); + assert_timestamp_unmarked("empty child", &empty); + assert_timestamp_marked("TIMESTAMP child", &with_ts); + + let empty_reading: u64 = empty + .outcome + .storage_value(CONTRACT, U256::from(REMAINING_SLOT)) + .try_into() + .expect("remainingComputeGas fits in u64"); + let ts_reading: u64 = with_ts + .outcome + .storage_value(CONTRACT, U256::from(REMAINING_SLOT)) + .try_into() + .expect("remainingComputeGas fits in u64"); + assert!(empty_reading > 0, "undetained remainingComputeGas must be non-zero"); + assert!( + ts_reading < empty_reading, + "TIMESTAMP detention must shrink remainingComputeGas; empty={empty_reading} ts={ts_reading}" + ); + assert_eq!(with_ts.outcome.compute_gas, empty.outcome.compute_gas + TIMESTAMP_STATIC_GAS,); +} + +/// Dedicated-checkpoint sibling of the Codex edge: `SELFBALANCE; POP; STOP` with compute +/// limit `intrinsic + 4`. +/// +/// Headroom at `SELFBALANCE` is `static_gas − 1`. Charging after the prologue records the +/// body and reverts `MegaLimitExceeded` with compute `intrinsic + 5`. Charging before the +/// prologue (the e4dfbca shape) lets the clamp stop the opcode: a TX-level +/// `Halt(ComputeGasLimitExceeded)` with compute `intrinsic + 4`. +#[test] +fn test_top_frame_selfbalance_one_below_static_gas_reverts_after_the_body() { + let intrinsic = intrinsic_stop(); + assert_eq!(intrinsic.compute_gas, 21_000, "intrinsic compute is 21_000"); + let limit = intrinsic.compute_gas + SELFBALANCE_STATIC_GAS - 1; + assert_eq!(limit, 21_004); + + let run = run(selfbalance_pop_stop(), rex7_compute_limit(limit)); + + let decoded = decode_top_revert("SELFBALANCE headroom=static-1", &run.outcome); + assert_eq!(decoded.kind, LimitKind::ComputeGas.as_u8()); + // Top-frame remaining after intrinsic equals the headroom (`static_gas − 1`). The + // per-opcode record path classifies that as frame-local, so the payload names the + // frame budget (4), not the TX compute limit (21004). + assert_eq!( + decoded.limit, + SELFBALANCE_STATIC_GAS - 1, + "the payload names the top-frame budget that the body overshot" + ); + assert_eq!( + run.outcome.compute_gas, + intrinsic.compute_gas + SELFBALANCE_STATIC_GAS, + "the body charge is recorded; compute must be 21005, not the clamp's 21004" + ); + assert_eq!(run.outcome.compute_gas, 21_005); + assert_eq!( + run.outcome.gas_used, + run.outcome.compute_gas + intrinsic.storage_overhead(), + "receipt gas is compute plus the intrinsic storage component" + ); + assert_eq!(run.outcome.gas_used, 60_005); + assert_eq!( + run.remaining_compute_gas, 0, + "usage is past the limit, so remainingComputeGas is zero" + ); +} + +/// Neighbouring edge: headroom equals the static fee, so `SELFBALANCE` itself can finish. +/// +/// Nothing follows the checkpoint (`SELFBALANCE; STOP`) so the transaction continues rather +/// than walking into a zero-headroom clamp on `POP`. +#[test] +fn test_top_frame_selfbalance_at_static_gas_continues() { + let intrinsic = intrinsic_stop(); + let limit = intrinsic.compute_gas + SELFBALANCE_STATIC_GAS; + assert_eq!(limit, 21_005); + + let run = run(selfbalance_stop(), rex7_compute_limit(limit)); + + assert!( + run.outcome.is_success(), + "headroom=static_gas must let SELFBALANCE finish; got {:?}", + run.outcome.result + ); + assert_eq!(run.outcome.compute_gas, intrinsic.compute_gas + SELFBALANCE_STATIC_GAS); + assert_eq!(run.outcome.gas_used, run.outcome.compute_gas + intrinsic.storage_overhead(),); + assert_eq!(run.remaining_compute_gas, 0); +} diff --git a/crates/mega-evm/tests/rex7/inspector_cheat_matrix.rs b/crates/mega-evm/tests/rex7/inspector_cheat_matrix.rs new file mode 100644 index 00000000..b6ef843e --- /dev/null +++ b/crates/mega-evm/tests/rex7/inspector_cheat_matrix.rs @@ -0,0 +1,1415 @@ +//! Every rewrite shape, at every callback that can carry it. +//! +//! `tests/rex7/shim_lanes.rs`, `shim_settlement.rs` and `shim_blind_spots.rs` pin one mechanism +//! per test. This module asks the complementary question: not "does each mechanism work" but "is +//! there a callback on the `Inspector` trait, or a rewrite shape a callback admits, that nothing +//! measures". So the cases here are laid out as a matrix over the +//! trait's own surface — one row per callback, one column per rewrite shape — rather than over the +//! shapes any particular tool is known to use. A callback added upstream, or a shape a callback +//! newly admits, shows up as an empty cell. +//! +//! The matrix is machine-checked, not documented: [`test_the_matrix_leaves_no_cell_unaccounted`] +//! enumerates every row × column pair and requires each to be either covered by a case below or +//! named in [`inapplicable`] with the reason it cannot exist. A doc table would go stale the first +//! time a callback grew a mutable argument. +//! +//! # What every cell asserts +//! +//! - **The ledger recorded what the cheat did**, on the lane it belongs to and to the gas — an +//! under-booked lane is what makes a transaction's reported numbers a fiction, and an over-booked +//! one is the same failure with the sign flipped. +//! - **The conservation law closes** against the envelope the receipt reports. This is the +//! assertion the whole ledger exists to keep true, and it is the one that goes red when a lane is +//! missed. +//! - **The state agrees with the result the caller was handed.** A cheat that fails a frame must +//! leave that frame's writes rolled back, and one that revives a reverted frame must leave them +//! committed — the journal decision is taken after the last rewrite, so it has to follow it. +//! +//! # Which loop runs +//! +//! The matrix runs on the inspected loops, because that is where a callback exists at all. +//! [`test_the_matrix_is_inert_with_the_inspected_loops_switched_off`] samples it against the plain +//! loop through `set_inspector_enabled`: the same inspector, the same context, the same +//! transaction, and no callback — every sampled cell must then be bit-identical to a run with no +//! inspector attached, which is what says the shim itself contributes nothing. + +use crate::{ + common::{ + call_contract_tx, context, drive, state_view, transact, Outcome, CALLEE, CALLER, CONTRACT, + ONE_ETH, + }, + inspector_common::{ + call_then_stop, db_with_callee, ledger_env, ledger_gas, ledger_intervention, ledger_refund, + ledger_reservoir, ledger_result, ledger_state_gas, limits, plain_and_cheated, + }, +}; +use alloy_primitives::{Address, Bytes, Log, U256}; +use mega_evm::{ + test_utils::{BytecodeBuilder, MemoryDatabase}, + InspectorLedger, MegaEvm, MegaSpecId, +}; +use revm::{ + bytecode::opcode::{CALL, CREATE, LOG1, MSTORE, MSTORE8, POP, RETURN, SSTORE, STOP}, + context::{Cfg, ContextTr, JournalTr}, + handler::FrameResult, + interpreter::{ + interpreter_types::{Jumps, LoopControl, MemoryTr, StackTr}, + CallInputs, CallOutcome, CreateInputs, CreateOutcome, FrameInput, Gas, InstructionResult, + Interpreter, InterpreterAction, InterpreterResult, InterpreterTypes, + }, + Inspector, +}; +use std::{collections::BTreeMap, string::String, vec::Vec}; + +/// High enough that EVM gas is never what binds. +const TX_GAS_LIMIT: u64 = 100_000_000; +/// Gas the fixture's inner `CALL` forwards. +const INNER_CALL_GAS: u64 = 2_000_000; + +/// Gas an injecting cheat writes into a live interpreter's counter. +const INJECT: u64 = 3_000; +/// Gas a draining cheat takes out of one. +const DRAIN: u64 = 1_000; +/// Gas an envelope cheat adds to, or removes from, a frame input's `gas_limit`. +const ENVELOPE: u64 = 5_000; +/// Gas a result cheat adds to, or removes from, a frame result's remaining gas. +const RESULT: u64 = 2_000; +/// Gas an action cheat adds to, or removes from, the gas a pending `InterpreterAction` carries. +const ACTION: u64 = 1_500; +/// Gas an interception cheat's synthetic outcome hands back over, or under, the envelope it was +/// given. +const INTERCEPTION: u64 = 4_000; +/// Refund a refund cheat adds to, or removes from, a `Gas`'s refund counter. +const REFUND: i64 = 2_000; +/// The EIP-8037 pool a reservoir cheat fills. +const RESERVOIR: u64 = 3_500; +/// The EIP-8037 spend counter a state-gas cheat writes. +const STATE_GAS: i64 = 1_200; + +/// Slot the top frame writes, last of all, so a cheat that fails the top frame is visible. +const TOP_SLOT: u64 = 0x10; +/// Slot the inner `CALL`'s callee writes. +const CALLEE_SLOT: u64 = 0x20; +/// Slot the fixture's constructor writes. +const INIT_SLOT: u64 = 0x30; +/// Slot every frame sets and then clears, so each ends holding a refund the EVM itself produced — +/// which is what the refund-lowering column needs to take from. +const CLEARED_SLOT: u64 = 0x40; +/// Value every fixture write stores, so a stack cheat that bumps it is visible as `2`. +const STORED: u64 = 1; + +/// The address the outcome-metadata column makes a successful creation report instead of the one +/// it deployed to. +const RELABELLED_DEPLOYMENT: Address = + alloy_primitives::address!("00000000000000000000000000000000000f00d0"); + +/// The address the fixture's `CREATE` deploys to. +fn deployed_address() -> Address { + CONTRACT.create(0) +} + +/// Declares one axis of the matrix, with the list of its members derived from the declaration. +/// +/// The two axes are swept exhaustively by `test_the_matrix_leaves_no_cell_unaccounted`, so a +/// variant added without a corresponding entry in `ALL` would silently shrink the sweep instead of +/// failing. Deriving the list removes that possibility. +macro_rules! axis { + ( + $(#[$meta:meta])* + enum $name:ident { $($(#[$vmeta:meta])* $variant:ident,)* } + ) => { + $(#[$meta])* + enum $name { $($(#[$vmeta])* $variant,)* } + + impl $name { + const ALL: &'static [Self] = &[$(Self::$variant,)*]; + } + }; +} + +// --- rows and columns ----------------------------------------------------------------------- + +axis! { +/// One row of the matrix: a callback on the `Inspector` trait. +/// +/// Every method of the trait is here. `log` is the one that never fires in these fixtures — it is +/// reached only when a precompile's logs are forwarded, which no wired `MegaETH` precompile +/// produces — and [`inapplicable`] carries that, together with the reason its every column is +/// empty anyway. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum At { + InitializeInterp, + Step, + StepEnd, + Log, + LogFull, + FrameStart, + FrameEnd, + Call, + CallEnd, + Create, + CreateEnd, + Selfdestruct, +} +} + +axis! { +/// One column of the matrix: a shape a rewrite can take. +/// +/// The columns are the rewrite's *mechanism*, not its purpose: what argument it reaches through +/// and in which direction it moves it. Two shapes that move the same argument in opposite +/// directions are separate columns because the ledger's sign convention is exactly that +/// distinction, and a lane that books one direction and drops the other is a real failure mode. +/// +/// The EIP-8037 dimensions are the one place that pairing does not apply: `MegaETH` runs with the +/// EIP off, so a `Gas` reaches every callback with both of its state-gas figures at zero and there +/// is nothing to lower. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum Shape { + /// Write gas into a live interpreter's counter. + InjectGas, + /// Take gas out of one. + DrainGas, + /// Raise the `gas_limit` a frame is about to be built with. + RaiseEnvelope, + /// Lower it. + LowerEnvelope, + /// Edit a semantic field of a frame's inputs — what the frame will do, not what it costs. + EditInput, + /// Return a synthetic outcome, so no frame is built at all. + Intercept, + /// Return one whose gas hands back more than the envelope it was given. + RaiseInterceptionGas, + /// Return one whose gas hands back less. + LowerInterceptionGas, + /// Raise the gas a finished frame hands back to its caller. + RaiseResultGas, + /// Lower it. + LowerResultGas, + /// Rewrite a successful frame result into a failure. + FailResult, + /// Rewrite a failed frame result into a success. + ReviveResult, + /// Raise the gas a pending `Return` action carries — the gas the frame it ends will hand back. + RaiseActionResultGas, + /// Lower it. + LowerActionResultGas, + /// Raise the `gas_limit` a pending `NewFrame` action carries — the envelope of the child the + /// frame is suspending into. + RaiseActionEnvelope, + /// Lower it. + LowerActionEnvelope, + /// Add to a `Gas`'s refund counter — what the sender is billed, which the envelope does not + /// reach. + RaiseRefund, + /// Take from one. + LowerRefund, + /// Fill a `Gas`'s or a frame input's EIP-8037 state-gas pool. + WriteReservoir, + /// Write a `Gas`'s EIP-8037 spend counter. + WriteStateGas, + /// Edit the interpreter's stack or memory — the frame's working state, which the EVM reads + /// back as operands and as data. + /// + /// Two different rewrites share this column, and the shim can see one of them. A push or a pop + /// moves the stack's *length*, which is a constant-time reading the shim takes; overwriting a + /// word in place moves neither length and is the contents rewrite that has no lane. Which one + /// a row gets is decided by what the frame is doing at that callback, and each row's cell + /// states the ledger that follows. + EditStackOrMemory, + /// Grow the frame's memory and the memo of how far it has been paid for, together — so the + /// interpreter stays consistent and the next expanding opcode is charged nothing. + GrowMemoryFree, + /// Edit a finished outcome's metadata: the range a call's return data lands in, or the address + /// a creation reports. Neither is part of the `InterpreterResult` the same callback holds. + EditOutcomeMetadata, + /// Write to the journal directly, behind the EVM's back. + JournalWrite, +} +} + +impl Shape { + /// Whether this shape answers the frame with a synthetic outcome instead of letting the EVM + /// build it. + const fn is_interception(self) -> bool { + matches!(self, Self::Intercept | Self::RaiseInterceptionGas | Self::LowerInterceptionGas) + } + + /// Whether this shape reaches through a `Gas`'s refund counter. + const fn is_refund(self) -> bool { + matches!(self, Self::RaiseRefund | Self::LowerRefund) + } + + /// Whether this shape reaches through the EIP-8037 state-gas dimension. + const fn is_state_gas(self) -> bool { + matches!(self, Self::WriteReservoir | Self::WriteStateGas) + } + + /// Whether this shape reaches through the interpreter's *pending action* rather than through + /// the interpreter itself. + const fn is_pending_action(self) -> bool { + matches!( + self, + Self::RaiseActionResultGas | + Self::LowerActionResultGas | + Self::RaiseActionEnvelope | + Self::LowerActionEnvelope + ) + } +} + +/// Why a row × column pair cannot be covered, for every pair the matrix leaves out. +/// +/// A cell is left out only when the callback's signature makes the shape unreachable, or when +/// reaching it is a different mechanism that its own test already pins. Anything else is a hole. +fn inapplicable(at: At, shape: Shape) -> Option<&'static str> { + use At::*; + use Shape::*; + + // Callbacks that receive nothing mutable but the context. + if at == Log { + return match shape { + JournalWrite => Some( + "`log` is reached only by the precompile-log forwarding, which no wired MegaETH \ + precompile produces; the forwarding itself is pinned by \ + `execution.rs::test_precompile_logs_reach_the_inspector_from_both_places_they_live`", + ), + _ => Some("`log` takes the log by value and no interpreter or frame input at all"), + }; + } + if at == Selfdestruct { + return Some( + "`selfdestruct` takes every argument by value and is handed no context, so it has no \ + mutable surface; `test_a_selfdestruct_only_inspector_moves_nothing` pins that the \ + shim still forwards it", + ); + } + + let interpreter_facing = matches!(at, InitializeInterp | Step | StepEnd | LogFull); + let input_facing = matches!(at, FrameStart | Call | Create); + let result_facing = matches!(at, FrameEnd | CallEnd | CreateEnd); + + if shape.is_pending_action() { + return match at { + StepEnd => None, + InitializeInterp | Step | LogFull => Some( + "no action is pending at this callback: revm's inspected loop breaks out as soon \ + as one is set, so `step` and `log_full` only ever run with none, and \ + `initialize_interp` runs before the loop on a fresh interpreter", + ), + _ => Some("no live interpreter is reachable from this callback"), + }; + } + + if shape.is_interception() && !input_facing { + return Some( + "only a callback that runs before the frame is built can answer it instead: the \ + `*_end` callbacks are handed a result the EVM already produced", + ); + } + + if shape.is_refund() || shape.is_state_gas() { + if !interpreter_facing && !input_facing && !result_facing { + return Some("no `Gas` and no frame input is reachable from this callback"); + } + if input_facing && shape != WriteReservoir { + return Some( + "a frame's inputs carry no refund counter and no state-gas spend counter; the \ + pool is the one figure of either dimension they do carry", + ); + } + if shape == WriteReservoir && at == Create { + return Some( + "`CreateInputs` keeps its pool private and offers no setter, so the only rewrite \ + that reaches it replaces the whole struct — which is the `EditInput` column", + ); + } + if shape == LowerRefund && at == InitializeInterp { + return Some( + "a frame is handed a fresh `Gas` whose refund counter is zero, so there is \ + nothing to lower before its first instruction runs", + ); + } + } + + match shape { + InjectGas | DrainGas | EditStackOrMemory | GrowMemoryFree if !interpreter_facing => { + Some("no live interpreter is reachable from this callback") + } + RaiseEnvelope | LowerEnvelope | EditInput if !input_facing => Some( + "this callback receives no frame input it can build a frame from: the `*_end` \ + callbacks take theirs by shared reference, after the frame has already run", + ), + RaiseResultGas | LowerResultGas | FailResult | ReviveResult | EditOutcomeMetadata + if !result_facing => + { + Some("no frame result exists yet at this callback") + } + ReviveResult if at == CreateEnd => Some( + "refused outright rather than measured — `test_reviving_a_failed_creation_is_refused` \ + in `shim_refusals.rs` pins the refusal at `create_end`, and \ + `test_reviving_a_failed_creation_is_refused_at_frame_end` pins it one callback later", + ), + _ => None, + } +} + +// --- the cheating inspector ----------------------------------------------------------------- + +/// Applies one cell's rewrite, once, and records what it actually did. +/// +/// Firing once rather than on every callback is what makes the ledger assertions exact: the cell +/// says "this many gas, on this lane", and a trickle would only support an inequality. +#[derive(Debug)] +struct Cheat { + at: At, + shape: Shape, + /// How many times the cheat fired. Every cell asserts this is 1, so a fixture that stops + /// reaching a callback fails loudly instead of passing as a run that cheated nothing. + fired: u32, + /// Ordinal of the `step` / `step_end` callback the interpreter-facing rows fire at. + step_at: u64, + steps: u64, + /// Gas actually moved on the interpreter lane, as the cheat measured it. + moved_gas: i128, +} + +impl Cheat { + fn new(at: At, shape: Shape) -> Self { + Self { at, shape, fired: 0, step_at: 8, steps: 0, moved_gas: 0 } + } + + /// Whether this callback is the cheat's row, and the cheat has not fired yet. + fn arm(&self, at: At) -> bool { + at == self.at && self.fired == 0 + } + + /// Applies an interpreter-facing shape. + fn hit_interpreter( + &mut self, + interp: &mut Interpreter, + context: &CTX, + ) { + match self.shape { + Shape::InjectGas => { + interp.gas.erase_cost(INJECT); + self.moved_gas += i128::from(INJECT); + self.fired += 1; + } + Shape::DrainGas => { + assert!( + interp.gas.record_regular_cost(DRAIN), + "the fixture must leave enough gas for a {DRAIN} gas removal to land", + ); + self.moved_gas -= i128::from(DRAIN); + self.fired += 1; + } + Shape::RaiseRefund => { + interp.gas.record_refund(REFUND); + self.fired += 1; + } + Shape::LowerRefund => { + interp.gas.record_refund(-REFUND); + self.fired += 1; + } + Shape::WriteReservoir => { + interp.gas.set_reservoir(RESERVOIR); + self.fired += 1; + } + Shape::WriteStateGas => { + interp.gas.set_state_gas_spent(STATE_GAS); + self.fired += 1; + } + Shape::EditStackOrMemory => { + // Bump the value an `SSTORE` is about to write, so the edit is visible in the + // produced state rather than only in the absence of an accounting change. Two + // pops and two pushes, so the stack's length is where it was: this is the + // contents half of the column. + let [key, value] = + interp.stack.popn::<2>().expect("an SSTORE has both its operands on the stack"); + assert!(interp.stack.push(value.wrapping_add(U256::from(1)))); + assert!(interp.stack.push(key)); + self.fired += 1; + } + Shape::GrowMemoryFree => { + Self::grow_memory_free(interp, context); + self.fired += 1; + } + _ => unreachable!("{:?} is not an interpreter-facing shape", self.shape), + } + } + + /// Whether a live-interpreter callback is one this cheat's shape can land at. + /// + /// Two shapes are choosy about the moment rather than about the callback. A refund the + /// interpreter is to *lose* needs one it already holds, which only exists once a frame has + /// cleared a storage slot; and any refund edit made while a terminating action is pending is + /// written into a counter the action has already copied, so it would land on the action's + /// number rather than on this column's mechanism. + fn interpreter_moment_is_right( + &self, + interp: &mut Interpreter, + ) -> bool { + if !self.shape.is_refund() { + return true; + } + if matches!(interp.bytecode.action(), Some(InterpreterAction::Return(_))) { + return false; + } + self.shape != Shape::LowerRefund || interp.gas.refunded() >= REFUND + } + + /// Overwrites the frame's first memory word — the edit the three interpreter-facing rows + /// that cannot safely touch the stack use instead. + /// + /// `initialize_interp` runs before the frame has any memory and before any operand exists, so + /// there it leaves a word at the bottom of the stack, under everything the frame will push; + /// `step_end` and `log_full` run between opcodes, where a pushed word would be consumed as the + /// next opcode's operand and would change the fixture rather than cheat inside it. + fn hit_frame_state(&mut self, interp: &mut Interpreter) { + if interp.memory.size() >= 32 { + interp.memory.set(0, &[0xAB; 32]); + } else { + assert!(interp.stack.push(U256::from(0xDEADu64))); + } + self.fired += 1; + } + + /// Grows the frame's memory by one word and moves the memo with it, so that the interpreter + /// is left in a state it could have reached by paying, having paid nothing. + /// + /// The fixture's first `MSTORE` then finds its word already paid for, and the transaction + /// spends exactly that expansion less than the uninspected run — which is what makes this a + /// rewrite the guard has to see rather than a curiosity. + fn grow_memory_free( + interp: &mut Interpreter, + context: &CTX, + ) { + let words = interp.memory.size() / 32 + 1; + assert!(interp.memory.resize(words * 32), "the fixture must allow a one-word growth"); + // Priced through revm's own table rather than a restatement of the formula: the memo has + // to be exactly what the EVM would have written, or a later expansion prices its + // increment from a baseline that never existed. + let cost = context.cfg().gas_params().memory_cost(words); + interp.gas.memory_mut().set_words_num(words, cost); + } + + /// Applies a shape that reaches through the interpreter's *pending action* — the object the + /// terminating or suspending instruction just left behind, which carries its own copy of the + /// gas the frame is handing on. + /// + /// Leaves the action alone and does not count as fired when the pending action is not the + /// variant this shape targets, so the cheat lands on the first `step_end` that offers the + /// right one rather than on whichever comes first. + fn hit_pending_action(&mut self, interp: &mut Interpreter) { + match (self.shape, interp.bytecode.action()) { + (Shape::RaiseActionResultGas, Some(InterpreterAction::Return(result))) => { + result.gas.erase_cost(ACTION); + } + (Shape::LowerActionResultGas, Some(InterpreterAction::Return(result))) => { + assert!( + result.gas.record_regular_cost(ACTION), + "the fixture must leave the action enough gas for a {ACTION} gas removal", + ); + } + ( + Shape::RaiseActionEnvelope, + Some(InterpreterAction::NewFrame(FrameInput::Call(inputs))), + ) => inputs.gas_limit += ACTION, + ( + Shape::LowerActionEnvelope, + Some(InterpreterAction::NewFrame(FrameInput::Call(inputs))), + ) => inputs.gas_limit -= ACTION, + _ => return, + } + self.fired += 1; + } + + /// Applies the one shape that reaches past the EVM entirely. + fn hit_journal(&mut self, context: &mut CTX) { + context.journal_mut().tstore(CONTRACT, U256::from(0xF00Du64), U256::from(1)); + self.fired += 1; + } + + /// Applies an input-facing shape to a call's inputs, or intercepts the frame. + fn hit_call_inputs(&mut self, inputs: &mut CallInputs) -> Option { + match self.shape { + Shape::RaiseEnvelope => { + inputs.gas_limit += ENVELOPE; + self.fired += 1; + None + } + Shape::LowerEnvelope => { + inputs.gas_limit -= ENVELOPE; + self.fired += 1; + None + } + Shape::EditInput => { + // A static call: the callee's `SSTORE` now fails, which is a change to what the + // frame does rather than to what it is allowed to spend. + inputs.is_static = true; + self.fired += 1; + None + } + Shape::WriteReservoir => { + inputs.reservoir += RESERVOIR; + self.fired += 1; + None + } + Shape::Intercept | Shape::RaiseInterceptionGas | Shape::LowerInterceptionGas => { + self.fired += 1; + Some(CallOutcome::new( + InterpreterResult::new( + InstructionResult::Stop, + Bytes::new(), + Gas::new(self.interception_gas(inputs.gas_limit)), + ), + inputs.return_memory_offset.clone(), + )) + } + _ => unreachable!("{:?} is not an input-facing shape", self.shape), + } + } + + /// The gas an interception's synthetic outcome hands back, given the envelope it was handed. + /// + /// The echo — hand back exactly what was forwarded — is the convention every tool that + /// intercepts follows, and the reason the two neighbouring columns exist: with it, the + /// accounting closes whether or not anything measures the figure. + fn interception_gas(&self, envelope: u64) -> u64 { + match self.shape { + Shape::Intercept => envelope, + Shape::RaiseInterceptionGas => envelope + INTERCEPTION, + Shape::LowerInterceptionGas => envelope - INTERCEPTION, + _ => unreachable!("{:?} is not an interception", self.shape), + } + } + + /// Applies an input-facing shape to a creation's inputs, or intercepts the frame. + fn hit_create_inputs(&mut self, inputs: &mut CreateInputs) -> Option { + match self.shape { + Shape::RaiseEnvelope => { + inputs.set_gas_limit(inputs.gas_limit() + ENVELOPE); + self.fired += 1; + None + } + Shape::LowerEnvelope => { + inputs.set_gas_limit(inputs.gas_limit() - ENVELOPE); + self.fired += 1; + None + } + Shape::EditInput => { + // Init code that reverts immediately: PUSH1 0, PUSH1 0, REVERT. + inputs.set_init_code(Bytes::from_static(&[0x60, 0x00, 0x60, 0x00, 0xfd])); + self.fired += 1; + None + } + Shape::Intercept | Shape::RaiseInterceptionGas | Shape::LowerInterceptionGas => { + self.fired += 1; + Some(CreateOutcome::new( + InterpreterResult::new( + InstructionResult::Stop, + Bytes::new(), + Gas::new(self.interception_gas(inputs.gas_limit())), + ), + None, + )) + } + _ => unreachable!("{:?} is not an input-facing shape", self.shape), + } + } + + /// Applies the one result-facing shape that reaches past the `InterpreterResult` — a finished + /// outcome's own metadata. + /// + /// A call's return range is shrunk to nothing rather than moved, because moving it past the + /// caller's allocated memory is a panic in revm and this fixture's caller holds one word. The + /// visible-effect form, where the caller then reads a word the callee never wrote, is pinned + /// in `shim_blind_spots.rs`. + fn hit_outcome_metadata(&mut self, result: &mut FrameResult) { + match result { + FrameResult::Call(outcome) => { + assert!( + !outcome.memory_offset.is_empty(), + "the fixture's inner CALL must ask for a return range, or there is nothing to shrink", + ); + outcome.memory_offset = outcome.memory_offset.start..outcome.memory_offset.start; + } + FrameResult::Create(outcome) => outcome.address = Some(RELABELLED_DEPLOYMENT), + } + self.fired += 1; + } + + /// Applies a result-facing shape to a finished frame's result. + fn hit_result(&mut self, result: &mut InterpreterResult) { + match self.shape { + Shape::RaiseResultGas => { + result.gas.erase_cost(RESULT); + self.fired += 1; + } + Shape::LowerResultGas => { + assert!( + result.gas.record_regular_cost(RESULT), + "the fixture must leave the frame enough gas for a {RESULT} gas removal", + ); + self.fired += 1; + } + Shape::FailResult => { + assert!( + result.result.is_ok(), + "the fixture must hand this cell a successful frame" + ); + result.result = InstructionResult::Revert; + self.fired += 1; + } + Shape::ReviveResult => { + assert!( + result.result.is_revert(), + "the fixture must hand this cell a reverted frame, got {:?}", + result.result, + ); + result.result = InstructionResult::Stop; + self.fired += 1; + } + Shape::RaiseRefund => { + result.gas.record_refund(REFUND); + self.fired += 1; + } + Shape::LowerRefund => { + assert!( + result.gas.refunded() >= REFUND, + "the fixture must hand this cell a frame that refunded something, got {}", + result.gas.refunded(), + ); + result.gas.record_refund(-REFUND); + self.fired += 1; + } + Shape::WriteReservoir => { + result.gas.set_reservoir(RESERVOIR); + self.fired += 1; + } + Shape::WriteStateGas => { + result.gas.set_state_gas_spent(STATE_GAS); + self.fired += 1; + } + _ => unreachable!("{:?} is not a result-facing shape", self.shape), + } + } +} + +/// Whether a frame input is the fixture's inner call — the one frame every input-facing and +/// result-facing cell targets, so the top-level frame is never the one rewritten. +fn is_inner_call(input: &FrameInput) -> bool { + matches!(input, FrameInput::Call(inputs) if inputs.target_address == CALLEE) +} + +impl Inspector for Cheat { + fn initialize_interp(&mut self, interp: &mut Interpreter, context: &mut CTX) { + if !self.arm(At::InitializeInterp) { + return; + } + match self.shape { + Shape::JournalWrite => self.hit_journal(context), + Shape::EditStackOrMemory => self.hit_frame_state(interp), + _ if !self.interpreter_moment_is_right(interp) => {} + _ => self.hit_interpreter(interp, context), + } + } + + fn step(&mut self, interp: &mut Interpreter, context: &mut CTX) { + self.steps += 1; + if !self.arm(At::Step) { + return; + } + // The refund columns fire on the first callback that offers the moment they need, rather + // than on a fixed ordinal. + if self.shape.is_refund() { + if self.interpreter_moment_is_right(interp) { + self.hit_interpreter(interp, context); + } + return; + } + match self.shape { + Shape::JournalWrite if self.steps == self.step_at => self.hit_journal(context), + // Fire on the first `SSTORE` the transaction reaches, whose operands are on the stack + // and about to be consumed. + Shape::EditStackOrMemory if interp.bytecode.opcode() == SSTORE => { + self.hit_interpreter(interp, context) + } + Shape::EditStackOrMemory | Shape::JournalWrite => {} + _ if self.steps == self.step_at => self.hit_interpreter(interp, context), + _ => {} + } + } + + fn step_end(&mut self, interp: &mut Interpreter, context: &mut CTX) { + if !self.arm(At::StepEnd) { + return; + } + // The action shapes fire on the first `step_end` that offers the action variant they + // target, not on a fixed ordinal — only a handful of opcodes leave an action behind. + if self.shape.is_pending_action() { + self.hit_pending_action(interp); + return; + } + if self.shape.is_refund() { + if self.interpreter_moment_is_right(interp) { + self.hit_interpreter(interp, context); + } + return; + } + if self.steps != self.step_at { + return; + } + match self.shape { + Shape::JournalWrite => self.hit_journal(context), + Shape::EditStackOrMemory => self.hit_frame_state(interp), + _ => self.hit_interpreter(interp, context), + } + } + + fn log_full(&mut self, interp: &mut Interpreter, context: &mut CTX, _log: Log) { + if !self.arm(At::LogFull) { + return; + } + match self.shape { + Shape::JournalWrite => self.hit_journal(context), + Shape::EditStackOrMemory => self.hit_frame_state(interp), + _ if !self.interpreter_moment_is_right(interp) => {} + _ => self.hit_interpreter(interp, context), + } + } + + fn frame_start( + &mut self, + context: &mut CTX, + frame_input: &mut FrameInput, + ) -> Option { + if !self.arm(At::FrameStart) || !is_inner_call(frame_input) { + return None; + } + if self.shape == Shape::JournalWrite { + self.hit_journal(context); + return None; + } + let FrameInput::Call(inputs) = frame_input else { unreachable!() }; + self.hit_call_inputs(inputs).map(FrameResult::Call) + } + + fn frame_end(&mut self, context: &mut CTX, frame_input: &FrameInput, result: &mut FrameResult) { + if !self.arm(At::FrameEnd) || !is_inner_call(frame_input) { + return; + } + if self.shape == Shape::JournalWrite { + self.hit_journal(context); + return; + } + if self.shape == Shape::EditOutcomeMetadata { + self.hit_outcome_metadata(result); + return; + } + self.hit_result(result.interpreter_result_mut()); + } + + fn call(&mut self, context: &mut CTX, inputs: &mut CallInputs) -> Option { + if !self.arm(At::Call) || inputs.target_address != CALLEE { + return None; + } + if self.shape == Shape::JournalWrite { + self.hit_journal(context); + return None; + } + self.hit_call_inputs(inputs) + } + + fn call_end(&mut self, context: &mut CTX, inputs: &CallInputs, outcome: &mut CallOutcome) { + if !self.arm(At::CallEnd) || inputs.target_address != CALLEE { + return; + } + if self.shape == Shape::JournalWrite { + self.hit_journal(context); + return; + } + if self.shape == Shape::EditOutcomeMetadata { + assert!(!outcome.memory_offset.is_empty(), "the inner CALL must ask for a range"); + outcome.memory_offset = outcome.memory_offset.start..outcome.memory_offset.start; + self.fired += 1; + return; + } + self.hit_result(&mut outcome.result); + } + + fn create(&mut self, context: &mut CTX, inputs: &mut CreateInputs) -> Option { + if !self.arm(At::Create) { + return None; + } + if self.shape == Shape::JournalWrite { + self.hit_journal(context); + return None; + } + self.hit_create_inputs(inputs) + } + + fn create_end( + &mut self, + context: &mut CTX, + _inputs: &CreateInputs, + outcome: &mut CreateOutcome, + ) { + if !self.arm(At::CreateEnd) { + return; + } + if self.shape == Shape::JournalWrite { + self.hit_journal(context); + return; + } + if self.shape == Shape::EditOutcomeMetadata { + outcome.address = Some(RELABELLED_DEPLOYMENT); + self.fired += 1; + return; + } + self.hit_result(&mut outcome.result); + } +} + +// --- fixtures ------------------------------------------------------------------------------- + +/// Which contract the fixture's inner `CALL` reaches. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum Fixture { + /// The callee writes storage and returns. Every cell that rewrites a *successful* frame uses + /// this one. + ReturningCallee, + /// The callee writes storage and reverts. The two `ReviveResult` cells need a failed frame to + /// revive, and need the write behind it to be one the journal has already decided to roll + /// back — so that a revival that commits it is visible. + RevertingCallee, +} + +/// Init code that writes [`INIT_SLOT`], leaves a refund behind, and returns two bytes of runtime +/// code. +fn init_code() -> Vec { + clear_a_slot(BytecodeBuilder::default().sstore(U256::from(INIT_SLOT), U256::from(STORED))) + .push_number(0x6000u64) + .push_number(0u64) + .append(MSTORE) + .push_number(2u64) // size + .push_number(30u64) // offset: the last two bytes of the word just stored + .append(RETURN) + .build() + .to_vec() +} + +/// Sets a slot and clears it again, which leaves the frame holding a refund the EVM produced. +/// +/// Every frame in the fixture does this, because the refund-lowering column needs a refund to take +/// from wherever it lands — an interpreter's counter, a finished call's result, or a finished +/// creation's. +fn clear_a_slot(builder: BytecodeBuilder) -> BytecodeBuilder { + builder + .sstore(U256::from(CLEARED_SLOT), U256::from(STORED)) + .sstore(U256::from(CLEARED_SLOT), U256::ZERO) +} + +/// The transaction's entry contract: one `LOG1`, one inner `CALL`, one `CREATE`, a slot set and +/// cleared, a second `LOG1`, and one `SSTORE`. +/// +/// One fixture rather than one per row, so that every callback fires in the same transaction and +/// a cell's assertions are about the cheat rather than about which fixture it got. The second +/// `LOG1` is there so the `log_full` row has a callback that runs *after* the frame has a refund; +/// the first one runs before anything has cleared a slot. +fn caller_code() -> Bytes { + let init = init_code(); + let mut builder = BytecodeBuilder::default() + // A word in memory for the LOG to read. + .push_number(0xAAu64) + .push_number(0u64) + .append(MSTORE) + // LOG1(offset=0, size=32, topic=1) + .push_number(1u64) + .push_number(32u64) + .push_number(0u64) + .append(LOG1) + // CALL(gas, CALLEE, value=0, argsOffset=0, argsSize=0, retOffset=0, retSize=32). + // + // The return range is asked for so that the outcome-metadata column has one to shrink; the + // callee returns nothing, so no byte is ever copied and no other cell's numbers move — the + // word of memory the range covers was already allocated by the `MSTORE` above. + .push_number(32u64) + .push_number(0u64) + .push_number(0u64) + .push_number(0u64) + .push_number(0u64) + .push_address(CALLEE) + .push_number(u128::from(INNER_CALL_GAS)) + .append(CALL) + .append(POP); + // The init code, byte by byte, into memory at offset 0. + for (offset, byte) in init.iter().enumerate() { + builder = builder.push_number(u64::from(*byte)).push_number(offset as u64).append(MSTORE8); + } + let builder = builder + .push_number(init.len() as u64) // size + .push_number(0u64) // offset + .push_number(0u64) // value + .append(CREATE) + .append(POP); + clear_a_slot(builder) + // LOG1(offset=0, size=32, topic=2), now that the frame carries a refund. + .push_number(2u64) + .push_number(32u64) + .push_number(0u64) + .append(LOG1) + .sstore(U256::from(TOP_SLOT), U256::from(STORED)) + .append(STOP) + .build() +} + +fn callee_code(fixture: Fixture) -> Bytes { + let builder = clear_a_slot( + BytecodeBuilder::default().sstore(U256::from(CALLEE_SLOT), U256::from(STORED)), + ); + match fixture { + Fixture::ReturningCallee => builder.append(STOP).build(), + Fixture::RevertingCallee => builder.revert().build(), + } +} + +fn db_for(fixture: Fixture) -> MemoryDatabase { + db_with_callee(caller_code(), callee_code(fixture)) +} + +// --- running one cell ----------------------------------------------------------------------- + +/// A storage slot as the produced state has it, taking the slot as the small integer the fixtures +/// use. +fn slot(outcome: &Outcome, address: Address, slot: u64) -> U256 { + outcome.storage_value(address, U256::from(slot)) +} + +/// Whether the fixture's `CREATE` left code behind. +fn deployed(outcome: &Outcome) -> bool { + outcome.state.get(&deployed_address()).is_some_and(|account| !account.info.is_empty_code_hash()) +} + +/// Runs the fixture with no inspector at all. +fn transact_plain(fixture: Fixture) -> Outcome { + transact(MegaSpecId::REX7, db_for(fixture), limits()) +} + +/// Runs the fixture with `cheat` attached, on the inspected loops or with them switched off. +fn transact_cheating(fixture: Fixture, cheat: &mut Cheat, inspected: bool) -> Outcome { + let mut db = db_for(fixture); + let mut evm = MegaEvm::new(context(&mut db, MegaSpecId::REX7, limits())).with_inspector(cheat); + if !inspected { + alloy_evm::Evm::set_inspector_enabled(&mut evm, false); + } + drive(MegaSpecId::REX7, &mut evm, call_contract_tx(TX_GAS_LIMIT)) +} + +// --- the matrix ----------------------------------------------------------------------------- + +/// One cell: a callback, a shape, and what the two must produce together. +struct Cell { + at: At, + shape: Shape, + fixture: Fixture, + /// The ledger the shim must have booked, exactly. + ledger: InspectorLedger, + /// What the produced state must show, given that the cheat landed. + state: fn(&Outcome, &str), +} + +/// A rewrite that moved gas *and* came back changed in something the shim compares. +fn plus_intervention(mut ledger: InspectorLedger) -> InspectorLedger { + ledger.interventions += 1; + ledger +} + +/// The fixture ran to its end and every frame committed: the callee's write, the deployment, and +/// the top frame's own write are all there. +fn state_all_committed(r: &Outcome, label: &str) { + assert!( + r.result.is_success(), + "{label}: expected a successful transaction, got {:?}", + r.result + ); + assert_eq!(slot(r, CALLEE, CALLEE_SLOT), U256::from(STORED), "{label}: the callee's write"); + assert!(deployed(r), "{label}: the fixture's CREATE must have deployed code"); + assert_eq!(slot(r, CONTRACT, TOP_SLOT), U256::from(STORED), "{label}: the top frame's write"); +} + +/// The callee's frame did not commit: whatever ended it, its write is gone. +fn state_callee_write_rolled_back(r: &Outcome, label: &str) { + assert!( + r.result.is_success(), + "{label}: the caller absorbs the inner failure, got {:?}", + r.result + ); + assert_eq!( + slot(r, CALLEE, CALLEE_SLOT), + U256::ZERO, + "{label}: a frame the caller was told failed must leave no write behind", + ); + assert!(deployed(r), "{label}: the rest of the transaction must be unaffected"); +} + +/// The callee's frame committed a write the EVM had decided to roll back — the journal followed +/// the rewritten result rather than the classification. +fn state_callee_write_revived(r: &Outcome, label: &str) { + assert!(r.result.is_success(), "{label}: {:?}", r.result); + assert_eq!( + slot(r, CALLEE, CALLEE_SLOT), + U256::from(STORED), + "{label}: a reverted frame rewritten into a success must have its state committed with it", + ); +} + +/// The stack edit landed on the callee's `SSTORE` operand. +fn state_callee_write_bumped(r: &Outcome, label: &str) { + assert!(r.result.is_success(), "{label}: {:?}", r.result); + assert_eq!( + slot(r, CALLEE, CALLEE_SLOT), + U256::from(STORED + 1), + "{label}: the value the inspector put on the stack is the value the EVM wrote", + ); +} + +/// The creation did not happen. +fn state_no_deployment(r: &Outcome, label: &str) { + assert!(r.result.is_success(), "{label}: the caller absorbs it, got {:?}", r.result); + assert!(!deployed(r), "{label}: no code may be deployed"); + assert_eq!( + slot(r, deployed_address(), INIT_SLOT), + U256::ZERO, + "{label}: nor its storage write" + ); +} + +/// Every cell the matrix covers. +fn matrix() -> Vec { + use At::*; + use Shape::*; + + let mut cells = Vec::new(); + /// One cell of the matrix, as a data row. + /// + /// The fixture defaults to the returning callee and the state check to "every frame + /// committed", because that is what a cell wants unless the rewrite it makes is one that + /// changes what the transaction leaves behind. + macro_rules! cell { + ($at:expr, $shape:expr, $ledger:expr $(, $fixture:expr, $state:expr)?) => {{ + #[allow(unused_mut, unused_assignments)] + let mut fixture = Fixture::ReturningCallee; + #[allow(unused_mut, unused_assignments)] + let mut state: fn(&Outcome, &str) = state_all_committed; + $( + fixture = $fixture; + state = $state; + )? + cells.push(Cell { at: $at, shape: $shape, fixture, ledger: $ledger, state }); + }}; + } + + // The four callbacks that are handed a live interpreter. + for at in [InitializeInterp, Step, StepEnd, LogFull] { + cell!(at, InjectGas, ledger_gas(i128::from(INJECT))); + cell!(at, DrainGas, ledger_gas(-i128::from(DRAIN))); + // The two halves of the working-state column. `step` fires on an `SSTORE`'s operands and + // pops and pushes the same two words, so both sizes come back where they were and nothing + // is booked — the contents rewrite that has no lane. The other three rows run where there + // is no operand to swap, so the cheat leaves a word on the stack instead, and a stack that + // came back one word longer is a constant-time reading the shim takes. + cell!( + at, + EditStackOrMemory, + if at == InitializeInterp { ledger_intervention() } else { InspectorLedger::default() }, + Fixture::ReturningCallee, + if at == Step { state_callee_write_bumped } else { state_all_committed } + ); + // Growing the memory and its memo together leaves every interpreter invariant intact and + // still skips the next expansion's charge, so no gas lane sees it and the intervention + // counter must. + cell!(at, GrowMemoryFree, ledger_intervention()); + cell!(at, JournalWrite, InspectorLedger::default()); + // The receipt's other two numbers, reached through the same `Gas` as the counter above. + cell!(at, RaiseRefund, ledger_refund(i128::from(REFUND))); + if at != InitializeInterp { + cell!(at, LowerRefund, ledger_refund(-i128::from(REFUND))); + } + cell!(at, WriteReservoir, ledger_reservoir(i128::from(RESERVOIR))); + cell!(at, WriteStateGas, ledger_state_gas(i128::from(STATE_GAS))); + } + + // The pending action, which only `step_end` ever sees: revm's inspected loop breaks out the + // moment one is set, so it is the one callback that runs with an instruction's action already + // in place. Which lane the edit lands on is decided by the action's own variant — a `Return` + // action is what the frame hands back, a `NewFrame` action is what the child is built with. + cell!(StepEnd, RaiseActionResultGas, ledger_result(i128::from(ACTION))); + cell!(StepEnd, LowerActionResultGas, ledger_result(-i128::from(ACTION))); + cell!(StepEnd, RaiseActionEnvelope, ledger_env(i128::from(ACTION))); + cell!(StepEnd, LowerActionEnvelope, ledger_env(-i128::from(ACTION))); + + // The three callbacks that are handed a frame's inputs before the frame is built. `create` + // reaches the fixture's `CREATE`; the other two reach its inner `CALL`. + for at in [FrameStart, Call, Create] { + // A rewrite of what the frame will *do* is the one that changes what it leaves behind, and + // the creation side of the sweep leaves no deployment where the call side leaves no write. + let undone: fn(&Outcome, &str) = + if at == Create { state_no_deployment } else { state_callee_write_rolled_back }; + cell!(at, RaiseEnvelope, ledger_env(i128::from(ENVELOPE))); + cell!(at, LowerEnvelope, ledger_env(-i128::from(ENVELOPE))); + cell!(at, EditInput, ledger_intervention(), Fixture::ReturningCallee, undone); + cell!(at, Intercept, ledger_intervention(), Fixture::ReturningCallee, undone); + // The same interception, sized against the envelope rather than echoing it. No frame is + // built, so the whole of what the outcome hands back is the inspector's number, and the + // difference from what the caller forwarded is what the ledger has to carry. + for (shape, sign) in [(RaiseInterceptionGas, 1i128), (LowerInterceptionGas, -1)] { + cell!( + at, + shape, + plus_intervention(ledger_result(sign * i128::from(INTERCEPTION))), + Fixture::ReturningCallee, + undone + ); + } + cell!(at, JournalWrite, InspectorLedger::default()); + if at != Create { + // The pool a call's inputs seed the child with. It travels to the child and back, so + // it is booked as gas — and the inputs came back changed in a field the envelope lane + // does not cover, which the rewrite comparison books separately. + cell!(at, WriteReservoir, plus_intervention(ledger_reservoir(i128::from(RESERVOIR)))); + } + } + + // The three callbacks that are handed a finished frame's result. + for at in [FrameEnd, CallEnd, CreateEnd] { + let undone: fn(&Outcome, &str) = + if at == CreateEnd { state_no_deployment } else { state_callee_write_rolled_back }; + cell!(at, RaiseResultGas, ledger_result(i128::from(RESULT))); + cell!(at, LowerResultGas, ledger_result(-i128::from(RESULT))); + cell!(at, FailResult, ledger_intervention(), Fixture::ReturningCallee, undone); + if at != CreateEnd { + // Reviving a reverted *call* is honoured; the creation form is refused, and the two + // tests that pin the refusal are named in `inapplicable`. + cell!( + at, + ReviveResult, + ledger_intervention(), + Fixture::RevertingCallee, + state_callee_write_revived + ); + } + // The half of a finished outcome that sits outside the `InterpreterResult`: where a call's + // return data lands, and which address a creation reports. Neither moves gas, and this + // fixture discards both — the caller asks for a range the callee never fills and pops the + // address — so what the cell pins is the booking. `shim_blind_spots.rs` pins the forms + // that change the produced state. + cell!(at, EditOutcomeMetadata, ledger_intervention()); + cell!(at, JournalWrite, InspectorLedger::default()); + cell!(at, RaiseRefund, ledger_refund(i128::from(REFUND))); + cell!(at, LowerRefund, ledger_refund(-i128::from(REFUND))); + cell!(at, WriteReservoir, ledger_reservoir(i128::from(RESERVOIR))); + cell!(at, WriteStateGas, ledger_state_gas(i128::from(STATE_GAS))); + } + + cells +} + +fn label(at: At, shape: Shape) -> String { + std::format!("{at:?} × {shape:?}") +} + +// --- the tests ------------------------------------------------------------------------------ + +/// Every cell of the matrix, run: the ledger books exactly what the cheat did, the conservation +/// law closes against the receipt, and the state agrees with the result the caller was handed. +#[test] +fn test_every_cheat_shape_is_booked_and_the_law_still_closes() { + for cell in matrix() { + let label = label(cell.at, cell.shape); + let mut cheat = Cheat::new(cell.at, cell.shape); + let reading = transact_cheating(cell.fixture, &mut cheat, true); + + assert_eq!(cheat.fired, 1, "{label}: the fixture must reach this callback exactly once"); + assert_eq!( + reading.inspector_ledger, cell.ledger, + "{label}: the shim must book exactly what the cheat did, and nothing else", + ); + // The interpreter lane the cheat measured for itself and the lane the shim booked are two + // independent readings of the same edit. + if cheat.moved_gas != 0 { + assert_eq!( + reading.inspector_ledger.gas.net(), + cheat.moved_gas, + "{label}: the shim's reading of the counter edit must match the cheat's own", + ); + } + (cell.state)(&reading, &label); + } +} + +/// The matrix has an entry, or a stated reason not to, for every callback × shape pair. +/// +/// This is what keeps the coverage honest as the trait moves: a callback revm adds, or a mutable +/// argument a callback grows, produces a pair that is neither covered nor explained, and this test +/// names it. A table in a doc comment could not. +#[test] +fn test_the_matrix_leaves_no_cell_unaccounted() { + let covered: Vec<(At, Shape)> = matrix().iter().map(|c| (c.at, c.shape)).collect(); + let mut holes = Vec::new(); + let (mut tested, mut excused) = (0usize, 0usize); + + for &at in At::ALL { + for &shape in Shape::ALL { + match (covered.contains(&(at, shape)), inapplicable(at, shape)) { + (true, None) => tested += 1, + (false, Some(_)) => excused += 1, + (true, Some(reason)) => holes.push(std::format!( + "{} is both covered and excused ({reason})", + label(at, shape) + )), + (false, None) => holes.push(std::format!( + "{} has no case and no stated reason it cannot have one", + label(at, shape) + )), + } + } + } + + assert!(holes.is_empty(), "the matrix has holes:\n {}", holes.join("\n ")); + assert_eq!( + tested + excused, + At::ALL.len() * Shape::ALL.len(), + "every pair must fall into exactly one of the two buckets", + ); + assert_eq!(tested, matrix().len(), "no cell may be listed twice"); +} + +/// With the inspected loops switched off, the same inspector is inert — and inertness is +/// bit-for-bit, against a run with no inspector attached at all. +/// +/// This is the two-loop half of the matrix. The sample is one cell per callback family, chosen so +/// that every kind of cheat is represented: a counter edit, an envelope edit, a result edit, a +/// classification rewrite, and a journal write. +#[test] +fn test_the_matrix_is_inert_with_the_inspected_loops_switched_off() { + let sample = [ + (At::Step, Shape::InjectGas, Fixture::ReturningCallee), + (At::StepEnd, Shape::DrainGas, Fixture::ReturningCallee), + (At::Call, Shape::RaiseEnvelope, Fixture::ReturningCallee), + (At::FrameStart, Shape::Intercept, Fixture::ReturningCallee), + (At::Call, Shape::LowerInterceptionGas, Fixture::ReturningCallee), + (At::Create, Shape::EditInput, Fixture::ReturningCallee), + (At::CallEnd, Shape::LowerResultGas, Fixture::ReturningCallee), + (At::CreateEnd, Shape::FailResult, Fixture::ReturningCallee), + (At::FrameEnd, Shape::ReviveResult, Fixture::RevertingCallee), + (At::Step, Shape::JournalWrite, Fixture::ReturningCallee), + (At::StepEnd, Shape::RaiseActionResultGas, Fixture::ReturningCallee), + (At::Step, Shape::LowerRefund, Fixture::ReturningCallee), + (At::CallEnd, Shape::WriteReservoir, Fixture::ReturningCallee), + (At::FrameEnd, Shape::WriteStateGas, Fixture::ReturningCallee), + (At::Step, Shape::GrowMemoryFree, Fixture::ReturningCallee), + (At::CreateEnd, Shape::EditOutcomeMetadata, Fixture::ReturningCallee), + ]; + + let mut plain: BTreeMap = BTreeMap::new(); + for fixture in [Fixture::ReturningCallee, Fixture::RevertingCallee] { + plain.insert(fixture, transact_plain(fixture)); + } + + for (at, shape, fixture) in sample { + let label = label(at, shape); + let mut cheat = Cheat::new(at, shape); + let off = transact_cheating(fixture, &mut cheat, false); + let reference = &plain[&fixture]; + + assert_eq!(cheat.fired, 0, "{label}: no callback may run with the inspected loops off"); + assert!( + off.inspector_ledger.is_zero(), + "{label}: an inert inspector books nothing: {:?}", + off.inspector_ledger + ); + assert_eq!( + std::format!("{:?}", off.result), + std::format!("{:?}", reference.result), + "{label}" + ); + assert_eq!(off.compute_gas, reference.compute_gas, "{label}"); + assert_eq!(off.enforced(), reference.enforced(), "{label}"); + assert_eq!(off.destroyed, reference.destroyed, "{label}"); + assert_eq!(off.data_size, reference.data_size, "{label}"); + assert_eq!(off.kv_updates, reference.kv_updates, "{label}"); + assert_eq!(off.state_growth, reference.state_growth, "{label}"); + assert_eq!(off.gas_used, reference.gas_used, "{label}"); + assert_eq!(off.total_gas_spent, reference.total_gas_spent, "{label}"); + assert_eq!(off.terms, reference.terms, "{label}"); + assert_eq!( + state_view(&off.state), + state_view(&reference.state), + "{label}: the produced state" + ); + } +} + +/// The two fixtures are what the cells assume they are, checked without an inspector in the way. +/// +/// Every "the cheat moved this" assertion is a comparison against this baseline; if the returning +/// callee ever stopped committing its write, or the reverting one started, half the matrix would +/// assert the fixture rather than the mechanism and would still be green. +#[test] +fn test_the_fixtures_behave_as_the_cells_assume() { + let returning = transact_plain(Fixture::ReturningCallee); + state_all_committed(&returning, "returning callee"); + + let reverting = transact_plain(Fixture::RevertingCallee); + assert!(reverting.result.is_success(), "the caller absorbs the revert: {:?}", reverting.result); + assert_eq!( + slot(&reverting, CALLEE, CALLEE_SLOT), + U256::ZERO, + "the reverting callee's write must be rolled back without an inspector", + ); +} + +/// An inspector that implements only `selfdestruct` moves nothing. +/// +/// The callback takes every argument by value and is handed no context, so there is nothing for +/// the shim to measure — which is exactly why it is worth pinning that the shim still *forwards* +/// it. A wrapper that dropped the callback would be invisible to every accounting assertion in +/// this file. +#[test] +fn test_a_selfdestruct_only_inspector_moves_nothing() { + use revm::bytecode::opcode::SELFDESTRUCT; + + #[derive(Default)] + struct Watcher { + seen: Vec<(Address, Address, U256)>, + } + + impl Inspector for Watcher { + fn selfdestruct(&mut self, contract: Address, target: Address, value: U256) { + self.seen.push((contract, target, value)); + } + } + + let callee = BytecodeBuilder::default().push_address(CALLER).append(SELFDESTRUCT).build(); + let code = call_then_stop(CALLEE, INNER_CALL_GAS); + let build_db = || { + db_with_callee(code.clone(), callee.clone()).account_balance(CALLEE, U256::from(ONE_ETH)) + }; + + let mut watcher = Watcher::default(); + let (plain, watched) = plain_and_cheated(build_db, &mut watcher); + + assert_eq!(watcher.seen.len(), 1, "the shim must forward the callback: {:?}", watcher.seen); + assert_eq!(watcher.seen[0].0, CALLEE, "the self-destructing contract"); + assert_eq!(watcher.seen[0].1, CALLER, "the beneficiary"); + assert!( + watched.inspector_ledger.is_zero(), + "nothing to measure: {:?}", + watched.inspector_ledger + ); + assert_eq!(watched.compute_gas, plain.compute_gas); + assert_eq!(watched.total_gas_spent, plain.total_gas_spent); + assert_eq!(state_view(&watched.state), state_view(&plain.state)); +} diff --git a/crates/mega-evm/tests/rex7/inspector_common.rs b/crates/mega-evm/tests/rex7/inspector_common.rs new file mode 100644 index 00000000..23e55b27 --- /dev/null +++ b/crates/mega-evm/tests/rex7/inspector_common.rs @@ -0,0 +1,235 @@ +//! Shared fixtures for the tests that attach an inspector. +//! +//! [`crate::common`] drives the transaction and checks the conservation law on every run. What is +//! left over — the REX7 limits, the bytecode shapes an inspector needs something to reach into, the +//! magnitudes more than one module edits by, the one-lane ledgers a test asserts against, and the +//! two ways a refused rewrite surfaces — lives here, because a rewrite is only ever pinned by +//! comparing an inspected run against the uninspected one over the same fixture. + +use alloy_primitives::{Address, Bytes}; +use mega_evm::{ + test_utils::{BytecodeBuilder, MemoryDatabase}, + EmptyExternalEnv, EvmTxRuntimeLimits, InspectorLedger, Lane, MegaContext, MegaEvm, MegaSpecId, +}; +use revm::{ + bytecode::opcode::{CALL, CREATE, DUP1, JUMPDEST, JUMPI, MSTORE8, POP, STOP, SUB, SWAP1}, + Inspector, +}; +use std::{boxed::Box, string::String, vec::Vec}; + +use crate::common::{ + call_contract_tx, context, plain_filler, transact, transact_inspected, Outcome, CALLEE, + DEFAULT_TX_GAS_LIMIT, +}; + +/// The spec every fixture here runs under, and its default runtime limits. +pub(crate) fn limits() -> EvmTxRuntimeLimits { + EvmTxRuntimeLimits::from_spec(MegaSpecId::REX7) +} + +/// [`limits`] with the per-transaction compute budget lowered to `limit`. +pub(crate) fn limits_with_compute(limit: u64) -> EvmTxRuntimeLimits { + limits().with_tx_compute_gas_limit(limit) +} + +// --- bytecode ------------------------------------------------------------------------------ + +/// A `CALL` to `target` forwarding `gas` and `value`, with empty argument and return ranges. +pub(crate) fn append_call( + builder: BytecodeBuilder, + target: Address, + gas: u64, + value: u64, +) -> BytecodeBuilder { + builder + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(0u64) // argsSize + .push_number(0u64) // argsOffset + .push_number(value) + .push_address(target) + .push_number(gas) + .append(CALL) +} + +/// The minimal frame that makes one inner call and ignores what it returned. +pub(crate) fn call_then_stop(target: Address, gas: u64) -> Bytes { + append_call(BytecodeBuilder::default(), target, gas, 0).append(POP).append(STOP).build() +} + +/// A straight run of `pairs` plain opcodes that always succeeds. +pub(crate) fn plain_run_code(pairs: usize) -> Bytes { + plain_filler(BytecodeBuilder::default(), pairs).append(STOP).build() +} + +/// A countdown loop of plain opcodes with no checkpoint anywhere in the body, so the whole run is +/// one settlement segment and the gas clamp is the only thing enforcing the compute limit inside +/// it. +pub(crate) fn countdown_loop_code(iterations: u16) -> Bytes { + let mut code = Vec::new(); + code.push(0x61); // PUSH2 + code.extend_from_slice(&iterations.to_be_bytes()); + let loop_target = u8::try_from(code.len()).expect("loop target must fit in a PUSH1"); + code.push(JUMPDEST); + code.extend_from_slice(&[0x60, 0x01]); // PUSH1 1 + code.push(SWAP1); + code.push(SUB); + code.push(DUP1); + code.extend_from_slice(&[0x60, loop_target]); // PUSH1 loop + code.push(JUMPI); + code.push(STOP); + Bytes::from(code) +} + +/// Writes `init_code` into memory a byte at a time and `CREATE`s from it, so a test can choose the +/// constructor without a second account. +pub(crate) fn deploy_then_stop(init_code: &[u8]) -> Bytes { + let mut builder = BytecodeBuilder::default(); + for (offset, byte) in init_code.iter().enumerate() { + builder = builder.push_number(u64::from(*byte)).push_number(offset as u64).append(MSTORE8); + } + builder + .push_number(init_code.len() as u64) // size + .push_number(0u64) // offset + .push_number(0u64) // value + .append(CREATE) + .append(POP) + .append(STOP) + .build() +} + +/// [`crate::common::base_db`] with `callee` installed at [`CALLEE`]. +pub(crate) fn db_with_callee(code: Bytes, callee: Bytes) -> MemoryDatabase { + crate::common::base_db(code).account_code(CALLEE, callee) +} + +/// Runs one fixture twice: with no inspector, then with `inspector` attached. +/// +/// Every rewrite in this suite is pinned as a difference between those two runs, so the fixture +/// has to be built twice from the same recipe — `db` is a closure for that reason. +pub(crate) fn plain_and_cheated( + db: impl Fn() -> MemoryDatabase, + inspector: &mut I, +) -> (Outcome, Outcome) +where + I: for<'a> Inspector>, +{ + let plain = transact(MegaSpecId::REX7, db(), limits()); + let cheated = transact_inspected(MegaSpecId::REX7, db(), limits(), inspector); + (plain, cheated) +} + +/// [`crate::common::transact_inspected`] with the shim delegating on the strength of a +/// `TrustedObserver` declaration instead of measuring. +pub(crate) fn transact_trusted(db: MemoryDatabase, inspector: &mut I) -> Outcome +where + I: for<'a> Inspector> + + mega_evm::TrustedObserver, +{ + let mut db = db; + let mut evm = MegaEvm::new(context(&mut db, MegaSpecId::REX7, limits())) + .with_trusted_inspector(inspector); + crate::common::drive(MegaSpecId::REX7, &mut evm, call_contract_tx(DEFAULT_TX_GAS_LIMIT)) +} + +// --- magnitudes ------------------------------------------------------------------------------ + +/// Gas an action edit moves, and gas a cancelling pair moves through the result lane's two +/// windows. +pub(crate) const ACTION_DELTA: u64 = 700; + +/// Refund a refund edit moves, and the magnitude a cancelling pair moves in each direction. +/// +/// Small enough to stay well under the EIP-3529 cap on every fixture that uses it, so that what +/// survives to the receipt is the whole of the surviving half rather than whatever the cap left of +/// it. +pub(crate) const REFUND: i64 = 2_000; + +// --- ledgers ------------------------------------------------------------------------------- + +/// The ledger of a rewrite that moved gas on exactly one lane. +/// +/// Separate constructors rather than one, because which lane a shape moves is exactly what decides +/// whether the conservation law can see it: only the gas, envelope, result and reservoir lanes are +/// terms of it. +pub(crate) fn ledger_gas(gas: i128) -> InspectorLedger { + InspectorLedger { gas: Lane::once(gas), ..InspectorLedger::default() } +} + +pub(crate) fn ledger_env(env: i128) -> InspectorLedger { + InspectorLedger { env: Lane::once(env), ..InspectorLedger::default() } +} + +pub(crate) fn ledger_result(result: i128) -> InspectorLedger { + InspectorLedger { result: Lane::once(result), ..InspectorLedger::default() } +} + +pub(crate) fn ledger_refund(refund: i128) -> InspectorLedger { + InspectorLedger { refund: Lane::once(refund), ..InspectorLedger::default() } +} + +pub(crate) fn ledger_reservoir(reservoir: i128) -> InspectorLedger { + InspectorLedger { reservoir: Lane::once(reservoir), ..InspectorLedger::default() } +} + +pub(crate) fn ledger_state_gas(state_gas: i128) -> InspectorLedger { + InspectorLedger { state_gas: Lane::once(state_gas), ..InspectorLedger::default() } +} + +/// The ledger of a rewrite that moves no gas: the shim saw the argument it was handed come back +/// changed, and that is the whole of what it books. +/// +/// These are the cells that would otherwise be indistinguishable from an observation-only run, and +/// the reason the canonical block path could not tell them apart before this lane existed. +pub(crate) fn ledger_intervention() -> InspectorLedger { + InspectorLedger { interventions: 1, ..InspectorLedger::default() } +} + +// --- refusals ------------------------------------------------------------------------------ + +/// [`crate::common::transact_inspected`] surfacing the `EVMError` instead of panicking on it. +pub(crate) fn try_transact_inspected( + db: MemoryDatabase, + limits: EvmTxRuntimeLimits, + inspector: &mut I, +) -> Result<(), String> +where + I: for<'a> Inspector>, +{ + let mut db = db; + let mut evm = + MegaEvm::new(context(&mut db, MegaSpecId::REX7, limits)).with_inspector(inspector); + evm.execute_transaction(call_contract_tx(DEFAULT_TX_GAS_LIMIT)) + .map(|_| ()) + .map_err(|e| std::format!("{e:?}")) +} + +/// Drives `run` and asserts the shim refused the rewrite, however this build surfaces a refusal: +/// a debug build asserts (the shape is a detector, and a corpus that produces it should stop), a +/// release build fails the transaction with the same message. +pub(crate) fn assert_refused(message: &str, run: impl Fn() -> Result<(), String>) { + if cfg!(debug_assertions) { + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(|_| {})); + let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(run)); + std::panic::set_hook(previous); + let payload = panicked.expect_err("the detector must fire in debug builds"); + let caught = payload + .downcast_ref::() + .map(String::as_str) + .or_else(|| payload.downcast_ref::<&str>().copied()) + .unwrap_or_default(); + assert!(caught.contains(message), "the assertion must name the shape; got {caught:?}"); + } else { + let error = run().expect_err("the refusal must surface as an EVMError in release builds"); + assert!(error.contains(message), "the error must name the shape; got {error:?}"); + } +} + +/// The message the shim refuses a resurrected creation with, on both of the paths that can catch +/// it. +pub(crate) const REVIVED_CREATION: &str = + "inspector rewrote a failed contract creation into a successful one"; + +/// Init code that reverts immediately, so the creation it is handed to fails. +pub(crate) const REVERTING_INIT_CODE: [u8; 5] = [0x60, 0x00, 0x60, 0x00, 0xfd]; diff --git a/crates/mega-evm/tests/rex7/interceptor_resume.rs b/crates/mega-evm/tests/rex7/interceptor_resume.rs new file mode 100644 index 00000000..1fb385ef --- /dev/null +++ b/crates/mega-evm/tests/rex7/interceptor_resume.rs @@ -0,0 +1,394 @@ +//! REX7 checkpoint settlement across a CALL that never runs a child frame. +//! +//! Two call targets return to their caller without an EVM frame ever being created for them: +//! +//! - a **system contract interceptor**, which short-circuits inside `frame_init` and hands back a +//! synthetic `FrameResult` carrying the full forwarded gas; +//! - a **precompile**, which revm executes inside `frame_init` and returns as a result rather than +//! as a frame to run. +//! +//! Both take the CALL checkpoint on the way out and the frame-resume clamp on the way back, but +//! neither runs `AdditionalLimit::before_frame_init` against a real child. That makes them the two +//! places where the caller's segment settlement and the clamp round trip have to work without any +//! child-frame bookkeeping to lean on. +//! +//! What these tests pin: +//! +//! - the caller's open segment is settled **before** the interceptor reads the tracker, so a system +//! contract that reports remaining compute gas reports the same number it reports under +//! per-opcode accounting; +//! - the clamp is restored across the boundary, so the forwarded gas returns intact and the receipt +//! does not depend on how much gas was forwarded; +//! - the caller's window re-opens on resume, so a limit crossing in the segment *after* the return +//! is still stopped at the clamp boundary rather than overshooting to the next checkpoint. + +use crate::common::{ + assert_outcomes_identical, base_db as common_base_db, compute_limit, detention_cap, + plain_filler, transact, transact_default, Outcome, CALLEE, CONTRACT, +}; +use alloy_primitives::{address, Address, Bytes, U256}; +use alloy_sol_types::SolCall as _; +use mega_evm::{ + test_utils::{BytecodeBuilder, MemoryDatabase}, + EvmTxRuntimeLimits, IMegaLimitControl, MegaHaltReason, MegaSpecId, LIMIT_CONTROL_ADDRESS, + LIMIT_CONTROL_CODE, +}; +use revm::bytecode::opcode::{CALL, MLOAD, POP, SSTORE, STOP, TIMESTAMP}; + +/// The identity precompile: returns its input unchanged, and is executed inside `frame_init`. +const IDENTITY_PRECOMPILE: Address = address!("0000000000000000000000000000000000000004"); + +/// Slot the contract stores the value it observed through the CALL into. +const OBSERVED_SLOT: u64 = 0x20; +/// Slot a downstream checkpoint writes, so a halt before it is observable in state. +const DOWNSTREAM_SLOT: u64 = 0x21; + +/// Memory offset the CALL's return data lands at, clear of the calldata at `0x00`. +const RET_OFFSET: u64 = 0x40; + +/// Gas forwarded to the call target unless a test varies it. +const FORWARDED_GAS: u64 = 1_000_000; + +fn base_db(code: Bytes) -> MemoryDatabase { + common_base_db(code).account_code(LIMIT_CONTROL_ADDRESS, LIMIT_CONTROL_CODE) +} + +/// A CALL to `target` forwarding `forwarded_gas`, with `args_size` bytes of calldata taken from +/// `mem[0..]` and 32 bytes of return data written to `mem[RET_OFFSET..]`. +fn call_with_return_data( + builder: BytecodeBuilder, + target: Address, + args_size: u64, + forwarded_gas: u64, +) -> BytecodeBuilder { + builder + .push_number(32u64) // retSize + .push_number(RET_OFFSET) // retOffset + .push_number(args_size) // argsSize + .push_number(0u64) // argsOffset + .push_number(0u64) // value + .push_address(target) + .push_number(forwarded_gas) + .append(CALL) + .append(POP) +} + +/// Everything up to and including the CALL into `MegaLimitControl.remainingComputeGas()`: +/// a plain run, the calldata write, and the CALL itself. +fn interceptor_prefix(prologue_volatile: bool, forwarded_gas: u64) -> BytecodeBuilder { + let mut builder = plain_filler(BytecodeBuilder::default(), 5); + if prologue_volatile { + builder = builder.append(TIMESTAMP).append(POP); + } + let builder = + plain_filler(builder, 5).mstore(0, IMegaLimitControl::remainingComputeGasCall::SELECTOR); + call_with_return_data(builder, LIMIT_CONTROL_ADDRESS, 4, forwarded_gas) +} + +/// Everything up to and including the CALL into the identity precompile. +fn precompile_prefix(prologue_volatile: bool, forwarded_gas: u64) -> BytecodeBuilder { + let mut builder = plain_filler(BytecodeBuilder::default(), 5); + if prologue_volatile { + builder = builder.append(TIMESTAMP).append(POP); + } + let builder = plain_filler(builder, 5).mstore(0, [0x5au8; 32]); + call_with_return_data(builder, IDENTITY_PRECOMPILE, 32, forwarded_gas) +} + +/// Stores the 32 bytes the CALL returned into [`OBSERVED_SLOT`]. +fn store_returned_word(builder: BytecodeBuilder) -> BytecodeBuilder { + builder + .push_number(RET_OFFSET) + .append(MLOAD) + .push_u256(U256::from(OBSERVED_SLOT)) + .append(SSTORE) +} + +/// The compute gas a transaction running `code` uses when nothing constrains it. +fn unconstrained_compute_gas(code: Bytes) -> u64 { + transact_default(MegaSpecId::REX7, base_db(code)).compute_gas +} + +/// Runs `code` under both specs with `limits` and returns `(REX6, REX7)`. +fn run_both( + code: &Bytes, + limits: &impl Fn(MegaSpecId) -> EvmTxRuntimeLimits, +) -> (Outcome, Outcome) { + let r6 = transact(MegaSpecId::REX6, base_db(code.clone()), limits(MegaSpecId::REX6)); + let r7 = transact(MegaSpecId::REX7, base_db(code.clone()), limits(MegaSpecId::REX7)); + (r6, r7) +} + +/// The interceptor reports the caller's remaining compute gas straight out of the tracker, so the +/// number it returns is a direct readout of how much of the caller's execution had been settled at +/// the moment `frame_init` ran. +/// +/// Under per-opcode accounting every opcode ahead of the CALL is already recorded. Under checkpoint +/// accounting the whole plain segment ahead of it is still open until the CALL's checkpoint +/// prologue settles it — which runs at the CALL opcode, before `frame_init`. If that settlement +/// were deferred (to the resume, or to the frame's tail), the contract would observe more remaining +/// compute gas than REX6 reports. Comparing the stored word is what pins the ordering. +#[test] +fn test_interceptor_observes_the_settled_remaining_compute_gas() { + let code = plain_filler( + store_returned_word(plain_filler(interceptor_prefix(false, FORWARDED_GAS), 10)), + 10, + ) + .append(STOP) + .build(); + let (r6, r7) = run_both(&code, &EvmTxRuntimeLimits::from_spec); + + assert!(r6.is_success(), "REX6 must succeed: {:?}", r6.result); + assert!(r7.is_success(), "REX7 must succeed: {:?}", r7.result); + + let slot = U256::from(OBSERVED_SLOT); + let observed = r7.storage_value(CONTRACT, slot); + assert!(!observed.is_zero(), "the interceptor must have returned a remaining-gas reading"); + assert_eq!( + r6.storage_value(CONTRACT, slot), + observed, + "the interceptor must observe the caller's segment already settled", + ); + assert_outcomes_identical("limit-control interception", &r6, &r7); +} + +/// The same readout while a clamp is outstanding. +/// +/// A detention cap engaged before the CALL leaves the interpreter running on clamped gas through +/// the plain segment ahead of it. The CALL checkpoint has to restore the hidden gas before +/// `frame_init` runs, or the interceptor and the forwarding math would both be computed against a +/// counter missing the hidden remainder. +#[test] +fn test_interceptor_observes_the_same_reading_under_an_active_clamp() { + let code = store_returned_word(plain_filler(interceptor_prefix(true, FORWARDED_GAS), 10)) + .append(STOP) + .build(); + // Well above what this transaction spends, so detention is engaged but never binding. + let (r6, r7) = run_both(&code, &detention_cap(1_000_000)); + + assert!(r6.is_success(), "REX6 must succeed: {:?}", r6.result); + assert!(r7.is_success(), "REX7 must succeed: {:?}", r7.result); + + let slot = U256::from(OBSERVED_SLOT); + let observed = r7.storage_value(CONTRACT, slot); + assert!(!observed.is_zero(), "the interceptor must have returned a remaining-gas reading"); + assert_eq!( + r6.storage_value(CONTRACT, slot), + observed, + "an outstanding clamp must not change what the interceptor observes", + ); + assert_outcomes_identical("limit-control interception under a clamp", &r6, &r7); +} + +/// Gas leakage path 1 — the system contract interception short-circuit. +/// +/// The synthetic result carries `Gas::new(call_inputs.gas_limit)`: nothing is spent, so every gas +/// unit forwarded comes back. The receipt is therefore independent of the forwarded amount, and +/// that independence is what catches a clamp leak — if the clamp were still outstanding across +/// `frame_init`, or if the resume restored the forwarded amount rather than the hidden one, the two +/// runs below would not cost the same. +#[test] +fn test_interception_returns_the_forwarded_gas_regardless_of_the_amount() { + let build = |forwarded| { + store_returned_word(plain_filler(interceptor_prefix(true, forwarded), 10)) + .append(STOP) + .build() + }; + // Two PUSH3 operands, so the programs are byte-for-byte the same length and the only difference + // is how much gas the interceptor is handed. + let small = build(0x10_0000u64); + let large = build(0x50_0000u64); + assert_eq!(small.len(), large.len(), "the two programs must differ only in the operand"); + // A detention cap nowhere near binding, so a clamp is outstanding at the CALL in both REX7 + // arms. + let limits = detention_cap(2_000_000); + + let (small6, small7) = run_both(&small, &limits); + let (large6, large7) = run_both(&large, &limits); + + for (label, r) in [ + ("small REX6", &small6), + ("small REX7", &small7), + ("large REX6", &large6), + ("large REX7", &large7), + ] { + assert!(r.is_success(), "{label}: must succeed: {:?}", r.result); + } + assert_eq!( + small7.gas_used, large7.gas_used, + "REX7: the interception must return the forwarded gas intact; 1M forwarded={} 5M \ + forwarded={}", + small7.gas_used, large7.gas_used + ); + assert_eq!( + small6.gas_used, large6.gas_used, + "REX6: same invariant, as the baseline the REX7 arm has to reproduce", + ); + assert_outcomes_identical("1M forwarded to the interceptor", &small6, &small7); + assert_outcomes_identical("5M forwarded to the interceptor", &large6, &large7); +} + +/// Enforcement after an interceptor resume: the caller's settlement window re-opens at the resume, +/// so a crossing in the segment that follows is stopped at the clamp boundary. +/// +/// The limit is placed inside the tail plain run, after the CALL has already returned. REX6 +/// executes the crossing opcode and records it, so its usage ends up over the limit; REX7 stops +/// exactly at the limit and the downstream SSTORE never runs. +#[test] +fn test_crossing_after_an_interceptor_resume_stops_at_the_clamp_boundary() { + let tail_pairs = 200; + let code = plain_filler(interceptor_prefix(false, FORWARDED_GAS), tail_pairs) + .sstore(U256::from(DOWNSTREAM_SLOT), U256::from(1)) + .append(STOP) + .build(); + // The same program truncated at the resume, and at the end of the tail: the crossing goes + // halfway between them, which is inside the tail and clear of both checkpoints. + let at_resume = + unconstrained_compute_gas(interceptor_prefix(false, FORWARDED_GAS).append(STOP).build()); + let after_tail = unconstrained_compute_gas( + plain_filler(interceptor_prefix(false, FORWARDED_GAS), tail_pairs).append(STOP).build(), + ); + assert!(after_tail > at_resume, "the tail must cost something; {at_resume} -> {after_tail}"); + let limit = at_resume + (after_tail - at_resume) / 2; + + let (r6, r7) = run_both(&code, &compute_limit(limit)); + + assert!(!r6.is_success(), "REX6 must halt on the tight compute limit: {:?}", r6.result); + assert!(!r7.is_success(), "REX7 must halt on the tight compute limit: {:?}", r7.result); + assert!( + r6.compute_gas > limit, + "REX6 records the crossing opcode before halting; compute={} limit={limit}", + r6.compute_gas + ); + assert_eq!( + r7.compute_gas, limit, + "REX7 must stop exactly at the limit in the segment opened by the resume", + ); + // The top-level frame's compute budget equals the TX-level remaining, and the clamp breaks that + // tie towards the TX-level constraint, so a REX7 crossing reports the compute-gas limit. + // (REX6's per-opcode check tries the frame budget first and reports the tie as a + // frame-local exceed, which the top-level frame absorbs into a revert — the models classify + // the tie differently.) + assert!( + matches!(r7.halt_reason("REX7"), MegaHaltReason::ComputeGasLimitExceeded { .. }), + "REX7: the halt must report the compute-gas limit; got {:?}", + r7.halt_reason("REX7"), + ); + for (label, r) in [("REX6", &r6), ("REX7", &r7)] { + assert!( + r.storage_value(CONTRACT, U256::from(DOWNSTREAM_SLOT)).is_zero(), + "{label}: the stop lands inside the tail, so the SSTORE after it never runs", + ); + } +} + +/// A precompile is executed inside `frame_init` and returns as a result, so like an interceptor it +/// resumes the caller without a child frame ever running. Unlike an interceptor it does spend gas, +/// so the resume merges a partially consumed budget back into the caller. +#[test] +fn test_precompile_resume_matches_per_opcode_accounting() { + let code = plain_filler( + store_returned_word(plain_filler(precompile_prefix(false, FORWARDED_GAS), 10)), + 10, + ) + .append(STOP) + .build(); + let (r6, r7) = run_both(&code, &EvmTxRuntimeLimits::from_spec); + + assert!(r6.is_success(), "REX6 must succeed: {:?}", r6.result); + assert!(r7.is_success(), "REX7 must succeed: {:?}", r7.result); + assert_eq!( + r7.storage_value(CONTRACT, U256::from(OBSERVED_SLOT)), + U256::from_be_bytes([0x5au8; 32]), + "the identity precompile must have returned its input", + ); + assert_outcomes_identical("identity precompile", &r6, &r7); +} + +/// The precompile resume with a clamp outstanding across the CALL. +#[test] +fn test_precompile_resume_under_an_active_clamp() { + let code = store_returned_word(plain_filler(precompile_prefix(true, FORWARDED_GAS), 10)) + .append(STOP) + .build(); + let (r6, r7) = run_both(&code, &detention_cap(1_000_000)); + + assert!(r6.is_success(), "REX6 must succeed: {:?}", r6.result); + assert!(r7.is_success(), "REX7 must succeed: {:?}", r7.result); + assert_outcomes_identical("identity precompile under a clamp", &r6, &r7); +} + +/// Enforcement after a precompile resume, the counterpart of the interceptor case: the precompile +/// consumed part of the forwarded gas, so the resume re-clamps against a counter the child moved. +#[test] +fn test_crossing_after_a_precompile_resume_stops_at_the_clamp_boundary() { + let tail_pairs = 200; + let code = plain_filler(precompile_prefix(false, FORWARDED_GAS), tail_pairs) + .sstore(U256::from(DOWNSTREAM_SLOT), U256::from(1)) + .append(STOP) + .build(); + let at_resume = + unconstrained_compute_gas(precompile_prefix(false, FORWARDED_GAS).append(STOP).build()); + let after_tail = unconstrained_compute_gas( + plain_filler(precompile_prefix(false, FORWARDED_GAS), tail_pairs).append(STOP).build(), + ); + let limit = at_resume + (after_tail - at_resume) / 2; + + let (r6, r7) = run_both(&code, &compute_limit(limit)); + + assert!(!r6.is_success(), "REX6 must halt on the tight compute limit: {:?}", r6.result); + assert!(!r7.is_success(), "REX7 must halt on the tight compute limit: {:?}", r7.result); + assert!( + r6.compute_gas > limit, + "REX6 records the crossing opcode before halting; compute={} limit={limit}", + r6.compute_gas + ); + assert_eq!( + r7.compute_gas, limit, + "REX7 must stop exactly at the limit in the segment opened by the resume", + ); + assert!( + r7.storage_value(CONTRACT, U256::from(DOWNSTREAM_SLOT)).is_zero(), + "the halt lands inside the tail, so the SSTORE after it never runs", + ); +} + +/// The interception resume one frame down: the caller is a sub-frame, so the settlement and the +/// re-clamp happen against a frame-local compute budget rather than the TX-level remaining. +#[test] +fn test_nested_frame_interceptor_resume_matches_per_opcode_accounting() { + let callee = + plain_filler(store_returned_word(plain_filler(interceptor_prefix(false, 200_000), 10)), 10) + .append(STOP) + .build(); + let code = BytecodeBuilder::default() + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(0u64) // argsSize + .push_number(0u64) // argsOffset + .push_number(0u64) // value + .push_address(CALLEE) + .push_number(2_000_000u64) // gas + .append(CALL) + .append(POP) + .append(STOP) + .build(); + let build_db = || base_db(code.clone()).account_code(CALLEE, callee.clone()); + + let r6 = transact_default(MegaSpecId::REX6, build_db()); + let r7 = transact_default(MegaSpecId::REX7, build_db()); + + assert!(r6.is_success(), "REX6 must succeed: {:?}", r6.result); + assert!(r7.is_success(), "REX7 must succeed: {:?}", r7.result); + let slot = U256::from(OBSERVED_SLOT); + assert!( + !r7.storage_value(CALLEE, slot).is_zero(), + "the nested interception must have returned a remaining-gas reading", + ); + assert_eq!( + r6.storage_value(CALLEE, slot), + r7.storage_value(CALLEE, slot), + "a nested caller must observe the same settled remaining compute gas", + ); + assert_outcomes_identical("nested interception", &r6, &r7); +} diff --git a/crates/mega-evm/tests/rex7/keyless_synthetic_halt.rs b/crates/mega-evm/tests/rex7/keyless_synthetic_halt.rs new file mode 100644 index 00000000..d813550e --- /dev/null +++ b/crates/mega-evm/tests/rex7/keyless_synthetic_halt.rs @@ -0,0 +1,252 @@ +//! The `KeylessDeploy` interceptor's synthetic halts destroy the envelope they burn. +//! +//! An interceptor returns its result out of `frame_init`, before a child EVM frame exists, so the +//! frame-exit settlement that splits an ordinary exceptional halt never runs for it. Two of the +//! interceptor's halts keep the whole call envelope, and under REX7 the part they did not perform +//! belongs in the destroyed lane: +//! +//! - the call cannot pay the fixed dispatch overhead, so nothing at all was performed; +//! - the call paid the overhead but cannot pay the deploy signer's materialization storage gas, so +//! the overhead is the only work performed. +//! +//! Every other synthetic result on this path is a `Return`, a `Revert`, or an out-of-gas whose +//! remaining gas is rescued for the sender. None of those destroys anything: the first two hand +//! the envelope back to the parent, and a rescue is a refund — booking it as destroyed as well +//! would report gas the sender got back, and would inflate the block's compute statistic by the +//! same amount. The rescue shape is pinned here next to the two destroying ones so the difference +//! stays visible. +//! +//! Every probe's expected destroyed amount is predicted from the frame's gas envelope, and that +//! envelope is measured through the `GasLimitTooLow` revert rather than read back out of the lane +//! under test. + +use crate::common::{keyless_tx_bytes, transact_tx, Outcome, ONE_ETH}; +use alloy_primitives::{address, Address, Bytes, U256}; +use alloy_sol_types::{SolCall as _, SolError as _}; +use mega_evm::{ + constants::{rex::NEW_ACCOUNT_STORAGE_GAS_BASE, rex2::KEYLESS_DEPLOY_OVERHEAD_GAS}, + test_utils::{BytecodeBuilder, MemoryDatabase}, + EvmTxRuntimeLimits, IKeylessDeploy, MegaSpecId, TestExternalEnvs, KEYLESS_DEPLOY_ADDRESS, + MIN_BUCKET_SIZE, +}; +use revm::{ + bytecode::opcode::STOP, + context::{result::ExecutionResult, tx::TxEnvBuilder}, +}; + +/// Relayer that sends the keyless-deploy transactions. +const RELAYER: Address = address!("0000000000000000000000000000000000340009"); + +/// The minimum bucket capacity, at which deploy-signer materialization is free and the +/// materialization arm is unreachable. +const MIN_BUCKET: u64 = MIN_BUCKET_SIZE as u64; + +/// Twice the minimum capacity, which prices deploy-signer materialization at one base unit +/// (`NEW_ACCOUNT_STORAGE_GAS_BASE × (multiplier − 1)`). +const DOUBLE_BUCKET: u64 = 2 * MIN_BUCKET; + +/// The materialization charge [`DOUBLE_BUCKET`] produces. +const MATERIALIZATION_GAS: u64 = NEW_ACCOUNT_STORAGE_GAS_BASE; + +/// The inner keyless transaction's own gas limit. `gasLimitOverride` is passed well above it so +/// the pre-cap check clears; the post-cap re-check at step 4b is what the calibration below reads. +const INNER_TX_GAS_LIMIT: u64 = 200_000; + +/// Runs a top-level keyless-deploy call. Depth zero is the only depth the interceptor fires at, so +/// every probe here has to be a direct transaction. +fn run( + spec: MegaSpecId, + tx_gas_limit: u64, + bucket_capacity: u64, + tx_compute_gas_limit: Option, +) -> Outcome { + let call_data = IKeylessDeploy::keylessDeployCall { + keylessDeploymentTransaction: keyless_tx_bytes( + BytecodeBuilder::default().append(STOP).build(), + INNER_TX_GAS_LIMIT, + ), + gasLimitOverride: U256::from(1_000_000u64), + } + .abi_encode(); + + let mut limits = EvmTxRuntimeLimits::from_spec(spec); + if let Some(limit) = tx_compute_gas_limit { + limits = limits.with_tx_compute_gas_limit(limit); + } + + let db = MemoryDatabase::default().account_balance(RELAYER, U256::from(10 * ONE_ETH)); + let tx = TxEnvBuilder::default() + .caller(RELAYER) + .call(KEYLESS_DEPLOY_ADDRESS) + .gas_limit(tx_gas_limit) + .chain_id(Some(1)) + .data(Bytes::from(call_data)) + .build_fill(); + + transact_tx( + spec, + db, + limits, + tx, + &TestExternalEnvs::default().with_default_bucket_capacity(bucket_capacity), + ) +} + +/// The transaction gas limit the envelope calibration runs at. +/// +/// It has to sit in the window where the interceptor clears both the dispatch overhead and the +/// materialization charge — so the same value works at either bucket capacity — while leaving the +/// capped override below the inner transaction's own gas limit, which is what makes the post-cap +/// re-check revert instead of letting the sandbox start. +const CALIBRATION_GAS: u64 = 300_000; + +/// The gas envelope the top-level frame opens with, measured through a channel the destroyed lane +/// cannot influence. +/// +/// A keyless-deploy call that clears the dispatch overhead but cannot cover the inner +/// transaction's own gas limit reverts with `GasLimitTooLow`, and the `providedGasLimit` it +/// carries is what the outer frame had left at that point. Adding back the charges taken before it +/// recovers the envelope the frame started with — so the probes below predict their expected +/// destroyed amount instead of reading it back out of the lane under test. +fn frame_envelope(bucket_capacity: u64, charges_before: u64) -> u64 { + let probe = run(MegaSpecId::REX7, CALIBRATION_GAS, bucket_capacity, None); + let ExecutionResult::Revert { output, .. } = &probe.result else { + panic!("the calibration probe must revert, got {:?}", probe.result); + }; + let decoded = IKeylessDeploy::GasLimitTooLow::abi_decode(output) + .expect("the calibration probe must revert with GasLimitTooLow"); + assert_eq!( + decoded.txGasLimit, INNER_TX_GAS_LIMIT, + "the revert must be the post-cap re-check against the inner transaction's gas limit", + ); + decoded.providedGasLimit + charges_before +} + +/// The gas the transaction pays before the top-level frame opens. +fn withheld_intrinsic() -> u64 { + let envelope_at_min = frame_envelope(MIN_BUCKET, KEYLESS_DEPLOY_OVERHEAD_GAS); + // Cross-check: raising the bucket capacity must change nothing except the materialization + // charge. This validates the charge's size and, at the same time, that the pre-frame intrinsic + // is not itself bucket-scaled — which is what lets one calibration serve both probes. + let envelope_at_double = + frame_envelope(DOUBLE_BUCKET, KEYLESS_DEPLOY_OVERHEAD_GAS + MATERIALIZATION_GAS); + assert_eq!( + envelope_at_min, envelope_at_double, + "the materialization charge must be the only thing the bucket capacity changes", + ); + CALIBRATION_GAS - envelope_at_min +} + +/// Asserts that REX7 changed nothing a consumer of the transaction can see, which is what lets the +/// destroyed lane be a pure addition to the reported total. +fn assert_receipt_parity(rex6: &Outcome, rex7: &Outcome, label: &str) { + assert_eq!( + std::format!("{:?}", rex6.result), + std::format!("{:?}", rex7.result), + "{label}: the execution result must be identical across the two specs", + ); + assert_eq!(rex6.gas_used, rex7.gas_used, "{label}: receipt gas_used must be identical"); + assert_eq!(rex6.destroyed, 0, "{label}: REX6 has no destroyed lane"); + assert_eq!( + rex7.enforced(), + rex6.compute_gas, + "{label}: REX7 must enforce exactly what REX6 recorded — the destroyed lane is an \ + addition to the reported total, never a change to the enforced one", + ); +} + +/// The call cannot pay the fixed dispatch overhead. Nothing was recorded as compute and nothing is +/// rescued, so the whole envelope the frame opened with is destroyed. +#[test] +fn test_underfunded_dispatch_destroys_the_whole_envelope() { + // An envelope below the dispatch overhead, and not a round number, so an amount that happened + // to coincide with some other quantity would not pass. + const ENVELOPE: u64 = 33_333; + const { assert!(ENVELOPE < KEYLESS_DEPLOY_OVERHEAD_GAS) }; + let tx_gas_limit = withheld_intrinsic() + ENVELOPE; + + let rex6 = run(MegaSpecId::REX6, tx_gas_limit, MIN_BUCKET, None); + let rex7 = run(MegaSpecId::REX7, tx_gas_limit, MIN_BUCKET, None); + + assert!(!rex7.is_success(), "the underfunded dispatch must halt: {:?}", rex7.result); + assert_eq!( + rex7.gas_used, tx_gas_limit, + "the whole transaction envelope is burnt — nothing is rescued on this path", + ); + assert_receipt_parity(&rex6, &rex7, "underfunded dispatch"); + + assert_eq!( + rex7.destroyed, ENVELOPE, + "the destroyed part is the whole envelope the frame opened with, because the call failed \ + before performing anything", + ); +} + +/// The overhead is paid, the deploy signer's materialization storage gas is not. The overhead is +/// the only work performed, so what the call still held is what gets destroyed. +#[test] +fn test_underfunded_signer_materialization_destroys_what_the_overhead_left() { + // Enough envelope for the dispatch overhead, then a remainder too small for the + // materialization charge — so the charge is the thing that cannot fit. + const REMAINDER: u64 = 9_999; + const { assert!(REMAINDER < MATERIALIZATION_GAS) }; + let tx_gas_limit = withheld_intrinsic() + KEYLESS_DEPLOY_OVERHEAD_GAS + REMAINDER; + + let rex6 = run(MegaSpecId::REX6, tx_gas_limit, DOUBLE_BUCKET, None); + let rex7 = run(MegaSpecId::REX7, tx_gas_limit, DOUBLE_BUCKET, None); + + assert!(!rex7.is_success(), "the underfunded materialization must halt: {:?}", rex7.result); + assert_eq!( + rex7.gas_used, tx_gas_limit, + "the whole transaction envelope is burnt — this path does not rescue either", + ); + assert_receipt_parity(&rex6, &rex7, "underfunded materialization"); + + assert_eq!( + rex7.destroyed, REMAINDER, + "the destroyed part is what the call still held after paying the dispatch overhead, not \ + the whole envelope — the overhead was performed and stays enforcing", + ); + assert_eq!( + rex7.enforced() - withheld_compute(), + KEYLESS_DEPLOY_OVERHEAD_GAS, + "and the enforcing lane carries that overhead on top of the intrinsic, which is what \ + separates this probe from the underfunded-dispatch one", + ); +} + +/// The compute gas an underfunded-dispatch run records: the transaction intrinsic and nothing +/// else, because the call fails before the overhead is charged. +fn withheld_compute() -> u64 { + run(MegaSpecId::REX7, withheld_intrinsic() + 1_000, MIN_BUCKET, None).enforced() +} + +/// The transaction-level compute exceed crossed while recording the dispatch overhead rescues the +/// call's remaining gas for the sender. A rescue is a refund, so nothing on that path is +/// destroyed — booking the rescued remainder as destroyed as well would report gas that was +/// handed back. +#[test] +fn test_rescued_overhead_exceed_is_not_also_destroyed() { + // A compute limit under the overhead makes the overhead's own recording cross it. + const TX_COMPUTE_LIMIT: u64 = 50_000; + const TX_GAS_LIMIT: u64 = 30_000_000; + + let rex6 = run(MegaSpecId::REX6, TX_GAS_LIMIT, MIN_BUCKET, Some(TX_COMPUTE_LIMIT)); + let rex7 = run(MegaSpecId::REX7, TX_GAS_LIMIT, MIN_BUCKET, Some(TX_COMPUTE_LIMIT)); + + assert!(!rex7.is_success(), "the compute exceed must halt: {:?}", rex7.result); + assert_eq!( + rex7.destroyed, 0, + "a rescued halt destroys nothing: the remaining gas goes back to the sender, so counting \ + it as destroyed would report the same gas twice", + ); + assert_receipt_parity(&rex6, &rex7, "rescued overhead exceed"); + + // The rescue is real, so the receipt lands far below the envelope — the contrast that makes + // the zero above meaningful rather than vacuous. + assert!( + rex7.gas_used < TX_GAS_LIMIT / 2, + "the rescue must actually refund most of the envelope; gas_used={}", + rex7.gas_used, + ); +} diff --git a/crates/mega-evm/tests/rex7/latch_surfacing.rs b/crates/mega-evm/tests/rex7/latch_surfacing.rs new file mode 100644 index 00000000..5a26fec7 --- /dev/null +++ b/crates/mega-evm/tests/rex7/latch_surfacing.rs @@ -0,0 +1,315 @@ +//! REX7: where a latched non-compute limit exceed surfaces. +//! +//! Only the compute dimension is checked on the hot path. The other three — data size, KV updates, +//! state growth — are recorded at their own mutation sites, and each site latches the exceed into +//! `has_exceeded_limit` itself. The latch becomes a stop at the next position that consults it, +//! which under per-opcode accounting is the next metered opcode and under checkpoint accounting is +//! the next checkpoint. +//! +//! Every non-compute mutation site reachable from bytecode is *itself* a checkpoint (SSTORE, the +//! LOG family, SELFDESTRUCT, the CALL / CREATE family), and each of those settles its own body +//! after the mutation has run. The two surfacing rules therefore land on the same opcode, and these +//! tests pin that they do — by construction, not by coincidence: +//! +//! - the recorded compute gas is exactly what a run truncated at the mutation site records, so the +//! plain segment *after* the site never executed; +//! - the checkpoint downstream of that segment never ran, which its absent storage write shows; +//! - the reported dimension, limit and usage are identical to per-opcode accounting. +//! +//! The oracle-hint case at the bottom covers the one non-compute mutation site that is not an +//! opcode: it records inside `frame_init`, one step past the CALL checkpoint that settled the +//! caller's segment. + +use crate::common::{ + assert_outcomes_identical, base_db, plain_filler, transact, transact_default, transact_tx, + Outcome, CALLER, CONTRACT, DEFAULT_TX_GAS_LIMIT, +}; +use alloy_primitives::{Bytes, B256, U256}; +use alloy_sol_types::{SolCall as _, SolError as _}; +use mega_evm::{ + test_utils::BytecodeBuilder, EvmTxRuntimeLimits, IOracle, LimitKind, MegaHaltReason, + MegaLimitExceeded, MegaSpecId, TestExternalEnvs, ORACLE_CONTRACT_ADDRESS, + ORACLE_CONTRACT_CODE_REX2, +}; +use revm::{ + bytecode::opcode::{CALL, LOG1, POP, STOP}, + context::{result::ExecutionResult, tx::TxEnvBuilder}, +}; + +/// Slot written by the checkpoint downstream of the latching site; its absence proves the stop +/// landed at the site and not after it. +const DOWNSTREAM_SLOT: u64 = 0x31; +/// Slot written by the latching SSTORE itself. +const LATCHING_SLOT: u64 = 0x30; + +/// The plain segment placed between the latching site and the checkpoint downstream of it. Long +/// enough that including it in the recorded compute gas would be unmistakable. +const GAP_PAIRS: usize = 40; + +/// The dimension a failed transaction blamed, read out of whichever failure shape it produced. +/// +/// A TX-level exceed halts and carries the dimension in the halt reason; a frame-local exceed is +/// absorbed into a revert carrying `MegaLimitExceeded(uint8 kind, uint64 limit)`. Both are in +/// scope here, since which one a given limit produces is not what these tests are about. +fn blamed_dimension(label: &str, outcome: &Outcome) -> LimitKind { + match &outcome.result { + ExecutionResult::Halt { reason, .. } => match reason { + MegaHaltReason::DataLimitExceeded { .. } => LimitKind::DataSize, + MegaHaltReason::KVUpdateLimitExceeded { .. } => LimitKind::KVUpdate, + MegaHaltReason::ComputeGasLimitExceeded { .. } => LimitKind::ComputeGas, + MegaHaltReason::StateGrowthLimitExceeded { .. } => LimitKind::StateGrowth, + other => panic!("{label}: not a limit halt: {other:?}"), + }, + ExecutionResult::Revert { output, .. } => { + let decoded = MegaLimitExceeded::abi_decode(output) + .unwrap_or_else(|e| panic!("{label}: revert data is not MegaLimitExceeded: {e}")); + LimitKind::from_u8(decoded.kind) + .unwrap_or_else(|| panic!("{label}: unknown limit kind {}", decoded.kind)) + } + other => panic!("{label}: expected a limit failure, got {other:?}"), + } +} + +/// Runs `code` under both specs with `limits` and returns `(REX6, REX7)`. +fn run_both( + code: &Bytes, + limits: &impl Fn(MegaSpecId) -> EvmTxRuntimeLimits, +) -> (Outcome, Outcome) { + let r6 = transact(MegaSpecId::REX6, base_db(code.clone()), limits(MegaSpecId::REX6)); + let r7 = transact(MegaSpecId::REX7, base_db(code.clone()), limits(MegaSpecId::REX7)); + (r6, r7) +} + +/// The shared body of the three per-dimension cases. +/// +/// `full` runs the latching site, a plain gap, and a downstream SSTORE. `truncated` is the same +/// program cut off immediately after the latching site. Asserting the failing run's compute gas +/// against the truncated run's is what pins the stop to the site: the gap contributes nothing. +fn assert_stops_at_the_latching_site( + label: &str, + full: Bytes, + truncated: Bytes, + expected: LimitKind, + limits: impl Fn(MegaSpecId) -> EvmTxRuntimeLimits, +) -> (Outcome, Outcome) { + let at_site = transact_default(MegaSpecId::REX7, base_db(truncated)).compute_gas; + let (r6, r7) = run_both(&full, &limits); + + for (spec, r) in [("REX6", &r6), ("REX7", &r7)] { + assert!( + !r.is_success(), + "{label}/{spec}: the tight limit must stop the tx: {:?}", + r.result + ); + assert_eq!( + blamed_dimension(&format!("{label}/{spec}"), r), + expected, + "{label}/{spec}: the wrong dimension was blamed", + ); + assert!( + r.storage_value(CONTRACT, U256::from(DOWNSTREAM_SLOT)).is_zero(), + "{label}/{spec}: the checkpoint downstream of the gap must never run", + ); + } + assert_eq!( + r7.compute_gas, at_site, + "{label}: REX7 must stop at the latching site — the plain gap after it must contribute no \ + compute gas; stopped at {} vs {at_site} recorded up to the site", + r7.compute_gas + ); + assert_outcomes_identical(label, &r6, &r7); + (r6, r7) +} + +/// Data size: `on_log` records the log's topics and payload, then latches. LOG1 is a checkpoint, so +/// its own trailing settlement surfaces the latch on the spot. +#[test] +fn test_data_size_latch_stops_at_the_log_that_recorded_it() { + let log = |builder: BytecodeBuilder| { + builder + .mstore(0, [0x11u8; 32]) + .push_number(0xabcu64) // topic0 + .push_number(32u64) // len + .push_number(0u64) // offset + .append(LOG1) + }; + let truncated = log(plain_filler(BytecodeBuilder::default(), 10)).append(STOP).build(); + let full = plain_filler(log(plain_filler(BytecodeBuilder::default(), 10)), GAP_PAIRS) + .sstore(U256::from(DOWNSTREAM_SLOT), U256::from(1)) + .append(STOP) + .build(); + + // One byte under what the log needs, so the log's own recording is what overflows. + let before = transact_default(MegaSpecId::REX7, base_db(truncated.clone())); + let intrinsic = transact_default( + MegaSpecId::REX7, + base_db(BytecodeBuilder::default().append(STOP).build()), + ) + .data_size; + assert!( + intrinsic < before.data_size, + "the log must be what pushes data size past the intrinsic footprint; {intrinsic} vs {}", + before.data_size + ); + let limit = before.data_size - 1; + + assert_stops_at_the_latching_site( + "data size / LOG1", + full, + truncated, + LimitKind::DataSize, + move |spec| EvmTxRuntimeLimits::from_spec(spec).with_tx_data_size_limit(limit), + ); +} + +/// KV updates: `on_sstore` records the storage write, then latches. SSTORE is a checkpoint, so the +/// stop lands on it. +#[test] +fn test_kv_update_latch_stops_at_the_sstore_that_recorded_it() { + let truncated = plain_filler(BytecodeBuilder::default(), 10) + .sstore(U256::from(LATCHING_SLOT), U256::from(0x77)) + .append(STOP) + .build(); + let full = plain_filler( + plain_filler(BytecodeBuilder::default(), 10) + .sstore(U256::from(LATCHING_SLOT), U256::from(0x77)), + GAP_PAIRS, + ) + .sstore(U256::from(DOWNSTREAM_SLOT), U256::from(1)) + .append(STOP) + .build(); + + let before = transact_default(MegaSpecId::REX7, base_db(truncated.clone())); + let intrinsic = transact_default( + MegaSpecId::REX7, + base_db(BytecodeBuilder::default().append(STOP).build()), + ) + .kv_updates; + assert!( + intrinsic < before.kv_updates, + "the store must be what pushes KV updates past the intrinsic footprint; {intrinsic} vs {}", + before.kv_updates + ); + let limit = before.kv_updates - 1; + + let (_, r7) = assert_stops_at_the_latching_site( + "KV updates / SSTORE", + full, + truncated, + LimitKind::KVUpdate, + move |spec| EvmTxRuntimeLimits::from_spec(spec).with_tx_kv_updates_limit(limit), + ); + // The store's KV usage is frame-discardable, so popping the stopped frame takes it back out + // again — the post-transaction reading is the intrinsic footprint, under the limit that the + // store transiently crossed. + assert!( + r7.kv_updates <= limit, + "the stopped frame's KV usage must be discarded; usage={} limit={limit}", + r7.kv_updates + ); +} + +/// State growth: the same SSTORE records a net-new storage slot. With only the state-growth limit +/// tightened, that is the dimension `check_limit` reports. +#[test] +fn test_state_growth_latch_stops_at_the_sstore_that_recorded_it() { + let truncated = plain_filler(BytecodeBuilder::default(), 10) + .sstore(U256::from(LATCHING_SLOT), U256::from(0x77)) + .append(STOP) + .build(); + let full = plain_filler( + plain_filler(BytecodeBuilder::default(), 10) + .sstore(U256::from(LATCHING_SLOT), U256::from(0x77)), + GAP_PAIRS, + ) + .sstore(U256::from(DOWNSTREAM_SLOT), U256::from(1)) + .append(STOP) + .build(); + + let before = transact_default(MegaSpecId::REX7, base_db(truncated.clone())); + assert!(before.state_growth > 0, "the store must create a net-new slot"); + let limit = before.state_growth - 1; + + assert_stops_at_the_latching_site( + "state growth / SSTORE", + full, + truncated, + LimitKind::StateGrowth, + move |spec| EvmTxRuntimeLimits::from_spec(spec).with_tx_state_growth_limit(limit), + ); +} + +/// The one non-compute mutation site that is not an opcode: the oracle-hint interceptor meters the +/// payload inside `frame_init`, one step past the CALL checkpoint. +/// +/// On overflow the interceptor deliberately synthesizes nothing and returns `None`, leaving +/// `before_frame_init` to produce the canonical TX-level halt. Under checkpoint accounting the +/// caller's segment was already settled by the CALL's own checkpoint, which runs before +/// `frame_init` — so the halt reports the same usage REX6 reports, and the caller's plain segment +/// ahead of the CALL is fully accounted for despite the frame never starting. +#[test] +fn test_oracle_hint_data_size_latch_halts_at_the_frame_boundary() { + let payload = vec![0u8; 256]; + let calldata = + IOracle::sendHintCall { topic: B256::repeat_byte(0x5a), data: payload.into() }.abi_encode(); + let len = calldata.len() as u64; + let mut builder = plain_filler(BytecodeBuilder::default(), 20); + builder = builder.mstore(0, &calldata); + let code = builder + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(len) // argsSize + .push_number(0u64) // argsOffset + .push_number(0u64) // value + .push_address(ORACLE_CONTRACT_ADDRESS) + .push_number(1_000_000u64) // gas + .append(CALL) + .append(POP) + .sstore(U256::from(DOWNSTREAM_SLOT), U256::from(1)) + .append(STOP) + .build(); + let build_db = + || base_db(code.clone()).account_code(ORACLE_CONTRACT_ADDRESS, ORACLE_CONTRACT_CODE_REX2); + + // Enough for the calldata footprint but not for the hint payload the interceptor meters on top. + let unconstrained = transact_default(MegaSpecId::REX7, build_db()); + assert!( + unconstrained.is_success(), + "the unconstrained run must succeed: {:?}", + unconstrained.result + ); + let limit = unconstrained.data_size - len; + let limits = move |spec| EvmTxRuntimeLimits::from_spec(spec).with_tx_data_size_limit(limit); + + let envs = TestExternalEnvs::new(); + let tx = || { + TxEnvBuilder::default() + .caller(CALLER) + .call(CONTRACT) + .gas_limit(DEFAULT_TX_GAS_LIMIT) + .build_fill() + }; + let r6 = transact_tx(MegaSpecId::REX6, build_db(), limits(MegaSpecId::REX6), tx(), &envs); + let hints_after_rex6 = envs.recorded_hints().len(); + let r7 = transact_tx(MegaSpecId::REX7, build_db(), limits(MegaSpecId::REX7), tx(), &envs); + let hints_after_rex7 = envs.recorded_hints().len(); + + for (label, r) in [("REX6", &r6), ("REX7", &r7)] { + assert!(!r.is_success(), "{label}: the tight data-size limit must halt: {:?}", r.result); + assert_eq!( + blamed_dimension(label, r), + LimitKind::DataSize, + "{label}: the halt must blame data size", + ); + assert!( + r.storage_value(CONTRACT, U256::from(DOWNSTREAM_SLOT)).is_zero(), + "{label}: the store after the CALL must never run", + ); + } + assert_eq!( + (hints_after_rex6, hints_after_rex7), + (0, 0), + "an over-budget hint must never reach the oracle backend under either spec", + ); + assert_outcomes_identical("oracle hint data-size overflow", &r6, &r7); +} diff --git a/crates/mega-evm/tests/rex7/late_frame_local.rs b/crates/mega-evm/tests/rex7/late_frame_local.rs new file mode 100644 index 00000000..8879e079 --- /dev/null +++ b/crates/mega-evm/tests/rex7/late_frame_local.rs @@ -0,0 +1,230 @@ +//! REX7: a frame-local exceed that only becomes visible once the frame has been merged. +//! +//! A per-frame budget is the frame's usage weighed against its *caller's* budget after the merge, +//! so a frame can overrun one with nothing having noticed while it ran. The frame return is where +//! that is first detectable, and through REX6 it is detected one step too late to act on: the +//! merge has already happened on the frame's original classification, and so has the journal +//! decision. What the caller is handed is a revert over usage that was kept and state that was +//! committed. +//! +//! REX7 asks the question before the pop and writes the answer onto the frame's result first, so +//! all three follow one classification — the caller is told the frame reverted, the pop discards +//! the frame's usage the way it discards any reverting frame's, and the journal rolls the frame's +//! state back. +//! +//! # The construction +//! +//! Natural traffic produces no instance of this: a child frame is pushed with 98% of its caller's +//! remaining budget, so merging a child that stayed inside its own budget cannot push the caller +//! past its own. The one charge that breaks that arithmetic is REX6's creator nonce bump, which is +//! charged to the *caller's* lane after the child's budget has already been computed from the +//! caller's remaining. It costs one account-info write, so it can only tip the balance when 2% of +//! the caller's remaining data-size budget is under 40 bytes — a caller with under two kilobytes +//! left. The transaction below puts one there with an explicit runtime limit, and has it CREATE a +//! contract whose deployed code fills the child's budget almost exactly. +//! +//! Frame budgets, with `tx_data_size_limit` at 1171 and 150 bytes of intrinsic usage: +//! +//! | frame | budget | usage | +//! | ---------------- | ------ | ---------------------------------------------- | +//! | `CONTRACT` | 1 021 | 0 while the call is out | +//! | `CALLEE` | 1 000 | 40 — the creator nonce bump, charged after push | +//! | the constructor | 980 | 970 = 40 account + 32 log + 898 deployed code | +//! +//! 40 + 970 = 1 010 > 1 000: the constructor overran `CALLEE`'s budget, and nothing could have +//! seen it before the merge. + +use crate::common::{transact, Outcome, CALLEE, CALLER, CONTRACT, ONE_ETH}; +use alloy_primitives::{Address, Bytes, U256}; +use mega_evm::{ + test_utils::{BytecodeBuilder, MemoryDatabase}, + EvmTxRuntimeLimits, MegaSpecId, +}; +use revm::bytecode::opcode::{CALL, CREATE, ISZERO, LOG0, MSTORE, PUSH0, RETURN, SSTORE, STOP}; + +/// `CALLEE` stores 1 here when the CREATE it ran came back as a failure. +const CREATE_FAILED_SLOT: u64 = 0x11; +/// `CONTRACT` stores the CALL's own success flag here. +const CALL_RESULT_SLOT: u64 = 0x12; + +/// Deployed code length, chosen so the constructor's frame lands just under its own budget and +/// just over its caller's — see the table in the module docs. +const DEPLOYED_CODE_LEN: u16 = 898; +/// Leaves `CONTRACT`'s frame 1 021 bytes of data-size budget, after 150 bytes of intrinsic usage. +const TX_DATA_SIZE_LIMIT: u64 = 1171; + +/// Ample: the deposit alone is nearly 180 000 gas, and `MegaETH` charges storage gas on top. +const FORWARDED_GAS: u64 = 50_000_000; + +/// Emits a log and returns [`DEPLOYED_CODE_LEN`] zero bytes of runtime code. +/// +/// The log is what makes this a statement about receipts: it is emitted by a frame that runs to a +/// successful exit and is then failed by the merge. +fn constructor_code() -> Vec { + let mut code = vec![PUSH0, PUSH0, LOG0, 0x61]; + code.extend_from_slice(&DEPLOYED_CODE_LEN.to_be_bytes()); + code.extend_from_slice(&[PUSH0, RETURN]); + code +} + +/// Runs the CREATE, records whether it failed, and emits a log of its own. +fn callee_code() -> Bytes { + let constructor = constructor_code(); + let size = constructor.len(); + // `MSTORE` writes the pushed word right-aligned, so the constructor sits at the tail of the + // first memory word. + let offset = 32 - size; + BytecodeBuilder::default() + .push_bytes(&constructor) + .append(PUSH0) + .append(MSTORE) + .push_number(size as u64) + .push_number(offset as u64) + .append(PUSH0) + .append(CREATE) + .append(ISZERO) + .push_number(CREATE_FAILED_SLOT) + .append(SSTORE) + .append_many([PUSH0, PUSH0, LOG0]) + .append(STOP) + .build() +} + +/// Calls [`CALLEE`] and records whether that call survived. +fn contract_code() -> Bytes { + BytecodeBuilder::default() + .append(PUSH0) // retSize + .append(PUSH0) // retOffset + .append(PUSH0) // argsSize + .append(PUSH0) // argsOffset + .append(PUSH0) // value + .push_address(CALLEE) + .push_number(FORWARDED_GAS) + .append(CALL) + .push_number(CALL_RESULT_SLOT) + .append(SSTORE) + .append(STOP) + .build() +} + +/// The address the CREATE would deploy to: `CALLEE`'s first creation. +fn created_address() -> Address { + CALLEE.create(0) +} + +fn run(spec: MegaSpecId, data_size_limit: u64) -> Outcome { + let db = MemoryDatabase::default() + .account_balance(CALLER, U256::from(10 * ONE_ETH)) + .account_code(CONTRACT, contract_code()) + .account_code(CALLEE, callee_code()); + transact(spec, db, EvmTxRuntimeLimits::from_spec(spec).with_tx_data_size_limit(data_size_limit)) +} + +fn deployed_code_len(outcome: &Outcome) -> usize { + outcome + .state + .get(&created_address()) + .and_then(|account| account.info.code.as_ref()) + .map(|code| code.original_bytes().len()) + .unwrap_or(0) +} + +/// With room to spare, the same transaction deploys: this is the fixture's own control, and it +/// pins the usage the budgets in the module docs are derived from. +#[test] +fn test_the_same_transaction_deploys_when_the_caller_has_room() { + let outcome = run(MegaSpecId::REX7, u64::MAX); + + assert!(outcome.is_success(), "control: {:?}", outcome.result); + assert_eq!( + outcome.storage_value(CALLEE, U256::from(CREATE_FAILED_SLOT)), + U256::ZERO, + "control: the CREATE must succeed when nothing is binding", + ); + assert_eq!( + deployed_code_len(&outcome), + usize::from(DEPLOYED_CODE_LEN), + "control: the constructor's code must be deployed", + ); + assert_eq!( + outcome.data_size, 1232, + "control: 150 intrinsic + 40 nonce bump + 970 constructor + 32 CALLEE log \ + + 40 CALLEE store" + ); + assert_eq!(outcome.result.logs().len(), 2, "control: both logs must reach the receipt"); +} + +/// The whole of the settlement, on one transaction: the frame reverts, its usage is discarded, its +/// state is rolled back, and its caller carries on. +#[test] +fn test_a_late_frame_local_exceed_reverts_the_frame_and_discards_its_usage() { + let outcome = run(MegaSpecId::REX7, TX_DATA_SIZE_LIMIT); + + assert!(outcome.is_success(), "the transaction itself must survive: {:?}", outcome.result); + assert_eq!( + outcome.storage_value(CALLEE, U256::from(CREATE_FAILED_SLOT)), + U256::from(1), + "the constructor's frame must come back to its caller as a failure", + ); + assert_eq!( + outcome.storage_value(CONTRACT, U256::from(CALL_RESULT_SLOT)), + U256::from(1), + "and its caller must be free to carry on — the exceed was the constructor's", + ); + assert_eq!( + deployed_code_len(&outcome), + 0, + "a frame that reports a revert has reverted: no code may be deposited", + ); + assert_eq!( + outcome.state.get(&CALLEE).map(|account| account.info.nonce), + Some(1), + "the creator's nonce bump survives the child's revert, which is why it is charged to the \ + creator's own lane and why this shape exists at all", + ); + assert_eq!( + outcome.data_size, 302, + "150 intrinsic + 40 nonce bump + 32 CALLEE log + 40 CALLEE store + 40 CONTRACT store: \ + the reverted frame's 970 bytes are discarded, not merged", + ); +} + +/// The receipt of a *successful* transaction carries no log from the frame the merge failed. +/// +/// `strip_logs_if_not_success` cannot be what removed it: that function returns a `Success` result +/// untouched. What removed it is the journal decision, which now follows the frame's final result +/// — the same rollback that left no deployed code behind. This is the pin that the strip is a +/// no-op under REX7 rather than the thing holding the receipt together. +#[test] +fn test_a_reverted_frames_log_never_reaches_a_successful_receipt() { + let outcome = run(MegaSpecId::REX7, TX_DATA_SIZE_LIMIT); + + assert!(outcome.is_success(), "the strip does nothing to a success: {:?}", outcome.result); + let logs = outcome.result.logs(); + assert_eq!(logs.len(), 1, "exactly one log survives: {logs:?}"); + assert_eq!(logs[0].address, CALLEE, "and it is the one the surviving frame emitted"); +} + +/// Frozen specs keep the split, so the same transaction ends differently there. +/// +/// REX6 merges the constructor's usage on its original classification, then rewrites the result; +/// the caller resumes over its own budget and is failed on the spot. The caller's call fails, its +/// store never runs, and its log never reaches the receipt — none of which happens under REX7. +#[test] +fn test_frozen_specs_fail_the_caller_instead() { + let outcome = run(MegaSpecId::REX6, TX_DATA_SIZE_LIMIT); + + assert!(outcome.is_success(), "the top-level frame still returns: {:?}", outcome.result); + assert_eq!( + outcome.storage_value(CONTRACT, U256::from(CALL_RESULT_SLOT)), + U256::ZERO, + "REX6: the caller is failed by its child's exceed, not just told about it", + ); + assert_eq!( + outcome.storage_value(CALLEE, U256::from(CREATE_FAILED_SLOT)), + U256::ZERO, + "REX6: the caller never gets to record the failure", + ); + assert_eq!(outcome.result.logs().len(), 0, "REX6: nothing survives to the receipt"); + assert_eq!(outcome.data_size, 150, "REX6: the whole call frame's usage is discarded with it"); +} diff --git a/crates/mega-evm/tests/rex7/main.rs b/crates/mega-evm/tests/rex7/main.rs index da0c0c50..e51ef9a2 100644 --- a/crates/mega-evm/tests/rex7/main.rs +++ b/crates/mega-evm/tests/rex7/main.rs @@ -1,3 +1,130 @@ //! Tests for the `REX7` spec. +//! +//! - `checkpoint_settlement` — checkpoint compute-gas settlement: per-transaction totals stay +//! bit-identical to per-opcode recording, and the two places where the models diverge. +//! - `frame_loop_parity` — one case per branch of the frame lifecycle that can end a frame, each +//! run through both frame loops and compared on everything a transaction produces, state +//! included: the loops share one body, and an observation-only inspector adds nothing to it. +//! - `gas_clamp` — gas-clamp enforcement: a crossing opcode is stopped before it executes, and the +//! resulting out-of-gas is restored and reclassified by the constraint that bound the clamp. +//! - `clamp_classification` — which constraint a clamp binds to, including the exact-value case, +//! and the ABI payload / halt fields a clamp-induced exceed reports. +//! - `checkpoint_families` — one parity case per checkpoint opcode the REX7 table wires, so the set +//! is covered exhaustively rather than through representatives. +//! - `create_code_deposit_charge` — a CREATE's canonical code-deposit compute gas is weighed before +//! it is recorded, so a creation that fails at its frame exit is charged nothing for a deposit +//! the EVM never makes. +//! - `interceptor_resume` — the two ways a CALL returns without a child frame ever running: a +//! system contract interceptor's synthetic result, and a precompile. +//! - `keyless_synthetic_halt` — the `KeylessDeploy` interceptor's synthetic halts: the two that +//! keep the envelope book the unperformed part as destroyed, the rescued one books nothing. +//! - `latch_surfacing` — where a latched data-size / KV-update / state-growth exceed becomes a +//! stop. +//! - `shim_lanes` — what each lane of the measurement shim books: gas written into an interpreter +//! counter or a frame's gas limit, and the receipt's two other numbers, the EIP-3529 refund and +//! the EIP-8037 state-gas dimension. +//! - `shim_settlement` — where a rewrite is settled when the number the shim reads and the number +//! the envelope carries are not the same object: the two windows a rewrite can land in after the +//! accounting that should have read it, and the gas a synthetic outcome carries. +//! - `shim_blind_spots` — the rewrite shapes an all-zero ledger used to admit: a frame's memory +//! grown for free, an outcome's metadata rewritten around the result inside it, two edits to one +//! signed lane that cancel, an instruction deleted by stepping the program counter past it, and a +//! return buffer conjured in front of a frame that made no call. +//! - `shim_input_comparison` — what the entry callbacks call a rewrite of a frame's inputs: every +//! field a creation is built from is compared, and the two `OnceCell` memos a tracer fills by +//! asking where a deployment landed are not. +//! - `shim_refusals` — the rewrites the shim refuses outright: a failed creation revived (at both +//! callbacks that can), and the classification of a result frame init produced; with the near +//! boundary, a frame the inspector answered itself, which is supported. +//! - `gas_surface` — the closed enumeration one level below the cheat matrix: every field of every +//! gas-carrying object an inspector callback is handed, each with a verdict, pinned against what +//! upstream's own `Debug` renders. +//! - `gas_leakage` — the three paths a per-frame gas mechanism can leak through (interception, +//! TX-level rescue, frame return), each with a clamp outstanding. +//! - `opcode_set_parity` — all 256 opcodes probed under both specs, so the REX7 table cannot gain +//! an opcode REX6 does not have when revm's base table grows one. +//! - `parity_shapes` — parity on the transaction shapes that enter through a different door: +//! EIP-7702 authorizations, the `KeylessDeploy` sandbox, system-originated (exempt) transactions, +//! the REX5 storage-call stipend, and oracle hints. +//! - `double_exceed_corner` — the adjudicated corner swept one gas at a time, so the classification +//! is shown to be stable rather than merely correct at one point. +//! - `exceptional_halt` — every exceptional-halt classification, in both frame positions: the +//! frame's whole burned budget settles as compute gas without changing the receipt. +//! - `frame_init_reject_burn` — the budget a refused frame init decides the fate of: the halting +//! rejections (a CREATE onto an occupied address) swallow it and book it as destroyed, the +//! returning ones hand it back and book nothing, and a precompile is settled from what its own +//! recording site staged; then through the deposit-receipt rewrite and the sandbox merge that run +//! after the booking. +//! - `call_body_halt_charges` — what a CALL-family body already charged when it halts: the +//! value-transfer surcharge and the return-range memory expansion stay in the open segment and +//! settle as work, rather than being dropped by the wrapper's tail. +//! - `burn_split` — which half of that budget enforces: the work the frame performed does, the +//! remainder it destroyed does not, and both boundaries (a checkpoint's storage charge, revm's +//! post-action create rejects) land on the right side. +//! - `charge_on_reject` — a `disableVolatileDataAccess` rejection still pays the opcode's static +//! fee, which segment settlement records as compute; REX6 keeps the historical zero-charge +//! revert. +//! - `guard_pass_static_gas` — a passing guard charges the checkpoint static fee after the prologue +//! restores true gas, so a compute headroom of `static_gas − 1` records the body rather than +//! stopping at the clamp. +//! - `detention_window` — an underfunded CALL / EXTCODECOPY that OOGs before the target load does +//! not mark beneficiary access; that order is specified, so the frozen-window tripwire stays +//! silent. +//! - `checkpoint_static_fee_edges` — a table-prepaid checkpoint (`GAS`, `LOG1`) whose static fee +//! exceeds the clamp headroom is a plain-segment crossing; `CREATE`'s 32,000 is charged inside +//! the body, so the same headroom runs the body and then reverts. +//! - `precompile_halt` — a precompile that halts exceptionally is split the same way an interpreter +//! frame is, and at the same settlement point: executed work enforces, the unused forwarded +//! envelope does not. +//! - `pre_execution_intrinsic_reject` — the one envelope-keeping synthetic halt REX7 cannot reach: +//! for an ordinary transaction an intrinsic overrun is a validation error from REX5 on, and a +//! validation error produces no receipt for any lane to account for. +//! - `deposit_receipt_rewrite` — the transactions that break that last step. A failed OP deposit +//! does get a receipt, rebuilt to report its whole gas limit after every settlement has run; the +//! boundary that rebuilds it books the difference as destroyed without moving what enforces. +//! - `result_space_tripwire` — every `InstructionResult` variant has an explicit destroyed- +//! remainder class (swallow / return / unreachable), with no catch-all, so a revm bump that adds +//! a variant fails to compile until a human assigns it; the early-fail arms of frame init are +//! listed beside it for the upgrade diff that those arms have no type-level tie to. +//! - `trusted_observer` — the declared read-only fast path: a declaration that holds produces the +//! same transaction as no inspector and as a measured one, and a declaration that does not panics +//! in debug builds at the callback that broke it. +mod burn_split; +mod call_body_halt_charges; +mod charge_on_reject; +mod checkpoint_families; +mod checkpoint_settlement; +mod checkpoint_static_fee_edges; +mod clamp_classification; +mod common; +mod conservation_terms; +mod create_code_deposit_charge; +mod deposit_receipt_rewrite; +mod detention_window; +mod double_exceed_corner; +mod exceptional_halt; +mod frame_init_reject_burn; +mod frame_loop_parity; +mod gas_clamp; +mod gas_leakage; +mod gas_surface; +mod guard_pass_static_gas; +mod inspector_cheat_matrix; +mod inspector_common; +mod interceptor_resume; +mod keyless_synthetic_halt; +mod latch_surfacing; +mod late_frame_local; mod modexp_gas; +mod opcode_set_parity; +mod parity_shapes; +mod pre_execution_intrinsic_reject; +mod precompile_halt; +mod result_space_tripwire; +mod shim_blind_spots; +mod shim_input_comparison; +mod shim_lanes; +mod shim_refusals; +mod shim_settlement; +mod trusted_observer; diff --git a/crates/mega-evm/tests/rex7/opcode_set_parity.rs b/crates/mega-evm/tests/rex7/opcode_set_parity.rs new file mode 100644 index 00000000..ad0fbfb1 --- /dev/null +++ b/crates/mega-evm/tests/rex7/opcode_set_parity.rs @@ -0,0 +1,98 @@ +//! REX7: the opcode set the table exposes must be exactly the one REX6 exposes. +//! +//! Every table through REX6 is built from scratch — `mini_rex` starts from an +//! all-`control::unknown` array and wires the opcodes it supports — so an opcode revm gains in a +//! future release stays unknown there until someone wires it. The REX7 table starts from revm's own +//! table instead, which makes the same release silently hand REX7 an opcode REX6 does not have. +//! That is a fail-open construction, and the four slots REX7 inherits back from REX6 (`DUPN`, +//! `SWAPN`, `EXCHANGE`, `SLOTNUM`) were found by reading revm's table by hand. +//! +//! This file replaces that reading with a check. It probes all 256 opcodes under both specs and +//! asserts they agree on one bit: whether the table dispatched `control::unknown`. +//! +//! ## What the probe reads, and what it does not +//! +//! The probe is a one-byte contract holding the opcode alone, run as a whole transaction. Its stack +//! is empty, so a supported opcode usually halts on a stack underflow — the probe deliberately does +//! not compare the two specs' full results, only whether each reports `OpcodeNotFound`. +//! `control::unknown` is the only handler that produces it, so the bit says exactly which slots +//! hold that handler, and nothing about how the surrounding wrappers differ. +//! +//! Two limits follow from reading a single bit. An opcode revm activates for the Ethereum spec +//! `MegaSpecId` maps to is caught, because REX7 would then execute it (or reject it some other way) +//! while REX6 still answers `OpcodeNotFound` — but an opcode revm wires and gates behind a later +//! fork is caught only because its `NotActivated` rejection is a different reason, not because the +//! opcode sets truly diverge. And an immediate-taking opcode whose immediate fails to decode maps +//! to `OpcodeNotFound` in revm, so an activated opcode of that shape could read as unknown here. +//! Both are conservative in the direction that matters: the check fails loudly and asks for an +//! explicit decision rather than passing quietly. + +use crate::common::{transact_default, CALLER, CONTRACT, ONE_ETH}; +use alloy_primitives::{Bytes, U256}; +use mega_evm::{ + test_utils::MemoryDatabase, EthHaltReason, MegaHaltReason, MegaSpecId, OpHaltReason, +}; +use revm::{bytecode::opcode::OpCode, context::result::ExecutionResult}; + +/// Runs the one-byte probe for `opcode` under `spec` and reports whether the table dispatched +/// `control::unknown`. +fn dispatches_unknown(spec: MegaSpecId, opcode: u8) -> bool { + let db = MemoryDatabase::default() + .account_balance(CALLER, U256::from(10 * ONE_ETH)) + .account_code(CONTRACT, Bytes::from(vec![opcode])); + matches!( + transact_default(spec, db).result, + ExecutionResult::Halt { + reason: MegaHaltReason::Base(OpHaltReason::Base(EthHaltReason::OpcodeNotFound)), + .. + } + ) +} + +/// Names `opcode` for a failure message, falling back to its hex value when revm does not know it. +fn opcode_name(opcode: u8) -> String { + OpCode::new(opcode).map_or_else(|| format!("{opcode:#04x}"), |op| op.as_str().to_string()) +} + +/// The guard: no opcode may be unknown under one spec and dispatched under the other. +#[test] +fn test_rex7_opcode_set_matches_rex6() { + let divergent: Vec = (0..=u8::MAX) + .filter_map(|opcode| { + let rex6 = dispatches_unknown(MegaSpecId::REX6, opcode); + let rex7 = dispatches_unknown(MegaSpecId::REX7, opcode); + (rex6 != rex7).then(|| { + let known_to = if rex7 { "REX6" } else { "REX7" }; + format!("{} ({opcode:#04x}): dispatched only by {known_to}", opcode_name(opcode)) + }) + }) + .collect(); + + assert!( + divergent.is_empty(), + "REX7 must expose the same opcode set as REX6, but they disagree on:\n {}\n\ + Wire the opcode into both tables, or inherit the slot from REX6 in `mod rex7`.", + divergent.join("\n ") + ); +} + +/// The probe has to be able to answer both ways, or the guard above passes vacuously — a run where +/// every transaction failed before reaching the opcode would report no divergence at all. +#[test] +fn test_opcode_probe_discriminates_unknown_from_dispatched() { + // 0x0c is unassigned in the EVM: unknown under both specs, in revm's table and in MegaETH's. + const UNASSIGNED: u8 = 0x0c; + // `PUSH0` needs no operands, so it runs to completion under both. + let dispatched = revm::bytecode::opcode::PUSH0; + + for spec in [MegaSpecId::REX6, MegaSpecId::REX7] { + assert!( + dispatches_unknown(spec, UNASSIGNED), + "{spec:?}: an unassigned opcode must report OpcodeNotFound" + ); + assert!( + !dispatches_unknown(spec, dispatched), + "{spec:?}: PUSH0 must not report OpcodeNotFound" + ); + } +} diff --git a/crates/mega-evm/tests/rex7/parity_shapes.rs b/crates/mega-evm/tests/rex7/parity_shapes.rs new file mode 100644 index 00000000..8d7f406e --- /dev/null +++ b/crates/mega-evm/tests/rex7/parity_shapes.rs @@ -0,0 +1,432 @@ +//! REX6 ↔ REX7 bit-for-bit parity on the transaction shapes the settlement suite does not reach. +//! +//! The precision invariant is that a transaction which stays inside every per-tx limit is +//! indistinguishable under the two accounting models. `checkpoint_settlement` establishes that for +//! bytecode shapes reached from a plain call; the shapes here are the ones that enter or leave the +//! interpreter through a different door: +//! +//! - **EIP-7702 authorizations** — accounted in validate / pre-execution, before any frame exists, +//! and able to re-derive the beneficiary detention cap from usage the checkpoint model settles +//! differently; +//! - **`KeylessDeploy`** — a system contract intercepted at depth 0, whose sandbox runs a whole +//! nested transaction and merges its usage back; +//! - **system-originated transactions** — exempt from per-tx metering, which also switches the +//! clamp off entirely, so an exempt transaction must run to completion under a limit that would +//! stop a user transaction; +//! - **the REX5 storage-call stipend** — a per-frame allowance drawn only at the storage-gas +//! surcharge sites, which are exactly the checkpoints; +//! - **oracle hints** — metered from inside `frame_init`, one step past the CALL checkpoint. +//! +//! Every case asserts the full outcome tuple: execution result, compute gas, all four dimensions, +//! receipt `gas_used` and the detained compute-gas limit. + +use std::vec::Vec; + +use crate::common::{ + assert_outcomes_identical, base_db, default_envs, keyless_tx_bytes, plain_filler, transact_tx, + Outcome, CALLEE, CALLER, CONTRACT, DEFAULT_TX_GAS_LIMIT, EMPTY_TARGET, ONE_ETH, +}; +use alloy_eips::eip7702::{Authorization, RecoveredAuthority, RecoveredAuthorization}; +use alloy_primitives::{address, Address, Bytes, B256, U256}; +use alloy_sol_types::SolCall as _; +use mega_evm::{ + test_utils::{BytecodeBuilder, MemoryDatabase}, + EvmTxRuntimeLimits, IKeylessDeploy, IOracle, MegaSpecId, TestExternalEnvs, + KEYLESS_DEPLOY_ADDRESS, ORACLE_CONTRACT_ADDRESS, ORACLE_CONTRACT_CODE_REX2, +}; +use revm::{ + bytecode::opcode::{CALL, LOG1, POP, SLOAD, STOP, TIMESTAMP}, + context::{tx::TxEnvBuilder, TxEnv}, +}; + +/// The protocol's own system caller (EIP-4788 / EIP-2935 pre-block calls). A transaction from this +/// address is system-originated, and REX6+ exempts it from per-tx metering. +const PROTOCOL_SYSTEM_CALLER: Address = address!("fffffffffffffffffffffffffffffffffffffffe"); + +/// The address authorizations in this file delegate to. +const DELEGATE: Address = address!("0000000000000000000000000000000000330001"); +/// An authority that already exists in state. +const EXISTING_AUTHORITY: Address = address!("0000000000000000000000000000000000330002"); +/// An authority that does not exist yet, so applying its authorization grows state. +const NEW_AUTHORITY: Address = address!("0000000000000000000000000000000000330003"); + +const KEYLESS_RELAYER: Address = address!("0000000000000000000000000000000000330004"); + +/// A mixed body: plain opcodes around one of every checkpoint family that does not need operands +/// from the caller — a storage read, a storage write, a log, and a volatile opcode. +fn mixed_checkpoint_body() -> Bytes { + let builder = plain_filler(BytecodeBuilder::default(), 10) + .append(TIMESTAMP) + .append(POP) + .push_u256(U256::from(3)) + .append(SLOAD) + .append(POP) + .sstore(U256::from(1), U256::from(0x11)); + let builder = plain_filler(builder, 10) + .mstore(0, [0x22u8; 32]) + .push_number(0xabcu64) // topic0 + .push_number(32u64) // len + .push_number(0u64) // offset + .append(LOG1); + plain_filler(builder, 10).append(STOP).build() +} + +/// Runs `tx` under both specs against a freshly built database and asserts the two are +/// indistinguishable. Returns `(REX6, REX7)` for any case-specific assertions on top. +fn assert_parity( + label: &str, + build_db: impl Fn() -> MemoryDatabase, + build_tx: impl Fn() -> TxEnv, + limits: impl Fn(MegaSpecId) -> EvmTxRuntimeLimits, +) -> (Outcome, Outcome) { + let envs6 = default_envs(); + let r6 = + transact_tx(MegaSpecId::REX6, build_db(), limits(MegaSpecId::REX6), build_tx(), &envs6); + let envs7 = default_envs(); + let r7 = + transact_tx(MegaSpecId::REX7, build_db(), limits(MegaSpecId::REX7), build_tx(), &envs7); + assert_outcomes_identical(label, &r6, &r7); + (r6, r7) +} + +fn recovered_auth(authority: Address, nonce: u64) -> RecoveredAuthorization { + RecoveredAuthorization::new_unchecked( + Authorization { chain_id: U256::from(1), address: DELEGATE, nonce }, + RecoveredAuthority::Valid(authority), + ) +} + +/// EIP-7702 authorization accounting happens in `validate` / pre-execution — before the first +/// frame, and therefore before any checkpoint exists. It also charges dynamic SALT account-creation +/// gas into the transaction's intrinsic gas, which the first frame's settlement window then has to +/// open on top of. One net-new authority and one existing one exercise both arms of +/// `on_rex6_eip7702_authority_applied`. +#[test] +fn test_eip7702_authorization_accounting_matches_per_opcode() { + let code = mixed_checkpoint_body(); + let build_db = || { + base_db(code.clone()) + .account_balance(EXISTING_AUTHORITY, U256::from(1u64)) + .account_code(DELEGATE, BytecodeBuilder::default().append(STOP).build()) + }; + let build_tx = || { + TxEnvBuilder::default() + .caller(CALLER) + .call(CONTRACT) + .gas_limit(DEFAULT_TX_GAS_LIMIT) + .chain_id(Some(1)) + .authorization_list_recovered(Vec::from([ + recovered_auth(EXISTING_AUTHORITY, 0), + recovered_auth(NEW_AUTHORITY, 0), + ])) + .build_fill() + }; + + let (_, r7) = + assert_parity("EIP-7702 authorizations", build_db, build_tx, EvmTxRuntimeLimits::from_spec); + assert!(r7.is_success(), "the authorized transaction must succeed: {:?}", r7.result); + assert!( + r7.state_growth > 0, + "the net-new authority must register state growth; growth={}", + r7.state_growth + ); +} + +/// The same shape with the authorizations applied under an engaged detention cap. The cap is +/// re-derived from settled usage when an applied authority is the block beneficiary, so this also +/// checks that a cap installed outside any frame lands on the same number under both models. +#[test] +fn test_eip7702_authorization_under_detention_matches_per_opcode() { + let code = mixed_checkpoint_body(); + let build_db = || { + base_db(code.clone()) + .account_balance(EXISTING_AUTHORITY, U256::from(1u64)) + .account_code(DELEGATE, BytecodeBuilder::default().append(STOP).build()) + }; + let build_tx = || { + TxEnvBuilder::default() + .caller(CALLER) + .call(CONTRACT) + .gas_limit(DEFAULT_TX_GAS_LIMIT) + .chain_id(Some(1)) + .authorization_list_recovered(Vec::from([recovered_auth(NEW_AUTHORITY, 0)])) + .build_fill() + }; + let limits = |spec| { + let mut limits = EvmTxRuntimeLimits::from_spec(spec); + limits.block_env_access_compute_gas_limit = 1_000_000; + limits + }; + + let (_, r7) = assert_parity("EIP-7702 under detention", build_db, build_tx, limits); + assert!(r7.is_success(), "the authorized transaction must succeed: {:?}", r7.result); +} + +/// `KeylessDeploy` is intercepted at depth 0, so the interception happens before any frame — and +/// therefore before any checkpoint — has been created. Its sandbox then runs a whole nested +/// transaction under the same spec, with its own tracker, and merges the usage back. +/// +/// Two accounting models have to agree across all of that: the sandbox's own checkpoint settlement, +/// the merge, and the outer transaction's view of it. +#[test] +fn test_keyless_deploy_sandbox_accounting_matches_per_opcode() { + // Initcode that runs some plain opcodes and a storage write, then deploys a small runtime. + let runtime = BytecodeBuilder::default().append(STOP).build_vec(); + let init_code = plain_filler(BytecodeBuilder::default(), 10) + .sstore(U256::from(7), U256::from(0x99)) + .return_with_data(&runtime) + .build(); + let call_data = IKeylessDeploy::keylessDeployCall { + keylessDeploymentTransaction: keyless_tx_bytes(init_code, 200_000), + gasLimitOverride: U256::from(1_000_000u64), + } + .abi_encode(); + + let build_db = + || MemoryDatabase::default().account_balance(KEYLESS_RELAYER, U256::from(10 * ONE_ETH)); + let build_tx = || { + TxEnvBuilder::default() + .caller(KEYLESS_RELAYER) + .call(KEYLESS_DEPLOY_ADDRESS) + .gas_limit(30_000_000) + .chain_id(Some(1)) + .data(Bytes::from(call_data.clone())) + .build_fill() + }; + + let (_, r7) = + assert_parity("keyless deploy sandbox", build_db, build_tx, EvmTxRuntimeLimits::from_spec); + assert!(r7.is_success(), "the keyless deployment must succeed: {:?}", r7.result); + let returns = IKeylessDeploy::keylessDeployCall::abi_decode_returns( + r7.result.output().expect("the interceptor must return data"), + ) + .expect("the output must decode as keylessDeployReturn"); + assert!( + !returns.deployedAddress.is_zero(), + "the sandbox must report a deployed address; errorData={}", + returns.errorData + ); +} + +/// The same keyless deployment under a detention cap engaged by the sandboxed code, so the sandbox +/// runs with a clamp of its own. +#[test] +fn test_keyless_deploy_sandbox_under_detention_matches_per_opcode() { + let runtime = BytecodeBuilder::default().append(STOP).build_vec(); + let init_code = plain_filler(BytecodeBuilder::default(), 5) + .append(TIMESTAMP) + .append(POP) + .return_with_data(&runtime) + .build(); + let call_data = IKeylessDeploy::keylessDeployCall { + keylessDeploymentTransaction: keyless_tx_bytes(init_code, 200_000), + gasLimitOverride: U256::from(1_000_000u64), + } + .abi_encode(); + + let build_db = + || MemoryDatabase::default().account_balance(KEYLESS_RELAYER, U256::from(10 * ONE_ETH)); + let build_tx = || { + TxEnvBuilder::default() + .caller(KEYLESS_RELAYER) + .call(KEYLESS_DEPLOY_ADDRESS) + .gas_limit(30_000_000) + .chain_id(Some(1)) + .data(Bytes::from(call_data.clone())) + .build_fill() + }; + let limits = |spec| { + let mut limits = EvmTxRuntimeLimits::from_spec(spec); + limits.block_env_access_compute_gas_limit = 500_000; + limits + }; + + let (_, r7) = assert_parity("keyless deploy under detention", build_db, build_tx, limits); + assert!(r7.is_success(), "the keyless deployment must succeed: {:?}", r7.result); +} + +/// A system-originated transaction is exempt from per-tx metering, and the exemption also switches +/// the clamp off: `checkpoint_clamp_amount` refuses to hide anything once the tracker is not in the +/// `WithinLimit` state. +/// +/// The compute limit here is far below what the transaction spends, so a user transaction running +/// the same code would be stopped. The exempt one must run to completion under both models — and +/// still report the same compute usage, since the recording continues while only the halt decision +/// is suppressed. +#[test] +fn test_system_originated_transaction_is_unclamped_under_both_models() { + let code = { + let mut code = Vec::new(); + code.extend_from_slice(&[0x61, 0x03, 0xe8]); // PUSH2 1000 + let target = code.len() as u8; + code.extend_from_slice(&[0x5b, 0x60, 0x01, 0x90, 0x03, 0x80, 0x60, target, 0x57, 0x00]); + Bytes::from(code) + }; + let build_db = || { + MemoryDatabase::default() + .account_code(CONTRACT, code.clone()) + .account_balance(PROTOCOL_SYSTEM_CALLER, U256::from(ONE_ETH)) + }; + let build_tx = || { + TxEnvBuilder::default() + .caller(PROTOCOL_SYSTEM_CALLER) + .call(CONTRACT) + .gas_limit(DEFAULT_TX_GAS_LIMIT) + .gas_price(0) + .build_fill() + }; + // Well under the loop's cost: binding for a user transaction, ignored for this one. + let limits = |spec| EvmTxRuntimeLimits::from_spec(spec).with_tx_compute_gas_limit(25_000); + + let (_, r7) = assert_parity("system-originated exemption", build_db, build_tx, limits); + assert!(r7.is_success(), "an exempt transaction must not be stopped: {:?}", r7.result); + assert!( + r7.compute_gas > 25_000, + "the exempt transaction must have spent past the limit it ignores; compute={}", + r7.compute_gas + ); +} + +/// The REX5 storage-call stipend is a per-frame allowance drawn only at `MegaETH`'s storage-gas +/// surcharge sites — which are exactly the checkpoints. A value-transferring internal CALL into a +/// callee that logs and writes storage draws on it at three of them. +/// +/// Under checkpoint accounting the same sites do the drawing, but the compute they record is now a +/// segment delta rather than a per-opcode capture, so the subtraction of the drawn storage gas has +/// to land on the same number. +#[test] +fn test_storage_call_stipend_allowance_matches_per_opcode() { + // The callee is reached by a value-transferring CALL with no gas of its own beyond the stipend + // revm adds, so its storage work is paid for out of the allowance. + let callee = BytecodeBuilder::default() + .mstore(0, [0x33u8; 32]) + .push_number(0xdefu64) // topic0 + .push_number(32u64) // len + .push_number(0u64) // offset + .append(LOG1) + .append(STOP) + .build(); + let code = plain_filler(BytecodeBuilder::default(), 10) + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(0u64) // argsSize + .push_number(0u64) // argsOffset + .push_number(1u64) // value — arms the stipend + .push_address(CALLEE) + .push_number(0u64) // gas — only the stipend is available + .append(CALL) + .append(POP) + .append(STOP) + .build(); + let build_db = || base_db(code.clone()).account_code(CALLEE, callee.clone()); + let build_tx = || { + TxEnvBuilder::default() + .caller(CALLER) + .call(CONTRACT) + .gas_limit(DEFAULT_TX_GAS_LIMIT) + .build_fill() + }; + + let (_, r7) = + assert_parity("storage-call stipend", build_db, build_tx, EvmTxRuntimeLimits::from_spec); + assert!(r7.is_success(), "the stipend-funded call must succeed: {:?}", r7.result); + assert_eq!( + r7.result.logs().len(), + 1, + "the callee's log must have been emitted out of the stipend allowance; logs={:?}", + r7.result.logs() + ); +} + +/// The stipend's other arm: a value transfer to an account that does not exist yet, so the +/// new-account materialisation surcharge is what draws on the allowance. +#[test] +fn test_storage_call_stipend_new_account_matches_per_opcode() { + let code = plain_filler(BytecodeBuilder::default(), 10) + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(0u64) // argsSize + .push_number(0u64) // argsOffset + .push_number(1u64) // value + .push_address(EMPTY_TARGET) + .push_number(0u64) // gas + .append(CALL) + .append(POP) + .append(STOP) + .build(); + let build_tx = || { + TxEnvBuilder::default() + .caller(CALLER) + .call(CONTRACT) + .gas_limit(DEFAULT_TX_GAS_LIMIT) + .build_fill() + }; + + let (_, r7) = assert_parity( + "storage-call stipend / new account", + || base_db(code.clone()), + build_tx, + EvmTxRuntimeLimits::from_spec, + ); + assert!(r7.is_success(), "the value transfer must succeed: {:?}", r7.result); +} + +/// The oracle-hint site on its success arm: the payload is metered into the data-size lane from +/// inside `frame_init`, then forwarded to the backend, then the inner Oracle frame runs. +/// +/// Under checkpoint accounting the caller's segment was settled at the CALL checkpoint one step +/// earlier, so both the metering and the forwarding observe the same state they observe under +/// per-opcode accounting — and the hint that reaches the backend has to be identical. +#[test] +fn test_oracle_hint_forwarding_matches_per_opcode() { + let payload = Bytes::from(vec![0xa5u8; 96]); + let topic = B256::repeat_byte(0x5a); + let calldata = IOracle::sendHintCall { topic, data: payload.clone() }.abi_encode(); + let len = calldata.len() as u64; + let code = plain_filler(BytecodeBuilder::default(), 10) + .mstore(0, &calldata) + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(len) // argsSize + .push_number(0u64) // argsOffset + .push_number(0u64) // value + .push_address(ORACLE_CONTRACT_ADDRESS) + .push_number(1_000_000u64) // gas + .append(CALL) + .append(POP) + .append(STOP) + .build(); + let build_db = + || base_db(code.clone()).account_code(ORACLE_CONTRACT_ADDRESS, ORACLE_CONTRACT_CODE_REX2); + let tx = || { + TxEnvBuilder::default() + .caller(CALLER) + .call(CONTRACT) + .gas_limit(DEFAULT_TX_GAS_LIMIT) + .build_fill() + }; + + let envs6 = TestExternalEnvs::new(); + let r6 = transact_tx( + MegaSpecId::REX6, + build_db(), + EvmTxRuntimeLimits::from_spec(MegaSpecId::REX6), + tx(), + &envs6, + ); + let envs7 = TestExternalEnvs::new(); + let r7 = transact_tx( + MegaSpecId::REX7, + build_db(), + EvmTxRuntimeLimits::from_spec(MegaSpecId::REX7), + tx(), + &envs7, + ); + + let hints6 = envs6.recorded_hints(); + let hints7 = envs7.recorded_hints(); + assert_eq!(hints7.len(), 1, "the hint must have reached the backend; got {hints7:?}"); + assert_eq!(hints6, hints7, "the forwarded hint must be identical under both models"); + assert_eq!(hints7[0].data, payload, "the payload must survive intact"); + assert_outcomes_identical("oracle hint forwarding", &r6, &r7); +} diff --git a/crates/mega-evm/tests/rex7/pre_execution_intrinsic_reject.rs b/crates/mega-evm/tests/rex7/pre_execution_intrinsic_reject.rs new file mode 100644 index 00000000..c63ffb86 --- /dev/null +++ b/crates/mega-evm/tests/rex7/pre_execution_intrinsic_reject.rs @@ -0,0 +1,136 @@ +//! The pre-execution intrinsic overrun: a transaction whose MegaETH-side intrinsic gas outgrows +//! the gas limit its sender supplied. +//! +//! `MINI_REX`..REX4 answer that with a synthetic top-level out-of-gas that burns the whole +//! envelope having executed nothing — the one halt shape that keeps a transaction's entire gas +//! envelope without ever creating a frame. REX5 moved the initial-gas check to the end of the +//! `MegaETH` storage-gas additions, which turns the same transaction into a validation error +//! before `pre_execution` debits the sender. +//! +//! That ordering is what keeps the REX7 destroyed lane complete without the synthetic halt having +//! to participate: no transaction on a spec that has the lane can reach the halt. These probes pin +//! both sides of the boundary, so a future change that re-opens the halt for REX7 turns red here +//! rather than silently reporting a transaction whose burnt envelope no lane accounts for. +//! +//! Every probe here is an ordinary transaction, and that is the whole of what they claim. The +//! second half of the reasoning — a validation reject produces no receipt, so there is nothing for +//! the lane to account for — holds only for ordinary transactions. A rejected deposit does produce +//! a receipt, rebuilt to report its whole gas limit, and the destroyed lane does have to account +//! for it; that shape lives in `deposit_receipt_rewrite`. + +use std::convert::Infallible; + +use alloy_primitives::{address, Address, Bytes, TxKind, U256}; +use mega_evm::{ + test_utils::MemoryDatabase, EVMError, MegaContext, MegaEvm, MegaSpecId, MegaTransaction, + MegaTransactionError, MegaTransactionNew as _, MegaTransactionOutcome, SaltEnv, + TestExternalEnvs, MIN_BUCKET_SIZE, +}; +use revm::{context::TxEnv, Database as _}; + +const CALLER: Address = address!("2000000000000000000000000000000000000002"); +/// Value-transfer recipient that does not exist yet, so the transaction owes new-account storage +/// gas for materialising it. +const NEW_ACCOUNT: Address = address!("9000000000000000000000000000000000000009"); + +/// Covers the standard EVM intrinsic (21,000) and the REX flat intrinsic storage gas (39,000), +/// but not the dynamic new-account storage gas the hot bucket below scales up. +const INSUFFICIENT_GAS_LIMIT: u64 = 80_000; + +/// Standard EVM intrinsic gas for this transaction — no calldata, no access list. This is the +/// whole of what `validate` records as compute gas before the first frame. +const INTRINSIC_COMPUTE_GAS: u64 = 21_000; + +const CALLER_BALANCE: u64 = 10_000_000; + +/// Runs the overrun transaction under `spec`: a value-transferring call to an empty account whose +/// SALT bucket is ten times the minimum, so the dynamic new-account storage gas alone is far +/// larger than the gas limit. +fn run_intrinsic_overrun( + db: &mut MemoryDatabase, + spec: MegaSpecId, +) -> Result> { + let bucket_id = TestExternalEnvs::::bucket_id_for_account(NEW_ACCOUNT); + let external_envs = TestExternalEnvs::::new() + .with_bucket_capacity(bucket_id, MIN_BUCKET_SIZE as u64 * 10); + + let mut context = MegaContext::new(db, spec).with_external_envs(external_envs.into()); + context.modify_chain(|chain| { + chain.operator_fee_scalar = Some(U256::ZERO); + chain.operator_fee_constant = Some(U256::ZERO); + }); + + let mut tx = MegaTransaction::new(TxEnv { + caller: CALLER, + kind: TxKind::Call(NEW_ACCOUNT), + data: Bytes::new(), + value: U256::from(1), + gas_limit: INSUFFICIENT_GAS_LIMIT, + ..Default::default() + }); + tx.enveloped_tx = Some(Bytes::new()); + + MegaEvm::new(context).execute_transaction(tx) +} + +fn funded_db() -> MemoryDatabase { + let mut db = MemoryDatabase::default(); + db.set_account_balance(CALLER, U256::from(CALLER_BALANCE)); + db +} + +fn assert_sender_untouched(db: &mut MemoryDatabase) { + let info = db.basic(CALLER).expect("db read should succeed").unwrap_or_default(); + assert_eq!( + info.balance, + U256::from(CALLER_BALANCE), + "a validation reject must not debit the sender", + ); + assert_eq!(info.nonce, 0, "a validation reject must not bump the sender's nonce"); +} + +/// REX7 (and REX6, its immediate predecessor) reject the overrun in validation. There is no +/// receipt, no burnt envelope, and therefore nothing for the destroyed lane to account for — the +/// synthetic halt that would keep the envelope is unreachable on both specs. +#[test] +fn test_intrinsic_overrun_is_a_validation_reject_from_rex6_on() { + for spec in [MegaSpecId::REX6, MegaSpecId::REX7] { + let mut db = funded_db(); + let err = match run_intrinsic_overrun(&mut db, spec) { + Err(err) => err, + Ok(outcome) => panic!( + "{spec:?} must reject the intrinsic overrun before execution, got {:?}", + outcome.result_and_state.result, + ), + }; + let rendered = format!("{err:?}"); + assert!( + rendered.contains("CallGasCostMoreThanGasLimit"), + "{spec:?}: expected CallGasCostMoreThanGasLimit, got {rendered}", + ); + assert_sender_untouched(&mut db); + } +} + +/// REX4 keeps the frozen shape: validation accepts the transaction, the sender pays, and execution +/// answers with a synthetic out-of-gas that burns the whole envelope. The compute-gas total is +/// exactly the intrinsic `validate` recorded — the burnt remainder is attributed to nothing, which +/// is the frozen accounting the destroyed lane must not retroactively change. +#[test] +fn test_rex4_intrinsic_overrun_burns_the_envelope_with_no_destroyed_lane() { + let mut db = funded_db(); + let outcome = run_intrinsic_overrun(&mut db, MegaSpecId::REX4) + .expect("pre-REX5 specs must not reject the overrun as a validation error"); + + assert!(outcome.result_and_state.result.is_halt(), "REX4 answers the overrun with a halt"); + assert_eq!( + outcome.result_and_state.result.tx_gas_used(), + INSUFFICIENT_GAS_LIMIT, + "the halt burns the whole envelope", + ); + assert_eq!( + outcome.compute_gas_used, INTRINSIC_COMPUTE_GAS, + "REX4 reports only the intrinsic compute gas validate recorded", + ); + assert_eq!(outcome.compute_gas_destroyed, 0, "pre-REX7 specs have no destroyed lane"); +} diff --git a/crates/mega-evm/tests/rex7/precompile_halt.rs b/crates/mega-evm/tests/rex7/precompile_halt.rs new file mode 100644 index 00000000..0debcc74 --- /dev/null +++ b/crates/mega-evm/tests/rex7/precompile_halt.rs @@ -0,0 +1,400 @@ +//! A precompile that halts exceptionally is split the same way as an interpreter frame. +//! +//! A precompile runs inside `frame_init` and comes back as a result, so it never reaches the +//! interpreter-frame halt settlement. REX7 splits it at the same settlement point every other +//! frame outcome goes through, from the classification the caller is handed — the recording site +//! stages what only it knows, and the split is taken from that: +//! +//! - **Executed** — the work the precompile actually performed (the KZG fixed fee when the call +//! reached verification; zero when the input was rejected before any work). This is enforcing. +//! - **Destroyed** — the rest of the forwarded envelope, which is the caller-supplied forwarded +//! envelope, not the REX5-capped effective limit. This is reported and never enforced. +//! +//! KZG therefore lands on both sides of the split depending on where it failed: an input whose +//! length is not 192 bytes is turned away at the doorway, before the commitment is read, while +//! any failure past that point means verification was under way and is priced at the whole fixed +//! fee. +//! +//! Through REX6 the recording site stays single-lane and stages nothing: success / revert still +//! charge spent, every KZG failure past the wrapper's gas gate charges the fixed fee — doorway +//! rejects included — and every other error still charges the (capped) limit as enforcing usage. + +use crate::common::{transact, transact_default, Outcome, CALLEE, CALLER, CONTRACT, ONE_ETH}; +use alloy_primitives::{address, Address, Bytes}; +use mega_evm::{ + kzg_point_evaluation, + test_utils::{BytecodeBuilder, MemoryDatabase}, + EvmTxRuntimeLimits, MegaSpecId, +}; +use revm::bytecode::opcode::{CALL, INVALID, POP, STOP}; +use sha2::{Digest, Sha256}; + +/// KZG point evaluation. +const KZG: Address = address!("000000000000000000000000000000000000000a"); +/// blake2f. Rejects any input whose length is not 213 bytes, before charging anything. +const BLAKE2F: Address = address!("0000000000000000000000000000000000000009"); + +/// Gas every probed CALL forwards. Far above both precompiles' real costs, so the destroyed +/// remainder dominates every other term and the REX5 forwarded-gas cap is a no-op unless a +/// test tightens the compute limit. +const FORWARDED: u64 = 1_000_000; + +/// Calldata short enough that every precompile probed here rejects it on length alone — for KZG +/// that is the doorway reject, for blake2f the generic malformed-input error. +fn malformed_calldata() -> Vec { + vec![0xAAu8; 32] +} + +/// The EIP-4844 point-evaluation test vector with the last byte of the proof flipped. +/// +/// Still 192 bytes with a matching versioned hash, so KZG clears the length doorway and the +/// commitment comparison and fails inside proof verification — the priced side of the split. +fn verification_failure_calldata() -> Vec { + let commitment = hex::decode( + "8f59a8d2a1a625a17f3fea0fe5eb8c896db3764f3185481bc22f91b4aaffcca2\ + 5f26936857bc3a7c2539ea8ec3a952b7", + ) + .unwrap(); + let mut versioned_hash = Sha256::digest(&commitment).to_vec(); + versioned_hash[0] = 0x01; // VERSIONED_HASH_VERSION_KZG + let z = + hex::decode("73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000000").unwrap(); + let y = + hex::decode("1522a4a7f34e1ea350ae07c29c96c7e79655aa926122e95fe69fcbd932ca49e9").unwrap(); + let proof = hex::decode( + "a62ad71d14c5719385c0686f1871430475bf3a00f0aa3f7b8dd99a9abc216074\ + 4faf0070725e00b60ad9a026a15b1a8c", + ) + .unwrap(); + + let mut input = Vec::new(); + input.extend_from_slice(&versioned_hash); + input.extend_from_slice(&z); + input.extend_from_slice(&y); + input.extend_from_slice(&commitment); + input.extend_from_slice(&proof); + assert_eq!(input.len(), 192, "the priced probe must clear the 192-byte doorway"); + let last = input.len() - 1; + input[last] ^= 0x01; + input +} + +/// A CALL forwarding [`FORWARDED`] gas to `target`, with `calldata` laid out at `mem[0..]`. The +/// success flag is popped so the caller survives, and `tail_pairs` plain pairs run afterwards. +fn call_then_work(target: Address, calldata: &[u8], tail_pairs: usize) -> Bytes { + let mut builder = BytecodeBuilder::default() + .mstore(0, calldata) + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(calldata.len() as u64) // argsSize + .push_number(0u64) // argsOffset + .push_number(0u64) // value + .push_address(target) + .push_number(FORWARDED) + .append(CALL) + .append(POP); + for _ in 0..tail_pairs { + builder = builder.push_number(1u64).append(POP); + } + builder.append(STOP).build() +} + +/// Runs `code`, optionally deploying `callee` at [`CALLEE`]. +fn run( + spec: MegaSpecId, + code: Bytes, + callee: Option, + limits: EvmTxRuntimeLimits, +) -> Outcome { + let mut db = MemoryDatabase::default() + .account_balance(CALLER, alloy_primitives::U256::from(10 * ONE_ETH)) + .account_code(CONTRACT, code) + .account_balance(CONTRACT, alloy_primitives::U256::from(ONE_ETH)); + if let Some(callee) = callee { + db = db.account_code(CALLEE, callee); + } + transact(spec, db, limits) +} + +fn default_limits(spec: MegaSpecId) -> EvmTxRuntimeLimits { + EvmTxRuntimeLimits::from_spec(spec) +} + +fn stop_code() -> Bytes { + BytecodeBuilder::default().append(STOP).build() +} + +fn invalid_code() -> Bytes { + BytecodeBuilder::default().append(INVALID).build() +} + +/// The `(reported, enforced, destroyed, gas_used)` deltas of a failing call over a baseline +/// that runs byte-identical caller code against a STOP callee. +/// +/// Sharing the calldata between case and baseline is what makes the deltas exact: the caller's +/// own MSTORE / memory-expansion / CALL cost cancels, leaving only what the failed call +/// contributed. +fn deltas( + spec: MegaSpecId, + target: Address, + callee: Option, + calldata: &[u8], + limits: EvmTxRuntimeLimits, + label: &str, +) -> (i64, i64, i64, i64) { + let base = run(spec, call_then_work(CALLEE, calldata, 0), Some(stop_code()), limits); + let case = run(spec, call_then_work(target, calldata, 0), callee, limits); + assert!(base.is_success(), "{label}: the baseline must succeed: {:?}", base.result); + assert!(case.is_success(), "{label}: the caller must absorb the failure: {:?}", case.result); + ( + case.compute_gas as i64 - base.compute_gas as i64, + case.enforced() as i64 - base.enforced() as i64, + case.destroyed as i64 - base.destroyed as i64, + case.gas_used as i64 - base.gas_used as i64, + ) +} + +/// REX7 splits a precompile halt by actual work. +/// +/// Four shapes, each measured against a baseline running the identical caller code against a +/// STOP callee: +/// +/// - KZG fed a 192-byte input that fails proof verification — the call got past the length doorway, +/// so the fixed fee is the work performed and enforces; the rest of the forwarded envelope is +/// destroyed. +/// - KZG fed a 32-byte input — turned away at the length doorway before the commitment is read, so +/// nothing was performed and the whole envelope is destroyed. +/// - blake2f fed the same 32-byte input — the generic error arm, which behaves the same way. +/// - an interpreter frame that `INVALID`s in the same position — the control, which destroys the +/// whole envelope too. +#[test] +fn test_precompile_halt_splits_executed_work_from_the_destroyed_envelope() { + let spec = MegaSpecId::REX7; + let limits = default_limits(spec); + let malformed = malformed_calldata(); + + let (kzg_dc, kzg_de, kzg_dd, kzg_dg) = + deltas(spec, KZG, None, &verification_failure_calldata(), limits, "kzg verification"); + let (door_dc, door_de, door_dd, door_dg) = + deltas(spec, KZG, None, &malformed, limits, "kzg doorway"); + let (blake_dc, blake_de, blake_dd, blake_dg) = + deltas(spec, BLAKE2F, None, &malformed, limits, "blake2f"); + let (interp_dc, interp_de, interp_dd, interp_dg) = + deltas(spec, CALLEE, Some(invalid_code()), &malformed, limits, "interpreter"); + + for (label, dg) in [ + ("kzg verification", kzg_dg), + ("kzg doorway", door_dg), + ("blake2f", blake_dg), + ("interpreter", interp_dg), + ] { + assert!( + dg >= FORWARDED as i64, + "{label}: the forwarded envelope must actually be lost; Δgas_used={dg}", + ); + } + + assert_eq!( + interp_dd, FORWARDED as i64, + "the control frame destroys exactly the forwarded envelope", + ); + assert_eq!(interp_de, 0, "and none of the control frame's envelope is enforced"); + assert_eq!( + interp_dc, FORWARDED as i64, + "the control's reported total is the destroyed envelope on top of the caller", + ); + + assert_eq!( + kzg_de, + kzg_point_evaluation::GAS_COST as i64, + "a KZG failure raised inside verification enforces the fixed fee, not the forwarded \ + envelope", + ); + assert_eq!( + kzg_dd, + (FORWARDED - kzg_point_evaluation::GAS_COST) as i64, + "the rest of the forwarded envelope is destroyed, not enforced", + ); + assert_eq!(kzg_dc, FORWARDED as i64, "the reported total covers the whole forwarded envelope",); + + assert_eq!( + door_de, 0, + "a KZG input rejected on length performed no work, so nothing enforces — the fixed fee \ + must not be charged for a call that never read the commitment", + ); + assert_eq!(door_dd, FORWARDED as i64, "the whole envelope is destroyed on a doorway reject"); + assert_eq!(door_dc, FORWARDED as i64, "the reported total still covers the envelope"); + + assert_eq!(blake_de, 0, "a generic precompile error performed no work, so nothing enforces"); + assert_eq!( + blake_dd, FORWARDED as i64, + "the whole forwarded envelope is destroyed on the generic error arm", + ); + assert_eq!( + blake_dc, FORWARDED as i64, + "the reported total still covers the forwarded envelope" + ); +} + +/// Through REX6 the same shapes stay on the historical single-lane recording: every KZG failure +/// past the wrapper's gas gate charges the fixed fee as enforcing usage — doorway rejects +/// included, which is where REX7 now differs — the generic error charges the (capped) limit as +/// enforcing usage, and nothing is booked as destroyed. +#[test] +fn test_rex6_precompile_halt_accounting_is_unchanged() { + let spec = MegaSpecId::REX6; + let limits = default_limits(spec); + let malformed = malformed_calldata(); + + let kzg_verification = + deltas(spec, KZG, None, &verification_failure_calldata(), limits, "kzg verification"); + let kzg_doorway = deltas(spec, KZG, None, &malformed, limits, "kzg doorway"); + let blake = deltas(spec, BLAKE2F, None, &malformed, limits, "blake2f"); + let interpreter = deltas(spec, CALLEE, Some(invalid_code()), &malformed, limits, "interpreter"); + + for (label, (dc, de, dd, _)) in [ + ("kzg verification", kzg_verification), + ("kzg doorway", kzg_doorway), + ("blake2f", blake), + ("interpreter", interpreter), + ] { + assert_eq!(dd, 0, "{label}: REX6 has no destroyed lane"); + assert_eq!(de, dc, "{label}: REX6 charges are entirely enforcing"); + } + + assert_eq!( + kzg_verification.0, + kzg_point_evaluation::GAS_COST as i64, + "REX6 KZG still records only the fixed fee", + ); + assert_eq!( + kzg_doorway.0, + kzg_point_evaluation::GAS_COST as i64, + "REX6 charges the fixed fee for a doorway reject too", + ); + assert_eq!(blake.0, FORWARDED as i64, "REX6 generic error still records the whole envelope"); + assert_eq!( + interpreter.0, 0, + "REX6 attributes neither the failing opcode nor the destroyed remainder", + ); +} + +/// The generic-arm destroyed remainder is not enforcing, so work after the failing CALL can +/// still run under the same compute limit that REX6 spends entirely on the envelope. REX6 +/// keeps starving the tail — that single-lane charge is frozen. +#[test] +fn test_generic_precompile_halt_does_not_starve_the_tail() { + const TAIL_PAIRS: usize = 2_000; + + let malformed = malformed_calldata(); + let base = run( + MegaSpecId::REX7, + call_then_work(CALLEE, &malformed, TAIL_PAIRS), + Some(stop_code()), + default_limits(MegaSpecId::REX7), + ); + assert!(base.is_success(), "the baseline shape must fit: {:?}", base.result); + + let limit = base.compute_gas + 5_000; + let limits7 = EvmTxRuntimeLimits::from_spec(MegaSpecId::REX7).with_tx_compute_gas_limit(limit); + let limits6 = EvmTxRuntimeLimits::from_spec(MegaSpecId::REX6).with_tx_compute_gas_limit(limit); + + let blake7 = + run(MegaSpecId::REX7, call_then_work(BLAKE2F, &malformed, TAIL_PAIRS), None, limits7); + let interp7 = run( + MegaSpecId::REX7, + call_then_work(CALLEE, &malformed, TAIL_PAIRS), + Some(invalid_code()), + limits7, + ); + let blake6 = + run(MegaSpecId::REX6, call_then_work(BLAKE2F, &malformed, TAIL_PAIRS), None, limits6); + let interp6 = run( + MegaSpecId::REX6, + call_then_work(CALLEE, &malformed, TAIL_PAIRS), + Some(invalid_code()), + limits6, + ); + + assert!( + blake7.is_success(), + "REX7 does not enforce the generic-arm envelope, so the tail must run: {:?}", + blake7.result, + ); + assert!( + interp7.is_success(), + "the interpreter control's destroyed remainder is not enforcing either: {:?}", + interp7.result, + ); + assert!( + !blake6.is_success(), + "REX6 still enforces the generic-arm envelope, so the same tail must starve: {:?}", + blake6.result, + ); + assert!( + interp6.is_success(), + "REX6 still attributes nothing to an interpreter halt, so that tail survives: {:?}", + interp6.result, + ); +} + +/// When the REX5 forwarded-gas cap binds (`effective < gas_limit`), the parent still burns +/// the caller-supplied envelope. The gap is part of the forwarded envelope, not work, so it lands +/// in the destroyed remainder rather than disappearing from both lanes. +#[test] +fn test_destroyed_remainder_includes_the_forwarded_cap_gap() { + let malformed = malformed_calldata(); + let unconstrained = run( + MegaSpecId::REX7, + call_then_work(CALLEE, &malformed, 0), + Some(stop_code()), + default_limits(MegaSpecId::REX7), + ); + assert!(unconstrained.is_success(), "calibration run must succeed: {:?}", unconstrained.result); + + // Comfortable room for the caller and the CALL body, nowhere near the forwarded envelope. + // The cap therefore binds: effective remaining is a few tens of thousands, forwarded is 1M. + let limit = unconstrained.compute_gas + 50_000; + let limits = EvmTxRuntimeLimits::from_spec(MegaSpecId::REX7).with_tx_compute_gas_limit(limit); + + let blake = run(MegaSpecId::REX7, call_then_work(BLAKE2F, &malformed, 0), None, limits); + assert!(blake.is_success(), "the caller must absorb the generic error: {:?}", blake.result); + assert_eq!( + blake.enforced() - unconstrained.enforced(), + 0, + "the generic arm still performed no work under the cap", + ); + assert_eq!( + blake.destroyed, FORWARDED, + "destroyed is the caller-supplied envelope, including the cap gap; \ + recording the effective limit instead would report only the leftover headroom", + ); +} + +/// Unconstrained default-limit readings, so a regression in the caller's own cost is visible +/// next to the split numbers rather than only inside a delta. Both KZG sides are read here: +/// a failure inside verification leaves the fixed fee out of the destroyed lane, a doorway +/// reject leaves nothing out of it. +#[test] +fn test_kzg_halt_default_limits_report_the_full_parent_loss() { + let run_default = |calldata: &[u8]| { + let db = MemoryDatabase::default() + .account_balance(CALLER, alloy_primitives::U256::from(10 * ONE_ETH)) + .account_code(CONTRACT, call_then_work(KZG, calldata, 0)) + .account_balance(CONTRACT, alloy_primitives::U256::from(ONE_ETH)); + let r = transact_default(MegaSpecId::REX7, db); + assert!(r.is_success(), "the caller must absorb the KZG failure: {:?}", r.result); + r + }; + + assert_eq!( + run_default(&verification_failure_calldata()).destroyed, + FORWARDED - kzg_point_evaluation::GAS_COST, + "default limits still leave the unused envelope in the destroyed lane", + ); + assert_eq!( + run_default(&malformed_calldata()).destroyed, + FORWARDED, + "a doorway reject leaves the whole envelope in the destroyed lane", + ); +} diff --git a/crates/mega-evm/tests/rex7/result_space_tripwire.rs b/crates/mega-evm/tests/rex7/result_space_tripwire.rs new file mode 100644 index 00000000..f7b6f7fd --- /dev/null +++ b/crates/mega-evm/tests/rex7/result_space_tripwire.rs @@ -0,0 +1,369 @@ +//! Closed classification of every [`InstructionResult`] variant for the destroyed-remainder +//! protocol, and the early-fail arm list a revm bump has to diff by hand. +//! +//! revm's `InstructionResult` is a closed enumeration of envelope endings, but until this file the +//! destroyed-remainder protocol classified them through `is_ok_or_revert()` — a catch-all on the +//! halt side. A variant revm added later would be swallowed without anyone assigning it. The +//! `CreateCollision` booking was that gap: the halt class happened to be right, and nothing forced +//! a human to say so. +//! +//! [`destroyed_disposition`] is the assignment table: every variant has an arm, and there is no +//! `_`. Commenting one out, or a revm bump that adds a variant, fails to compile. This file is the +//! readable copy of that table, plus the second closed set that *is* a catch-all: the early-fail +//! arms of `make_call_frame` / `make_create_frame` / `classify_create_return`, which are not an +//! enum and have to be read by hand on an upgrade. +//! +//! Complementary to the EEST corpus sweep, which collides with whatever the fixtures reach. This +//! file is the enumeration seal. + +use mega_evm::{destroyed_disposition, DestroyedDisposition}; +use revm::interpreter::InstructionResult; + +/// One row of the destroyed-remainder assignment table. +struct VariantRow { + result: InstructionResult, + disposition: DestroyedDisposition, +} + +/// Every [`InstructionResult`] variant, with the disposition [`destroyed_disposition`] assigns. +/// +/// A new variant is a new row here *and* a new arm in [`destroyed_disposition`]. Omitting the arm +/// is a compile error; omitting the row is what +/// [`test_every_instruction_result_has_an_explicit_destroyed_disposition`] catches. +const VARIANTS: &[VariantRow] = &[ + // Return: remaining gas is erased back into the caller. + VariantRow { result: InstructionResult::Stop, disposition: DestroyedDisposition::Return }, + VariantRow { result: InstructionResult::Return, disposition: DestroyedDisposition::Return }, + VariantRow { + result: InstructionResult::SelfDestruct, + disposition: DestroyedDisposition::Return, + }, + VariantRow { result: InstructionResult::Revert, disposition: DestroyedDisposition::Return }, + VariantRow { + result: InstructionResult::CallTooDeep, + disposition: DestroyedDisposition::Return, + }, + VariantRow { result: InstructionResult::OutOfFunds, disposition: DestroyedDisposition::Return }, + VariantRow { + result: InstructionResult::CreateInitCodeStartingEF00, + disposition: DestroyedDisposition::Return, + }, + VariantRow { + result: InstructionResult::InvalidEOFInitCode, + disposition: DestroyedDisposition::Return, + }, + VariantRow { + result: InstructionResult::InvalidExtDelegateCallTarget, + disposition: DestroyedDisposition::Return, + }, + // Unreachable: never a frame result. + VariantRow { + result: InstructionResult::Suspend, + disposition: DestroyedDisposition::Unreachable, + }, + // Swallow: remaining gas is never handed back. + VariantRow { result: InstructionResult::OutOfGas, disposition: DestroyedDisposition::Swallow }, + VariantRow { result: InstructionResult::MemoryOOG, disposition: DestroyedDisposition::Swallow }, + VariantRow { + result: InstructionResult::MemoryLimitOOG, + disposition: DestroyedDisposition::Swallow, + }, + VariantRow { + result: InstructionResult::PrecompileOOG, + disposition: DestroyedDisposition::Swallow, + }, + VariantRow { + result: InstructionResult::InvalidOperandOOG, + disposition: DestroyedDisposition::Swallow, + }, + VariantRow { + result: InstructionResult::ReentrancySentryOOG, + disposition: DestroyedDisposition::Swallow, + }, + VariantRow { + result: InstructionResult::OpcodeNotFound, + disposition: DestroyedDisposition::Swallow, + }, + VariantRow { + result: InstructionResult::CallNotAllowedInsideStatic, + disposition: DestroyedDisposition::Swallow, + }, + VariantRow { + result: InstructionResult::StateChangeDuringStaticCall, + disposition: DestroyedDisposition::Swallow, + }, + VariantRow { + result: InstructionResult::InvalidFEOpcode, + disposition: DestroyedDisposition::Swallow, + }, + VariantRow { + result: InstructionResult::InvalidJump, + disposition: DestroyedDisposition::Swallow, + }, + VariantRow { + result: InstructionResult::NotActivated, + disposition: DestroyedDisposition::Swallow, + }, + VariantRow { + result: InstructionResult::StackUnderflow, + disposition: DestroyedDisposition::Swallow, + }, + VariantRow { + result: InstructionResult::StackOverflow, + disposition: DestroyedDisposition::Swallow, + }, + VariantRow { + result: InstructionResult::OutOfOffset, + disposition: DestroyedDisposition::Swallow, + }, + VariantRow { + result: InstructionResult::CreateCollision, + disposition: DestroyedDisposition::Swallow, + }, + VariantRow { + result: InstructionResult::OverflowPayment, + disposition: DestroyedDisposition::Swallow, + }, + VariantRow { + result: InstructionResult::PrecompileError, + disposition: DestroyedDisposition::Swallow, + }, + VariantRow { + result: InstructionResult::NonceOverflow, + disposition: DestroyedDisposition::Swallow, + }, + VariantRow { + result: InstructionResult::CreateContractSizeLimit, + disposition: DestroyedDisposition::Swallow, + }, + VariantRow { + result: InstructionResult::CreateContractStartingWithEF, + disposition: DestroyedDisposition::Swallow, + }, + VariantRow { + result: InstructionResult::CreateInitCodeSizeLimit, + disposition: DestroyedDisposition::Swallow, + }, + VariantRow { + result: InstructionResult::FatalExternalError, + disposition: DestroyedDisposition::Swallow, + }, + VariantRow { + result: InstructionResult::InvalidImmediateEncoding, + disposition: DestroyedDisposition::Swallow, + }, +]; + +/// The early-fail arms of revm's frame-init / create-return as of revm-handler 20.0.3. +/// +/// This list is not type-tied to upstream. A revm bump that adds an arm does not fail to compile. +/// Diff `EthFrame::make_call_frame`, `EthFrame::make_create_frame`, and `return_create` / +/// `classify_create_return` against it, then add the row and assign the produced +/// [`InstructionResult`] in [`destroyed_disposition`]. +/// +/// The nonce-overflow arm is the live mismatch the `CreateCollision` gap was a sibling of: the +/// arm returns `Return`, not `NonceOverflow`. The variant is still classified (swallow) so a +/// future arm that starts producing it has a defined booking. +struct EarlyFailArm { + /// Upstream function and the condition that returns without a child body. + site: &'static str, + result: InstructionResult, +} + +const EARLY_FAIL_ARMS: &[EarlyFailArm] = &[ + EarlyFailArm { + site: "make_call_frame: depth > CALL_STACK_LIMIT", + result: InstructionResult::CallTooDeep, + }, + EarlyFailArm { + site: "make_call_frame: transfer_loaded → TransferError::OutOfFunds", + result: InstructionResult::OutOfFunds, + }, + EarlyFailArm { + site: "make_call_frame: transfer_loaded → TransferError::OverflowPayment", + result: InstructionResult::OverflowPayment, + }, + EarlyFailArm { + site: "make_call_frame: transfer_loaded → TransferError::CreateCollision", + result: InstructionResult::CreateCollision, + }, + EarlyFailArm { site: "make_call_frame: empty bytecode", result: InstructionResult::Stop }, + EarlyFailArm { + site: "make_create_frame: depth > CALL_STACK_LIMIT", + result: InstructionResult::CallTooDeep, + }, + EarlyFailArm { + site: "make_create_frame: caller balance < value", + result: InstructionResult::OutOfFunds, + }, + EarlyFailArm { + site: "make_create_frame: nonce bump fails (NOT NonceOverflow)", + result: InstructionResult::Return, + }, + EarlyFailArm { + site: "make_create_frame: create_account_checkpoint → TransferError::CreateCollision", + result: InstructionResult::CreateCollision, + }, + EarlyFailArm { + site: "make_create_frame: create_account_checkpoint → TransferError::OverflowPayment", + result: InstructionResult::OverflowPayment, + }, + EarlyFailArm { + site: "make_create_frame: create_account_checkpoint → TransferError::OutOfFunds", + result: InstructionResult::OutOfFunds, + }, + EarlyFailArm { + site: "classify_create_return: runtime code size", + result: InstructionResult::CreateContractSizeLimit, + }, + EarlyFailArm { + site: "classify_create_return: 0xEF prefix", + result: InstructionResult::CreateContractStartingWithEF, + }, + EarlyFailArm { + site: "classify_create_return: code-deposit charge", + result: InstructionResult::OutOfGas, + }, +]; + +/// Naming every variant, with no `_`, is the compile-time tripwire in this file. +/// +/// Commenting one out is a non-exhaustive match. A revm bump that adds a variant is the same +/// error, and the next step is an arm in [`destroyed_disposition`] plus a row in [`VARIANTS`]. +#[test] +fn test_instruction_result_space_has_no_catchall() { + match InstructionResult::Stop { + InstructionResult::Stop | + InstructionResult::Return | + InstructionResult::SelfDestruct | + InstructionResult::Suspend | + InstructionResult::Revert | + InstructionResult::CallTooDeep | + InstructionResult::OutOfFunds | + InstructionResult::CreateInitCodeStartingEF00 | + InstructionResult::InvalidEOFInitCode | + InstructionResult::InvalidExtDelegateCallTarget | + InstructionResult::OutOfGas | + InstructionResult::MemoryOOG | + InstructionResult::MemoryLimitOOG | + InstructionResult::PrecompileOOG | + InstructionResult::InvalidOperandOOG | + InstructionResult::ReentrancySentryOOG | + InstructionResult::OpcodeNotFound | + InstructionResult::CallNotAllowedInsideStatic | + InstructionResult::StateChangeDuringStaticCall | + InstructionResult::InvalidFEOpcode | + InstructionResult::InvalidJump | + InstructionResult::NotActivated | + InstructionResult::StackUnderflow | + InstructionResult::StackOverflow | + InstructionResult::OutOfOffset | + InstructionResult::CreateCollision | + InstructionResult::OverflowPayment | + InstructionResult::PrecompileError | + InstructionResult::NonceOverflow | + InstructionResult::CreateContractSizeLimit | + InstructionResult::CreateContractStartingWithEF | + InstructionResult::CreateInitCodeSizeLimit | + InstructionResult::FatalExternalError | + InstructionResult::InvalidImmediateEncoding => {} + } +} + +/// Every variant has a row, and the row matches [`destroyed_disposition`]. +/// +/// Commenting out a match arm in `destroyed_disposition` fails this crate at compile time — that +/// is the tripwire. This test is the readable copy: a missing row, or a row that disagrees with +/// the match, is a runtime failure naming the variant. +#[test] +fn test_every_instruction_result_has_an_explicit_destroyed_disposition() { + for row in VARIANTS { + assert_eq!( + destroyed_disposition(row.result), + row.disposition, + "{:?}: the tripwire table and destroyed_disposition must assign the same class", + row.result, + ); + } +} + +/// Return / Swallow follow revm's ok-or-revert / halt macros; Unreachable is only `Suspend`. +/// +/// The protocol owns the assignment, so this is a snapshot of today's agreement rather than a +/// requirement that they stay coupled. A variant reclassified away from the macros is a deliberate +/// row change here. +#[test] +fn test_return_and_swallow_agree_with_revm_ok_or_revert() { + for row in VARIANTS { + match row.disposition { + DestroyedDisposition::Return => { + assert!( + row.result.is_ok_or_revert(), + "{:?} is Return, so is_ok_or_revert must hold", + row.result, + ); + assert!(!row.result.is_halt(), "{:?} is Return, so it is not a halt", row.result); + } + DestroyedDisposition::Swallow => { + assert!(row.result.is_halt(), "{:?} is Swallow, so it must be a halt", row.result,); + assert!( + !row.result.is_ok_or_revert(), + "{:?} is Swallow, so is_ok_or_revert must not hold", + row.result, + ); + } + DestroyedDisposition::Unreachable => { + assert_eq!( + row.result, + InstructionResult::Suspend, + "the only unreachable variant is Suspend (internal interpreter state); \ + got {:?}", + row.result, + ); + } + } + } +} + +/// Each documented early-fail arm produces a variant the disposition table already classifies. +/// +/// Adding an arm in revm without a row here is what the upgrade checklist is for; this test only +/// pins that the arms we already know about have a defined booking. +#[test] +fn test_every_documented_early_fail_arm_has_a_classified_result() { + for arm in EARLY_FAIL_ARMS { + let class = destroyed_disposition(arm.result); + assert!( + VARIANTS.iter().any(|row| row.result == arm.result && row.disposition == class), + "{} produces {:?}, which must have a tripwire row", + arm.site, + arm.result, + ); + assert_ne!( + class, + DestroyedDisposition::Unreachable, + "{} produces {:?}, which cannot be classified unreachable: it is a live frame result", + arm.site, + arm.result, + ); + } +} + +/// The CREATE nonce-overflow arm returns `Return`, not `NonceOverflow`. +/// +/// That is the shape the `CreateCollision` gap was a sibling of: the variant exists, the live arm +/// produces a different one, and both must stay classified. +#[test] +fn test_create_nonce_overflow_arm_returns_return_not_nonce_overflow() { + let arm = EARLY_FAIL_ARMS + .iter() + .find(|arm| arm.site.contains("nonce bump fails")) + .expect("the nonce-overflow arm is part of the early-fail list"); + assert_eq!(arm.result, InstructionResult::Return); + assert_eq!(destroyed_disposition(InstructionResult::Return), DestroyedDisposition::Return); + assert_eq!( + destroyed_disposition(InstructionResult::NonceOverflow), + DestroyedDisposition::Swallow, + "NonceOverflow is a halt; if an arm starts producing it, the booking is swallow", + ); +} diff --git a/crates/mega-evm/tests/rex7/shim_blind_spots.rs b/crates/mega-evm/tests/rex7/shim_blind_spots.rs new file mode 100644 index 00000000..e9176c7a --- /dev/null +++ b/crates/mega-evm/tests/rex7/shim_blind_spots.rs @@ -0,0 +1,922 @@ +//! The rewrite shapes an all-zero ledger used to admit. +//! +//! The measurement shim's contract is that a transaction an inspector rewrote never reaches a +//! block: the canonical path refuses one whose `InspectorLedger` is non-zero, so every rewrite has +//! to leave a mark on it. `shim_lanes.rs`, `shim_settlement.rs` and `inspector_cheat_matrix.rs` +//! pin that per mechanism and per callback × shape pair. This module pins the shapes that slipped +//! *between* those two questions — each one a rewrite the shim was handed, that changes what the +//! transaction produces, and that every lane read as nothing: +//! +//! - a frame's memory grown for free, by moving the interpreter's memory and the memo of how far it +//! has been paid for in the same step, so that neither goes out of bounds and the next expanding +//! opcode charges nothing; +//! - a `CallOutcome` / `CreateOutcome` metadata field — where the callee's return data lands, and +//! which address a creation reports — rewritten without touching the `InterpreterResult` inside +//! it, which is the only part the rewrite comparison used to read; +//! - two edits to the *same* signed lane in opposite directions, which a net-only reading cancels +//! to zero; +//! - the same cancellation spread across two frames, where only one of the two survives to the +//! receipt, so the net is zero and the effect is not; +//! - an instruction deleted from a frame, by stepping the program counter past it, so the work is +//! never performed and there is nothing for any counter to meter; +//! - a return buffer put in front of a frame that made no call, so `RETURNDATASIZE` reads a length +//! no call produced. +//! +//! Four of them are booked on `InspectorLedger::interventions`, from readings the shim did not use +//! to take; the cancelling pair are what the per-lane gross activity counters exist for. Every test +//! here asserts the ledger the shim books *and* the effect the rewrite had, because a shape that no +//! longer changes anything is a shape that stopped testing the guard. +//! +//! The last two are also why the snapshot the first shape needed is now a *rule* rather than a +//! list. A snapshot of four chosen readings caught the memory pair and let the program counter +//! through, because `Interpreter::bytecode` was not among the things anyone had thought to name. +//! What the shim takes now is every constant-time reading of the interpreter, and what pins that is +//! the `Interpreter` row of `gas_surface.rs`'s closed table. + +use crate::{ + common::{base_db, transact, transact_inspected, CALLEE, CONTRACT, EMPTY_TARGET}, + inspector_common::{plain_and_cheated, ACTION_DELTA, REFUND}, +}; +use alloy_primitives::{address, Address, Bytes, U256}; +use mega_evm::{test_utils::BytecodeBuilder, EvmTxRuntimeLimits, MegaSpecId}; +use revm::{ + bytecode::opcode::{ + CALL, CALLER, CREATE, GAS, MLOAD, MSTORE, MSTORE8, POP, RETURN, RETURNDATASIZE, SSTORE, + STOP, + }, + context::{Cfg, ContextTr}, + interpreter::{ + interpreter::EthInterpreter, + interpreter_types::{InputsTr, Jumps, LoopControl, MemoryTr, ReturnData}, + CallInputs, CallOutcome, CreateInputs, CreateOutcome, Interpreter, InterpreterAction, + InterpreterTypes, + }, + Inspector, +}; + +/// A second callee, whose frame reverts. +const REVERTER: Address = EMPTY_TARGET; + +/// The address a rewritten `CreateOutcome` reports instead of the one the code was deployed at. +const FAKE_DEPLOYMENT: Address = address!("00000000000000000000000000000000000f00d0"); + +/// Slot the fixtures write their observable result to. +const RESULT_SLOT: u64 = 0x11; + +/// The mainnet memory expansion cost of a memory `words` words long. +const fn memory_cost(words: u64) -> u64 { + 3 * words + words * words / 512 +} + +// --- a frame's memory, grown for free ------------------------------------------------------------ + +/// How far the free-expansion inspector grows the frame's memory, in words. +/// +/// The fixture's own `MSTORE` lands inside it, so the expansion the EVM would have charged for is +/// exactly the one the inspector already did for nothing. +const STOLEN_WORDS: u64 = 129; + +/// Grows the frame's memory and tells the EVM it is already paid for. +/// +/// Both halves are needed and neither is a rewrite on its own. Moving the memory alone leaves the +/// memo behind, and the next expanding opcode charges for an expansion that already happened; +/// moving the memo alone leaves the memory behind, and the EVM reads out of bounds. Moving both +/// keeps every invariant the interpreter has and skips the charge, which is why the pair was the +/// hole and neither half was. +#[derive(Default)] +struct FreeExpansion { + fired: u32, +} + +impl Inspector for FreeExpansion { + fn step(&mut self, interp: &mut Interpreter, context: &mut CTX) { + if self.fired > 0 || interp.bytecode.opcode() != MSTORE { + return; + } + let words = STOLEN_WORDS as usize; + assert!(interp.memory.resize(words * 32), "the fixture must allow the memory to be grown",); + // Priced through revm's own table, so the memo is exactly what the EVM would have written + // had the frame paid; the assertion below restates the formula independently, which is + // what makes the two a check rather than one number written twice. + let cost = context.cfg().gas_params().memory_cost(words); + interp.gas.memory_mut().set_words_num(words, cost); + self.fired += 1; + } +} + +/// ★ A frame whose memory was grown for free is not an all-zero ledger. +/// +/// The rewrite reaches through no argument the shim used to compare: the interpreter's gas counter +/// is untouched, no action is pending, no frame input and no frame result exists yet. What it +/// moves is the interpreter's memory and the memo beside it, and the transaction then pays less +/// than it would have — which is the one thing the guard exists to keep out of a block. +#[test] +fn test_a_frame_whose_memory_was_grown_for_free_is_booked() { + // MSTORE(offset = STOLEN_WORDS * 32 - 32, value = 0xAA), which expands memory to exactly the + // size the inspector already grew it to. + let offset = (STOLEN_WORDS - 1) * 32; + let code = BytecodeBuilder::default() + .push_number(0xAAu64) + .push_number(offset) + .append(MSTORE) + .append(STOP) + .build(); + + let mut inspector = FreeExpansion::default(); + let (plain, cheated) = plain_and_cheated(|| base_db(code.clone()), &mut inspector); + + assert_eq!(inspector.fired, 1, "the fixture must reach the expanding opcode exactly once"); + assert!(plain.is_success() && cheated.is_success(), "both runs must succeed"); + assert_eq!( + plain.total_gas_spent - cheated.total_gas_spent, + memory_cost(STOLEN_WORDS), + "the expansion the inspector performed is the charge the EVM then skipped", + ); + assert!( + !cheated.inspector_ledger.is_zero(), + "a transaction that paid less because an inspector moved its memory must not read as \ + untouched: {:?}", + cheated.inspector_ledger, + ); +} + +// --- a call outcome's metadata ------------------------------------------------------------------- + +/// Where the fixture's `CALL` asks for its return data, and where the inspector moves it to. +const RETURN_AT: usize = 0; +const MOVED_TO: usize = 32; + +/// Moves a finished call's return data somewhere else in the caller's memory. +/// +/// The `InterpreterResult` inside the outcome — its classification, its output bytes, its gas — +/// comes back exactly as the EVM produced it. Only the range the caller will copy the output into +/// changes, which is not a field the result carries. +#[derive(Default)] +struct MoveReturnData { + fired: u32, +} + +impl Inspector for MoveReturnData { + fn call_end(&mut self, _context: &mut CTX, inputs: &CallInputs, outcome: &mut CallOutcome) { + if inputs.target_address != CALLEE || self.fired > 0 { + return; + } + outcome.memory_offset = MOVED_TO..MOVED_TO + 32; + self.fired += 1; + } +} + +/// ★ A call outcome whose return range was moved is not an all-zero ledger. +#[test] +fn test_a_moved_return_range_is_booked() { + // Size the caller's memory to two words, call the callee for one word of output at offset 0, + // then store what landed there. + let code = BytecodeBuilder::default() + .push_number(0u64) + .push_number(32u64) + .append(MSTORE) + .push_number(32u64) // retSize + .push_number(u64::try_from(RETURN_AT).unwrap()) // retOffset + .push_number(0u64) // argsSize + .push_number(0u64) // argsOffset + .push_number(0u64) // value + .push_address(CALLEE) + .push_number(100_000u64) + .append(CALL) + .append(POP) + .push_number(u64::try_from(RETURN_AT).unwrap()) + .append(MLOAD) + .push_number(RESULT_SLOT) + .append(SSTORE) + .append(STOP) + .build(); + // The callee returns one word of 0x11s. + let callee = BytecodeBuilder::default() + .push_u256(U256::from(0x11u64)) + .push_number(0u64) + .append(MSTORE) + .push_number(32u64) + .push_number(0u64) + .append(RETURN) + .build(); + let db = || base_db(code.clone()).account_code(CALLEE, callee.clone()); + + let mut inspector = MoveReturnData::default(); + let (plain, cheated) = plain_and_cheated(db, &mut inspector); + + assert_eq!(inspector.fired, 1, "the fixture must reach `call_end` for the callee once"); + assert_eq!( + plain.storage_value(CONTRACT, U256::from(RESULT_SLOT)), + U256::from(0x11u64), + "without the rewrite the return data lands where the caller asked for it", + ); + assert_eq!( + cheated.storage_value(CONTRACT, U256::from(RESULT_SLOT)), + U256::ZERO, + "with it, the caller reads a word the callee never wrote", + ); + assert!( + !cheated.inspector_ledger.is_zero(), + "a transaction whose state a rewritten return range changed must not read as untouched: \ + {:?}", + cheated.inspector_ledger, + ); +} + +// --- a frame's returned output ------------------------------------------------------------------ + +/// The word a rewritten output buffer feeds the caller instead of the one the callee returned. +const FORGED_OUTPUT: u64 = 0xdead; + +/// Replaces the output buffer a finished call hands back, leaving its classification alone. +/// +/// The classification and the remaining gas are what every other lane reads. The output is +/// neither, and it is what the caller copies into its own memory. +#[derive(Default)] +struct ForgeCallOutput { + fired: u32, +} + +impl Inspector for ForgeCallOutput { + fn call_end(&mut self, _context: &mut CTX, inputs: &CallInputs, outcome: &mut CallOutcome) { + if inputs.target_address != CALLEE || self.fired > 0 { + return; + } + outcome.result.output = Bytes::from(U256::from(FORGED_OUTPUT).to_be_bytes::<32>().to_vec()); + self.fired += 1; + } +} + +/// ★ A call outcome whose returned output was replaced is not an all-zero ledger. +#[test] +fn test_a_forged_call_output_is_booked() { + // Call the callee for one word of output, then store what landed there. + let code = BytecodeBuilder::default() + .push_number(32u64) // retSize + .push_number(0u64) // retOffset + .push_number(0u64) // argsSize + .push_number(0u64) // argsOffset + .push_number(0u64) // value + .push_address(CALLEE) + .push_number(100_000u64) + .append(CALL) + .append(POP) + .push_number(0u64) + .append(MLOAD) + .push_number(RESULT_SLOT) + .append(SSTORE) + .append(STOP) + .build(); + // The callee returns one word of 0x11s. + let callee = BytecodeBuilder::default() + .push_u256(U256::from(0x11u64)) + .push_number(0u64) + .append(MSTORE) + .push_number(32u64) + .push_number(0u64) + .append(RETURN) + .build(); + let db = || base_db(code.clone()).account_code(CALLEE, callee.clone()); + + let mut inspector = ForgeCallOutput::default(); + let (plain, cheated) = plain_and_cheated(db, &mut inspector); + + assert_eq!(inspector.fired, 1, "the fixture must reach `call_end` for the callee once"); + assert_eq!( + plain.storage_value(CONTRACT, U256::from(RESULT_SLOT)), + U256::from(0x11u64), + "without the rewrite the caller reads what the callee returned", + ); + assert_eq!( + cheated.storage_value(CONTRACT, U256::from(RESULT_SLOT)), + U256::from(FORGED_OUTPUT), + "with it, the caller reads a word no frame produced", + ); + assert!( + !cheated.inspector_ledger.is_zero(), + "a transaction whose state a replaced output buffer changed must not read as untouched: \ + {:?}", + cheated.inspector_ledger, + ); +} + +/// Reports a different address than the one the creation deployed to. +#[derive(Default)] +struct MoveDeploymentAddress { + fired: u32, +} + +impl Inspector for MoveDeploymentAddress { + fn create_end( + &mut self, + _context: &mut CTX, + _inputs: &CreateInputs, + outcome: &mut CreateOutcome, + ) { + if self.fired > 0 || outcome.address.is_none() { + return; + } + outcome.address = Some(FAKE_DEPLOYMENT); + self.fired += 1; + } +} + +/// ★ A creation outcome whose reported address was rewritten is not an all-zero ledger. +/// +/// The code is still deployed where the EVM put it; only the address the caller's stack receives +/// changes, so the caller goes on to talk to an account that holds nothing. +#[test] +fn test_a_rewritten_deployment_address_is_booked() { + // Init code that returns two bytes of runtime code. + let init: [u8; 11] = [0x60, 0x00, 0x60, 0x00, 0x52, 0x60, 0x02, 0x60, 0x1e, 0xf3, 0x00]; + let mut builder = BytecodeBuilder::default(); + for (offset, byte) in init.iter().enumerate() { + builder = builder.push_number(u64::from(*byte)).push_number(offset as u64).append(MSTORE8); + } + let code = builder + .push_number(init.len() as u64) + .push_number(0u64) + .push_number(0u64) + .append(CREATE) + .push_number(RESULT_SLOT) + .append(SSTORE) + .append(STOP) + .build(); + + let mut inspector = MoveDeploymentAddress::default(); + let (plain, cheated) = plain_and_cheated(|| base_db(code.clone()), &mut inspector); + + assert_eq!(inspector.fired, 1, "the fixture must reach `create_end` once"); + let deployed = plain.storage_value(CONTRACT, U256::from(RESULT_SLOT)); + assert_ne!(deployed, U256::ZERO, "the fixture's CREATE must succeed"); + assert_eq!( + cheated.storage_value(CONTRACT, U256::from(RESULT_SLOT)), + U256::from_be_slice(FAKE_DEPLOYMENT.as_slice()), + "the caller must have been handed the address the inspector wrote", + ); + assert!( + !cheated.inspector_ledger.is_zero(), + "a transaction told a contract lives somewhere it does not must not read as untouched: \ + {:?}", + cheated.inspector_ledger, + ); +} + +// --- a construction frame's pending action ------------------------------------------------------- + +/// Drains the gas a construction frame's pending `Return` action carries. +/// +/// The contract this module's other cases rest on — that an action is the frame's result a moment +/// later, so an edit to it settles with that result — does not hold for a creation. Between the +/// two, `classify_frame_action` charges the code deposit out of the gas *this action* carries, and +/// a creation that cannot pay it becomes an `OutOfGas` that deploys nothing. So this edit changes +/// what the transaction produces, and it does it by a route that leaves the classification and the +/// output the boundary compares exactly where they were. +#[derive(Default)] +struct DrainConstructionAction { + fired: u32, +} + +impl Inspector for DrainConstructionAction { + fn step_end(&mut self, interp: &mut Interpreter, _context: &mut CTX) { + // A construction frame runs no deployed code, so it has no bytecode address. + if self.fired > 0 || interp.input.bytecode_address().is_some() { + return; + } + let Some(InterpreterAction::Return(result)) = interp.bytecode.action() else { + return; + }; + if !result.result.is_ok() { + return; + } + let remaining = result.gas.remaining(); + assert!( + result.gas.record_regular_cost(remaining), + "the fixture must be able to drain the action it found", + ); + self.fired += 1; + } +} + +/// ★ A construction frame whose pending action was drained is not an all-zero ledger. +/// +/// Every lane the boundary reads stays put: the action's classification and output are untouched, +/// so nothing is an intervention; the gas edit is staged for the frame's settlement point, and +/// that point declines to book it because the result it finally sees is a swallowed one. The +/// deposit the drained action could no longer pay is what turned it into one. +#[test] +fn test_a_drained_construction_action_is_booked() { + // Init code that returns two bytes of runtime code. + let init: [u8; 11] = [0x60, 0x00, 0x60, 0x00, 0x52, 0x60, 0x02, 0x60, 0x1e, 0xf3, 0x00]; + let mut builder = BytecodeBuilder::default(); + for (offset, byte) in init.iter().enumerate() { + builder = builder.push_number(u64::from(*byte)).push_number(offset as u64).append(MSTORE8); + } + let code = builder + .push_number(init.len() as u64) + .push_number(0u64) + .push_number(0u64) + .append(CREATE) + .push_number(RESULT_SLOT) + .append(SSTORE) + .append(STOP) + .build(); + + let mut inspector = DrainConstructionAction::default(); + let (plain, cheated) = plain_and_cheated(|| base_db(code.clone()), &mut inspector); + + assert_eq!(inspector.fired, 1, "the fixture must reach the construction frame's step_end once"); + assert_ne!( + plain.storage_value(CONTRACT, U256::from(RESULT_SLOT)), + U256::ZERO, + "without the edit the creation must succeed", + ); + assert_eq!( + cheated.storage_value(CONTRACT, U256::from(RESULT_SLOT)), + U256::ZERO, + "with it the creation cannot pay its code deposit and deploys nothing", + ); + assert_ne!( + plain.gas_used, cheated.gas_used, + "and the receipt the sender is billed on moves with it", + ); + assert!( + !cheated.inspector_ledger.is_zero(), + "a transaction whose contract an inspector deleted must not read as untouched: {:?}", + cheated.inspector_ledger, + ); +} + +/// Raises the gas an inner call frame's pending `Return` action carries, then takes the same +/// amount back out of the result that action became. +/// +/// The two windows are one lane and one frame, and the pair nets to zero. They are still two +/// edits, made in two different callbacks, and the lane's traffic is what says so — the sum alone +/// reads as an inspector that did nothing. +#[derive(Default)] +struct CancellingActionAndResultEdits { + raised: u32, + lowered: u32, +} + +impl Inspector for CancellingActionAndResultEdits { + fn step_end(&mut self, interp: &mut Interpreter, _context: &mut CTX) { + if self.raised > 0 || interp.input.bytecode_address() != Some(&CALLEE) { + return; + } + let Some(InterpreterAction::Return(result)) = interp.bytecode.action() else { + return; + }; + if !result.result.is_ok() { + return; + } + result.gas.erase_cost(ACTION_DELTA); + self.raised += 1; + } + + fn call_end(&mut self, _context: &mut CTX, inputs: &CallInputs, outcome: &mut CallOutcome) { + if self.lowered > 0 || inputs.target_address != CALLEE { + return; + } + assert!( + outcome.result.gas.record_regular_cost(ACTION_DELTA), + "the fixture must leave the result enough gas for the removal to land", + ); + self.lowered += 1; + } +} + +/// ★ An edit staged at one callback and undone at the next is two edits, not none. +/// +/// Nothing about this transaction changes: a call frame's remaining gas is read by nobody between +/// the two windows, so the pair really is invisible in what the transaction produces. That is the +/// point — the lane's traffic is the only thing that separates it from an inspector that never +/// ran, and on a *creation* frame the same pair is the shape that deletes a contract. +#[test] +fn test_cancelling_action_and_result_edits_are_booked() { + let code = BytecodeBuilder::default() + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(0u64) // argsSize + .push_number(0u64) // argsOffset + .push_number(0u64) // value + .push_address(CALLEE) + .push_number(100_000u64) + .append(CALL) + .append(POP) + .append(STOP) + .build(); + let callee = BytecodeBuilder::default().append(STOP).build(); + let db = || base_db(code.clone()).account_code(CALLEE, callee.clone()); + + let mut inspector = CancellingActionAndResultEdits::default(); + let (plain, cheated) = plain_and_cheated(db, &mut inspector); + + assert_eq!((inspector.raised, inspector.lowered), (1, 1), "both windows must be reached"); + assert_eq!( + cheated.gas_used, plain.gas_used, + "the pair cancels, so the receipt really is the one the EVM would have produced", + ); + assert_eq!( + cheated.inspector_ledger.conjured_gas(), + 0, + "and the conservation law must read the net, which is zero", + ); + assert!( + !cheated.inspector_ledger.is_zero(), + "but the guard must still see that the lane carried two edits: {:?}", + cheated.inspector_ledger, + ); + assert_eq!( + cheated.inspector_ledger.result.gross(), + 2 * u128::from(ACTION_DELTA), + "one edit in each window, counted where each was made", + ); +} + +// --- two edits to one lane, in opposite directions ----------------------------------------------- + +/// Injects one gas before the frame reads its own remaining gas, and takes it back afterwards. +/// +/// Both edits land on the interpreter counter, which is one signed lane. Their net is zero and +/// the transaction's envelope is unmoved — and in between them the frame read a number one higher +/// than the EVM would have given it, and wrote that number to storage. +#[derive(Default)] +struct CancellingCounterEdits { + /// 0 before the injection, 1 between the two edits, 2 once both have landed. + phase: u8, +} + +impl Inspector for CancellingCounterEdits { + fn step(&mut self, interp: &mut Interpreter, _context: &mut CTX) { + match self.phase { + 0 if interp.bytecode.opcode() == GAS => { + interp.gas.erase_cost(1); + self.phase = 1; + } + 1 => { + assert!(interp.gas.record_regular_cost(1), "the frame must afford the give-back"); + self.phase = 2; + } + _ => {} + } + } +} + +/// ★ Two edits to the same lane that cancel are not an all-zero ledger. +/// +/// The net of the gas lane really is zero — the transaction spent exactly what it would have — so +/// nothing the conservation law reads has moved. What moved is the number the frame read in +/// between, and a guard that asks the net cannot see it. The gross activity counter is what does. +#[test] +fn test_cancelling_counter_edits_are_booked() { + let code = BytecodeBuilder::default() + .append(GAS) + .push_number(RESULT_SLOT) + .append(SSTORE) + .append(STOP) + .build(); + + // No compute-gas limit, so the REX7 gas clamp hides nothing and the frame's own reading of + // its remaining gas is the counter the injection moved. + let limits = EvmTxRuntimeLimits::no_limits(); + let plain = transact(MegaSpecId::REX7, base_db(code.clone()), limits); + let mut inspector = CancellingCounterEdits::default(); + let cheated = transact_inspected(MegaSpecId::REX7, base_db(code), limits, &mut inspector); + + assert_eq!(inspector.phase, 2, "both halves of the cancellation must have landed"); + assert_eq!( + cheated.storage_value(CONTRACT, U256::from(RESULT_SLOT)), + plain.storage_value(CONTRACT, U256::from(RESULT_SLOT)) + U256::from(1), + "the frame must have read one gas more than the EVM would have given it", + ); + assert_eq!( + cheated.total_gas_spent, plain.total_gas_spent, + "the two edits cancel, so the envelope the receipt reports is unmoved", + ); + assert_eq!( + cheated.inspector_conjured_gas(), + 0, + "and so is the law's term: this is exactly the shape a net-only reading cannot see", + ); + assert!( + !cheated.inspector_ledger.is_zero(), + "but the transaction was rewritten, and the guard has to see that: {:?}", + cheated.inspector_ledger, + ); +} + +/// Adds a refund to one child frame's result and takes the same amount out of another's. +/// +/// The frame that gets the addition returns, so its refund reaches the receipt. The frame that +/// gets the subtraction reverts, so revm discards its whole refund counter — the subtraction never +/// reaches anything. Net zero on the lane, one refund's worth of difference on the receipt. +#[derive(Default)] +struct CancellingRefundsAcrossFrames { + added: u32, + removed: u32, +} + +impl Inspector for CancellingRefundsAcrossFrames { + fn call_end(&mut self, _context: &mut CTX, inputs: &CallInputs, outcome: &mut CallOutcome) { + if inputs.target_address == CALLEE && self.added == 0 { + outcome.result.gas.record_refund(REFUND); + self.added += 1; + } else if inputs.target_address == REVERTER && self.removed == 0 { + assert!( + outcome.result.gas.refunded() >= REFUND, + "the reverting callee must hold a refund of its own to take from, got {}", + outcome.result.gas.refunded(), + ); + outcome.result.gas.record_refund(-REFUND); + self.removed += 1; + } + } +} + +/// ★ A cancellation split across a surviving frame and a discarded one is not an all-zero ledger. +/// +/// This is the previous shape with the asymmetry made explicit: the two halves are equal and +/// opposite where the ledger books them, and only one of them is still standing by the time the +/// receipt is built. +#[test] +fn test_cancelling_refunds_across_frames_are_booked() { + let call_to = |builder: BytecodeBuilder, target: Address| { + builder + .push_number(0u64) + .push_number(0u64) + .push_number(0u64) + .push_number(0u64) + .push_number(0u64) + .push_address(target) + .push_number(200_000u64) + .append(CALL) + .append(POP) + }; + let code = call_to(call_to(BytecodeBuilder::default(), CALLEE), REVERTER).append(STOP).build(); + // Both callees set a slot and clear it again, so each ends holding a refund the EVM produced. + let clearing = |builder: BytecodeBuilder| { + builder + .sstore(U256::from(RESULT_SLOT), U256::from(1u64)) + .sstore(U256::from(RESULT_SLOT), U256::ZERO) + }; + let returning = clearing(BytecodeBuilder::default()).append(STOP).build(); + let reverting = clearing(BytecodeBuilder::default()).revert().build(); + let db = || { + base_db(code.clone()) + .account_code(CALLEE, returning.clone()) + .account_code(REVERTER, reverting.clone()) + }; + + let mut inspector = CancellingRefundsAcrossFrames::default(); + let (plain, cheated) = plain_and_cheated(db, &mut inspector); + + assert_eq!((inspector.added, inspector.removed), (1, 1), "both halves must have landed"); + assert!( + plain.total_gas_spent >= 5 * u64::try_from(REFUND).unwrap(), + "the fixture must burn enough that the EIP-3529 cap does not hide the difference", + ); + assert_eq!( + plain.gas_used - cheated.gas_used, + u64::try_from(REFUND).unwrap(), + "only the surviving frame's half reaches the receipt, so the sender pays that much less", + ); + assert!( + !cheated.inspector_ledger.is_zero(), + "a receipt an inspector moved must not read as untouched: {:?}", + cheated.inspector_ledger, + ); +} + +// --- an opcode skipped, and a return buffer conjured +// ---------------------------------------------- + +/// What the fixture's `SSTORE` writes when it runs. +const STORED: u64 = 0x99; + +/// The gas a cold `SSTORE` into a zero slot costs, which is what skipping it saves. +const COLD_SSTORE_SET: u64 = 22_100; + +/// How many bytes of return data the forging inspector conjures. +/// +/// Non-zero and a whole number of words, so that the `SSTORE` that stores it turns a zero slot +/// into a non-zero one — which is a different charge as well as a different value. +const CONJURED_RETURN_DATA: u64 = 96; + +/// Advances the program counter past the frame's `SSTORE`, so the EVM never executes it. +/// +/// revm's inspected loop runs this callback *before* the instruction, and the interpreter reads +/// the opcode it is about to execute from the very pointer this moves. Stepping the pointer on by +/// one byte therefore deletes one instruction from the frame: the two operands the `SSTORE` would +/// have consumed stay on the stack, the `STOP` after it runs instead, and the frame ends where it +/// was going to end. +/// +/// Nothing about this reaches a gas counter. The work is not performed, so there is nothing for +/// the EVM to meter and nothing for a gas lane to see. +#[derive(Default)] +struct SkipTheStore { + fired: u32, +} + +impl Inspector for SkipTheStore { + fn step(&mut self, interp: &mut Interpreter, _context: &mut CTX) { + if self.fired > 0 || interp.bytecode.opcode() != SSTORE { + return; + } + interp.bytecode.relative_jump(1); + self.fired += 1; + } +} + +/// ★ A frame with an opcode skipped out from under it is not an all-zero ledger. +/// +/// The rewrite is the free-expansion shape's twin and is strictly worse: it does not merely make +/// the frame's next charge cheaper, it deletes an instruction from the frame. The transaction ends +/// with different storage *and* a smaller bill, and every gas lane reads zero because the gas that +/// went missing was never spent by anybody. +#[test] +fn test_a_skipped_opcode_is_booked() { + let code = BytecodeBuilder::default() + .sstore(U256::from(RESULT_SLOT), U256::from(STORED)) + .append(STOP) + .build(); + + let mut inspector = SkipTheStore::default(); + let (plain, cheated) = plain_and_cheated(|| base_db(code.clone()), &mut inspector); + + assert_eq!(inspector.fired, 1, "the fixture must reach the store exactly once"); + assert!(plain.is_success() && cheated.is_success(), "both runs must succeed"); + assert_eq!( + plain.storage_value(CONTRACT, U256::from(RESULT_SLOT)), + U256::from(STORED), + "without the rewrite the frame stores what its bytecode says", + ); + assert_eq!( + cheated.storage_value(CONTRACT, U256::from(RESULT_SLOT)), + U256::ZERO, + "with it, the store never happens", + ); + assert_eq!( + plain.total_gas_spent - cheated.total_gas_spent, + COLD_SSTORE_SET, + "the deleted instruction is the charge the transaction then did not pay", + ); + assert!( + !cheated.inspector_ledger.is_zero(), + "a transaction an inspector deleted an instruction from must not read as untouched: {:?}", + cheated.inspector_ledger, + ); +} + +/// Puts return data in front of a frame that has made no call. +/// +/// `RETURNDATASIZE` reads the buffer's length, so the frame goes on to store a number no call +/// produced. The buffer is the interpreter's own, reachable through `ReturnData` on any live +/// interpreter, and its length is a constant-time reading exactly like the memory's size. +#[derive(Default)] +struct ForgeReturnData { + fired: u32, +} + +impl Inspector for ForgeReturnData { + fn step(&mut self, interp: &mut Interpreter, _context: &mut CTX) { + if self.fired > 0 || interp.bytecode.opcode() != RETURNDATASIZE { + return; + } + interp.return_data.set_buffer(Bytes::from(vec![0u8; CONJURED_RETURN_DATA as usize])); + self.fired += 1; + } +} + +/// ★ A frame handed return data it never received is not an all-zero ledger. +/// +/// The frame made no call, so the EVM's own buffer is empty and the store is a zero-to-zero +/// no-op. With the rewrite the same store turns a zero slot into a non-zero one, which changes the +/// post-state and costs the transaction more — in the opposite direction to every other shape +/// here, and just as invisible to a lane that only watches gas counters. +#[test] +fn test_a_forged_return_buffer_is_booked() { + let code = BytecodeBuilder::default() + .append(RETURNDATASIZE) + .push_number(RESULT_SLOT) + .append(SSTORE) + .append(STOP) + .build(); + + let mut inspector = ForgeReturnData::default(); + let (plain, cheated) = plain_and_cheated(|| base_db(code.clone()), &mut inspector); + + assert_eq!(inspector.fired, 1, "the fixture must reach the read exactly once"); + assert!(plain.is_success() && cheated.is_success(), "both runs must succeed"); + assert_eq!( + plain.storage_value(CONTRACT, U256::from(RESULT_SLOT)), + U256::ZERO, + "a frame that made no call has no return data", + ); + assert_eq!( + cheated.storage_value(CONTRACT, U256::from(RESULT_SLOT)), + U256::from(CONJURED_RETURN_DATA), + "with the rewrite it reads the length of a buffer no call produced", + ); + assert!( + cheated.total_gas_spent > plain.total_gas_spent, + "and pays for the non-zero store the rewrite turned it into: {} vs {}", + cheated.total_gas_spent, + plain.total_gas_spent, + ); + assert!( + !cheated.inspector_ledger.is_zero(), + "a transaction whose state a forged buffer changed must not read as untouched: {:?}", + cheated.inspector_ledger, + ); +} + +// --- a frame invariant moved and moved back -------------------------------------------------- + +/// The caller the rewriting inspector shows the frame instead of the one that called it. +const IMPOSTOR: Address = address!("00000000000000000000000000000000000ca11e"); + +/// Moves the frame's caller for the length of one instruction, and puts it back. +/// +/// `CALLER` reads `input.caller_address`, so the frame pushes an address nobody called it from and +/// goes on to store that. The rewrite is undone in the very next callback, which is what makes the +/// shape worth pinning: the frame's identity is the one the EVM gave it at every point a *frame* +/// could be inspected — at its start, at its end, and at every callback but the two this touches. +/// +/// Nothing about it reaches a gas counter. Both runs execute the same instructions and pay the +/// same cold `SSTORE`; only the value written differs. +#[derive(Default)] +struct BorrowTheCaller { + /// The caller the EVM gave the frame, kept so it can be handed back. + original: Option
, + /// How many times each half of the rewrite ran. + moved: u32, + restored: u32, +} + +impl Inspector for BorrowTheCaller { + fn step(&mut self, interp: &mut Interpreter, _context: &mut CTX) { + if self.moved > 0 || interp.bytecode.opcode() != CALLER { + return; + } + self.original = Some(interp.input.caller_address); + interp.input.caller_address = IMPOSTOR; + self.moved += 1; + } + + fn step_end(&mut self, interp: &mut Interpreter, _context: &mut CTX) { + let Some(original) = self.original.filter(|_| self.restored == 0) else { + return; + }; + interp.input.caller_address = original; + self.restored += 1; + } +} + +/// ★ A frame invariant moved in `step` and moved back in `step_end` is not an all-zero ledger. +/// +/// The four addresses and the value a frame is identified by cannot change while it runs, which +/// makes them the readings a cheaper shim would be tempted to compare once per frame rather than +/// once per callback. This is the shape that answers that: an inspector borrows one of them for +/// exactly as long as it takes the frame to read it, and gives it back before anything outside the +/// two callbacks could look. A per-frame comparison sees the address it started with; a per-opcode +/// one sees it move twice. +#[test] +fn test_a_frame_invariant_moved_and_moved_back_is_booked() { + let code = BytecodeBuilder::default() + .append(CALLER) + .push_number(RESULT_SLOT) + .append(SSTORE) + .append(STOP) + .build(); + + let mut inspector = BorrowTheCaller::default(); + let (plain, cheated) = plain_and_cheated(|| base_db(code.clone()), &mut inspector); + + assert_eq!((inspector.moved, inspector.restored), (1, 1), "both halves must run once"); + assert_eq!( + inspector.original, + Some(crate::common::CALLER), + "and the half that gives the address back must have the one the EVM gave the frame", + ); + assert!(plain.is_success() && cheated.is_success(), "both runs must succeed"); + assert_eq!( + plain.storage_value(CONTRACT, U256::from(RESULT_SLOT)), + U256::from_be_slice(crate::common::CALLER.as_slice()), + "without the rewrite the frame stores the address that called it", + ); + assert_eq!( + cheated.storage_value(CONTRACT, U256::from(RESULT_SLOT)), + U256::from_be_slice(IMPOSTOR.as_slice()), + "with it, the frame stores one nobody called it from", + ); + assert_eq!( + plain.total_gas_spent, cheated.total_gas_spent, + "the two runs cost the same, so no gas lane can tell them apart", + ); + assert!( + cheated.inspector_ledger.interventions >= 2, + "each half of the rewrite is a rewrite: {:?}", + cheated.inspector_ledger, + ); +} diff --git a/crates/mega-evm/tests/rex7/shim_input_comparison.rs b/crates/mega-evm/tests/rex7/shim_input_comparison.rs new file mode 100644 index 00000000..f081049c --- /dev/null +++ b/crates/mega-evm/tests/rex7/shim_input_comparison.rs @@ -0,0 +1,340 @@ +//! What the shim calls a rewrite of a frame's inputs, and what it does not. +//! +//! The comparison the entry callbacks make has to answer one question about an object upstream +//! owns: did this come back describing a different frame? A creation's inputs make that harder +//! than a call's, because two of their fields are `OnceCell` memos — the address the creation will +//! occupy and the hash of its init code — filled on demand through a *shared* reference. So the +//! object a callback was handed comes back structurally different having had a derived value +//! computed off it, and the derived equality read that as an edit. +//! +//! It is not an exotic shape. `created_address` is what a tracer calls to record where a +//! deployment landed, so every `revm-inspectors` tracer did it at every `CREATE`: an undeclared one +//! reported an intervention it never made, and a declared one failed the debug verification at the +//! first creation in the transaction. The fixture here is the one no test had — a transaction that +//! actually creates something. +//! +//! The other half is the cost of narrowing a comparison: a field left out is a field an edit to is +//! invisible. So every field a creation's frame is built from gets a case that edits it and +//! asserts the shim still books it, and the case list is checked against the same table +//! `gas_surface.rs` pins upstream's field set with. + +use crate::{ + common::{base_db, transact_inspected, Outcome}, + gas_surface::{semantic_fields, Comparison, CREATE_INPUTS_COMPARISON}, + inspector_common::{ledger_intervention, limits, transact_trusted}, +}; +use alloy_primitives::{Address, Bytes, U256}; +use mega_evm::{ + test_utils::{BytecodeBuilder, MemoryDatabase}, + DeclaredObserver, MegaSpecId, +}; +use revm::{ + bytecode::opcode::{CREATE, CREATE2, MSTORE, POP, STOP}, + context::CreateScheme, + interpreter::{CreateInputs, CreateOutcome, InterpreterTypes}, + Inspector, +}; +use revm_inspectors::tracing::{TracingInspector, TracingInspectorConfig}; +use std::{vec, vec::Vec}; + +// --- the fixture --------------------------------------------------------------------------- + +/// A second address, for the case that moves the creation's caller. +const OTHER: Address = Address::repeat_byte(0x0C); + +/// The salt the fixture's `CREATE2` uses, and the one the scheme-swapping case supplies. +const SALT: u64 = 0x5A17; + +/// `PUSH1 0 PUSH1 0 RETURN` — init code that deploys an empty contract. +const RETURN_EMPTY: [u8; 5] = [0x60, 0x00, 0x60, 0x00, 0xf3]; + +/// Writes `code` into memory from offset zero, one 32-byte word at a time. +/// +/// The tail word is zero-padded, which the `CREATE` that follows never reads: it is given the +/// code's true length. +fn write_to_memory(builder: BytecodeBuilder, code: &[u8]) -> BytecodeBuilder { + let mut builder = builder; + for (index, chunk) in code.chunks(32).enumerate() { + let mut word = [0u8; 32]; + word[..chunk.len()].copy_from_slice(chunk); + builder = builder.push_bytes(word).push_number((index * 32) as u64).append(MSTORE); + } + builder +} + +/// Init code that itself creates a contract before returning, so the fixture has a creation +/// nested inside a creation. +fn nested_init_code() -> Vec { + write_to_memory(BytecodeBuilder::default(), &RETURN_EMPTY) + .push_number(RETURN_EMPTY.len() as u64) // size + .push_number(0u64) // offset + .push_number(0u64) // value + .append(CREATE) + .append(POP) + .append_many(RETURN_EMPTY) + .build_vec() +} + +/// The fixture: a `CREATE` and a `CREATE2` of init code that creates once more. +/// +/// Four `create` callbacks, over both schemes and both frame depths. `CREATE2` is not decoration: +/// its address does not depend on the caller's nonce, which is what makes filling its memo +/// something a test can do without changing where the contract lands. +fn creating_code() -> Bytes { + let init = nested_init_code(); + let size = init.len() as u64; + write_to_memory(BytecodeBuilder::default(), &init) + .push_number(size) // size + .push_number(0u64) // offset + .push_number(0u64) // value + .append(CREATE) + .append(POP) + .push_number(SALT) // salt + .push_number(size) // size + .push_number(0u64) // offset + .push_number(0u64) // value + .append(CREATE2) + .append(POP) + .append(STOP) + .build() +} + +fn creating_db() -> MemoryDatabase { + base_db(creating_code()) +} + +/// Asserts the run really exercised the shape the module is about. +/// +/// Counted off the produced state rather than inside an inspector, so that a fixture that stops +/// creating anything fails the tests that rest on it rather than passing them vacuously. +fn assert_created_four(label: &str, outcome: &Outcome) { + assert!( + outcome.is_success(), + "{label}: the fixture must run to completion, got {:?}", + outcome.result, + ); + assert_eq!( + outcome.state.values().filter(|account| account.is_created()).count(), + 4, + "{label}: the fixture must create four contracts", + ); +} + +// --- the inspectors ------------------------------------------------------------------------ + +/// Fills a creation's memo cells and changes nothing else. +/// +/// The address is asked for only under `CREATE2`, where it is derived from the caller, the salt +/// and the init code and the nonce argument is ignored — so this fills the same cell the EVM +/// would have filled, with the same value. Under `CREATE` the address depends on the caller's +/// nonce, which an inspector has to look up to get right; a fill with the wrong one is a rewrite +/// the boundary cannot price, and is left to the declaration the way every other value the +/// boundary cannot read back is. +#[derive(Default)] +struct FillsTheMemo { + fills: u32, +} + +impl Inspector for FillsTheMemo { + fn create(&mut self, _context: &mut CTX, inputs: &mut CreateInputs) -> Option { + inputs.init_code_hash(); + if matches!(inputs.scheme(), CreateScheme::Create2 { .. }) { + inputs.created_address(0); + } + self.fills += 1; + None + } +} + +/// Edits one field of the first creation it is handed, and nothing else ever. +/// +/// One struct rather than one per field so that what differs between the cases is the edit alone. +struct EditsOneField { + edit: fn(&mut CreateInputs), + fired: bool, +} + +impl EditsOneField { + fn new(edit: fn(&mut CreateInputs)) -> Self { + Self { edit, fired: false } + } +} + +impl Inspector for EditsOneField { + fn create(&mut self, _context: &mut CTX, inputs: &mut CreateInputs) -> Option { + if !self.fired { + self.fired = true; + (self.edit)(inputs); + } + None + } +} + +/// One case: the field a rewrite moves, and the rewrite. +type Case = (&'static str, fn(&mut CreateInputs)); + +/// One rewrite per field a creation's frame is built from, each moving what it is named for. +/// +/// Every one changes what the frame does: who is recorded as the creator, which address the +/// contract lands at, what it is funded with, what code runs, and what state-gas pool it draws +/// from. The gas limit is deliberately absent — it is booked as an amount on the envelope lane, +/// and [`test_an_edited_gas_limit_is_booked_as_an_amount`] is its case. +const CASES: [Case; 5] = [ + ("caller", |inputs| inputs.set_call(OTHER)), + ("scheme", |inputs| inputs.set_scheme(CreateScheme::Create2 { salt: U256::from(SALT) })), + ("value", |inputs| inputs.set_value(U256::from(1))), + ("init_code", |inputs| inputs.set_init_code(Bytes::from_static(&RETURN_EMPTY))), + ("reservoir", |inputs| inputs.set_reservoir(1)), +]; + +// --- an observation-only tracer books nothing ------------------------------------------------ + +/// ★ A declared tracer runs a transaction that creates contracts without booking anything. +/// +/// The shape the narrowed comparison exists for, at the callback it broke at. A declared observer +/// is measured anyway in a debug build and asserted to have booked nothing, so before the fix this +/// panicked at the first `CREATE` — `mega-evme replay --trace` over any transaction that deploys +/// something, and every offline fixture that had one, which is to say none of them. +#[test] +fn test_a_declared_tracer_books_nothing_over_a_transaction_that_creates() { + let mut tracer = DeclaredObserver(TracingInspector::new(TracingInspectorConfig::all())); + let outcome = transact_trusted(creating_db(), &mut tracer); + + assert_created_four("declared", &outcome); + assert!( + outcome.inspector_ledger.is_zero(), + "a tracer that only reads must leave every lane empty: {:?}", + outcome.inspector_ledger, + ); + assert_eq!( + outcome.inspector_ledger.interventions, 0, + "and asking a creation for the address it will occupy is not an intervention", + ); +} + +/// ★ And so does the same tracer with no declaration, on the measured path. +/// +/// The declared run above is measured too in a debug build, so on its own it says nothing about +/// the release path an embedder drives directly — which is the shape RPC tracing takes, and the +/// one whose outcome carried the false reading to whatever read it. +#[test] +fn test_an_undeclared_tracer_books_nothing_over_the_same_transaction() { + let mut tracer = TracingInspector::new(TracingInspectorConfig::all()); + let outcome = transact_inspected(MegaSpecId::REX7, creating_db(), limits(), &mut tracer); + + assert_created_four("undeclared", &outcome); + assert!( + outcome.inspector_ledger.is_zero(), + "the measured path must read the same: {:?}", + outcome.inspector_ledger, + ); +} + +/// ★ Filling both memo cells books nothing, and the fixture really fills them. +/// +/// The tracer tests above are the shape as it occurs; this is the mechanism on its own, so that a +/// future tracer that stops calling `created_address` does not quietly take the coverage with it. +#[test] +fn test_filling_a_creations_memo_cells_books_nothing() { + let mut filler = FillsTheMemo::default(); + let outcome = transact_inspected(MegaSpecId::REX7, creating_db(), limits(), &mut filler); + + assert_eq!(filler.fills, 4, "the fixture must hand the inspector four creations"); + assert_created_four("memo filler", &outcome); + assert!( + outcome.inspector_ledger.is_zero(), + "filling a memo is a derived value being computed, not an input being changed: {:?}", + outcome.inspector_ledger, + ); +} + +// --- and every real edit is still booked ------------------------------------------------------- + +/// ★ Every field a creation's frame is built from is one an edit to is booked. +/// +/// The bite of the narrowing. A comparison written field by field is one someone has to keep +/// complete, so each field gets a case that edits it in the `create` callback and asserts exactly +/// one intervention comes back — a field dropped from `create_inputs_rewritten` fails here by +/// name. +#[test] +fn test_every_semantic_field_of_a_creation_is_still_booked_when_edited() { + for (name, edit) in CASES { + let mut inspector = EditsOneField::new(edit); + let outcome = transact_inspected(MegaSpecId::REX7, creating_db(), limits(), &mut inspector); + assert!(inspector.fired, "{name}: the case must reach a creation"); + assert_eq!( + outcome.inspector_ledger.interventions, 1, + "{name}: an edited field must be booked exactly once, got {:?}", + outcome.inspector_ledger, + ); + } +} + +/// ★ The case list is the table's semantic field set. +/// +/// What closes the loop between the three places this is written down. `gas_surface.rs` pins the +/// field set against what upstream's `Debug` renders and classifies each field as semantic, +/// envelope or memo; the comparison in `inspector.rs` is written over the semantic ones; and this +/// is the list of edits that proves each of them is really compared. A field upstream adds has to +/// be classified, and a `Semantic` classification with no case fails here. +#[test] +fn test_the_case_list_is_the_tables_semantic_field_set() { + let mut cases: Vec<&str> = CASES.iter().map(|(name, _)| *name).collect(); + let declared = cases.len(); + cases.sort_unstable(); + cases.dedup(); + assert_eq!(cases.len(), declared, "no field may be listed twice"); + + let mut semantic = semantic_fields(&CREATE_INPUTS_COMPARISON); + semantic.sort_unstable(); + assert_eq!(cases, semantic, "every semantic field needs a case, and every case a field"); + + assert_eq!( + CREATE_INPUTS_COMPARISON + .iter() + .filter(|(_, how)| *how == Comparison::Memo) + .map(|(name, _)| *name) + .collect::>(), + vec!["cached_address", "cached_init_code_hash"], + "the two fields the comparison leaves out are the two memo cells", + ); +} + +/// ★ An edited gas limit is booked as an amount, not as an intervention. +/// +/// The other field the comparison leaves out, and the reason it is a different kind of exclusion +/// from the memos': it moves the frame's budget, so the envelope lane books how much. Counting it +/// here as well would report one edit twice, and a ledger reading zero on both would be the +/// failure that matters. +#[test] +fn test_an_edited_gas_limit_is_booked_as_an_amount() { + let mut inspector = EditsOneField::new(|inputs| inputs.set_gas_limit(inputs.gas_limit() - 1)); + let outcome = transact_inspected(MegaSpecId::REX7, creating_db(), limits(), &mut inspector); + + assert!(inspector.fired, "the case must reach a creation"); + assert_eq!( + outcome.inspector_ledger.interventions, 0, + "the gas limit is not part of the rewrite comparison", + ); + assert_eq!(outcome.inspector_ledger.env.net(), -1, "the envelope lane books the amount"); +} + +/// ★ A memo filled beside a real edit does not hide the edit. +/// +/// The two halves of the module in one run: the same callback asks the creation for its address +/// and moves its value, and what comes back is the one intervention the edit deserves. +#[test] +fn test_a_memo_filled_beside_an_edit_still_books_the_edit() { + let mut inspector = EditsOneField::new(|inputs| { + inputs.set_value(U256::from(1)); + inputs.init_code_hash(); + }); + let outcome = transact_inspected(MegaSpecId::REX7, creating_db(), limits(), &mut inspector); + + assert!(inspector.fired, "the case must reach a creation"); + assert_eq!( + outcome.inspector_ledger, + ledger_intervention(), + "the edit must be booked, once, and the memo must add nothing to it", + ); +} diff --git a/crates/mega-evm/tests/rex7/shim_lanes.rs b/crates/mega-evm/tests/rex7/shim_lanes.rs new file mode 100644 index 00000000..5b4711a4 --- /dev/null +++ b/crates/mega-evm/tests/rex7/shim_lanes.rs @@ -0,0 +1,1213 @@ +//! The lanes the measurement shim books a rewrite on, and what each one is measured against. +//! +//! `MegaETH` wraps every inspector it is handed. The EVM does not execute inside an inspector +//! callback, so anything that changes across one is the inspector's doing by construction — which +//! is what makes the callback boundary a sound place to measure from. Every fixture here is the +//! same comparison: one run with an inspector against one without, over the same fixture, with the +//! conservation law checked on both by the shared driver. +//! +//! The two groups, in the order they appear: +//! +//! 1. **The shim itself** — gas written into an interpreter's counter or a frame's gas limit is +//! measured, booked, and kept out of enforcement, with the clamp re-derived on the spot; an +//! observation-only inspector is bit-identical to no inspector at all. +//! 2. **The receipt's other two numbers** — the EIP-3529 refund, measured at the callback boundary +//! because the EVM produces refunds too, and the EIP-8037 state-gas dimension, settled from the +//! transaction's final figures because `MegaETH` produces none of it and revm propagates it by +//! replacement. +//! +//! The two windows in which a rewrite lands after the accounting that should have read it are in +//! `shim_settlement.rs`, and the shapes an all-zero ledger used to admit are in +//! `shim_blind_spots.rs`. The rewrites the shim *refuses* are in `shim_refusals.rs`; the exhaustive +//! callback × shape sweep is in `inspector_cheat_matrix.rs`. + +use crate::{ + common::{base_db, transact, transact_inspected, Outcome, CALLEE, CONTRACT}, + inspector_common::{ + append_call, call_then_stop, countdown_loop_code, db_with_callee, deploy_then_stop, limits, + limits_with_compute, plain_run_code, REFUND, + }, +}; +use alloy_primitives::{Bytes, U256}; +use mega_evm::{ + test_utils::{BytecodeBuilder, MemoryDatabase}, + ConservationTerms, EvmTxRuntimeLimits, InspectorLedger, Lane, MegaHaltReason, MegaSpecId, +}; +use revm::{ + bytecode::opcode::{CALL, MSTORE, POP, RETURN, STOP}, + interpreter::{ + interpreter_types::LoopControl, CallInputs, CallOutcome, CreateInputs, CreateOutcome, Gas, + InstructionResult, Interpreter, InterpreterAction, InterpreterResult, InterpreterTypes, + }, + Inspector, +}; +use std::vec::Vec; + +// === the shim itself ========================================================================= +// +// The measurement shim: what an inspector does to gas is measured, booked, and kept out of +// enforcement. +// +// `MegaETH` wraps every inspector it is handed. The EVM does not execute inside an inspector +// callback, so anything that changes across one is the inspector's doing by construction — which +// is what makes the callback boundary a sound place to measure from. +// +// Each test here is one shape a rewriting inspector can take, and each pins a different half of +// the mechanism: +// +// - injecting gas into a running interpreter must not buy compute headroom, and the gas clamp must +// tighten again immediately rather than at the next checkpoint; +// - raising a child frame's gas limit conjures gas the transaction never funded, which the ledger +// has to account for or the conservation law breaks; +// - an observation-only inspector changes nothing at all; +// - and removing gas is measured with the same machinery as adding it. + +/// Edits the interpreter's gas counter once, at the `at`-th step, by `delta` gas. +/// +/// One edit rather than a per-step trickle so that the amount conjured (or destroyed) is an exact +/// number a test can assert on, and so the edit lands well inside the plain segment rather than at +/// its boundary. +#[derive(Default)] +struct GasEditor { + at: u64, + delta: i64, + steps: u64, + applied: bool, +} + +impl GasEditor { + fn new(at: u64, delta: i64) -> Self { + Self { at, delta, steps: 0, applied: false } + } +} + +impl Inspector for GasEditor { + fn step(&mut self, interp: &mut Interpreter, _context: &mut CTX) { + self.steps += 1; + if self.steps != self.at || self.applied { + return; + } + self.applied = true; + if self.delta >= 0 { + interp.gas.erase_cost(self.delta.unsigned_abs()); + } else { + assert!( + interp.gas.record_regular_cost(self.delta.unsigned_abs()), + "the fixture must leave enough gas for the removal to land", + ); + } + } +} + +/// Raises the gas limit of every call to [`CALLEE`] by a fixed amount. +#[derive(Default)] +struct CallGasLimitRaiser { + bonus: u64, + raises: u64, +} + +impl Inspector for CallGasLimitRaiser { + fn call(&mut self, _context: &mut CTX, inputs: &mut CallInputs) -> Option { + if inputs.target_address == CALLEE { + inputs.gas_limit += self.bonus; + self.raises += 1; + } + None + } +} + +/// Rewrites every successful contract creation into a revert — the shape the frame loop has to +/// carry through to the journal. +#[derive(Default)] +struct CreateKiller { + killed: u64, +} + +impl Inspector for CreateKiller { + fn create_end( + &mut self, + _context: &mut CTX, + _inputs: &CreateInputs, + outcome: &mut CreateOutcome, + ) { + if outcome.result.result.is_ok() { + outcome.result.result = InstructionResult::Revert; + self.killed += 1; + } + } +} + +/// Counts callbacks and changes nothing. +#[derive(Default)] +struct Observer { + steps: u64, + calls: u64, + call_ends: u64, +} + +impl Inspector for Observer { + fn step(&mut self, _interp: &mut Interpreter, _context: &mut CTX) { + self.steps += 1; + } + + fn call(&mut self, _context: &mut CTX, _inputs: &mut CallInputs) -> Option { + self.calls += 1; + None + } + + fn call_end(&mut self, _context: &mut CTX, _inputs: &CallInputs, _outcome: &mut CallOutcome) { + self.call_ends += 1; + } +} + +/// (i) Gas injected into a running interpreter buys no compute headroom, is booked, and the clamp +/// tightens again on the spot. +/// +/// The fixture is a checkpoint-free loop under a compute limit far below what the loop needs, so +/// the gas clamp is the only thing that can stop it: the visible counter is pinned to the compute +/// headroom and revm's own gas check rejects the crossing opcode. An inspector then writes four +/// times that headroom into the counter, mid-loop. +/// +/// Three separate mechanisms are pinned: +/// +/// - **Enforcement does not eat the injection.** The recorded compute total is identical to the +/// uninspected run's, to the gas. Without the baseline shift, the injection reads as negative +/// work and the loop is handed free headroom. +/// - **The clamp is re-derived immediately.** Usage still stops exactly at the limit. Without the +/// re-clamp the loop runs on the injected gas until the frame ends, and the frame-exit settlement +/// then records the whole overshoot — the halt still lands, but hundreds of thousands of gas +/// late. +/// - **The ledger records it.** Exactly what was injected, no more. +#[test] +fn test_injected_gas_is_booked_and_never_becomes_compute_headroom() { + const INJECTED: u64 = 20_000; + let code = countdown_loop_code(10_000); + // Far below what the loop needs, so the clamp binds for the whole run. + let intrinsic = transact(MegaSpecId::REX7, base_db(plain_run_code(0)), limits()).compute_gas; + let limits = limits_with_compute(intrinsic + 5_000); + + let plain = transact(MegaSpecId::REX7, base_db(code.clone()), limits); + let mut inspector = GasEditor::new(20, INJECTED as i64); + let inspected = transact_inspected(MegaSpecId::REX7, base_db(code), limits, &mut inspector); + + assert!(inspector.applied, "the fixture must reach the injection point"); + assert!( + matches!(plain.halt_reason("plain"), MegaHaltReason::ComputeGasLimitExceeded { .. }), + "fixture check: the uninspected run must stop on the compute limit, got {:?}", + plain.halt_reason("plain"), + ); + assert_eq!( + inspected.enforced(), + plain.enforced(), + "the injection must be neither counted as work nor deducted from it, and the re-derived \ + clamp must stop the loop at the same opcode the uninspected run stopped at; \ + inspected result {:?}", + inspected.result, + ); + assert!( + matches!( + inspected.halt_reason("inspected"), + MegaHaltReason::ComputeGasLimitExceeded { .. } + ), + "injected gas must not turn a compute-limit halt into something else, got {:?}", + inspected.halt_reason("inspected"), + ); + assert_eq!( + inspected.inspector_ledger.gas, + Lane::once(i128::from(INJECTED)), + "the ledger must hold exactly what was injected", + ); + assert_eq!(inspected.inspector_ledger.env, Lane::default(), "no frame envelope was touched"); + assert_eq!( + i128::from(inspected.total_gas_spent) + i128::from(INJECTED), + i128::from(plain.total_gas_spent), + "the injected gas is refunded with the rest of the rescued remainder, so the transaction \ + spends exactly that much less than the uninspected run", + ); +} + +/// (v) The same machinery, in the other direction: gas removed from a running interpreter is +/// booked as a negative entry and is not charged as work. +/// +/// Under an active clamp the removal comes out of the hidden remainder rather than the visible +/// counter — the frame has more EVM gas than compute headroom, and destroying EVM gas does not +/// shrink the headroom — so the transaction runs to the same successful end while spending exactly +/// the removed amount more. +#[test] +fn test_removed_gas_is_booked_as_a_negative_entry_and_is_not_charged_as_work() { + const REMOVED: u64 = 1_000; + let code = plain_run_code(200); + + let plain = transact(MegaSpecId::REX7, base_db(code.clone()), limits()); + let mut inspector = GasEditor::new(20, -(REMOVED as i64)); + let inspected = transact_inspected(MegaSpecId::REX7, base_db(code), limits(), &mut inspector); + + assert!(inspector.applied, "the fixture must reach the removal point"); + assert!(plain.result.is_success(), "fixture check: {:?}", plain.result); + assert!(inspected.result.is_success(), "removing gas must not fail the transaction"); + assert_eq!( + inspected.inspector_ledger.gas, + Lane::once(-i128::from(REMOVED)), + "the ledger must hold the removal as a negative entry", + ); + assert_eq!( + inspected.enforced(), + plain.enforced(), + "gas the inspector destroyed is not work the EVM performed", + ); + assert_eq!( + inspected.total_gas_spent, + plain.total_gas_spent + REMOVED, + "the removed gas never comes back, so the envelope is exactly that much larger", + ); +} + +/// (ii) Raising a child frame's gas limit conjures gas the transaction never funded, and the +/// envelope only balances once the ledger accounts for it. +/// +/// The caller's `CALL` opcode debited the gas it forwards before any inspector callback ran, so the +/// bonus the inspector adds is paid for by nobody. The child hands it straight back on return, and +/// the transaction ends up spending exactly that much less than the uninspected run. +/// +/// Without the `env` lane the conservation law derives a destroyed total that is short by the +/// bonus, and the envelope assertion inside `execute_transaction` fails on the spot. +#[test] +fn test_a_raised_child_gas_limit_is_booked_as_conjured_gas() { + const BONUS: u64 = 10_000; + let callee = plain_run_code(20); + let code = BytecodeBuilder::default() + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(0u64) // argsSize + .push_number(0u64) // argsOffset + .push_number(0u64) // value + .push_address(CALLEE) + .push_number(50_000u64) // gas + .append(CALL) + .push_number(0u64) + .append(MSTORE) + .push_number(32u64) + .push_number(0u64) + .append(RETURN) + .build(); + let build_db = || db_with_callee(code.clone(), callee.clone()); + + let plain = transact(MegaSpecId::REX7, build_db(), limits()); + let mut inspector = CallGasLimitRaiser { bonus: BONUS, raises: 0 }; + let inspected = transact_inspected(MegaSpecId::REX7, build_db(), limits(), &mut inspector); + + assert_eq!(inspector.raises, 1, "the fixture must make exactly one inner call"); + assert!(plain.result.is_success(), "fixture check: {:?}", plain.result); + assert!(inspected.result.is_success(), "the inner call must still succeed"); + assert_eq!( + inspected.inspector_ledger.env, + Lane::once(i128::from(BONUS)), + "the ledger must hold exactly the gas the inspector added to the child's envelope", + ); + assert_eq!( + inspected.inspector_ledger.gas, + Lane::default(), + "no interpreter counter was touched" + ); + assert_eq!( + inspected.total_gas_spent + BONUS, + plain.total_gas_spent, + "the child returns the conjured gas to its caller, so the transaction spends that much less", + ); + assert_eq!( + inspected.enforced(), + plain.enforced(), + "a wider envelope is not more work: the child's compute budget comes from the compute \ + tracker, not from its gas limit", + ); +} + +/// (ii, mirror) An edit to inputs the EVM never reads conjures nothing, so nothing is booked. +/// +/// A callback that returns a synthetic outcome has intercepted the frame: no frame is built from +/// the inputs, so no edit of theirs can widen an envelope. The gas that outcome carries is the +/// inspector's own choice and has nothing to do with the edit — here it is deliberately the +/// original forwarded amount, so the transaction really does conjure nothing and the identity has +/// to close at zero. +/// +/// Booking the edit anyway would claim gas was conjured for a frame that never existed, and the +/// conservation law would come out over by the bonus — the same failure as not booking a real one, +/// with the sign flipped. +/// +/// The interception itself is booked, on the lane that carries rewrites rather than gas: answering +/// a frame the EVM was about to build changes what the transaction did, whatever it costs. +#[test] +fn test_an_intercepting_callback_books_no_envelope_adjustment() { + /// Raises the child's gas limit and then intercepts the call, handing back an outcome built + /// from the amount the caller actually forwarded. + #[derive(Default)] + struct Interceptor { + intercepted: u64, + } + + impl Inspector for Interceptor { + fn call(&mut self, _context: &mut CTX, inputs: &mut CallInputs) -> Option { + if inputs.target_address != CALLEE { + return None; + } + let forwarded = inputs.gas_limit; + inputs.gas_limit += 10_000; + self.intercepted += 1; + Some(CallOutcome::new( + InterpreterResult::new(InstructionResult::Stop, Bytes::new(), Gas::new(forwarded)), + inputs.return_memory_offset.clone(), + )) + } + } + + let callee = plain_run_code(20); + let code = call_then_stop(CALLEE, 50_000); + let db = db_with_callee(code, callee); + + let mut inspector = Interceptor::default(); + let inspected = transact_inspected(MegaSpecId::REX7, db, limits(), &mut inspector); + + assert_eq!(inspector.intercepted, 1, "the fixture must intercept exactly one call"); + assert!(inspected.result.is_success(), "fixture check: {:?}", inspected.result); + assert_eq!( + inspected.inspector_ledger, + InspectorLedger { interventions: 1, ..InspectorLedger::default() }, + "an edit to inputs that never reach a frame conjures nothing, but answering the frame is \ + itself a rewrite", + ); + assert_eq!(inspected.inspector_ledger.conjured_gas(), 0, "no gas lane may move on this shape"); +} + +/// An intercepted frame that halts destroys the envelope it was handed, and that has to be booked. +/// +/// A callback that returns a synthetic outcome skips the frame init entirely: no frame is built, +/// and the settlement that books what a refused frame init destroys never used to run on this +/// path. A halting outcome hands nothing back to the caller, so the transaction spends that +/// envelope with no compute total to show for it — which is exactly what the conservation law is +/// stated over, and what it goes red on. +#[test] +fn test_an_intercepted_frame_that_halts_books_the_envelope_it_destroys() { + /// Intercepts the call to [`CALLEE`] with an exceptional halt, keeping the forwarded gas. + #[derive(Default)] + struct HaltingInterceptor { + intercepted: u64, + forwarded: u64, + } + + impl Inspector for HaltingInterceptor { + fn call(&mut self, _context: &mut CTX, inputs: &mut CallInputs) -> Option { + if inputs.target_address != CALLEE { + return None; + } + self.intercepted += 1; + self.forwarded = inputs.gas_limit; + Some(CallOutcome::new( + InterpreterResult::new( + InstructionResult::OutOfGas, + Bytes::new(), + Gas::new(inputs.gas_limit), + ), + inputs.return_memory_offset.clone(), + )) + } + } + + let code = call_then_stop(CALLEE, 50_000); + let db = db_with_callee(code, plain_run_code(20)); + + let mut inspector = HaltingInterceptor::default(); + let inspected = transact_inspected(MegaSpecId::REX7, db, limits(), &mut inspector); + + assert_eq!(inspector.intercepted, 1, "the fixture must intercept exactly one call"); + assert!(inspected.result.is_success(), "the caller absorbs the halt: {:?}", inspected.result); + assert_eq!( + inspected.destroyed, inspector.forwarded, + "the whole intercepted envelope is destroyed — nothing hands it back", + ); + assert_eq!( + inspected.compute_gas, + inspected.enforced() + inspected.destroyed, + "and it is reported without being enforced", + ); +} + +/// A `create_end` that turns a *successful* contract creation into a failure is honoured — and the +/// state has to follow it. +/// +/// This is the rewrite direction there is something behind: the constructor ran, the deposit +/// predicates passed, and the inspector is telling the caller the frame failed. If the journal +/// decision were taken before the callback, the caller would be handed a failure over a deployed +/// contract, with the constructor's storage writes committed underneath it. +#[test] +fn test_killing_a_successful_creation_rolls_its_state_back() { + // Init code that stores to slot 1 and returns a two-byte runtime code. + let init_code: Vec = BytecodeBuilder::default() + .sstore(U256::from(1), U256::from(7)) + .push_number(0x6000u64) + .push_number(0u64) + .append(MSTORE) + .push_number(2u64) // size + .push_number(30u64) // offset: the last two bytes of the word just stored + .append(RETURN) + .build() + .to_vec(); + + let code = deploy_then_stop(&init_code); + + let deployed = CONTRACT.create(0); + + // The uninspected run deploys, so the rewrite has something to undo. + let mut observer = Observer::default(); + let plain = + transact_inspected(MegaSpecId::REX7, base_db(code.clone()), limits(), &mut observer); + assert!(plain.result.is_success(), "fixture check: {:?}", plain.result); + let deployed_account = plain.state.get(&deployed).expect("the fixture must deploy a contract"); + assert!( + !deployed_account.info.is_empty_code_hash(), + "the fixture must deploy code for the rewrite to have something to undo", + ); + + let mut killer = CreateKiller::default(); + let killed = transact_inspected(MegaSpecId::REX7, base_db(code), limits(), &mut killer); + + assert_eq!(killer.killed, 1, "the fixture must rewrite exactly one creation"); + assert!( + killed.state.get(&deployed).is_none_or(|account| account.info.is_empty_code_hash()), + "a creation the inspector failed must leave no code at {deployed}", + ); + assert_eq!( + killed + .state + .get(&deployed) + .and_then(|account| account.storage.get(&U256::from(1))) + .map(|slot| slot.present_value()) + .unwrap_or_default(), + U256::ZERO, + "and none of the constructor's storage writes", + ); +} + +/// (iv) An observation-only inspector leaves an empty ledger and a bit-identical transaction. +/// +/// This is the property every tracer in production depends on. The comparison is against a run with +/// no inspector attached at all, across every number the transaction reports and the state it +/// produced — not just the ones the ledger touches. +#[test] +fn test_an_observing_inspector_changes_nothing() { + let callee = plain_run_code(20); + let code = BytecodeBuilder::default() + .sstore(U256::from(0x20), U256::from(0x99)) + .push_number(0u64) + .push_number(0u64) + .push_number(0u64) + .push_number(0u64) + .push_number(0u64) + .push_address(CALLEE) + .push_number(50_000u64) + .append(CALL) + .append(POP) + .append(STOP) + .build(); + let build_db = || db_with_callee(code.clone(), callee.clone()); + + let plain = transact(MegaSpecId::REX7, build_db(), limits()); + let mut inspector = Observer::default(); + let inspected = transact_inspected(MegaSpecId::REX7, build_db(), limits(), &mut inspector); + + assert!(inspector.steps > 0, "the fixture must actually run opcodes under the inspector"); + assert_eq!(inspector.calls, 2, "one top-level frame plus one inner call"); + assert_eq!(inspector.call_ends, 2, "every call must be paired"); + + assert!( + inspected.inspector_ledger.is_zero(), + "an observation-only inspector must leave an empty ledger; got {:?}", + inspected.inspector_ledger, + ); + assert_eq!(format!("{:?}", inspected.result), format!("{:?}", plain.result)); + assert_eq!(inspected.compute_gas, plain.compute_gas); + assert_eq!(inspected.enforced(), plain.enforced()); + assert_eq!(inspected.destroyed, plain.destroyed); + assert_eq!(inspected.data_size, plain.data_size); + assert_eq!(inspected.kv_updates, plain.kv_updates); + assert_eq!(inspected.state_growth, plain.state_growth); + assert_eq!(inspected.gas_used, plain.gas_used); + assert_eq!(inspected.total_gas_spent, plain.total_gas_spent); + assert_eq!(inspected.terms, plain.terms); + assert_eq!(inspected.state, plain.state, "the produced state must be identical"); +} + +/// A transaction that ran with no inspector at all reports an empty ledger, and the law's `I` term +/// is zero — the shape every consumer of this API sees in practice. +/// +/// The stronger property is what the field is *for*: an all-zero ledger is a consumer's guarantee +/// that the gas numbers next to it are the EVM's own, so it has to be exactly zero rather than +/// merely small. A fixture that makes an inner call and writes storage is used, so the assertion +/// covers a transaction with something for a lane to have picked up. +#[test] +fn test_an_uninspected_transaction_reports_an_empty_ledger() { + let callee = plain_run_code(20); + let code = BytecodeBuilder::default() + .sstore(U256::from(1), U256::from(9)) + .push_number(0u64) + .push_number(0u64) + .push_number(0u64) + .push_number(0u64) + .push_number(0u64) + .push_address(CALLEE) + .push_number(50_000u64) + .append(CALL) + .append(POP) + .append(STOP) + .build(); + let db = db_with_callee(code, callee); + + let plain = transact(MegaSpecId::REX7, db, limits()); + + assert!(plain.result.is_success(), "fixture check: {:?}", plain.result); + assert!( + plain.terms.non_compute_gas > 0, + "fixture check: the transaction must have moved a lane other than compute", + ); + assert_eq!( + plain.inspector_ledger, + InspectorLedger::default(), + "no inspector ran, so every lane must be untouched", + ); + assert_eq!(plain.terms.inspector_conjured_gas, 0, "and the law's inspector term must be zero"); +} + +// === the receipt's other two numbers ========================================================= +// +// The two numbers on a receipt that the conservation law cannot see, and the lanes that do. +// +// The law is stated over `total_gas_spent`, which is `limit - remaining`. A transaction's receipt +// carries two more figures that arithmetic does not reach: the EIP-3529 refund, which decides what +// the sender actually pays, and the EIP-8037 state-gas dimension — a `Gas`'s `reservoir` and its +// `state_gas_spent` counter — which decides how much of the envelope the receipt counts as spent +// at all. +// +// Both are reachable from every callback that is handed a `Gas`, and both were unmeasured. The +// shapes here are what the two lanes now book, and each pins the *reason* its lane is measured +// where it is: +// +// - a **refund** is a quantity the EVM also produces, so only a difference across a callback +// isolates the inspector's share — the lane is measured at the boundary, and is nominal in both +// the senses that can make it differ from what reaches the receipt (the EIP-3529 cap, and the +// chain of successful frame returns an edit has to survive); +// - a **reservoir** is a quantity `MegaETH` never produces at all, and one revm propagates by +// replacement rather than by accumulation, so a boundary difference would book edits the EVM goes +// on to erase. The lane is settled once, from the number the transaction ends with, which is +// exactly the surviving part and is the inspector's in whole. + +/// Gas the fixture's inner `CALL` forwards. +const INNER_CALL_GAS: u64 = 200_000; + +/// A refund large enough that the cap keeps part of it out of the receipt. +const OVERSIZED_REFUND: i64 = 60_000; +/// The EIP-8037 pool an edit fills. +const RESERVOIR: u64 = 10_000; +/// The EIP-8037 spend an edit writes. +const STATE_GAS: i64 = 5_000; + +/// Slot the top frame writes. +const TOP_SLOT: u64 = 0x10; +/// Slot the callee writes. +const CALLEE_SLOT: u64 = 0x20; +/// Slot the callee sets and clears, so the frame ends holding a refund of the EVM's own making. +const CLEARED_SLOT: u64 = 0x30; + +// --- the fixture ------------------------------------------------------------------------------- + +/// How the fixture's callee ends, which is what decides whether its refund travels. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Callee { + /// Writes storage, produces a refund by clearing a slot it just set, and returns. + Returning, + /// Writes storage and reverts, so the EVM discards everything the frame held. + Reverting, +} + +fn caller_code() -> Bytes { + append_call(BytecodeBuilder::default(), CALLEE, INNER_CALL_GAS, 0) + .append(POP) + .sstore(U256::from(TOP_SLOT), U256::from(1u64)) + .append(STOP) + .build() +} + +fn callee_code(callee: Callee) -> Bytes { + let builder = BytecodeBuilder::default() + .sstore(U256::from(CALLEE_SLOT), U256::from(1u64)) + // Set and clear, so the frame ends holding a refund the EVM itself produced. + .sstore(U256::from(CLEARED_SLOT), U256::from(1u64)) + .sstore(U256::from(CLEARED_SLOT), U256::ZERO); + match callee { + Callee::Returning => builder.append(STOP).build(), + Callee::Reverting => builder.revert().build(), + } +} + +fn db_for(callee: Callee) -> MemoryDatabase { + db_with_callee(caller_code(), callee_code(callee)) +} + +// --- the edit ---------------------------------------------------------------------------------- + +/// One edit, applied once, to one of the `Gas` objects a callback is handed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Edit { + /// Add to the running interpreter's refund counter. + RefundAtStep(i64), + /// Add to the finished inner call's refund counter. + RefundAtCallEnd(i64), + /// Fill the running interpreter's EIP-8037 pool. + ReservoirAtStep, + /// Fill it at the one moment the frame is holding a `NewFrame` action, whose child overwrites + /// the pool on the way back. + ReservoirAtSuspension, + /// Fill the pool the inner call's inputs seed the child frame with. + ReservoirOnInputs, + /// Fill the finished inner call's pool. + ReservoirAtCallEnd, + /// Write the running interpreter's EIP-8037 spend counter. + StateGasAtStep, + /// Write the finished inner call's spend counter. + StateGasAtCallEnd, + /// Answer the inner call with a synthetic outcome that echoes the envelope and carries + /// neither figure — the control the two below are read against. + InterceptEcho, + /// The same, carrying a refund the frame never earned. + InterceptWithRefund, + /// The same, carrying an EIP-8037 pool. + InterceptWithReservoir, +} + +impl Edit { + /// Whether this edit answers the frame itself instead of letting the EVM build it. + const fn intercepts(self) -> bool { + matches!( + self, + Self::InterceptEcho | Self::InterceptWithRefund | Self::InterceptWithReservoir + ) + } +} + +/// Applies one [`Edit`], once, and records that it landed. +#[derive(Debug)] +struct Editor { + edit: Edit, + fired: u32, + steps: u64, +} + +impl Editor { + const fn new(edit: Edit) -> Self { + Self { edit, fired: 0, steps: 0 } + } +} + +impl Inspector for Editor { + fn step(&mut self, interp: &mut Interpreter, _context: &mut CTX) { + self.steps += 1; + if self.fired > 0 || self.steps != 4 { + return; + } + match self.edit { + Edit::RefundAtStep(amount) => interp.gas.record_refund(amount), + Edit::ReservoirAtStep => interp.gas.set_reservoir(RESERVOIR), + Edit::StateGasAtStep => interp.gas.set_state_gas_spent(STATE_GAS), + _ => return, + } + self.fired += 1; + } + + fn step_end(&mut self, interp: &mut Interpreter, _context: &mut CTX) { + if self.fired > 0 || self.edit != Edit::ReservoirAtSuspension { + return; + } + // The one window where the pool the frame holds is not the pool that travels: the child + // this action builds was already sized from the pre-edit value, and its own pool + // overwrites this one when it returns. + if !matches!(interp.bytecode.action(), Some(InterpreterAction::NewFrame(_))) { + return; + } + interp.gas.set_reservoir(RESERVOIR); + self.fired += 1; + } + + fn call(&mut self, _context: &mut CTX, inputs: &mut CallInputs) -> Option { + if self.fired > 0 || inputs.target_address != CALLEE { + return None; + } + if self.edit == Edit::ReservoirOnInputs { + inputs.reservoir += RESERVOIR; + self.fired += 1; + return None; + } + if !self.edit.intercepts() { + return None; + } + // The echo convention every tool that intercepts follows: hand back exactly what was + // forwarded, so the gas lanes see nothing and only the figures under test move. + let mut gas = Gas::new(inputs.gas_limit); + match self.edit { + Edit::InterceptWithRefund => gas.record_refund(REFUND), + Edit::InterceptWithReservoir => gas.set_reservoir(RESERVOIR), + _ => {} + } + self.fired += 1; + Some(CallOutcome::new( + InterpreterResult::new(InstructionResult::Stop, Bytes::new(), gas), + inputs.return_memory_offset.clone(), + )) + } + + fn call_end(&mut self, _context: &mut CTX, inputs: &CallInputs, outcome: &mut CallOutcome) { + if self.fired > 0 || inputs.target_address != CALLEE { + return; + } + match self.edit { + Edit::RefundAtCallEnd(amount) => outcome.result.gas.record_refund(amount), + Edit::ReservoirAtCallEnd => outcome.result.gas.set_reservoir(RESERVOIR), + Edit::StateGasAtCallEnd => outcome.result.gas.set_state_gas_spent(STATE_GAS), + _ => return, + } + self.fired += 1; + } +} + +/// Runs the fixture with no inspector at all. +fn transact_plain(callee: Callee) -> Outcome { + transact(MegaSpecId::REX7, db_for(callee), limits()) +} + +/// Runs it with one edit applied, asserting the edit landed exactly once. +fn transact_edited(callee: Callee, edit: Edit) -> Outcome { + let mut editor = Editor::new(edit); + let outcome = transact_inspected(MegaSpecId::REX7, db_for(callee), limits(), &mut editor); + assert_eq!( + editor.fired, 1, + "{edit:?}: the fixture must reach the edit's callback exactly once", + ); + outcome +} + +// --- the fixture's own assumptions --------------------------------------------------------------- + +/// The uninspected run is what the cells below assume it is: it succeeds, it produces a refund of +/// its own, and it reports no EIP-8037 dimension at all. +#[test] +fn test_the_fixture_refunds_on_its_own_and_holds_no_state_gas() { + let plain = transact_plain(Callee::Returning); + assert!(plain.result.is_success(), "{:?}", plain.result); + assert!( + plain.refunded() > 0, + "the callee's cleared slot must leave a refund for the lowering cell to take from", + ); + assert_eq!( + plain.gas_used, + plain.total_gas_spent - plain.refunded(), + "the receipt's two gas numbers differ by exactly the refund", + ); + assert_eq!(plain.state_gas_spent(), 0, "EIP-8037 is off on every MegaETH path"); + assert!(plain.inspector_ledger.is_zero(), "no inspector ran: {:?}", plain.inspector_ledger); +} + +// --- the refund lane +// ------------------------------------------------------------------------------ + +/// A refund written into a running interpreter's counter is booked, and moves what the sender pays +/// without moving the envelope. +#[test] +fn test_a_refund_written_into_a_live_interpreter_is_booked() { + let plain = transact_plain(Callee::Returning); + let edited = transact_edited(Callee::Returning, Edit::RefundAtStep(REFUND)); + + assert_eq!( + edited.inspector_ledger, + InspectorLedger { refund: Lane::once(i128::from(REFUND)), ..InspectorLedger::default() }, + "the shim must book the refund and nothing else", + ); + assert_eq!( + edited.total_gas_spent, plain.total_gas_spent, + "a refund does not move the envelope, which is why the law cannot see it", + ); + assert_eq!( + edited.refunded(), + plain.refunded() + u64::try_from(REFUND).unwrap(), + "but it does move the receipt's refund", + ); + assert_eq!( + edited.gas_used, + plain.gas_used - u64::try_from(REFUND).unwrap(), + "and through it what the sender pays", + ); + assert_eq!( + edited.terms.inspector_conjured_gas, 0, + "the refund lane is deliberately not a term of the law", + ); + assert!(!edited.inspector_ledger.is_zero(), "and the block guard has to see it"); +} + +/// The same edit made at the last callback that holds the finished frame's result. +#[test] +fn test_a_refund_written_into_a_finished_frame_result_is_booked() { + let plain = transact_plain(Callee::Returning); + let edited = transact_edited(Callee::Returning, Edit::RefundAtCallEnd(REFUND)); + + assert_eq!( + edited.inspector_ledger, + InspectorLedger { refund: Lane::once(i128::from(REFUND)), ..InspectorLedger::default() }, + ); + assert_eq!(edited.refunded(), plain.refunded() + u64::try_from(REFUND).unwrap()); + assert_eq!(edited.total_gas_spent, plain.total_gas_spent); +} + +/// A refund taken *out* is booked with the sign that says so — a lane that only saw one direction +/// would report an inspector that raised the sender's bill as having done nothing. +#[test] +fn test_a_refund_taken_out_of_a_frame_is_booked_with_the_sign_that_says_so() { + let plain = transact_plain(Callee::Returning); + assert!( + plain.refunded() >= u64::try_from(REFUND).unwrap(), + "fixture check: there must be a refund to take from, got {}", + plain.refunded(), + ); + + let edited = transact_edited(Callee::Returning, Edit::RefundAtCallEnd(-REFUND)); + assert_eq!( + edited.inspector_ledger, + InspectorLedger { refund: Lane::once(-i128::from(REFUND)), ..InspectorLedger::default() }, + ); + assert_eq!(edited.refunded(), plain.refunded() - u64::try_from(REFUND).unwrap()); + assert_eq!( + edited.gas_used, + plain.gas_used + u64::try_from(REFUND).unwrap(), + "the sender pays more, by exactly what was taken", + ); +} + +/// The lane reports what the inspector wrote, not what the EIP-3529 cap let through. +/// +/// The cap applies to the transaction's whole refund at once, over a sum in which the EVM's own +/// refunds and an inspector's are indistinguishable, at a point past every callback. Splitting it +/// between them needs a priority rule the protocol does not have, so the lane states the edit and +/// the receipt states the effect — and the two are allowed to differ. +#[test] +fn test_the_refund_lane_reports_what_was_written_not_what_the_cap_let_through() { + let plain = transact_plain(Callee::Returning); + let edited = transact_edited(Callee::Returning, Edit::RefundAtStep(OVERSIZED_REFUND)); + + assert_eq!( + edited.inspector_ledger, + InspectorLedger { + refund: Lane::once(i128::from(OVERSIZED_REFUND)), + ..InspectorLedger::default() + }, + "the lane carries the nominal edit", + ); + assert_eq!( + edited.refunded(), + edited.total_gas_spent / 5, + "while the receipt carries the EIP-3529 cap", + ); + assert!( + edited.refunded() < plain.refunded() + u64::try_from(OVERSIZED_REFUND).unwrap(), + "fixture check: the cap must actually bind, or this cell asserts nothing", + ); + assert_eq!(edited.total_gas_spent, plain.total_gas_spent, "the envelope is untouched"); +} + +/// A refund written into a frame the EVM then fails is booked too, even though it reaches nothing. +/// +/// revm hands a frame's refund to its caller only on success, so this edit dies with the frame. +/// The lane books it anyway, because the alternative is a rule that has to track every frame +/// between the edit and the top — and because a lane that under-reports lets exactly the shape +/// this module exists to catch into a block, while over-reporting costs nothing: the law has no +/// term for it. +#[test] +fn test_a_refund_the_frame_chain_discards_is_still_booked() { + let plain = transact_plain(Callee::Reverting); + let edited = transact_edited(Callee::Reverting, Edit::RefundAtCallEnd(REFUND)); + + assert_eq!( + edited.inspector_ledger, + InspectorLedger { refund: Lane::once(i128::from(REFUND)), ..InspectorLedger::default() }, + "the lane books the edit", + ); + assert_eq!( + edited.refunded(), + plain.refunded(), + "the receipt is unmoved: a reverting frame hands its caller no refund", + ); + assert_eq!(edited.gas_used, plain.gas_used); +} + +// --- the EIP-8037 state-gas dimension ------------------------------------------------------------ + +/// A reservoir an inspector fills is gas the transaction never funded: the receipt reports that +/// much less spent, and the law needs it back. +#[test] +fn test_a_reservoir_written_into_a_live_interpreter_is_booked_and_the_law_closes() { + let plain = transact_plain(Callee::Returning); + let edited = transact_edited(Callee::Returning, Edit::ReservoirAtStep); + + assert_eq!( + edited.inspector_ledger, + InspectorLedger { + reservoir: Lane::once(i128::from(RESERVOIR)), + ..InspectorLedger::default() + }, + ); + assert_eq!( + edited.total_gas_spent, + plain.total_gas_spent - RESERVOIR, + "the receipt counts the pool as unspent, so the envelope shrinks by exactly it", + ); + assert_eq!( + edited.terms.inspector_conjured_gas, + i128::from(RESERVOIR), + "which is why this lane, unlike the refund one, is a term of the law", + ); +} + +/// The same, written into the pool a call's inputs seed the child frame with. +#[test] +fn test_a_reservoir_written_into_a_frame_input_is_booked() { + let plain = transact_plain(Callee::Returning); + let edited = transact_edited(Callee::Returning, Edit::ReservoirOnInputs); + + assert_eq!( + edited.inspector_ledger, + InspectorLedger { + reservoir: Lane::once(i128::from(RESERVOIR)), + // The inputs came back changed in a field the envelope lane does not cover, which the + // rewrite comparison books on its own. + interventions: 1, + ..InspectorLedger::default() + }, + ); + assert_eq!(edited.total_gas_spent, plain.total_gas_spent - RESERVOIR); +} + +/// And into the finished frame's own pool, which its caller takes whatever the classification. +#[test] +fn test_a_reservoir_written_into_a_finished_frame_result_is_booked() { + let plain = transact_plain(Callee::Returning); + let edited = transact_edited(Callee::Returning, Edit::ReservoirAtCallEnd); + + assert_eq!( + edited.inspector_ledger, + InspectorLedger { + reservoir: Lane::once(i128::from(RESERVOIR)), + ..InspectorLedger::default() + }, + ); + assert_eq!(edited.total_gas_spent, plain.total_gas_spent - RESERVOIR); +} + +/// A reservoir edit the EVM overwrites books nothing — and there is nothing to book, because the +/// run it produces is the run the EVM would have produced alone. +/// +/// This is the window that decides where the lane is measured. A difference taken across this +/// callback would say `RESERVOIR` was conjured; the transaction says otherwise, and the settlement +/// point is the only reading that agrees with it. +#[test] +fn test_a_reservoir_edit_the_evm_overwrites_books_nothing() { + let plain = transact_plain(Callee::Returning); + let edited = transact_edited(Callee::Returning, Edit::ReservoirAtSuspension); + + assert!( + edited.inspector_ledger.is_zero(), + "an edit the child frame's own pool replaces moved nothing: {:?}", + edited.inspector_ledger, + ); + assert_eq!(edited.total_gas_spent, plain.total_gas_spent); + assert_eq!(edited.gas_used, plain.gas_used); + assert_eq!(edited.refunded(), plain.refunded()); +} + +/// The spend counter's own effect on the receipt: a successful transaction reports it, whether or +/// not EIP-8037 is enabled. +#[test] +fn test_state_gas_written_into_a_live_interpreter_reaches_the_receipt_and_is_booked() { + let plain = transact_plain(Callee::Returning); + let edited = transact_edited(Callee::Returning, Edit::StateGasAtStep); + + assert_eq!(plain.state_gas_spent(), 0, "fixture check"); + assert_eq!( + edited.state_gas_spent(), + u64::try_from(STATE_GAS).unwrap(), + "the receipt reports what was written", + ); + assert_eq!( + edited.inspector_ledger, + InspectorLedger { + state_gas: Lane::once(i128::from(STATE_GAS)), + ..InspectorLedger::default() + }, + ); + assert_eq!( + edited.total_gas_spent, plain.total_gas_spent, + "the envelope is untouched, so this lane is not a term of the law either", + ); + assert_eq!(edited.terms.inspector_conjured_gas, 0); +} + +/// The counter's *other* effect, at a site no callback sees: a frame that fails folds its spend +/// counter back into its caller's pool, which turns a state-gas edit into an envelope-moving one. +/// +/// The lane that catches it is the reservoir's, not the state-gas one, because the fold has +/// already happened by the time either is read. That is the second reason the two are settled from +/// the transaction's final figures rather than differenced across a callback. +#[test] +fn test_state_gas_on_a_failing_frame_becomes_its_callers_reservoir() { + let plain = transact_plain(Callee::Reverting); + let edited = transact_edited(Callee::Reverting, Edit::StateGasAtCallEnd); + + assert_eq!( + edited.inspector_ledger, + InspectorLedger { + reservoir: Lane::once(i128::from(STATE_GAS)), + ..InspectorLedger::default() + }, + "the spend counter of a reverting frame arrives in its caller as a pool", + ); + assert_eq!( + edited.state_gas_spent(), + 0, + "and not as a spend: a failing frame's counter is not accumulated", + ); + assert_eq!( + edited.total_gas_spent, + plain.total_gas_spent - u64::try_from(STATE_GAS).unwrap(), + "so the envelope moves, and the law's term has to move with it", + ); +} + +// --- a frame the inspector answers itself +// --------------------------------------------------------- + +/// A synthetic outcome carries figures of its own, and there is no EVM-produced number on the +/// other side of the callback to difference against — so the whole of what it carries is the +/// inspector's, measured against nothing rather than against a baseline. +/// +/// The echo control is what makes the two cells below readings of the figures rather than of the +/// interception: it moves the gas lanes not at all, which is the convention every tool that +/// intercepts follows. +#[test] +fn test_a_synthetic_outcome_carries_its_own_figures() { + let echo = transact_edited(Callee::Returning, Edit::InterceptEcho); + assert_eq!( + echo.inspector_ledger, + InspectorLedger { interventions: 1, ..InspectorLedger::default() }, + "an echoing interception moves no figure at all", + ); + + let refunding = transact_edited(Callee::Returning, Edit::InterceptWithRefund); + assert_eq!( + refunding.inspector_ledger, + InspectorLedger { + refund: Lane::once(i128::from(REFUND)), + interventions: 1, + ..InspectorLedger::default() + }, + "the refund a frame that never ran hands back is the inspector's in whole", + ); + assert_eq!( + refunding.refunded(), + echo.refunded() + u64::try_from(REFUND).unwrap(), + "and it reaches the receipt: the outcome succeeded, so its caller records it", + ); + assert_eq!(refunding.total_gas_spent, echo.total_gas_spent, "the envelope is unmoved"); + + let pooled = transact_edited(Callee::Returning, Edit::InterceptWithReservoir); + assert_eq!( + pooled.inspector_ledger, + InspectorLedger { + reservoir: Lane::once(i128::from(RESERVOIR)), + interventions: 1, + ..InspectorLedger::default() + }, + ); + assert_eq!( + pooled.total_gas_spent, + echo.total_gas_spent - RESERVOIR, + "a pool does move the envelope, wherever it came from", + ); +} + +// --- the frozen specs +// ----------------------------------------------------------------------------- + +/// On a frozen spec the two lanes report and settle nothing. +/// +/// The shim is not spec-gated, and must not be: the block guard has to see a rewritten receipt +/// whichever spec produced it. What is gated is the accounting the lanes feed, so a frozen spec's +/// own numbers have to be exactly what they were — which is what this reads, by comparing an +/// edited run against an unedited one on the same spec. +#[test] +fn test_a_frozen_spec_reports_the_lanes_without_settling_anything() { + const REX6: MegaSpecId = MegaSpecId::REX6; + fn run(edit: Option) -> Outcome { + let db = db_for(Callee::Returning); + let limits = EvmTxRuntimeLimits::from_spec(REX6); + match edit { + Some(edit) => { + let mut editor = Editor::new(edit); + let outcome = transact_inspected(REX6, db, limits, &mut editor); + assert_eq!(editor.fired, 1, "{edit:?} must land"); + outcome + } + None => transact(REX6, db, limits), + } + } + + let plain = run(None); + assert!(plain.inspector_ledger.is_zero()); + + for (edit, expected) in [ + ( + Edit::RefundAtStep(REFUND), + InspectorLedger { + refund: Lane::once(i128::from(REFUND)), + ..InspectorLedger::default() + }, + ), + ( + Edit::ReservoirAtStep, + InspectorLedger { + reservoir: Lane::once(i128::from(RESERVOIR)), + ..InspectorLedger::default() + }, + ), + ( + Edit::StateGasAtStep, + InspectorLedger { + state_gas: Lane::once(i128::from(STATE_GAS)), + ..InspectorLedger::default() + }, + ), + ] { + let edited = run(Some(edit)); + assert_eq!(edited.inspector_ledger, expected, "{edit:?}: the lane reports on every spec"); + assert_eq!( + edited.compute_gas, plain.compute_gas, + "{edit:?}: a frozen spec's compute total must not move", + ); + assert_eq!(edited.destroyed, plain.destroyed, "{edit:?}: nor its destroyed lane"); + // `inspector_conjured_gas` is a reading of the ledger rather than something the + // transaction recorded, so it moves with the lane on every spec. Every other term is what + // a frozen spec must leave alone. + assert_eq!( + ConservationTerms { inspector_conjured_gas: 0, ..edited.terms }, + plain.terms, + "{edit:?}: nothing a frozen spec records may move", + ); + assert_eq!( + edited.terms.inspector_conjured_gas, + edited.inspector_ledger.conjured_gas(), + "{edit:?}: and the term is the ledger's net, exactly as it is under REX7", + ); + } +} diff --git a/crates/mega-evm/tests/rex7/shim_refusals.rs b/crates/mega-evm/tests/rex7/shim_refusals.rs new file mode 100644 index 00000000..e5f9694c --- /dev/null +++ b/crates/mega-evm/tests/rex7/shim_refusals.rs @@ -0,0 +1,476 @@ +//! The rewrites the shim refuses outright, and the near boundary of each refusal. +//! +//! Almost everything an inspector does is measured and booked. Two shapes are not, because +//! honouring them would produce a receipt that contradicts state the EVM had already decided on +//! before any callback ran: +//! +//! - **A failed contract creation rewritten into a successful one.** By the time `create_end` or +//! `frame_end` runs, revm has reverted the frame's checkpoint and declined to deposit any code, +//! so the rewrite reports a deployment that did not happen. Both callbacks are covered, because a +//! refusal wired only to the earlier one is one an inspector can step past. +//! - **The classification of a result frame init produced.** A precompile, an empty-code call, and +//! the `KeylessDeploy` interceptor's synthetic result all come back out of frame init with the +//! journal decision behind them already taken and no frame checkpoint left to unwind. What each +//! case below pins is the state that decision left behind, and that the caller is not told +//! something else about it. +//! +//! The near boundary is a frame the inspector answered *itself*. That result comes out of frame +//! init too, and it is not refused — nothing in the EVM decided anything for it, so there is +//! nothing for the rewrite to contradict. +//! +//! Both halves surface the same way in both builds: a debug build asserts (the shape is a +//! detector, and a corpus that produces it should stop), a release build fails the transaction +//! with the same message. + +use crate::{ + common::{ + base_db, context, keyless_tx_bytes, try_drive, CALLEE, CALLER, CONTRACT, EMPTY_TARGET, + ONE_ETH, + }, + inspector_common::{ + append_call, assert_refused, deploy_then_stop, limits, try_transact_inspected, + REVERTING_INIT_CODE, REVIVED_CREATION, + }, +}; +use alloy_primitives::{address, Address, Bytes, B256, U256}; +use alloy_sol_types::SolCall as _; +use mega_evm::{ + test_utils::{BytecodeBuilder, MemoryDatabase}, + EmptyExternalEnv, EvmTxRuntimeLimits, IKeylessDeploy, MegaContext, MegaEvm, MegaHaltReason, + MegaSpecId, MegaTransaction, MegaTransactionNew as _, KEYLESS_DEPLOY_ADDRESS, +}; +use revm::{ + bytecode::opcode::{RETURN, SSTORE, STOP}, + context::{result::ExecutionResult, tx::TxEnvBuilder, ContextTr}, + handler::FrameResult, + interpreter::{ + CallInputs, CallOutcome, CreateInputs, CreateOutcome, FrameInput, Gas, InstructionResult, + InterpreterResult, InterpreterTypes, + }, + state::EvmState, + Inspector, +}; +use std::string::String; + +// === a failed creation, revived =============================================================== + +/// Rewrites every failed contract creation into a successful one, from `create_end`. +#[derive(Default)] +struct CreateReviver; + +impl Inspector for CreateReviver { + fn create_end( + &mut self, + _context: &mut CTX, + _inputs: &CreateInputs, + outcome: &mut CreateOutcome, + ) { + if !outcome.result.result.is_ok() { + outcome.result.result = InstructionResult::Return; + } + } +} + +/// A `create_end` that turns a failed contract creation into a successful one is refused, loudly. +/// +/// By that point revm has already reverted the frame's journal checkpoint and already declined to +/// deposit any code, so the rewrite would report a deployment that did not happen. The shim +/// restores the original classification and refuses to let the transaction produce a receipt at +/// all. +#[test] +fn test_reviving_a_failed_creation_is_refused() { + let db = base_db(deploy_then_stop(&REVERTING_INIT_CODE)); + + assert_refused(REVIVED_CREATION, || { + let mut inspector = CreateReviver; + try_transact_inspected(db.clone(), limits(), &mut inspector) + }); +} + +/// `frame_end` is the last callback that can rewrite a creation's classification, and the refusal +/// covers it too. +/// +/// `measured_inspector.rs` pins the `create_end` form. This is the one callback later: revm calls +/// `create_end` first and `frame_end` after it, so an inspector that leaves `create_end` alone and +/// rewrites in `frame_end` would slip past a refusal wired only to the earlier one. +#[test] +fn test_reviving_a_failed_creation_is_refused_at_frame_end() { + /// Rewrites a failed creation into a success, from `frame_end` only. + struct LateReviver; + + impl Inspector for LateReviver { + fn frame_end( + &mut self, + _context: &mut CTX, + _frame_input: &FrameInput, + frame_result: &mut FrameResult, + ) { + let FrameResult::Create(outcome) = frame_result else { return }; + if !outcome.result.result.is_ok() { + outcome.result.result = InstructionResult::Stop; + } + } + } + + let db = base_db(deploy_then_stop(&REVERTING_INIT_CODE)); + + assert_refused(REVIVED_CREATION, || { + try_transact_inspected(db.clone(), limits(), &mut LateReviver) + }); +} + +// === the classification of a result frame init produced ======================================= + +/// Transaction gas limit: high enough that EVM gas never binds. +const TX_GAS_LIMIT: u64 = 30_000_000; + +/// `ecrecover`, the precompile the failing-precompile case calls. +const ECRECOVER: Address = address!("0000000000000000000000000000000000000001"); + +/// The relayer that sends the keyless deployment. +const RELAYER: Address = address!("0000000000000000000000000000000000340009"); + +/// The slot `CONTRACT` writes its `CALL`'s success flag to. +const FLAG_SLOT: U256 = U256::from_limbs([7, 0, 0, 0]); + +/// The wei the value-transferring cases send. +const SENT: u64 = 1; + +/// What one run produced, in the shape the splits are asserted over. +struct Reading { + result: Result, String>, + rejected_rewrites: u32, + state: EvmState, +} + +impl Reading { + /// The balance the produced state gives `address`, or zero when it never touched it. + fn balance(&self, address: Address) -> U256 { + self.state.get(&address).map(|a| a.info.balance).unwrap_or_default() + } + + /// The value at `slot` on `address` in the produced state. + fn storage(&self, address: Address, slot: U256) -> U256 { + self.state + .get(&address) + .and_then(|a| a.storage.get(&slot)) + .map(|s| s.present_value()) + .unwrap_or_default() + } + + /// Whether the produced state gives `address` any code. + fn has_code(&self, address: Address) -> bool { + self.state + .get(&address) + .is_some_and(|a| a.info.code_hash != B256::ZERO && !a.info.is_empty_code_hash()) + } + + /// Whether the transaction produced a receipt at all, and a successful one. + fn succeeded(&self) -> bool { + matches!(&self.result, Ok(r) if r.is_success()) + } +} + +/// Rewrites the classification of the result of every call into `target`, once. +#[derive(Debug)] +struct RewriteInitResult { + target: Address, + to: InstructionResult, + /// How many results it actually rewrote. Asserted, so a fixture that stops reaching the + /// callback fails rather than passing as a run that rewrote nothing. + fired: u32, +} + +impl RewriteInitResult { + const fn new(target: Address, to: InstructionResult) -> Self { + Self { target, to, fired: 0 } + } +} + +impl Inspector for RewriteInitResult +where + CTX: ContextTr, + INTR: InterpreterTypes, +{ + fn call_end(&mut self, _context: &mut CTX, inputs: &CallInputs, outcome: &mut CallOutcome) { + if inputs.target_address != self.target || outcome.result.result == self.to { + return; + } + outcome.result.result = self.to; + self.fired += 1; + } +} + +/// Runs `tx` under `spec` with `inspector` attached. +fn run_on( + spec: MegaSpecId, + mut db: MemoryDatabase, + tx: MegaTransaction, + inspector: &mut I, +) -> Reading +where + I: for<'a> Inspector>, +{ + let limits = EvmTxRuntimeLimits::from_spec(spec); + let mut evm = MegaEvm::new(context(&mut db, spec, limits)).with_inspector(inspector); + match try_drive(spec, &mut evm, tx) { + Ok(outcome) => Reading { + result: Ok(outcome.result), + rejected_rewrites: outcome.inspector_ledger.rejected_rewrites, + state: outcome.state, + }, + Err(refusal) => Reading { + result: Err(refusal.error), + rejected_rewrites: refusal.rejected_rewrites, + state: EvmState::default(), + }, + } +} + +/// [`run_on`] under REX7, which is where every rewrite below is refused. +fn run(db: MemoryDatabase, tx: MegaTransaction, inspector: &mut I) -> Reading +where + I: for<'a> Inspector>, +{ + run_on(MegaSpecId::REX7, db, tx, inspector) +} + +/// The two facts every case here pins: the rewrite was counted as refused, and the transaction +/// failed with an error rather than reporting a receipt built on it. +fn assert_reading_refused(reading: &Reading) { + assert_eq!(reading.rejected_rewrites, 1, "the shim must count the refusal"); + assert!( + reading.result.is_err(), + "a refused rewrite must fail the transaction, got {:?}", + reading.result, + ); +} + +fn call_tx(to: Address) -> MegaTransaction { + let mut tx = MegaTransaction::new( + TxEnvBuilder::default().caller(CALLER).call(to).gas_limit(TX_GAS_LIMIT).build_fill(), + ); + tx.enveloped_tx = Some(Bytes::new()); + tx +} + +/// A contract that calls `target` with `value` wei and `gas`, then records whether the call +/// reported success. +/// +/// The recorded flag is what makes the split visible: it is the answer the *caller* was given, +/// which the state the call left behind has to agree with. +fn calls_and_records(target: Address, gas: u64, value: u64) -> Bytes { + append_call(BytecodeBuilder::default(), target, gas, value) + .push_u256(FLAG_SLOT) + .append(SSTORE) + .append(STOP) + .build() +} + +/// A value-transferring `CALL` into an empty-code account, rewritten from its `Stop` into a +/// revert. +/// +/// `make_call_frame` transfers the value and commits its checkpoint before returning `Stop`, so +/// the transfer is already in the journal by the time any callback runs and no journal decision is +/// left to follow the rewrite. Honouring it tells the caller its transfer failed while the +/// recipient keeps the wei. +#[test] +fn test_rewriting_an_empty_code_call_into_a_revert_is_refused() { + let mut inspector = RewriteInitResult::new(EMPTY_TARGET, InstructionResult::Revert); + let reading = run( + base_db(calls_and_records(EMPTY_TARGET, 200_000, SENT)), + call_tx(CONTRACT), + &mut inspector, + ); + assert_eq!(inspector.fired, 1, "the fixture must reach the callback exactly once"); + assert!( + !reading.succeeded(), + "the rewrite was honoured: the caller recorded the transfer as {} and {EMPTY_TARGET} \ + holds {}", + reading.storage(CONTRACT, FLAG_SLOT), + reading.balance(EMPTY_TARGET), + ); + assert_reading_refused(&reading); +} + +/// A value-transferring `CALL` into a precompile that cannot afford its own fee, rewritten from +/// its out-of-gas into a success. +/// +/// The split runs the other way: the precompile's failure made `make_call_frame` revert its +/// checkpoint, so the transfer is already rolled back. A success there tells the caller the +/// precompile was paid. +#[test] +fn test_reviving_a_failed_precompile_call_is_refused() { + // The 2,300 gas stipend a value-transferring call mints is under `ecrecover`'s 3,000 fee, so + // the precompile is reached and cannot pay. + let mut inspector = RewriteInitResult::new(ECRECOVER, InstructionResult::Stop); + let reading = + run(base_db(calls_and_records(ECRECOVER, 0, SENT)), call_tx(CONTRACT), &mut inspector); + assert_eq!(inspector.fired, 1, "the fixture must reach the callback exactly once"); + assert!( + !reading.succeeded(), + "the rewrite was honoured: the caller recorded the call as {} and {ECRECOVER} holds {}", + reading.storage(CONTRACT, FLAG_SLOT), + reading.balance(ECRECOVER), + ); + assert_reading_refused(&reading); +} + +/// The address the keyless transaction above deploys to, recovered from the receipt of an +/// unrewritten run. +fn deployed_address(reading: &Reading) -> Option
{ + let Ok(ExecutionResult::Success { output, .. }) = &reading.result else { return None }; + IKeylessDeploy::keylessDeployCall::abi_decode_returns(output.data()) + .ok() + .map(|returns| returns.deployedAddress) +} + +/// The `KeylessDeploy` interceptor's synthetic result, rewritten across the boundary. +/// +/// The interceptor runs a whole sandbox EVM and merges its state into the journal before it +/// returns, and it returns out of frame init, so there is no frame checkpoint the rewrite could +/// unwind. The deployment stands whatever the caller is told. +#[test] +fn test_rewriting_the_keyless_deploy_synthetic_result_is_refused() { + let deploy_tx = || { + let data = IKeylessDeploy::keylessDeployCall { + keylessDeploymentTransaction: keyless_tx_bytes( + // MSTORE8 a STOP at offset 0, then return that one byte as the runtime code. + BytecodeBuilder::default() + .push_number(u128::from(STOP)) + .push_number(0u64) + .append(0x53) // MSTORE8 + .push_number(1u64) + .push_number(0u64) + .append(RETURN) + .build(), + 200_000, + ), + gasLimitOverride: U256::from(1_000_000u64), + } + .abi_encode(); + let mut tx = MegaTransaction::new( + TxEnvBuilder::default() + .caller(RELAYER) + .call(KEYLESS_DEPLOY_ADDRESS) + .chain_id(Some(1)) + .data(Bytes::from(data)) + .gas_limit(TX_GAS_LIMIT) + .build_fill(), + ); + tx.enveloped_tx = Some(Bytes::new()); + tx + }; + let db = || MemoryDatabase::default().account_balance(RELAYER, U256::from(10 * ONE_ETH)); + + // The unrewritten run, which says where the deployment lands and that it lands at all. + let mut observer = RewriteInitResult::new(Address::ZERO, InstructionResult::Stop); + let plain = run(db(), deploy_tx(), &mut observer); + assert!(plain.succeeded(), "the fixture must deploy, got {:?}", plain.result); + let deployed = deployed_address(&plain).expect("the fixture must report a deployed address"); + assert!(plain.has_code(deployed), "the fixture must leave code at {deployed}"); + + let mut inspector = RewriteInitResult::new(KEYLESS_DEPLOY_ADDRESS, InstructionResult::Revert); + let reading = run(db(), deploy_tx(), &mut inspector); + assert_eq!(inspector.fired, 1, "the fixture must reach the callback exactly once"); + assert!( + !reading.has_code(deployed), + "the rewrite was honoured: the caller was told {:?} and {deployed} holds the sandbox's \ + deployed code anyway", + reading.result, + ); + assert_reading_refused(&reading); +} + +/// Answers the frame itself and then moves the classification of its own answer. +/// +/// The near boundary of the refusal: this result also comes back out of frame init with no child +/// frame built, and it is not refused — because nothing in the EVM decided anything for it. No +/// checkpoint was opened and no state was written, so there is no journal decision for a later +/// rewrite to contradict. +#[derive(Debug)] +struct AnswerThenRewrite { + target: Address, + answered: u32, + rewrote: u32, +} + +impl Inspector for AnswerThenRewrite +where + CTX: ContextTr, + INTR: InterpreterTypes, +{ + fn call(&mut self, _context: &mut CTX, inputs: &mut CallInputs) -> Option { + if inputs.target_address != self.target { + return None; + } + self.answered += 1; + Some(CallOutcome::new( + InterpreterResult::new( + InstructionResult::Stop, + Bytes::new(), + Gas::new(inputs.gas_limit), + ), + inputs.return_memory_offset.clone(), + )) + } + + fn call_end(&mut self, _context: &mut CTX, inputs: &CallInputs, outcome: &mut CallOutcome) { + if inputs.target_address != self.target || outcome.result.result.is_revert() { + return; + } + outcome.result.result = InstructionResult::Revert; + self.rewrote += 1; + } +} + +/// A frame the inspector answered itself, and then reclassified, is supported. +/// +/// The refusal is stated over what frame init *produced*, not over every result that reaches a +/// callback without a frame having run. An intercepting callback's own outcome is the inspector's +/// in whole — the EVM opened no checkpoint for it and wrote no state — so moving its +/// classification contradicts nothing, and is booked as an ordinary intervention. +#[test] +fn test_rewriting_an_inspector_s_own_synthetic_outcome_is_supported() { + let mut inspector = AnswerThenRewrite { target: CALLEE, answered: 0, rewrote: 0 }; + let reading = run( + base_db(calls_and_records(CALLEE, 200_000, 0)).account_code( + CALLEE, + BytecodeBuilder::default().sstore(U256::from(1), U256::from(7)).stop().build(), + ), + call_tx(CONTRACT), + &mut inspector, + ); + assert_eq!(inspector.answered, 1, "the fixture must reach the answering callback once"); + assert_eq!(inspector.rewrote, 1, "and the rewriting one once"); + assert_eq!(reading.rejected_rewrites, 0, "an inspector's own answer is not refused"); + assert!(reading.succeeded(), "the transaction must still execute, got {:?}", reading.result); + assert_eq!( + reading.storage(CONTRACT, FLAG_SLOT), + U256::ZERO, + "the caller must be handed the classification the callback last wrote", + ); + assert_eq!( + reading.storage(CALLEE, U256::from(1)), + U256::ZERO, + "no frame ran, so there is no write for the rewrite to disagree with", + ); +} + +/// The same three rewrites under the frozen spec, which does not defend against them. +/// +/// REX6 is closed: what it replays includes whatever an inspector on that path produced, so the +/// refusal is REX7-only and this pins that it is. +#[test] +fn test_the_frozen_spec_refuses_nothing() { + let mut inspector = RewriteInitResult::new(EMPTY_TARGET, InstructionResult::Revert); + let reading = run_on( + MegaSpecId::REX6, + base_db(calls_and_records(EMPTY_TARGET, 200_000, SENT)), + call_tx(CONTRACT), + &mut inspector, + ); + + assert_eq!(reading.rejected_rewrites, 0, "a frozen spec refuses nothing"); + assert!(reading.succeeded(), "the frozen run must still succeed, got {:?}", reading.result); +} diff --git a/crates/mega-evm/tests/rex7/shim_settlement.rs b/crates/mega-evm/tests/rex7/shim_settlement.rs new file mode 100644 index 00000000..1030df44 --- /dev/null +++ b/crates/mega-evm/tests/rex7/shim_settlement.rs @@ -0,0 +1,1073 @@ +//! Where a rewrite is settled, in the two places the shim's reading and the envelope's number are +//! not the same object. +//! +//! Both halves of the measurement shim rest on the same claim: what the shim books is what the +//! transaction's envelope actually moved by. Two groups of fixture stand behind it, in the order +//! they appear: +//! +//! 1. **The two settlement windows** — a terminating opcode's `step_end`, whose counter edit +//! reaches nobody, and a precompile's classification, whose split has to follow the callback +//! rather than the recording site. +//! 2. **Interception** — the gas an inspector puts into a synthetic outcome, over the four sizings +//! it can choose relative to the envelope it was handed, and the halt direction where the choice +//! reaches nothing. +//! +//! What each lane books is in `shim_lanes.rs`, and the shapes an all-zero ledger used to admit are +//! in `shim_blind_spots.rs`. The rewrites the shim *refuses* are in `shim_refusals.rs`; the +//! exhaustive callback × shape sweep is in `inspector_cheat_matrix.rs`. + +use crate::{ + common::{ + base_db, transact, transact_inspected, transact_inspected_refused, Outcome, Refusal, + CALLEE, DEFAULT_TX_GAS_LIMIT, + }, + inspector_common::{ + call_then_stop, db_with_callee, deploy_then_stop, limits, plain_run_code, ACTION_DELTA, + }, +}; +use alloy_primitives::{address, Address, Bytes, U256}; +use mega_evm::{ + kzg_point_evaluation, + test_utils::{BytecodeBuilder, MemoryDatabase}, + EvmTxRuntimeLimits, InspectorLedger, Lane, MegaSpecId, +}; +use revm::{ + bytecode::opcode::{CALL, INVALID, MSTORE, POP, RETURN, STOP}, + context::ContextTr, + handler::FrameResult, + interpreter::{ + interpreter_types::LoopControl, CallInputs, CallOutcome, CreateInputs, CreateOutcome, + FrameInput, Gas, InstructionResult, Interpreter, InterpreterAction, InterpreterResult, + InterpreterTypes, + }, + Inspector, +}; +use sha2::{Digest, Sha256}; +use std::vec::Vec; + +// === the two settlement windows ============================================================== +// +// The two windows in which a rewrite lands after the accounting that should have read it. +// +// Both halves of the measurement shim rest on the same claim: what the shim books is what the +// transaction's envelope actually moved by. There are two places where the number the shim reads +// and the number the envelope carries are not the same object, and each of them is a fixture +// here. +// +// - **A terminating opcode's `step_end`.** revm's inspected loop runs `step_end` *after* the +// instruction that produced the frame's action, and that action carries its own copy of the gas +// counter. An edit to `interp.gas` at that moment changes the counter `MegaETH`'s tail settlement +// measures work against and nothing the caller will ever see, so it must move the settlement +// baseline and must not move the ledger. The two neighbouring windows — a `step_end` in +// mid-frame, and the one after a `CALL` has set a `NewFrame` action — are the boundary of that +// rule: the frame resumes on the edited counter in both, so both are booked. +// +// - **A precompile's classification.** A precompile is answered inside the frame init and never +// becomes a child frame, so its recording site is the only place that knows the forwarded +// envelope and the work performed. The split is nonetheless settled at the frame's settlement +// point, from what that site staged, exactly as an ordinary frame's is — because a callback runs +// in between, and the classification is what decides whether the caller reclaims the remainder. +// What that callback may do to the classification is bounded: the journal decision behind a +// result frame init produced was taken before any callback ran and is not reachable from one, so +// a rewrite that moves such a result across the success / revert / halt boundary is refused and +// the settlement reads the classification the EVM produced. The cases below pin the uninspected +// split each precompile arm produces, and the refusal that keeps it the one the settlement sees. +// +// Every case here is checked by the identity `common::finish` runs on every transaction: the +// tracker lanes must account for the whole receipt envelope, with the inspector's own term in it. + +/// Gas the edit-once inspector writes into a live interpreter's counter. +const INJECT: u64 = 1_000; + +/// Gas every probed CALL forwards. Well inside the 63/64 rule at the default transaction gas +/// limit and well inside the default compute budget, so the forwarded envelope is exactly this. +const PROBE_GAS: u64 = 1_000_000; + +/// The transaction gas limit is not what binds any fixture here — pinned at compile time, so a +/// change to the shared limit cannot silently turn a destroyed-remainder case into an +/// out-of-gas one. +const _: () = assert!(DEFAULT_TX_GAS_LIMIT > 10 * PROBE_GAS); + +/// The identity precompile. +const IDENTITY: Address = address!("0000000000000000000000000000000000000004"); +/// blake2f. Rejects any input whose length is not 213 bytes, before charging anything. +const BLAKE2F: Address = address!("0000000000000000000000000000000000000009"); +/// KZG point evaluation. +const KZG: Address = address!("000000000000000000000000000000000000000a"); + +// --- A: the window a terminating opcode's `step_end` sits in --------------------------------- + +/// Which of the three `step_end` windows an edit is aimed at, told apart by the action the +/// instruction that just ran left behind. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Window { + /// No action yet: the frame carries on, and the edited counter is what it carries on with. + MidFrame, + /// A `NewFrame` action: the frame suspends into a child and then resumes on this counter. + Suspending, + /// A `Return` action: the frame is over, and the gas it hands back was copied into the action + /// before this callback ran. + Terminating, +} + +impl Window { + fn of(interp: &mut Interpreter) -> Self { + match interp.bytecode.action() { + None => Self::MidFrame, + Some(InterpreterAction::NewFrame(_)) => Self::Suspending, + Some(InterpreterAction::Return(_)) => Self::Terminating, + } + } +} + +/// Writes [`INJECT`] into the interpreter's counter once, at the first `step_end` that sits in +/// `window`. +#[derive(Debug)] +struct CounterEditor { + window: Window, + fired: u32, +} + +impl CounterEditor { + fn new(window: Window) -> Self { + Self { window, fired: 0 } + } +} + +impl Inspector for CounterEditor { + fn step_end(&mut self, interp: &mut Interpreter, _context: &mut CTX) { + if self.fired > 0 || Window::of(interp) != self.window { + return; + } + self.fired += 1; + interp.gas.erase_cost(INJECT); + } +} + +/// `PUSH1 1; POP; STOP` — three opcodes, so a mid-frame `step_end` and a terminating one are both +/// reached, and nothing else happens in between. +fn straight_line_code() -> Bytes { + BytecodeBuilder::default().push_number(1u64).append(POP).append(STOP).build() +} + +/// A `CALL` into the identity precompile, its success flag popped, then `STOP` — so the frame +/// suspends once and the `step_end` after the `CALL` opcode sits in [`Window::Suspending`]. +fn suspending_code() -> Bytes { + call_then_stop(IDENTITY, PROBE_GAS) +} + +fn run_counter_edit(code: Bytes, window: Window) -> (Outcome, Outcome, u32) { + let plain = transact(MegaSpecId::REX7, base_db(code.clone()), limits()); + let mut inspector = CounterEditor::new(window); + let edited = transact_inspected(MegaSpecId::REX7, base_db(code), limits(), &mut inspector); + (plain, edited, inspector.fired) +} + +/// An edit made in the terminating window reaches nobody, so nothing is booked for it — and the +/// transaction is the one the EVM would have produced alone. +/// +/// The action the terminating instruction set already holds its own copy of the counter, so the +/// caller is handed a number this edit never touched. Booking it would tell the conservation law +/// that the transaction spent [`INJECT`] less than it did. +/// +/// `compute_gas` being unmoved is the other half of the rule, and the one that would break if the +/// fix were written as "leave the counter alone" rather than "book nothing for it": the tail +/// settlement measures work as a drop in this very counter, so without the baseline shift the +/// injection would read as [`INJECT`] gas of work the frame never performed. +#[test] +fn test_an_edit_in_the_terminating_window_is_not_booked() { + let (plain, edited, fired) = run_counter_edit(straight_line_code(), Window::Terminating); + + assert_eq!(fired, 1, "the fixture must reach a terminating step_end exactly once"); + assert_eq!( + edited.inspector_ledger, + InspectorLedger::default(), + "an edit that cannot reach the envelope must leave the ledger untouched", + ); + assert_eq!( + edited.compute_gas, plain.compute_gas, + "the settlement baseline must absorb the edit, so it counts as no work at all", + ); + assert_eq!( + edited.total_gas_spent, plain.total_gas_spent, + "the envelope must be the one the uninspected run produced", + ); +} + +/// The near boundary: a mid-frame edit is booked, because the frame carries on spending the +/// counter the callback left behind. +#[test] +fn test_an_edit_in_mid_frame_is_still_booked() { + let (_, edited, fired) = run_counter_edit(straight_line_code(), Window::MidFrame); + + assert_eq!(fired, 1, "the fixture must reach a mid-frame step_end exactly once"); + assert_eq!( + edited.inspector_ledger.gas, + Lane::once(i128::from(INJECT)), + "gas written into a counter the frame will keep spending is conjured gas", + ); +} + +/// The far boundary, and the one a coarser rule would get wrong: a `CALL` has set an action too, +/// but it is a `NewFrame` action — the frame suspends, the child runs, and then the frame resumes +/// on exactly this counter. So the edit reaches the envelope and must be booked, even though the +/// interpreter is "at the end of its loop" in precisely the same sense as the terminating case. +#[test] +fn test_an_edit_in_the_suspending_window_is_still_booked() { + let (_, edited, fired) = run_counter_edit(suspending_code(), Window::Suspending); + + assert_eq!(fired, 1, "the fixture must suspend into a child frame exactly once"); + assert_eq!( + edited.inspector_ledger.gas, + Lane::once(i128::from(INJECT)), + "a suspended frame resumes on the edited counter, so the edit reaches the envelope", + ); +} + +// --- B: a precompile's classification, rewritten after its recording site --------------------- + +/// Rewrites the result of the call to `target` into `to`, once. +#[derive(Debug)] +struct Reclassifier { + target: Address, + to: InstructionResult, + fired: u32, +} + +impl Reclassifier { + fn new(target: Address, to: InstructionResult) -> Self { + Self { target, to, fired: 0 } + } +} + +impl Inspector for Reclassifier { + fn call_end(&mut self, _context: &mut CTX, inputs: &CallInputs, outcome: &mut CallOutcome) { + if self.fired > 0 || inputs.target_address != self.target { + return; + } + self.fired += 1; + outcome.result.result = self.to; + } +} + +/// A `CALL` forwarding [`PROBE_GAS`] gas to `target` with `calldata` at `mem[0..]`, its success +/// flag popped so the caller survives either classification. +fn call_precompile(target: Address, calldata: &[u8]) -> Bytes { + BytecodeBuilder::default() + .mstore(0, calldata) + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(calldata.len() as u64) // argsSize + .push_number(0u64) // argsOffset + .push_number(0u64) // value + .push_address(target) + .push_number(PROBE_GAS) + .append(CALL) + .append(POP) + .append(STOP) + .build() +} + +/// The EIP-4844 point-evaluation test vector with the last byte of the proof flipped: 192 bytes +/// with a matching versioned hash, so KZG clears the length doorway and fails inside verification +/// — the one halt shape `MegaETH` prices as work performed. +fn kzg_verification_failure() -> Vec { + let commitment = hex::decode( + "8f59a8d2a1a625a17f3fea0fe5eb8c896db3764f3185481bc22f91b4aaffcca2\ + 5f26936857bc3a7c2539ea8ec3a952b7", + ) + .unwrap(); + let mut versioned_hash = Sha256::digest(&commitment).to_vec(); + versioned_hash[0] = 0x01; // VERSIONED_HASH_VERSION_KZG + let z = + hex::decode("73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000000").unwrap(); + let y = + hex::decode("1522a4a7f34e1ea350ae07c29c96c7e79655aa926122e95fe69fcbd932ca49e9").unwrap(); + let proof = hex::decode( + "a62ad71d14c5719385c0686f1871430475bf3a00f0aa3f7b8dd99a9abc216074\ + 4faf0070725e00b60ad9a026a15b1a8c", + ) + .unwrap(); + + let mut input = Vec::new(); + input.extend_from_slice(&versioned_hash); + input.extend_from_slice(&z); + input.extend_from_slice(&y); + input.extend_from_slice(&commitment); + input.extend_from_slice(&proof); + assert_eq!(input.len(), 192, "the priced probe must clear the 192-byte doorway"); + let last = input.len() - 1; + input[last] ^= 0x01; + input +} + +/// Runs the fixture twice: once uninspected, and once with the classification rewritten across +/// the boundary the shim refuses. +/// +/// The refusal is asserted here rather than in each case, so every case below is left stating the +/// one thing that differs between them — which arm of the precompile it reaches, and what the +/// uninspected run's split therefore is. +fn run_reclassified(target: Address, calldata: &[u8], to: InstructionResult) -> (Outcome, Refusal) { + let code = call_precompile(target, calldata); + let plain = transact(MegaSpecId::REX7, base_db(code.clone()), limits()); + let mut inspector = Reclassifier::new(target, to); + let refusal = + transact_inspected_refused(MegaSpecId::REX7, base_db(code), limits(), &mut inspector); + assert_eq!(inspector.fired, 1, "the fixture must reach the precompile's call_end exactly once"); + assert_eq!(refusal.rejected_rewrites, 1, "the shim must count the refusal"); + assert!( + refusal.error.contains("classification of a result frame init produced"), + "the transaction must fail with the refusal's own reason, got {}", + refusal.error, + ); + (plain, refusal) +} + +/// A successful precompile rewritten into a halt is refused, and the uninspected run destroys +/// nothing. +/// +/// The rewrite is the direction with state behind it: `make_call_frame` commits the checkpoint +/// before it returns a successful precompile's result, so a caller told the call halted would be +/// told so with the transfer that funded it standing. +#[test] +fn test_rewriting_a_successful_precompile_into_a_halt_is_refused() { + let (plain, _) = run_reclassified(IDENTITY, &[], InstructionResult::OutOfGas); + + assert_eq!(plain.destroyed, 0, "the uninspected run destroys nothing"); + assert_eq!( + plain.compute_gas, + plain.enforced(), + "with nothing destroyed the reported total is the work performed", + ); +} + +/// A rejected precompile rewritten into a success is refused, and the uninspected run destroys the +/// whole envelope. +/// +/// The other direction, and the other half of the split: `blake2f` rejects the input before any +/// work, so `make_call_frame` reverted the checkpoint and nothing was performed. +#[test] +fn test_reviving_a_rejected_precompile_is_refused() { + let (plain, _) = run_reclassified(BLAKE2F, &[], InstructionResult::Stop); + + assert_eq!( + plain.destroyed, PROBE_GAS, + "blake2f rejects the input before any work, so the uninspected run destroys all of it", + ); + assert_eq!( + plain.enforced(), + plain.compute_gas - plain.destroyed, + "nothing was performed, so nothing enforces", + ); +} + +/// The third arm, and the only one whose failure `MegaETH` prices as work: a KZG verification that +/// ran and rejected. +/// +/// The refusal matters most here. A halting precompile's gas object carries the whole forwarded +/// envelope as remaining — it is reset rather than spent down — so a caller told such a call +/// succeeded would reclaim all of it, the fixed fee included. That fee is gas the execution priced +/// and the envelope never paid, which is exactly the shape the refusal keeps out. +#[test] +fn test_reviving_a_priced_precompile_failure_is_refused() { + let calldata = kzg_verification_failure(); + let (plain, _) = run_reclassified(KZG, &calldata, InstructionResult::Stop); + + assert_eq!( + plain.destroyed, + PROBE_GAS - kzg_point_evaluation::GAS_COST, + "verification ran, so the uninspected run destroys the envelope less the fixed fee", + ); + assert_eq!( + plain.compute_gas - plain.destroyed, + plain.enforced(), + "the fee is the work performed, and it is what enforces", + ); +} + +// --- C: the pending action itself --------------------------------------------------------------- + +/// Reaches past the interpreter's gas counter and into the action the interpreter is holding, once. +/// +/// The counter and the action are two different objects at exactly one moment — after a +/// terminating or suspending instruction has run and before the loop hands the action on — and +/// this is the inspector that edits the second one. +#[derive(Debug)] +struct ActionEditor { + window: Window, + /// Positive raises the gas the action carries, negative lowers it. + delta: i64, + /// Fire only on an action whose classification is (or is not) an exceptional halt. + halting: bool, + fired: u32, +} + +impl ActionEditor { + fn raise(window: Window) -> Self { + Self { window, delta: ACTION_DELTA as i64, halting: false, fired: 0 } + } + + fn lower(window: Window) -> Self { + Self { window, delta: -(ACTION_DELTA as i64), halting: false, fired: 0 } + } + + fn on_halt() -> Self { + Self { window: Window::Terminating, delta: ACTION_DELTA as i64, halting: true, fired: 0 } + } +} + +impl Inspector for ActionEditor { + fn step_end(&mut self, interp: &mut Interpreter, _context: &mut CTX) { + if self.fired > 0 || Window::of(interp) != self.window { + return; + } + match interp.bytecode.action() { + Some(InterpreterAction::Return(result)) => { + if result.result.is_ok_or_revert() == self.halting { + return; + } + if self.delta >= 0 { + result.gas.erase_cost(self.delta.unsigned_abs()); + } else { + assert!( + result.gas.record_regular_cost(self.delta.unsigned_abs()), + "the fixture must leave the action enough gas for the removal to land", + ); + } + } + Some(InterpreterAction::NewFrame(FrameInput::Call(inputs))) => { + inputs.gas_limit = inputs.gas_limit.saturating_add(self.delta.unsigned_abs()); + } + _ => return, + } + self.fired += 1; + } +} + +/// A `CALL` into [`CALLEE`], its result flag popped, then `STOP` — so the first terminating +/// `step_end` of the transaction belongs to an *inner* frame, and what that frame's action carries +/// is decided by the callee the fixture installs. +fn call_callee_code() -> Bytes { + call_then_stop(CALLEE, PROBE_GAS) +} + +/// Gas written into a returning frame's pending action is gas the caller really reclaims, so it +/// has to be booked — the frame's classification is what says so, and the classification is only +/// known at the frame's settlement point. +#[test] +fn test_raising_a_returning_frames_pending_action_is_booked() { + let plain = transact(MegaSpecId::REX7, base_db(straight_line_code()), limits()); + let mut inspector = ActionEditor::raise(Window::Terminating); + let edited = transact_inspected( + MegaSpecId::REX7, + base_db(straight_line_code()), + limits(), + &mut inspector, + ); + + assert_eq!(inspector.fired, 1, "the fixture must reach a terminating step_end exactly once"); + assert_eq!( + edited.inspector_ledger, + InspectorLedger { + result: Lane::once(i128::from(ACTION_DELTA)), + ..InspectorLedger::default() + }, + "an edit to the action a returning frame hands back is an edit to the envelope", + ); + assert_eq!( + edited.total_gas_spent, + plain.total_gas_spent - ACTION_DELTA, + "the transaction really did spend less, which is why the ledger has to carry it", + ); + assert_eq!( + edited.compute_gas, plain.compute_gas, + "the edit is not work: the frame performed exactly what it performed uninspected", + ); +} + +/// The same edit in the other direction. +#[test] +fn test_lowering_a_returning_frames_pending_action_is_booked() { + let plain = transact(MegaSpecId::REX7, base_db(straight_line_code()), limits()); + let mut inspector = ActionEditor::lower(Window::Terminating); + let edited = transact_inspected( + MegaSpecId::REX7, + base_db(straight_line_code()), + limits(), + &mut inspector, + ); + + assert_eq!(inspector.fired, 1, "the fixture must reach a terminating step_end exactly once"); + assert_eq!( + edited.inspector_ledger, + InspectorLedger { + result: Lane::once(-i128::from(ACTION_DELTA)), + ..InspectorLedger::default() + }, + "gas taken out of the action is gas the caller never gets back", + ); + assert_eq!( + edited.total_gas_spent, + plain.total_gas_spent + ACTION_DELTA, + "the transaction really did spend more", + ); +} + +/// The classification branch: a halting frame hands nothing back, so an edit to the gas its action +/// carries moves nothing and must not reach the lane's *net* — and the remainder it destroys is +/// the EVM's own number, not the edited one. +/// +/// The lane's gross carries the edit all the same. Whether it moved the envelope is what the +/// classification decides; whether the inspector made it is not, and the block guard asks the +/// second question. +#[test] +fn test_editing_a_halting_frames_pending_action_moves_nothing() { + let callee = BytecodeBuilder::default().append(INVALID).build(); + let plain = + transact(MegaSpecId::REX7, db_with_callee(call_callee_code(), callee.clone()), limits()); + let mut inspector = ActionEditor::on_halt(); + let edited = transact_inspected( + MegaSpecId::REX7, + db_with_callee(call_callee_code(), callee), + limits(), + &mut inspector, + ); + + assert_eq!(inspector.fired, 1, "the fixture must halt an inner frame exactly once"); + assert_eq!( + edited.inspector_ledger, + InspectorLedger { + result: Lane::of(0, u128::from(ACTION_DELTA)), + ..InspectorLedger::default() + }, + "a halting frame hands its remainder to nobody, so the edit moves the envelope by nothing \ + — and the lane still has to show it was made", + ); + assert_eq!( + edited.inspector_ledger.conjured_gas(), + 0, + "the conservation law reads the net, which is what stays zero", + ); + assert!( + !edited.inspector_ledger.is_zero(), + "and the block guard reads the gross, which is what does not", + ); + assert_eq!( + edited.destroyed, plain.destroyed, + "the destroyed remainder is the EVM's own, not the one the inspector wrote", + ); + assert_eq!(edited.total_gas_spent, plain.total_gas_spent, "and the envelope is unmoved"); +} + +/// The other action variant: gas written into a pending `NewFrame` action is the envelope a child +/// frame is about to be built with, which the caller was never debited for. +#[test] +fn test_raising_a_pending_new_frame_action_is_booked_as_an_envelope() { + let plain = transact(MegaSpecId::REX7, base_db(suspending_code()), limits()); + let mut inspector = ActionEditor::raise(Window::Suspending); + let edited = + transact_inspected(MegaSpecId::REX7, base_db(suspending_code()), limits(), &mut inspector); + + assert_eq!(inspector.fired, 1, "the fixture must suspend into a child frame exactly once"); + assert_eq!( + edited.inspector_ledger, + InspectorLedger { env: Lane::once(i128::from(ACTION_DELTA)), ..InspectorLedger::default() }, + "the child's budget grew by gas the caller's CALL never forwarded", + ); + assert_eq!( + edited.total_gas_spent, + plain.total_gas_spent - ACTION_DELTA, + "the child hands the extra budget straight back, so the transaction spends less", + ); +} + +/// Rewrites the classification inside a pending `Return` action, once, at the terminating +/// `step_end` of the frame that set it. +#[derive(Debug)] +struct ActionReclassifier { + to: InstructionResult, + fired: u32, +} + +impl Inspector for ActionReclassifier { + fn step_end(&mut self, interp: &mut Interpreter, _context: &mut CTX) { + if self.fired > 0 { + return; + } + let Some(InterpreterAction::Return(result)) = interp.bytecode.action() else { return }; + result.result = self.to; + self.fired += 1; + } +} + +/// An edit to a pending action that is not to its gas moves nothing and is booked as an +/// intervention — but it still decides what the frame did, so the frame's state follows it. +/// +/// The action is what `classify_frame_action` builds the frame's result from, so a classification +/// written here is the one the caller sees and the one the journal decision is taken on. Nothing +/// on any gas lane can see that, which is what the intervention counter is for. +#[test] +fn test_rewriting_a_pending_actions_classification_is_an_intervention() { + let callee = + BytecodeBuilder::default().sstore(U256::from(1u64), U256::from(1u64)).append(STOP).build(); + let plain = + transact(MegaSpecId::REX7, db_with_callee(call_callee_code(), callee.clone()), limits()); + let mut inspector = ActionReclassifier { to: InstructionResult::Revert, fired: 0 }; + let edited = transact_inspected( + MegaSpecId::REX7, + db_with_callee(call_callee_code(), callee), + limits(), + &mut inspector, + ); + + assert_eq!(inspector.fired, 1, "the fixture must reach a terminating step_end exactly once"); + assert_eq!( + plain.storage_value(CALLEE, U256::from(1u64)), + U256::from(1u64), + "uninspected, the callee's write is committed", + ); + assert_eq!( + edited.inspector_ledger, + InspectorLedger { interventions: 1, ..InspectorLedger::default() }, + "no gas moved, and the only thing left to say is that the transaction was not left alone", + ); + assert_eq!( + edited.storage_value(CALLEE, U256::from(1u64)), + U256::ZERO, + "a frame the caller was told reverted must leave no write behind", + ); +} + +// === interception ============================================================================ +// +// The gas a synthetic outcome carries. +// +// A `frame_start` / `call` / `create` callback that returns `Some(outcome)` answers the frame +// itself: no frame is built, `frame_init` never runs, and the number the caller reclaims is +// whatever `Gas` the inspector put in that outcome. Nothing about it is derived from the +// execution — the inspector chooses it outright — so it is a gas figure the transaction's +// accounting has to be told about, exactly like an edit to a result the EVM did produce. +// +// The tests here are laid out over the sign of that choice, because the two directions settle +// differently and a lane that books one and drops the other is a real failure mode: +// +// - an outcome that hands back **less** than the envelope makes the caller spend gas no frame ever +// performed work for; +// - an outcome that hands back **more** conjures gas the transaction never funded; +// - an outcome that hands back **exactly** the envelope — the echo convention every tracer that +// intercepts follows — moves nothing, and must book nothing. +// +// The halt direction is the asymmetry: a halting outcome hands nothing back at all, so what the +// inspector wrote in the gas figure changes nothing the transaction spends, and the destroyed +// remainder is settled against the envelope instead. + +/// Gas the fixture's `CALL` forwards, and the envelope every interception is measured against. +const FORWARDED: u64 = 50_000; + +/// The entry contract: one `CALL` to [`CALLEE`] forwarding [`FORWARDED`], then `STOP`. +fn call_fixture() -> MemoryDatabase { + db_with_callee(call_then_stop(CALLEE, FORWARDED), plain_run_code(20)) +} + +/// How an interception sizes the `Gas` it hands back, relative to the envelope it was given. +#[derive(Clone, Copy, Debug)] +enum Sizing { + /// The echo convention: exactly the envelope. + Echo, + /// Half of it — the caller spends the other half for work no frame performed. + Half, + /// None of it. + Zero, + /// More than it — gas the transaction never funded. + Excess(u64), +} + +impl Sizing { + fn gas(self, envelope: u64) -> u64 { + match self { + Self::Echo => envelope, + Self::Half => envelope / 2, + Self::Zero => 0, + Self::Excess(extra) => envelope + extra, + } + } + + /// What the ledger must carry for this sizing, as a signed movement from the envelope. + fn expected_delta(self, envelope: u64) -> i128 { + i128::from(self.gas(envelope)) - i128::from(envelope) + } +} + +/// Intercepts the call to [`CALLEE`], sizing the outcome's gas by [`Sizing`]. +struct CallInterceptor { + sizing: Sizing, + classification: InstructionResult, + intercepted: u64, + envelope: u64, +} + +impl CallInterceptor { + fn new(sizing: Sizing, classification: InstructionResult) -> Self { + Self { sizing, classification, intercepted: 0, envelope: 0 } + } +} + +impl Inspector for CallInterceptor { + fn call(&mut self, _context: &mut CTX, inputs: &mut CallInputs) -> Option { + if inputs.target_address != CALLEE { + return None; + } + self.intercepted += 1; + self.envelope = inputs.gas_limit; + Some(CallOutcome::new( + InterpreterResult::new( + self.classification, + Bytes::new(), + Gas::new(self.sizing.gas(inputs.gas_limit)), + ), + inputs.return_memory_offset.clone(), + )) + } +} + +/// An outcome that hands back less than the envelope makes the caller spend gas nothing performed. +#[test] +fn test_a_half_gas_interception_books_the_gas_it_took_from_the_caller() { + let mut inspector = CallInterceptor::new(Sizing::Half, InstructionResult::Stop); + let reading = transact_inspected(MegaSpecId::REX7, call_fixture(), limits(), &mut inspector); + + assert_eq!(inspector.intercepted, 1, "the fixture must intercept exactly one call"); + assert_eq!(inspector.envelope, FORWARDED, "fixture check: the forwarded envelope"); + assert!(reading.result.is_success(), "fixture check: {:?}", reading.result); + assert_eq!( + reading.inspector_ledger, + InspectorLedger { + result: Lane::once(Sizing::Half.expected_delta(FORWARDED)), + interventions: 1, + ..InspectorLedger::default() + }, + "the half the outcome withheld is gas the inspector destroyed", + ); +} + +/// The extreme of the same direction: the outcome hands back nothing at all. +#[test] +fn test_a_zero_gas_interception_books_the_whole_envelope() { + let mut inspector = CallInterceptor::new(Sizing::Zero, InstructionResult::Stop); + let reading = transact_inspected(MegaSpecId::REX7, call_fixture(), limits(), &mut inspector); + + assert_eq!(inspector.intercepted, 1, "the fixture must intercept exactly one call"); + assert_eq!( + reading.inspector_ledger, + InspectorLedger { + result: Lane::once(Sizing::Zero.expected_delta(FORWARDED)), + interventions: 1, + ..InspectorLedger::default() + }, + "an outcome that returns nothing consumed the whole envelope", + ); +} + +/// The other direction: an outcome that hands back more than it was given conjures the difference. +#[test] +fn test_an_over_funded_interception_books_the_gas_it_conjured() { + const EXTRA: u64 = 7_000; + let mut inspector = CallInterceptor::new(Sizing::Excess(EXTRA), InstructionResult::Stop); + let reading = transact_inspected(MegaSpecId::REX7, call_fixture(), limits(), &mut inspector); + + assert_eq!(inspector.intercepted, 1, "the fixture must intercept exactly one call"); + assert_eq!( + reading.inspector_ledger, + InspectorLedger { + result: Lane::once(Sizing::Excess(EXTRA).expected_delta(FORWARDED)), + interventions: 1, + ..InspectorLedger::default() + }, + "gas the transaction never funded is gas the inspector conjured", + ); +} + +/// The echo convention moves nothing, and must book nothing. +/// +/// This is the shape every tool that intercepts actually uses, and the reason the lane could go +/// missing for as long as it did: with the envelope echoed back the accounting closes whether or +/// not anything measures it. Pinning the zero is what says the lane is measuring rather than +/// coincidentally agreeing. +#[test] +fn test_an_echoing_interception_books_no_gas_at_all() { + for classification in [InstructionResult::Stop, InstructionResult::Revert] { + let mut inspector = CallInterceptor::new(Sizing::Echo, classification); + let reading = + transact_inspected(MegaSpecId::REX7, call_fixture(), limits(), &mut inspector); + + assert_eq!(inspector.intercepted, 1, "the fixture must intercept exactly one call"); + assert_eq!( + reading.inspector_ledger, + InspectorLedger { interventions: 1, ..InspectorLedger::default() }, + "{classification:?}: an echoed envelope moves no gas, so no gas lane may move", + ); + assert_eq!(reading.inspector_ledger.conjured_gas(), 0, "{classification:?}"); + } +} + +/// A halting outcome hands nothing back, so what the inspector wrote in its gas figure changes +/// nothing the transaction spends — and the envelope is destroyed whole. +/// +/// What the outcome claimed is still traffic on the result lane: the sizings below differ from the +/// envelope by different amounts, and each one is an edit the inspector made whether or not the +/// classification let it reach anybody. +#[test] +fn test_a_halting_interception_destroys_the_envelope_whatever_gas_it_reports() { + for sizing in [Sizing::Echo, Sizing::Half, Sizing::Zero, Sizing::Excess(7_000)] { + let mut inspector = CallInterceptor::new(sizing, InstructionResult::OutOfGas); + let reading = + transact_inspected(MegaSpecId::REX7, call_fixture(), limits(), &mut inspector); + + assert_eq!(inspector.intercepted, 1, "the fixture must intercept exactly one call"); + assert!(reading.result.is_success(), "the caller absorbs the halt: {:?}", reading.result); + assert_eq!( + reading.inspector_ledger.conjured_gas(), + 0, + "{sizing:?}: a halting frame hands nothing back, so no gas lane's net may move", + ); + assert_eq!( + reading.inspector_ledger, + InspectorLedger { + interventions: 1, + result: Lane::of(0, sizing.expected_delta(FORWARDED).unsigned_abs()), + ..InspectorLedger::default() + }, + "{sizing:?}: and the traffic is what the outcome claimed, off the envelope", + ); + assert_eq!( + reading.destroyed, FORWARDED, + "{sizing:?}: the whole envelope is destroyed, whatever the outcome claimed", + ); + } +} + +/// The generic callback intercepts too, and is measured by the same rule. +/// +/// revm runs `frame_start` before the variant-specific `call` / `create`, and an outcome returned +/// there skips both. A lane wired only to the variant hooks would leave this one unmeasured. +#[test] +fn test_the_generic_frame_start_interception_is_measured_too() { + /// Intercepts the call to [`CALLEE`] from the generic callback, handing back half. + #[derive(Default)] + struct GenericInterceptor { + intercepted: u64, + } + + impl Inspector for GenericInterceptor { + fn frame_start( + &mut self, + _context: &mut CTX, + frame_input: &mut FrameInput, + ) -> Option { + let FrameInput::Call(inputs) = frame_input else { return None }; + if inputs.target_address != CALLEE { + return None; + } + self.intercepted += 1; + Some(FrameResult::Call(CallOutcome::new( + InterpreterResult::new( + InstructionResult::Stop, + Bytes::new(), + Gas::new(inputs.gas_limit / 2), + ), + inputs.return_memory_offset.clone(), + ))) + } + } + + let mut inspector = GenericInterceptor::default(); + let reading = transact_inspected(MegaSpecId::REX7, call_fixture(), limits(), &mut inspector); + + assert_eq!(inspector.intercepted, 1, "the fixture must intercept exactly one call"); + assert_eq!( + reading.inspector_ledger, + InspectorLedger { + result: Lane::once(Sizing::Half.expected_delta(FORWARDED)), + interventions: 1, + ..InspectorLedger::default() + }, + "the generic callback's interception books on the same lane as the variant one's", + ); +} + +/// Init code that writes one slot and returns two bytes of runtime code. +fn init_code() -> Vec { + BytecodeBuilder::default() + .sstore(U256::from(0x30), U256::from(1)) + .push_number(0x6000u64) + .push_number(0u64) + .append(MSTORE) + .push_number(2u64) // size + .push_number(30u64) // offset + .append(RETURN) + .build() + .to_vec() +} + +/// The entry contract: one `CREATE`, then `STOP`. +fn create_fixture() -> MemoryDatabase { + base_db(deploy_then_stop(&init_code())) +} + +/// A creation answered by the inspector is measured against the envelope its `CREATE` forwarded. +/// +/// The envelope is not a constant here — `CREATE` forwards all but a sixty-fourth of what the +/// caller holds — so the test reads it back from the callback rather than asserting a figure. +#[test] +fn test_an_intercepted_creation_is_measured_against_the_envelope_it_was_handed() { + /// Intercepts the creation, handing back half of what it was given. + #[derive(Default)] + struct CreateInterceptor { + intercepted: u64, + envelope: u64, + } + + impl Inspector for CreateInterceptor { + fn create( + &mut self, + _context: &mut CTX, + inputs: &mut CreateInputs, + ) -> Option { + self.intercepted += 1; + self.envelope = inputs.gas_limit(); + Some(CreateOutcome::new( + InterpreterResult::new( + InstructionResult::Stop, + Bytes::new(), + Gas::new(inputs.gas_limit() / 2), + ), + None, + )) + } + } + + let mut inspector = CreateInterceptor::default(); + let reading = transact_inspected(MegaSpecId::REX7, create_fixture(), limits(), &mut inspector); + + assert_eq!(inspector.intercepted, 1, "the fixture must intercept exactly one creation"); + assert!(inspector.envelope > 0, "fixture check: the creation must forward an envelope"); + assert_eq!( + reading.inspector_ledger, + InspectorLedger { + result: Lane::once(Sizing::Half.expected_delta(inspector.envelope)), + interventions: 1, + ..InspectorLedger::default() + }, + "a creation's interception is measured against the envelope its CREATE forwarded", + ); +} + +/// The envelope an interception is measured against is the one the callback *received*. +/// +/// A callback is free to edit the inputs and then answer the frame itself. The edit reaches no +/// frame — nothing is built from those inputs — so the envelope the caller actually funded is the +/// one the callback was handed, and an outcome echoing the *edited* limit hands back more than +/// that. Measuring against the post-edit number instead would read this run as conjuring nothing. +#[test] +fn test_the_envelope_is_the_one_the_callback_received_not_the_one_it_left() { + const BONUS: u64 = 9_000; + + /// Raises the child's gas limit and then intercepts, echoing the raised figure. + #[derive(Default)] + struct RaisingInterceptor { + intercepted: u64, + } + + impl Inspector for RaisingInterceptor { + fn call(&mut self, _context: &mut CTX, inputs: &mut CallInputs) -> Option { + if inputs.target_address != CALLEE { + return None; + } + self.intercepted += 1; + inputs.gas_limit += BONUS; + Some(CallOutcome::new( + InterpreterResult::new( + InstructionResult::Stop, + Bytes::new(), + Gas::new(inputs.gas_limit), + ), + inputs.return_memory_offset.clone(), + )) + } + } + + let mut inspector = RaisingInterceptor::default(); + let reading = transact_inspected(MegaSpecId::REX7, call_fixture(), limits(), &mut inspector); + + assert_eq!(inspector.intercepted, 1, "the fixture must intercept exactly one call"); + assert_eq!( + reading.inspector_ledger, + InspectorLedger { + result: Lane::once(i128::from(BONUS)), + interventions: 1, + ..InspectorLedger::default() + }, + "the bonus reaches the caller through the outcome, so it is booked once, on the result \ + lane — the env lane stays empty because no frame was ever built from those inputs", + ); +} + +/// The lane reports on a frozen spec too, and reporting it settles nothing there. +/// +/// The measurement is not REX7-gated, and neither are the two lanes it joins: `InspectorLedger` is +/// what the canonical block path's guard reads, so a frame an inspector answered has to be visible +/// on it whatever spec is executing. What is REX7's alone is the settlement the lane feeds — the +/// envelope a refused frame init decides the fate of. REX6 derives nothing from the envelope and +/// books no destroyed remainder, so what it reports is what it always reported. +/// +/// The transaction's own gas does follow the figure the inspector wrote, on both specs. That is +/// the EVM handing the caller back what the result carries, which is upstream's arithmetic rather +/// than `MegaETH`'s, and it is the movement the lane exists to account for rather than to prevent. +#[test] +fn test_a_frozen_spec_reports_the_lane_without_settling_anything() { + let mut echoing = CallInterceptor::new(Sizing::Echo, InstructionResult::Stop); + let echo = transact_inspected( + MegaSpecId::REX6, + call_fixture(), + EvmTxRuntimeLimits::from_spec(MegaSpecId::REX6), + &mut echoing, + ); + let mut halving = CallInterceptor::new(Sizing::Half, InstructionResult::Stop); + let half = transact_inspected( + MegaSpecId::REX6, + call_fixture(), + EvmTxRuntimeLimits::from_spec(MegaSpecId::REX6), + &mut halving, + ); + + assert_eq!(echoing.intercepted, 1, "fixture check"); + assert_eq!(halving.intercepted, 1, "fixture check"); + assert_eq!( + echo.inspector_ledger, + InspectorLedger { interventions: 1, ..InspectorLedger::default() }, + "REX6: an echoed envelope moves no gas here either", + ); + assert_eq!( + half.inspector_ledger, + InspectorLedger { + result: Lane::once(Sizing::Half.expected_delta(FORWARDED)), + interventions: 1, + ..InspectorLedger::default() + }, + "REX6: the lane reports, because the block guard has to see this frame on every spec", + ); + assert_eq!( + (echo.destroyed, half.destroyed), + (0, 0), + "REX6 has no destroyed remainder to book, on either sizing", + ); + assert_eq!( + echo.compute_gas, half.compute_gas, + "and its compute total does not follow the figure the inspector wrote", + ); + assert_eq!( + half.total_gas_spent - echo.total_gas_spent, + FORWARDED / 2, + "the caller really did lose the half the outcome withheld — that is the EVM's arithmetic", + ); +} diff --git a/crates/mega-evm/tests/rex7/trusted_observer.rs b/crates/mega-evm/tests/rex7/trusted_observer.rs new file mode 100644 index 00000000..94012286 --- /dev/null +++ b/crates/mega-evm/tests/rex7/trusted_observer.rs @@ -0,0 +1,215 @@ +//! The one inspector the shim does not measure, and the two things that keep that safe. +//! +//! A type whose author has declared it `TrustedObserver` is delegated to without any of the +//! shim's readings. The declaration is a promise about source, not a detection, so it is held up +//! by exactly two things and both are here: +//! +//! - **A declared type that keeps the promise is indistinguishable from one that is measured.** The +//! same observer, over the same fixture, run three ways — with no inspector, declared, and +//! undeclared — produces the same receipt, the same four resource dimensions, the same state, and +//! the same callbacks in the same numbers. +//! - **A declared type that breaks it fails where it is exercised.** Debug builds take the +//! measuring path anyway and assert the ledger stayed empty, so a wrong declaration panics at the +//! callback that broke it rather than reaching a node. +//! +//! The second half is a `debug_assertions` property by construction, so the two anchors below are +//! compiled only into debug builds — which is how this repository's tests run. +//! +//! Which leaves the first half needing a release run to mean anything: in a debug build the +//! declared run takes the measuring path like every other, so it is the same code the other two +//! runs exercise. `cargo test -p mega-evm --release --test rex7` is where the comparison is +//! actually against the fast path, and it is an acceptance gate for that reason. + +use crate::{ + common::{transact, transact_inspected, Outcome, CALLEE}, + inspector_common::{append_call, db_with_callee, limits, transact_trusted}, +}; +use alloy_primitives::U256; +use mega_evm::{ + test_utils::{BytecodeBuilder, MemoryDatabase}, + MegaSpecId, TrustedObserver, +}; +use revm::{ + bytecode::opcode::{POP, STOP}, + interpreter::{CallInputs, CallOutcome, Interpreter, InterpreterTypes}, + Inspector, +}; + +/// Asserts two runs of the fixture are the same run, field by field. +/// +/// Written out rather than derived from `PartialEq` on the whole struct so that the field that +/// disagrees is the one the failure names. +fn assert_same(label: &str, left: &Outcome, right: &Outcome) { + assert_eq!(format!("{:?}", left.result), format!("{:?}", right.result), "{label}: result"); + assert_eq!(left.compute_gas, right.compute_gas, "{label}: compute gas"); + assert_eq!(left.enforced(), right.enforced(), "{label}: enforced compute gas"); + assert_eq!(left.destroyed, right.destroyed, "{label}: destroyed compute gas"); + assert_eq!(left.data_size, right.data_size, "{label}: data size"); + assert_eq!(left.kv_updates, right.kv_updates, "{label}: kv updates"); + assert_eq!(left.state_growth, right.state_growth, "{label}: state growth"); + assert_eq!(left.gas_used, right.gas_used, "{label}: gas used"); + assert_eq!(left.total_gas_spent, right.total_gas_spent, "{label}: total gas spent"); + assert_eq!(left.state, right.state, "{label}: produced state"); +} + +/// Counts the callbacks it is handed and changes nothing — a declaration that holds. +#[derive(Default, Debug, PartialEq, Eq)] +struct Observer { + initialize_interps: u64, + steps: u64, + step_ends: u64, + calls: u64, + call_ends: u64, +} + +impl TrustedObserver for Observer {} + +impl Inspector for Observer { + fn initialize_interp(&mut self, _interp: &mut Interpreter, _context: &mut CTX) { + self.initialize_interps += 1; + } + + fn step(&mut self, _interp: &mut Interpreter, _context: &mut CTX) { + self.steps += 1; + } + + fn step_end(&mut self, _interp: &mut Interpreter, _context: &mut CTX) { + self.step_ends += 1; + } + + fn call(&mut self, _context: &mut CTX, _inputs: &mut CallInputs) -> Option { + self.calls += 1; + None + } + + fn call_end(&mut self, _context: &mut CTX, _inputs: &CallInputs, _outcome: &mut CallOutcome) { + self.call_ends += 1; + } +} + +/// The fixture: a frame that writes storage and makes an inner call, so every dimension the +/// comparison covers has something in it. +fn fixture_db() -> MemoryDatabase { + let callee = + BytecodeBuilder::default().sstore(U256::from(0x11), U256::from(0x22)).append(STOP).build(); + let code = append_call( + BytecodeBuilder::default().sstore(U256::from(0x20), U256::from(0x99)), + CALLEE, + 100_000, + 0, + ) + .append(POP) + .append(STOP) + .build(); + db_with_callee(code, callee) +} + +/// ★ Declaring an observer read-only changes what the measurement costs and nothing it says. +/// +/// The same observer runs the same fixture three ways: uninspected, measured, and declared. All +/// three produce the same receipt, the same four resource dimensions and the same state; both +/// inspected runs leave an empty ledger; and the declared run is handed exactly the callbacks the +/// measured one was, in the same numbers. +/// +/// That last part is what separates "the shim skipped its own work" from "the shim skipped the +/// inspector": a fast path that delegated less would pass every other assertion here. +#[test] +fn test_a_declared_observer_runs_the_transaction_the_other_two_runs_produce() { + let plain = transact(MegaSpecId::REX7, fixture_db(), limits()); + + let mut measured_observer = Observer::default(); + let measured = + transact_inspected(MegaSpecId::REX7, fixture_db(), limits(), &mut measured_observer); + + let mut trusted_observer = Observer::default(); + let trusted = transact_trusted(fixture_db(), &mut trusted_observer); + + assert!(measured_observer.steps > 0, "the fixture must run opcodes under the inspector"); + assert_eq!(measured_observer.calls, 2, "one top-level frame plus one inner call"); + assert_eq!( + measured_observer, trusted_observer, + "the declared run must be handed the same callbacks as the measured one", + ); + + assert!(measured.inspector_ledger.is_zero(), "measured: {:?}", measured.inspector_ledger); + assert!( + trusted.inspector_ledger.is_zero(), + "the fast path books nothing by construction: {:?}", + trusted.inspector_ledger, + ); + + assert_same("declared against uninspected", &trusted, &plain); + assert_same("declared against measured", &trusted, &measured); +} + +/// A rewriting inspector that moves gas, wearing a declaration it has no right to. +/// +/// `TrustedObserver` is implemented for it here and nowhere else: this is the only place in the +/// repository where the promise is deliberately broken, and it exists so that breaking it is +/// known to be caught. +#[cfg(debug_assertions)] +#[derive(Default)] +struct LiarThatMovesGas { + fired: bool, +} + +#[cfg(debug_assertions)] +impl TrustedObserver for LiarThatMovesGas {} + +#[cfg(debug_assertions)] +impl Inspector for LiarThatMovesGas { + fn step(&mut self, interp: &mut Interpreter, _context: &mut CTX) { + if !self.fired { + self.fired = true; + interp.gas.set_remaining(interp.gas.remaining() + 10_000); + } + } +} + +/// A rewriting inspector that moves no gas at all, wearing the same declaration. +/// +/// Stepping the program counter deletes an instruction from the frame and costs the transaction +/// nothing, so no gas lane sees it — only `interventions` does. It is here because a verification +/// written over the gas lanes alone would pass this one. +#[cfg(debug_assertions)] +#[derive(Default)] +struct LiarThatMovesNoGas { + fired: bool, +} + +#[cfg(debug_assertions)] +impl TrustedObserver for LiarThatMovesNoGas {} + +#[cfg(debug_assertions)] +impl Inspector for LiarThatMovesNoGas { + fn step(&mut self, interp: &mut Interpreter, _context: &mut CTX) { + use revm::interpreter::interpreter_types::{Jumps, LoopControl}; + if !self.fired && interp.bytecode.is_not_end() { + self.fired = true; + interp.bytecode.relative_jump(1); + } + } +} + +/// ★ A declaration that is false fails at the callback that made it false. +/// +/// Debug builds run the whole measurement behind the declaration and assert the ledger came back +/// empty, so this is what "trust, and verify" means in practice: the release build pays nothing +/// and the build every test, every CI job and every chaos sweep runs catches the mis-declaration +/// on the spot. +#[cfg(debug_assertions)] +#[test] +#[should_panic(expected = "declared `TrustedObserver` wrote something back at `step`")] +fn test_a_declared_inspector_that_moves_gas_fails_the_debug_verification() { + let mut liar = LiarThatMovesGas::default(); + let _ = transact_trusted(fixture_db(), &mut liar); +} + +/// ★ And so does one whose rewrite moves no gas. +#[cfg(debug_assertions)] +#[test] +#[should_panic(expected = "declared `TrustedObserver` wrote something back at `step`")] +fn test_a_declared_inspector_that_moves_no_gas_fails_the_debug_verification_too() { + let mut liar = LiarThatMovesNoGas::default(); + let _ = transact_trusted(fixture_db(), &mut liar); +} diff --git a/crates/mega-state-test/AGENTS.md b/crates/mega-state-test/AGENTS.md index d5852081..7432d3ef 100644 --- a/crates/mega-state-test/AGENTS.md +++ b/crates/mega-state-test/AGENTS.md @@ -6,7 +6,9 @@ Published as `mega-state-test`; the library keeps the `state_test` import name. The `state-test` CLI (`crates/state-test`) is a thin front-end over this crate. ## STRUCTURE -- `src/runner.rs`: test discovery, execution pipeline, validation, worker concurrency. +- `src/runner.rs`: test discovery, execution pipeline, validation, worker concurrency, `--fill`. +- `src/diff.rs`: differential execution — run one fixture under two specs and classify any disagreement against the target spec's precision invariant. +- `src/panic_capture.rs`: turning a panic inside one fixture unit into a recorded result instead of a lost run. - `src/types/`: forked revm statetest data model and deserializers. - `src/utils.rs`: root/hash validation helpers and utility glue. - `tests/`: replay-corpus validation, fixture benches, and dump round-trip tests (rely on `bench/replay/fixtures/`, so they are excluded from the published package). @@ -17,10 +19,26 @@ The `state-test` CLI (`crates/state-test`) is a thin front-end over this crate. - Known slow/problematic vectors are explicitly skipped by filename list. - Failure debugging path can re-run with tracer context for inspection. - Parallel execution uses shared queue and atomic counters with optional single-thread mode. +- Differential classification is evidence-based, never a list of fixtures allowed to differ: every `Mechanism` is a fact read off an execution, and the hypothesis it falsifies is what licenses a difference. +- Only an execution-provenance observation licenses anything. The fixture is the input under test, so a `Mechanism` read out of revert-payload bytes is reported and never falsifies a hypothesis; a derived quantity (the Rex7 destroyed remainder) needs an independent witness rather than certifying itself. +- Frame evidence is admissible only from a rerun that reproduced the run it stands in for: each inspected rerun is compared against its own plain outcome over every quantity but the frames it was run to collect, and a rerun that moved anything has its evidence discarded with the plain verdict left standing. +- A differential run is defined for exactly one spec pair, the one whose precision invariant the classifier encodes (`DiffSpecs::new`). There is no general two-spec comparator. +- A unit is a family of transactions, one per vector its `post` names (`TestUnit::vectors`). Diff, fill and bench each enumerate them; nothing takes index `{0,0,0}` and calls it the unit. +- The transaction vector is the unit of counting everywhere (`FillReport::vectors`, `diff_test_suite`, validation's judged count), so one corpus produces one total whichever mode swept it. +- Every mode fails when it judged nothing: an empty tally is truthful and meaningless, and neither a corpus that never arrived nor a unit that pins no expectation may read as a pass. What counts is work actually judged — an expectation checked, a vector filled — never a file walked or a unit parsed. +- A value whose constructor is the check keeps its fields private (`DiffSpecs`), so the classifier cannot be handed a pair that was assembled around `new`. +- Corpus drivers keep going per unit (`fill_test_suite_keep_going`, `diff_test_suite`) and record a unit's failure or panic rather than ending the file. - BaseFeeVault state changes are pruned as MegaETH-specific normalization. - The SALT bucket hasher comes from `mega_evm::AHashBucketHasher` (via the `test-utils` feature); never introduce a standalone salt/hasher dependency. ## ANTI-PATTERNS +- Do not explain a differential disagreement with a fixture allowlist; add a `Mechanism` that reads the evidence instead, and state which hypothesis it falsifies. +- Do not judge a difference on an inspected rerun's frames without first checking the rerun against its plain run; an inspector that changed the execution can explain the very difference it introduced. +- Do not let a `Mechanism` inferred from bytes the fixture could have written falsify a hypothesis; `Mechanism::provenance` records where an observation came from and the licensing rule follows it. +- Do not classify a halt by matching its `Debug` rendering; match the `MegaHaltReason` variants with no catch-all arm, so a new variant has to be decided rather than defaulted. +- Do not drop an entry the fixture-discovery walk could not read; an unreadable directory is a hole in coverage, not an empty one. +- Do not count units, files, or anything else a run merely reached in a tally that gates a sweep; count the judgements it made. +- Do not write a unit's `post` for some of its vectors, and do not record a unit-wide field (`out`) for a multi-vector unit whose vectors disagree on it; refuse the unit instead. - Do not spread exception matching logic across multiple files. - Keep it centralized to avoid drift. - Do not bypass `compute_test_roots` when changing validation outputs. @@ -31,5 +49,11 @@ The `state-test` CLI (`crates/state-test`) is a thin front-end over this crate. - Add/adjust skip policy: `runner.rs::skip_test`. - Change validation semantics for roots/output/exception: `runner.rs::{validate_exception,validate_output,check_evm_execution}`. - Change worker behavior or fail-fast policy: `runner.rs::{run_test_worker,run,TestRunnerConfig}`. +- Change what a differential run compares or what licenses a difference: `diff.rs::{DiffField,Mechanism,Provenance,halt_kind,judge}`. +- Change when frame evidence may decide a difference: `diff.rs::{judge_with_frame_evidence,rerun_drift}`. +- Change which spec pair a differential run accepts: `diff.rs::DiffSpecs::new`. +- Change how a unit's transaction vectors are enumerated: `types/test_unit.rs::TestUnit::vectors`. +- Change what a fill records per unit or reports per vector: `runner.rs::{fill_unit,fill_suite,FillReport}`. +- Change the corpus sweep, how it decides a cached corpus is whole, or its CI gates: `tools/eest-sweep/` (`run.sh`, `tests/cache_integrity.sh`) and `.github/workflows/eest-nightly.yml`. - Update JSON schema mapping for test fixtures: `src/types/*` and deserializer modules. - Change CLI flags or path handling: `crates/state-test/src/main.rs`. diff --git a/crates/mega-state-test/src/chaos.rs b/crates/mega-state-test/src/chaos.rs new file mode 100644 index 00000000..8a44feb7 --- /dev/null +++ b/crates/mega-state-test/src/chaos.rs @@ -0,0 +1,1734 @@ +//! A deterministic rewriting inspector, and the corpus sweep that runs it. +//! +//! # What this is for +//! +//! `MegaETH` supports rewriting inspectors in full: the measurement shim books what one does to a +//! transaction's gas, and the conservation law accounts for it. `tests/rex7/shim_lanes.rs`, +//! `tests/rex7/shim_settlement.rs`, `tests/rex7/shim_blind_spots.rs` and +//! `tests/rex7/inspector_cheat_matrix.rs` pin that mechanism shape by shape, on fixtures built +//! to reach each shape. What they cannot do is put a rewriting inspector on top of *arbitrary* +//! execution — the corner of the state space where a rewrite meets a detained frame, a latched +//! resource exceed, a precompile, a `SELFDESTRUCT`, an EIP-7702 delegation, a nested revert. +//! +//! The EEST corpus is that state space, already written down. This module drives it: every vector +//! is executed three times — with no inspector, with a read-only one, and with a rewriting one — +//! and asks two questions. +//! +//! - **Does anything break?** Every gas-accounting cross-check `MegaETH` has is a `debug_assert`, +//! so a build with debug assertions live turns a broken conservation law into a panic, which +//! [`panic_capture`](crate::panic_capture) turns into that vector's verdict rather than a lost +//! worker thread. Zero panics over the corpus is the gate. +//! - **Is observation still free?** The read-only run must be bit-identical to the run with no +//! inspector at all, on every quantity the differential classifier compares. That is the property +//! every tracer in production depends on, and it is checked here against 44,000 transactions +//! rather than against a handful of fixtures. +//! - **Can the ledger still see it?** A rewriting run that applied a mutation the shim is +//! contracted to book unconditionally must not end with an all-zero ledger. The ledger is what +//! the conservation law reads as its inspector term, what the block executor's backstop refuses a +//! result over, and the only thing that tells a consumer an execution was inspector-influenced — +//! see [`ChaosClass::LedgerBlind`] and [`ChaosShape::is_always_booked`] for why the gate is +//! stated over a subset of the pool rather than over all of it. +//! +//! # Why the randomness is not random +//! +//! A sweep whose failures cannot be reproduced is a sweep whose failures cannot be fixed. Every +//! decision the chaos inspector makes comes from a hash of two things: a global seed the caller +//! chooses, and the vector's own identity (its fixture path, its unit name, its transaction +//! indexes). No clock, no address, no iteration order, no thread id. The same seed and the same +//! corpus produce the same mutations on any machine, in any thread count, in any order — so a +//! flagged vector comes with everything needed to re-run exactly it. +//! +//! # What the pool leaves out, and the one refusal it draws on purpose +//! +//! One rewrite shape is missing: turning a *failed contract creation* into a successful one. The +//! shim refuses that shape and asserts on it, deliberately — by the time `create_end` runs, the +//! journal has been reverted and no code was deposited, so a success there reports a deployment +//! that did not happen, and a corpus that produces it should stop rather than quietly take the +//! rejection path. Including it here would make the detector's own firing the sweep's dominant +//! result. The refusal is pinned end-to-end by the two tests named in +//! `tests/rex7/inspector_cheat_matrix.rs`'s `inapplicable` table instead. +//! +//! The other refused shape *is* in the pool: [`ChaosShape::MoveInitResultClass`], which moves the +//! classification of a result frame init produced. It is drawn rather than withheld because the +//! shim answers it by declining the transaction rather than by asserting, and a decline is +//! something a sweep can count — [`ChaosClass::Refused`] is that count. So the corpus exercises +//! the refusal at scale instead of leaving it to the fixtures that reach it on purpose, and the +//! number says how much of the corpus its draws actually land on. The two general shapes +//! `FailFrame` and `ReviveCall` reach the same refusal whenever they happen to land on an +//! init-produced result, and are counted the same way. +//! +//! The four interception shapes do not, and that is a boundary rather than an omission: a result +//! an inspector answered a frame with is the inspector's in whole, with no checkpoint opened and +//! no state written behind it, so rewriting its classification contradicts nothing and is +//! supported. A draw that intercepts a frame and then reclassifies its own answer therefore +//! executes, which is what keeps the two halves of the pool composable. + +use crate::{ + diff::{compare, execute_unit_in_mode, execute_unit_reporting_chaos, RunMode}, + panic_capture, + runner::{is_skipped_fixture, skip_test, vector_label, FixtureScan, TestError, TestErrorKind}, + types::{SpecName, TestSuite, TestUnit, TxPartIndices}, +}; +use indicatif::{ProgressBar, ProgressDrawTarget}; +use mega_evm::{ + revm::{ + context::{Cfg, ContextTr, JournalTr}, + handler::FrameResult, + inspector::Inspector, + interpreter::{ + interpreter_types::{Jumps, LoopControl, MemoryTr, ReturnData, StackTr}, + CallInputs, CallOutcome, CreateInputs, CreateOutcome, FrameInput, Gas, + InstructionResult, Interpreter, InterpreterAction, InterpreterResult, InterpreterTypes, + }, + primitives::{Address, Bytes, Log, U256}, + }, + FrameResultOriginTr, FORBIDDEN_CREATE_REVIVAL, FORBIDDEN_FRAME_INIT_REWRITE, +}; +use std::{ + collections::BTreeMap, + path::Path, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, Mutex, + }, +}; + +/// How many mutations one transaction may receive. +/// +/// Bounded for two reasons. A budget keeps the sweep's running time bounded — every gas injection +/// buys the transaction more opcodes to execute, and an unbounded trickle into a loop is an +/// unbounded sweep. And a budget keeps a flagged vector legible: a dozen mutations can be listed +/// in a report, a hundred thousand cannot. +const MUTATION_BUDGET: u32 = 12; + +/// One in this many callbacks carries a mutation, until the budget runs out. +const FIRE_IN: u64 = 8; + +/// The largest gas amount a single mutation moves. +/// +/// Small on purpose: the whole budget can move at most `MUTATION_BUDGET * GAS_DELTA_MAX` gas, +/// which is far less than a fixture's gas limit. The shapes are being tested, not the magnitudes — +/// a lane that drops an adjustment drops it whatever its size. +const GAS_DELTA_MAX: u64 = 512; + +/// Transient-storage slot the journal-write shape writes to. +const CHAOS_SLOT: u64 = 0xC4A05; + +/// Account the journal-write shape writes that slot on. +/// +/// An address no fixture uses, so the write cannot collide with one the transaction makes and be +/// mistaken for it. Transient storage is discarded at the end of the transaction either way, so +/// the write reaches no post-state — the point is that it goes through the journal, which is the +/// surface an inspector can reach without any `MegaETH` lane metering it. +const CHAOS_ADDRESS: Address = + mega_evm::revm::primitives::address!("00000000000000000000000000000000c4a05c4a"); + +// --- the deterministic stream ----------------------------------------------------------------- + +/// `splitmix64`: a full-period, well-distributed mixing function with no state but its input. +/// +/// Written out rather than taken from a crate so that the stream is fixed by this file: a +/// dependency bump that changed a generator's algorithm would silently change what every seed +/// means, and a seed that no longer reproduces its own failure is worse than no seed at all. +const fn mix(seed: u64) -> u64 { + let mut x = seed.wrapping_add(0x9E37_79B9_7F4A_7C15); + x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + x ^ (x >> 31) +} + +/// FNV-1a over bytes — the hash a vector's identity is folded through. +/// +/// Also written out rather than taken from the standard library: `DefaultHasher`'s output is +/// explicitly not guaranteed stable across Rust releases, and a seed whose meaning depends on the +/// toolchain does not reproduce anything. +fn fnv1a(bytes: &[u8], mut hash: u64) -> u64 { + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01B3); + } + hash +} + +/// The part of a fixture's path a seed is allowed to depend on: its file name, and nothing above +/// it. +/// +/// A reported seed has to mean the same thing on the machine that reports it and the machine that +/// triages it, and the rest of the path does not: the same corpus sits under a different checkout +/// root on every machine, under whatever directory `--corpus-dir` names, and under the private +/// fallback directory a sweep falls back to. A fixture triaged on its own is reached by a path +/// that shares no prefix at all with the one the sweep walked. Every one of those would move the +/// seed while the fixture stayed the same, which is the one thing a reported seed must not do — +/// and the failure mode is the worst kind, a nightly failure that quietly stops reproducing. +/// +/// Two fixtures with the same file name in different directories therefore feed the same bytes +/// into the hash. That is not a defect: a seed selects a mutation stream, and two vectors drawing +/// the same stream is exactly as useful as two drawing different ones. What the identity is for is +/// stability, not uniqueness — and the unit name, which follows it into the hash, carries the test +/// id that distinguishes them anyway. +fn fixture_identity(path: &Path) -> std::borrow::Cow<'_, str> { + path.file_name().unwrap_or(path.as_os_str()).to_string_lossy() +} + +/// The seed one vector's chaos run uses, derived from the global seed and the vector's identity. +/// +/// The identity is everything that distinguishes this transaction from every other in the corpus +/// *and* means the same thing on every machine: the fixture's file name (see +/// [`fixture_identity`]), which unit of that file it is, and which of that unit's transaction +/// vectors. Two runs of the same corpus with the same global seed therefore mutate the same +/// vectors the same way, whatever order the files are swept in, however many threads sweep them, +/// and wherever the corpus is checked out. +pub fn vector_seed(global: u64, path: &Path, name: &str, indexes: TxPartIndices) -> u64 { + let mut hash = fnv1a(fixture_identity(path).as_bytes(), 0xCBF2_9CE4_8422_2325); + hash = fnv1a(&[0], hash); + hash = fnv1a(name.as_bytes(), hash); + hash = fnv1a(&[0], hash); + hash = fnv1a(&(indexes.data as u64).to_le_bytes(), hash); + hash = fnv1a(&(indexes.gas as u64).to_le_bytes(), hash); + hash = fnv1a(&(indexes.value as u64).to_le_bytes(), hash); + mix(hash ^ mix(global)) +} + +// --- the shapes --------------------------------------------------------------------------------- + +/// Declares the shape pool as one row per shape. +/// +/// The enum, the list every sweep iterates and the label a report and `--chaos-shapes` use are +/// three views of one row. Declared separately, a shape added to the enum and missed in the list +/// shrinks the sweep silently, and one missed in the labels prints the wrong name in a report. +macro_rules! shapes { + ($( $(#[$meta:meta])* $variant:ident = $label:literal; )*) => { + /// A rewrite shape the chaos pool draws from — one legal column of the cheat-shape matrix. + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] + pub enum ChaosShape { + $($(#[$meta])* $variant,)* + } + + impl ChaosShape { + /// Every shape, in the order the labels are listed by `--chaos-shapes`, which is also + /// the order [`ShapeFilter`]'s bitmask indexes through the discriminant. + pub const ALL: [Self; [$(shapes!(@unit $variant)),*].len()] = + [$(Self::$variant,)*]; + + /// Stable label, for reports. + pub const fn label(self) -> &'static str { + match self { + $(Self::$variant => $label,)* + } + } + } + }; + // One `()` per row, so the array's length is counted from the declaration rather than + // written out beside it. + (@unit $variant:ident) => { () }; +} + +shapes! { + /// Gas written into a live interpreter's counter. + InjectGas = "inject_gas"; + /// Gas taken out of one. + DrainGas = "drain_gas"; + /// The interpreter's own working state — a memory word, or the operand an `SSTORE` is about + /// to consume. + EditFrameState = "edit_frame_state"; + /// A transient-storage write made behind the EVM's back. + JournalWrite = "journal_write"; + /// A raised `gas_limit` on a frame about to be built. + RaiseEnvelope = "raise_envelope"; + /// A lowered one. + LowerEnvelope = "lower_envelope"; + /// A call turned static, so what the frame is allowed to do changes rather than what it costs. + MakeStatic = "make_static"; + /// A synthetic outcome, so no frame is built at all. Its gas echoes the envelope the callback + /// was handed, which is what every tool that intercepts does. + Intercept = "intercept"; + /// The same, sized above the envelope, so the outcome hands the caller back gas the + /// transaction never funded. + InterceptOverGas = "intercept_over_gas"; + /// Sized below it, so the caller spends the difference on a frame that never ran. + InterceptUnderGas = "intercept_under_gas"; + /// Sized at nothing, the extreme of the same direction: the whole envelope is consumed. + InterceptNoGas = "intercept_no_gas"; + /// A raised remaining-gas figure on a finished frame's result. + RaiseResultGas = "raise_result_gas"; + /// A lowered one. + LowerResultGas = "lower_result_gas"; + /// A successful frame result rewritten into a revert or an exceptional halt. + FailFrame = "fail_frame"; + /// A failed *call* frame rewritten into a success. The creation form of this shape is refused + /// by the shim and is deliberately not in the pool — see the module docs. + ReviveCall = "revive_call"; + /// Gas written into the action the interpreter is already holding — the object a terminating + /// or suspending instruction left behind, which carries its own copy of what the frame is + /// handing on. + RaiseActionGas = "raise_action_gas"; + /// Gas taken out of one. + LowerActionGas = "lower_action_gas"; + /// A refund added to a `Gas`'s refund counter — what the sender is billed, which the envelope + /// the conservation law is stated over does not reach. + RaiseRefund = "raise_refund"; + /// A refund taken out of one. Skipped when the `Gas` has none, rather than driving the counter + /// negative — a state revm documents as invalid at the end of a transaction. + LowerRefund = "lower_refund"; + /// An EIP-8037 state-gas pool written into a `Gas` or a call's inputs. `MegaETH` runs with the + /// EIP off and fills no pool, so anything found in one is gas the transaction never funded. + WriteReservoir = "write_reservoir"; + /// An EIP-8037 spend counter written into a `Gas`. Structurally zero for the same reason, and + /// reachable through two different receipt figures depending on how the frame ends. + WriteStateGas = "write_state_gas"; + /// The frame's memory grown, together with the memo of how far it has been paid for, so that + /// the interpreter stays consistent and the next expanding opcode is charged nothing. + GrowMemoryFree = "grow_memory_free"; + /// A finished outcome's metadata rewritten around the `InterpreterResult` inside it: the range + /// a call's return data lands in, shrunk to nothing, or the address a creation reports. + MoveOutcomeMetadata = "move_outcome_metadata"; + /// Gas injected into an interpreter counter and taken straight back out at the next + /// live-interpreter callback, so the lane's net is zero and the frame saw a number in between + /// that the EVM would never have produced. + CancelGasEdit = "cancel_gas_edit"; + /// A refund added to one finished frame's result and taken out of the next one's, so the + /// lane's net is zero and — whenever the two frames end differently — the receipt's is + /// not. + CancelRefundEdit = "cancel_refund_edit"; + /// The program counter stepped past the instruction the frame was about to execute, deleting + /// it from the frame. The work is never performed, so no counter falls and no lane moves. + SkipOpcode = "skip_opcode"; + /// A return buffer put in front of the frame, so its `RETURNDATASIZE` and `RETURNDATACOPY` + /// read data no call produced. + RewriteReturnData = "rewrite_return_data"; + /// The classification of a result *frame init* produced, moved across the success / revert / + /// halt boundary. The shim refuses this one, so the run it lands in is declined rather than + /// executed — which is the verdict [`ChaosClass::Refused`] names. + MoveInitResultClass = "move_init_result_class"; +} + +impl ChaosShape { + /// The shape a label names. + /// + /// # Errors + /// + /// Returns a message listing every label when `label` is not one. + pub fn parse(label: &str) -> Result { + Self::ALL.into_iter().find(|shape| shape.label() == label).ok_or_else(|| { + format!( + "unknown chaos shape {label:?}; known shapes are {}", + Self::ALL.map(Self::label).join(", ") + ) + }) + } + + /// Whether the shim is contracted to book a mutation of this shape unconditionally. + /// + /// Most shapes are booked *when the thing they moved still reaches something*: gas written into + /// a counter the interpreter is about to stop reading moves nothing, a result's remaining gas + /// edited on a halting frame is never handed back, an envelope edited by the same callback that + /// then answers the frame reaches no frame at all. Those are all correct non-bookings, and a + /// gate stated over them would be wrong. + /// + /// The shapes below have no such escape. Each of them either changes an argument the shim holds + /// — which the rewrite comparison reads on the spot — or moves a lane at a boundary that books + /// unconditionally. So a run that applied one of them and ended with an all-zero ledger is a + /// rewrite the canonical block path would admit, which is what + /// [`ChaosClass::LedgerBlind`] names. + pub const fn is_always_booked(self) -> bool { + matches!( + self, + // The rewrite comparison sees the argument come back changed. + Self::MakeStatic | + Self::Intercept | + Self::InterceptOverGas | + Self::InterceptUnderGas | + Self::InterceptNoGas | + Self::FailFrame | + Self::ReviveCall | + Self::GrowMemoryFree | + Self::MoveOutcomeMetadata | + // Refunds are booked at the callback boundary, whatever becomes of the frame — + // which is why the interpreter-facing arm of `hit_interpreter` withholds one that + // would land in a counter a terminating action has already displaced. That is the + // single window in which a refund edit reaches nothing, and the pool skips it + // rather than leaving these three shapes out of the gate. + Self::RaiseRefund | + Self::LowerRefund | + Self::CancelRefundEdit | + // Both move a constant-time reading the shim takes off every live interpreter, + // and both are drawn only in the window where they move it: a skip is withheld + // unless the frame is still running, and a rewritten return buffer always gets a + // length the current one does not have. + Self::SkipOpcode | + Self::RewriteReturnData | + // Gas written into a pending action is staged for the point that can say whether + // it moved the envelope, and the lane's traffic is booked at this boundary rather + // than at that point — so a staged edit shows up whatever the frame's + // classification turns out to be. The draw is withheld when the action cannot + // take the edit, so every applied one moves a number. + Self::RaiseActionGas | + Self::LowerActionGas | + // The rewrite comparison books it before the shim refuses it, and the refusal is + // counted beside that — so the ledger carries two reasons to be non-zero. + Self::MoveInitResultClass + ) + } +} + +/// Shapes reachable from a callback that holds a live interpreter. +/// +/// `RaiseActionGas` and `LowerActionGas` only land at the one callback that runs with an action +/// already pending — `step_end`, which revm's inspected loop runs after the instruction that set +/// it. A draw for them anywhere else leaves the interpreter alone and spends no budget. +/// `SkipOpcode` is withheld in the opposite window, at a callback whose frame is already +/// terminating, and at an instruction whose deletion would move the frame off an instruction +/// boundary. +const INTERPRETER_SHAPES: [ChaosShape; 14] = [ + ChaosShape::InjectGas, + ChaosShape::DrainGas, + ChaosShape::EditFrameState, + ChaosShape::JournalWrite, + ChaosShape::RaiseActionGas, + ChaosShape::LowerActionGas, + ChaosShape::RaiseRefund, + ChaosShape::LowerRefund, + ChaosShape::WriteReservoir, + ChaosShape::WriteStateGas, + ChaosShape::GrowMemoryFree, + ChaosShape::CancelGasEdit, + ChaosShape::SkipOpcode, + ChaosShape::RewriteReturnData, +]; + +/// Shapes reachable from a callback that holds a frame's inputs, before the frame is built. +/// +/// The four interception shapes differ only in how the synthetic outcome's `Gas` is sized against +/// the envelope. That is the whole of what separates them, and it is the separation that matters: +/// the echo is the shape every real tool uses, and it is also the one shape whose accounting +/// closes without anything measuring the figure. +const INPUT_SHAPES: [ChaosShape; 9] = [ + ChaosShape::RaiseEnvelope, + ChaosShape::LowerEnvelope, + ChaosShape::MakeStatic, + ChaosShape::Intercept, + ChaosShape::InterceptOverGas, + ChaosShape::InterceptUnderGas, + ChaosShape::InterceptNoGas, + ChaosShape::JournalWrite, + ChaosShape::WriteReservoir, +]; + +/// Shapes reachable from a callback that holds a finished frame's result. +const RESULT_SHAPES: [ChaosShape; 12] = [ + ChaosShape::RaiseResultGas, + ChaosShape::LowerResultGas, + ChaosShape::FailFrame, + ChaosShape::ReviveCall, + ChaosShape::JournalWrite, + ChaosShape::RaiseRefund, + ChaosShape::LowerRefund, + ChaosShape::WriteReservoir, + ChaosShape::WriteStateGas, + ChaosShape::MoveOutcomeMetadata, + ChaosShape::CancelRefundEdit, + ChaosShape::MoveInitResultClass, +]; + +/// Which mutations a chaos run is allowed to make. +/// +/// The knob exists for triage rather than for the sweep's normal operation: a flagged vector is +/// re-run with the filter narrowed until the smallest set of shapes that still reproduces it is +/// found, which is the difference between "chaos broke something" and a defect report. See +/// [`ChaosInspector::new`] for what narrowing does and does not preserve. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ShapeFilter { + /// Bitmask over [`ChaosShape::ALL`], by index. + allowed: u32, +} + +impl Default for ShapeFilter { + fn default() -> Self { + Self { allowed: u32::MAX } + } +} + +impl ShapeFilter { + /// A filter allowing exactly the listed shapes. + pub fn only(shapes: &[ChaosShape]) -> Self { + let mut allowed = 0; + for shape in shapes { + allowed |= 1 << Self::index(*shape); + } + Self { allowed } + } + + /// Whether `shape` may be drawn. + pub const fn allows(&self, shape: ChaosShape) -> bool { + self.allowed & (1 << Self::index(shape)) != 0 + } + + /// Whether this filter allows every shape. + pub fn is_complete(&self) -> bool { + ChaosShape::ALL.into_iter().all(|s| self.allows(s)) + } + + const fn index(shape: ChaosShape) -> u32 { + shape as u32 + } +} + +/// How many mutations of each shape one run applied. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ChaosTally { + /// Mutations applied, per shape label. + pub applied: BTreeMap<&'static str, u32>, + /// Callbacks the inspector was handed. + pub callbacks: u64, +} + +impl ChaosTally { + /// Total mutations applied. + pub fn total(&self) -> u32 { + self.applied.values().sum() + } + + /// Folds another run's tally into this one. + pub fn merge(&mut self, other: &Self) { + for (shape, count) in &other.applied { + *self.applied.entry(shape).or_insert(0) += count; + } + self.callbacks += other.callbacks; + } +} + +// --- the inspector ------------------------------------------------------------------------------ + +/// A read-only inspector that counts every callback it is handed and changes nothing. +/// +/// The control the chaos run is judged against. It implements every callback, on purpose: an +/// inspector that implemented only one would exercise only one of the shim's wrappers, and the +/// claim under test is that *observation* costs nothing, not that one callback does. +#[derive(Debug, Default)] +pub struct CallbackCounter { + callbacks: u64, +} + +impl CallbackCounter { + /// How many callbacks this inspector was handed. + pub const fn callbacks(&self) -> u64 { + self.callbacks + } +} + +/// The control counts callbacks and writes nothing back, which is exactly what the declaration +/// promises — so it is also what [`RunMode::ObserveTrusted`](crate::diff::RunMode::ObserveTrusted) +/// drives the shim's fast path with. +impl mega_evm::TrustedObserver for CallbackCounter {} + +impl Inspector for CallbackCounter { + fn initialize_interp(&mut self, _interp: &mut Interpreter, _context: &mut CTX) { + self.callbacks += 1; + } + + fn step(&mut self, _interp: &mut Interpreter, _context: &mut CTX) { + self.callbacks += 1; + } + + fn step_end(&mut self, _interp: &mut Interpreter, _context: &mut CTX) { + self.callbacks += 1; + } + + fn log(&mut self, _context: &mut CTX, _log: Log) { + self.callbacks += 1; + } + + fn frame_start( + &mut self, + _context: &mut CTX, + _frame_input: &mut FrameInput, + ) -> Option { + self.callbacks += 1; + None + } + + fn frame_end( + &mut self, + _context: &mut CTX, + _frame_input: &FrameInput, + _frame_result: &mut FrameResult, + ) { + self.callbacks += 1; + } + + fn call(&mut self, _context: &mut CTX, _inputs: &mut CallInputs) -> Option { + self.callbacks += 1; + None + } + + fn call_end(&mut self, _context: &mut CTX, _inputs: &CallInputs, _outcome: &mut CallOutcome) { + self.callbacks += 1; + } + + fn create(&mut self, _context: &mut CTX, _inputs: &mut CreateInputs) -> Option { + self.callbacks += 1; + None + } + + fn create_end( + &mut self, + _context: &mut CTX, + _inputs: &CreateInputs, + _outcome: &mut CreateOutcome, + ) { + self.callbacks += 1; + } + + fn selfdestruct(&mut self, _contract: Address, _target: Address, _value: U256) { + self.callbacks += 1; + } +} + +/// Rewrites what it is handed, deterministically, from a seed. +/// +/// At every callback it draws one value from the stream; that value decides whether this callback +/// carries a mutation and, if so, which shape and how large. The stream advances on every callback +/// whether or not a mutation lands, so the decision sequence is a function of the seed and the +/// execution — never of what the inspector chose earlier. +#[derive(Debug)] +pub struct ChaosInspector { + seed: u64, + filter: ShapeFilter, + /// Position in the stream: how many callbacks have been seen. + tick: u64, + /// Mutations left in this transaction's budget. + budget: u32, + /// Gas a [`ChaosShape::CancelGasEdit`] injected and has not taken back out yet. + /// + /// The give-back is taken at the next live-interpreter callback rather than on a second draw, + /// so the pair closes tightly and usually inside the same frame. Both halves land on the + /// interpreter lane, and their net is zero — which is the whole point of the shape. + pending_gas: u64, + /// A refund a [`ChaosShape::CancelRefundEdit`] added and has not taken back out yet, settled + /// at the next result-facing callback for the same reason. + pending_refund: i64, + tally: ChaosTally, +} + +impl ChaosInspector { + /// A chaos inspector driven by `seed`, restricted to what `filter` allows. + /// + /// The decision stream does not depend on the filter: every callback draws the same value + /// whatever is allowed, so narrowing a filter keeps each remaining mutation exactly where the + /// full run put it. What narrowing does change is how far the budget reaches — a rejected draw + /// spends none of it — so a narrowed run can carry mutations further into a transaction than + /// the full run did. Narrowing therefore reproduces a flagged mutation; it does not reproduce + /// a flagged run. + pub fn new(seed: u64, filter: ShapeFilter) -> Self { + Self { + seed, + filter, + tick: 0, + budget: MUTATION_BUDGET, + pending_gas: 0, + pending_refund: 0, + tally: ChaosTally::default(), + } + } + + /// What this run mutated. + pub fn tally(&self) -> ChaosTally { + self.tally.clone() + } + + /// Draws the next value from the stream, advancing it by one callback. + fn draw(&mut self) -> u64 { + self.tick += 1; + self.tally.callbacks += 1; + mix(self.seed ^ mix(self.tick)) + } + + /// Picks a shape from `pool` for this callback, or `None` when this callback carries no + /// mutation or the budget is spent. + fn pick(&mut self, pool: &[ChaosShape]) -> Option<(ChaosShape, u64)> { + let draw = self.draw(); + if self.budget == 0 || !draw.is_multiple_of(FIRE_IN) { + return None; + } + let shape = pool[(draw / FIRE_IN) as usize % pool.len()]; + if !self.filter.allows(shape) { + return None; + } + Some((shape, mix(draw))) + } + + /// Books one applied mutation against the budget. + fn applied(&mut self, shape: ChaosShape) { + self.budget = self.budget.saturating_sub(1); + *self.tally.applied.entry(shape.label()).or_insert(0) += 1; + } + + /// A gas amount in `1..=GAS_DELTA_MAX`, drawn from `entropy`. + fn amount(entropy: u64) -> u64 { + entropy % GAS_DELTA_MAX + 1 + } + + /// Applies an interpreter-facing shape. + fn hit_interpreter( + &mut self, + interp: &mut Interpreter, + context: &mut CTX, + shape: ChaosShape, + entropy: u64, + ) { + match shape { + ChaosShape::InjectGas => interp.gas.erase_cost(Self::amount(entropy)), + ChaosShape::DrainGas => { + if !interp.gas.record_regular_cost(Self::amount(entropy)) { + // The frame cannot afford the removal; leave the counter alone rather than + // manufacture an out-of-gas the EVM did not reach. + return; + } + } + ChaosShape::EditFrameState => { + if !edit_frame_state(interp, entropy) { + return; + } + } + ChaosShape::JournalWrite => write_journal(context, entropy), + ChaosShape::RaiseActionGas | ChaosShape::LowerActionGas => { + let raise = shape == ChaosShape::RaiseActionGas; + if !edit_pending_action_gas(interp, raise, Self::amount(entropy)) { + return; + } + } + ChaosShape::RaiseRefund | ChaosShape::LowerRefund => { + // A refund written into the interpreter's own counter while a terminating action + // is pending lands in an object nobody reads again: the action carries its own + // `Gas`, and that is what becomes the frame's result. The edit would be applied, + // correctly booked nowhere, and would then look to the ledger gate like a rewrite + // the shim missed. Leave the counter alone and spend no budget; the same edit + // reaches the live object at the next callback, and the dead window itself is + // pinned by `tests/rex7/shim_settlement.rs`. + if matches!(interp.bytecode.action(), Some(InterpreterAction::Return(_))) { + return; + } + if !edit_receipt_figure(&mut interp.gas, shape, entropy) { + return; + } + } + ChaosShape::WriteReservoir | ChaosShape::WriteStateGas => { + if !edit_receipt_figure(&mut interp.gas, shape, entropy) { + return; + } + } + ChaosShape::GrowMemoryFree => { + if !grow_memory_free(interp, context) { + return; + } + } + ChaosShape::SkipOpcode => { + if !skip_opcode(interp) { + return; + } + } + ChaosShape::RewriteReturnData => rewrite_return_data(interp, entropy), + ChaosShape::CancelGasEdit => { + // The give-back is taken at the next live-interpreter callback, by + // `settle_pending_gas`. A second draw while one is outstanding leaves the + // interpreter alone and spends no budget. + if self.pending_gas != 0 { + return; + } + let amount = Self::amount(entropy); + interp.gas.erase_cost(amount); + self.pending_gas = amount; + } + _ => return, + } + self.applied(shape); + } + + /// Takes back the gas a [`ChaosShape::CancelGasEdit`] injected, closing the pair. + /// + /// Skipped when the frame cannot afford it, rather than manufacturing an out-of-gas the EVM + /// did not reach — the pair then stays open, its net stays non-zero, and the ledger is + /// non-zero either way. + fn settle_pending_gas(&mut self, interp: &mut Interpreter) { + if self.pending_gas != 0 && interp.gas.record_regular_cost(self.pending_gas) { + self.pending_gas = 0; + } + } + + /// The refund half of the same mechanism, settled against a finished frame's result. + fn settle_pending_refund(&mut self, gas: &mut Gas) { + if self.pending_refund != 0 && gas.refunded() >= self.pending_refund { + gas.record_refund(-self.pending_refund); + self.pending_refund = 0; + } + } + + /// The body all four live-interpreter callbacks share: settle whatever the previous one left + /// pending, then draw one interpreter-facing shape. + fn hit_live( + &mut self, + interp: &mut Interpreter, + context: &mut CTX, + ) { + self.settle_pending_gas(interp); + if let Some((shape, entropy)) = self.pick(&INTERPRETER_SHAPES) { + self.hit_interpreter(interp, context, shape, entropy); + } + } + + /// Applies an input-facing shape to a call's inputs, or intercepts the frame. + fn hit_call_inputs( + &mut self, + context: &mut CTX, + inputs: &mut CallInputs, + shape: ChaosShape, + entropy: u64, + ) -> Option { + let mut outcome = None; + match shape { + ChaosShape::RaiseEnvelope => { + inputs.gas_limit = inputs.gas_limit.saturating_add(Self::amount(entropy)); + } + ChaosShape::LowerEnvelope => { + inputs.gas_limit = inputs.gas_limit.saturating_sub(Self::amount(entropy)); + } + ChaosShape::MakeStatic => { + // A call already made static by its caller would come back unchanged, which is a + // mutation nothing has to book; leave it alone and spend no budget. + if inputs.is_static { + return None; + } + inputs.is_static = true; + } + ChaosShape::WriteReservoir => { + inputs.reservoir = inputs.reservoir.saturating_add(Self::amount(entropy)); + } + ChaosShape::Intercept | + ChaosShape::InterceptOverGas | + ChaosShape::InterceptUnderGas | + ChaosShape::InterceptNoGas => { + outcome = Some(CallOutcome::new( + InterpreterResult::new( + synthetic_result(entropy), + Bytes::new(), + Gas::new(interception_gas(shape, inputs.gas_limit, entropy)), + ), + inputs.return_memory_offset.clone(), + )); + } + ChaosShape::JournalWrite => write_journal(context, entropy), + _ => return None, + } + self.applied(shape); + outcome + } + + /// Applies an input-facing shape to a creation's inputs, or intercepts the frame. + fn hit_create_inputs( + &mut self, + context: &mut CTX, + inputs: &mut CreateInputs, + shape: ChaosShape, + entropy: u64, + ) -> Option { + let mut outcome = None; + match shape { + ChaosShape::RaiseEnvelope => { + inputs.set_gas_limit(inputs.gas_limit().saturating_add(Self::amount(entropy))); + } + ChaosShape::LowerEnvelope => { + inputs.set_gas_limit(inputs.gas_limit().saturating_sub(Self::amount(entropy))); + } + ChaosShape::Intercept | + ChaosShape::InterceptOverGas | + ChaosShape::InterceptUnderGas | + ChaosShape::InterceptNoGas => { + outcome = Some(CreateOutcome::new( + InterpreterResult::new( + synthetic_result(entropy), + Bytes::new(), + Gas::new(interception_gas(shape, inputs.gas_limit(), entropy)), + ), + None, + )); + } + ChaosShape::JournalWrite => write_journal(context, entropy), + // `MakeStatic` has no counterpart here — a creation carries no static flag — and + // `WriteReservoir` has none either, because `CreateInputs` keeps its pool private and + // offers no setter. The rest are not input-facing at all. All of them leave the inputs + // alone and spend no budget. + _ => return None, + } + self.applied(shape); + outcome + } + + /// Applies a result-facing shape to a finished frame's result. + /// + /// `is_creation` withholds the one shape the shim refuses with an assertion: a failed contract + /// creation rewritten into a success. The pool never offers it, so a creation drawing + /// `ReviveCall` leaves the result alone and spends no budget. + /// + /// `is_frame_init` is the other way round — it is what *arms* + /// [`ChaosShape::MoveInitResultClass`], which is only that shape when the result it lands on + /// came out of frame init. A draw for it anywhere else leaves the result alone, so the shape's + /// tally counts refusals reached rather than draws made. + fn hit_result( + &mut self, + result: &mut InterpreterResult, + is_creation: bool, + is_frame_init: bool, + shape: ChaosShape, + entropy: u64, + ) { + match shape { + ChaosShape::RaiseResultGas => result.gas.erase_cost(Self::amount(entropy)), + ChaosShape::LowerResultGas => { + if !result.gas.record_regular_cost(Self::amount(entropy)) { + return; + } + } + ChaosShape::FailFrame => { + if !result.result.is_ok() { + return; + } + result.result = if entropy.is_multiple_of(2) { + InstructionResult::Revert + } else { + InstructionResult::OutOfGas + }; + } + ChaosShape::ReviveCall => { + if is_creation || result.result.is_ok() { + return; + } + result.result = InstructionResult::Stop; + } + ChaosShape::RaiseRefund | + ChaosShape::LowerRefund | + ChaosShape::WriteReservoir | + ChaosShape::WriteStateGas => { + if !edit_receipt_figure(&mut result.gas, shape, entropy) { + return; + } + } + ChaosShape::CancelRefundEdit => { + if self.pending_refund != 0 { + return; + } + let amount = Self::amount(entropy) as i64; + result.gas.record_refund(amount); + self.pending_refund = amount; + } + ChaosShape::MoveInitResultClass => { + if !is_frame_init { + return; + } + result.result = across_the_class_boundary(result.result); + } + _ => return, + } + self.applied(shape); + } +} + +/// The class a result is moved *to*, which is any class but its own. +/// +/// Stated over all three so the shape reaches every arm of frame init, not only the ones that +/// return successfully: an empty-code call and a precompile come back `Stop`, a refusal comes back +/// a halt, and `MegaETH`'s own frame-local exceed comes back a revert. +const fn across_the_class_boundary(from: InstructionResult) -> InstructionResult { + if from.is_ok() { + InstructionResult::Revert + } else if from.is_revert() { + InstructionResult::OutOfGas + } else { + InstructionResult::Revert + } +} + +/// Grows the frame's memory and moves the memo of how far it has been paid for with it, returning +/// whether anything moved. +/// +/// The pair is what makes this a rewrite rather than a corruption: moving the memo alone leaves the +/// EVM reading out of bounds, moving the memory alone leaves the growth charged for twice, and +/// moving both leaves the interpreter in a state it could have reached by paying, having paid +/// nothing. The next expanding opcode inside the new bound is then charged nothing at all. +/// +/// The memo is priced through revm's own table rather than a restatement of the formula, because +/// `MemoryGas::set_words_num` hands revm's caller a `checked_sub` it unwraps unchecked: a memo +/// higher than the schedule would have written is undefined behaviour at the next expansion, not a +/// wrong number. +fn grow_memory_free( + interp: &mut Interpreter, + context: &CTX, +) -> bool { + let words = interp.memory.size() / 32 + 1; + if !interp.memory.resize(words * 32) { + return false; + } + let cost = context.cfg().gas_params().memory_cost(words); + interp.gas.memory_mut().set_words_num(words, cost); + true +} + +/// Steps the program counter past the instruction the frame was about to execute, returning +/// whether anything moved. +/// +/// This is the shape with the sharpest teeth in the pool — it does not make an instruction cheaper, +/// it deletes one — and it is also the only one that can move the frame off an instruction +/// boundary, so its guard is what keeps the sweep's executions well-formed rather than merely +/// different. +/// +/// Three conditions, each load-bearing: +/// +/// - **The frame is still running.** With a terminating action already pending, revm's inspected +/// loop breaks without reading the counter again, so a skip there changes nothing about the +/// execution while still costing budget. +/// - **The instruction is not a `PUSH`.** A `PUSH` is the one instruction whose bytes are not all +/// opcodes, so skipping its opcode byte leaves its immediate data to be executed as code and the +/// whole rest of the frame decodes at the wrong offsets. Skipping any other single-byte +/// instruction lands exactly on the next instruction boundary: the frame executes its own +/// bytecode with one instruction removed, which is a well-formed program and a reproducible one. +/// - **The instruction does not end the frame.** Skipping a `STOP` or a `RETURN` would carry +/// execution past the point the frame was going to stop, into whatever follows it. That is +/// bounded — the analysed bytecode is padded with `STOP`, and gas bounds it in any case — but it +/// makes a frame's cost a function of what happens to sit after its terminator, which is the +/// opposite of what a corpus sweep wants. +/// +/// Termination is not at risk under those conditions. Deleting an instruction cannot create a +/// backward jump the bytecode did not already contain, every path still ends at a terminator or +/// runs out of gas, and the budget bounds how many instructions one transaction can lose. +fn skip_opcode(interp: &mut Interpreter) -> bool { + /// `PUSH1` through `PUSH32` — every opcode that carries immediate bytes. + const PUSH_RANGE: core::ops::RangeInclusive = 0x60..=0x7F; + /// `STOP`, `RETURN`, `REVERT`, `INVALID`, `SELFDESTRUCT`. + const TERMINATORS: [u8; 5] = [0x00, 0xF3, 0xFD, 0xFE, 0xFF]; + + if !interp.bytecode.is_not_end() { + return false; + } + let opcode = interp.bytecode.opcode(); + if PUSH_RANGE.contains(&opcode) || TERMINATORS.contains(&opcode) { + return false; + } + interp.bytecode.relative_jump(1); + true +} + +/// Puts a return buffer in front of the frame that no call of its own produced. +/// +/// The length always differs from the one the buffer has, which is what makes the shape +/// unconditionally visible: the shim reads the buffer's identity, and a replacement of a different +/// length moves it whatever the allocator does with the old one. It is also what the frame reads — +/// `RETURNDATASIZE` returns exactly this number. +/// +/// Growing rather than shrinking, and by at most a word or so at a time, so that a +/// `RETURNDATACOPY` the fixture makes can only succeed where it would have reverted, and the +/// buffer stays small enough that the copy it pays for is bounded by the mutation budget. +fn rewrite_return_data(interp: &mut Interpreter, entropy: u64) { + let len = interp.return_data.buffer().len() + 1 + (entropy % 32) as usize; + interp.return_data.set_buffer(Bytes::from(vec![(entropy % 256) as u8; len])); +} + +/// Writes one of the receipt figures that is not the envelope, returning whether anything moved. +/// +/// The three are grouped because they are one surface — every `Gas` an inspector is handed carries +/// all of them — and separated from the gas lanes because the conservation law reaches only one of +/// the three. A refund reaches what the sender is billed; the EIP-8037 pool reaches the envelope +/// the receipt reports as spent; the EIP-8037 spend counter reaches the receipt's state-gas figure, +/// or its caller's pool when the frame fails. +/// +/// Lowering a refund the `Gas` does not have is skipped rather than driving the counter negative: +/// revm documents a negative refund at the end of a transaction as invalid, so producing one would +/// be testing a state the EVM cannot reach on its own. +fn edit_receipt_figure(gas: &mut Gas, shape: ChaosShape, entropy: u64) -> bool { + let amount = entropy % GAS_DELTA_MAX + 1; + match shape { + ChaosShape::RaiseRefund => gas.record_refund(amount as i64), + ChaosShape::LowerRefund => { + if gas.refunded() < amount as i64 { + return false; + } + gas.record_refund(-(amount as i64)); + } + ChaosShape::WriteReservoir => gas.set_reservoir(amount), + ChaosShape::WriteStateGas => gas.set_state_gas_spent(amount as i64), + _ => return false, + } + true +} + +/// The gas a synthetic outcome hands back, given the envelope the callback was handed. +/// +/// The four interception shapes are exactly this function's four cases. `Intercept` echoes the +/// envelope, which is the convention every tool that intercepts follows and the one sizing whose +/// accounting closes even if nothing measures it; the other three move it, in both directions and +/// down to nothing, so a lane that books one direction and drops the other is caught. +const fn interception_gas(shape: ChaosShape, envelope: u64, entropy: u64) -> u64 { + match shape { + ChaosShape::InterceptOverGas => envelope.saturating_add(entropy % GAS_DELTA_MAX + 1), + ChaosShape::InterceptUnderGas => envelope.saturating_sub(entropy % GAS_DELTA_MAX + 1), + ChaosShape::InterceptNoGas => 0, + _ => envelope, + } +} + +/// The classification a synthetic outcome carries — one of the three a real frame can end in. +fn synthetic_result(entropy: u64) -> InstructionResult { + match entropy % 3 { + 0 => InstructionResult::Stop, + 1 => InstructionResult::Revert, + _ => InstructionResult::OutOfGas, + } +} + +/// Edits the interpreter's working state, returning whether anything was edited. +/// +/// Two edits, chosen by what the frame is doing rather than at random: the operand an `SSTORE` is +/// about to consume, when that is what the interpreter is on, and a memory word otherwise. Neither +/// can fail the frame by itself — a pushed word would be read as the next opcode's operand, which +/// changes the fixture rather than cheating inside it. +fn edit_frame_state(interp: &mut Interpreter, entropy: u64) -> bool { + const SSTORE: u8 = 0x55; + if interp.bytecode.opcode() == SSTORE { + if let Some([key, value]) = interp.stack.popn::<2>() { + let pushed = interp.stack.push(value.wrapping_add(U256::from(entropy % 8 + 1))) && + interp.stack.push(key); + return pushed; + } + return false; + } + if interp.memory.size() >= 32 { + interp.memory.set(0, &[(entropy % 256) as u8; 32]); + return true; + } + false +} + +/// Moves gas in or out of the action the interpreter is holding, returning whether anything moved. +/// +/// The pending action is the one gas-carrying object a live-interpreter callback can reach that is +/// not the interpreter's own counter, and the two are different numbers at exactly one moment: a +/// terminating instruction has copied the counter into a `Return` action, or a `CALL` / `CREATE` +/// has put the child's envelope into a `NewFrame` one. With no action pending there is nothing to +/// edit and no budget is spent. +fn edit_pending_action_gas( + interp: &mut Interpreter, + raise: bool, + amount: u64, +) -> bool { + match interp.bytecode.action() { + Some(InterpreterAction::Return(result)) => { + if raise { + result.gas.erase_cost(amount); + true + } else { + // The action cannot afford the removal; leave it alone rather than manufacture an + // out-of-gas the EVM did not reach. + result.gas.record_regular_cost(amount) + } + } + // Both saturate, so an envelope already at either end does not move. Report that as "not + // applied" rather than spending the budget on it: the ledger gate below is stated over + // shapes that always book, and a mutation that moved nothing has nothing to book. + Some(InterpreterAction::NewFrame(FrameInput::Call(inputs))) => { + let moved = move_envelope(inputs.gas_limit, raise, amount); + let applied = moved != inputs.gas_limit; + inputs.gas_limit = moved; + applied + } + Some(InterpreterAction::NewFrame(FrameInput::Create(inputs))) => { + let moved = move_envelope(inputs.gas_limit(), raise, amount); + let applied = moved != inputs.gas_limit(); + inputs.set_gas_limit(moved); + applied + } + _ => false, + } +} + +/// A child envelope moved by `amount`, saturating at both ends. +const fn move_envelope(limit: u64, raise: bool, amount: u64) -> u64 { + if raise { + limit.saturating_add(amount) + } else { + limit.saturating_sub(amount) + } +} + +/// Writes one transient-storage slot on the frame's own account, behind the EVM's back. +/// +/// Transient storage is journalled, so the write follows the frame's checkpoint like any other +/// state change — which is the point: this is the unmetered surface an inspector reaches through, +/// and it must leave the accounting lanes alone without leaving the journal inconsistent. +fn write_journal(context: &mut CTX, entropy: u64) { + context.journal_mut().tstore(CHAOS_ADDRESS, U256::from(CHAOS_SLOT), U256::from(entropy)); +} + +impl Inspector + for ChaosInspector +{ + fn initialize_interp(&mut self, interp: &mut Interpreter, context: &mut CTX) { + self.hit_live(interp, context); + } + + fn step(&mut self, interp: &mut Interpreter, context: &mut CTX) { + self.hit_live(interp, context); + } + + fn step_end(&mut self, interp: &mut Interpreter, context: &mut CTX) { + self.hit_live(interp, context); + } + + fn log_full(&mut self, interp: &mut Interpreter, context: &mut CTX, _log: Log) { + self.hit_live(interp, context); + } + + fn frame_start( + &mut self, + context: &mut CTX, + frame_input: &mut FrameInput, + ) -> Option { + let (shape, entropy) = self.pick(&INPUT_SHAPES)?; + match frame_input { + FrameInput::Call(inputs) => { + self.hit_call_inputs(context, inputs, shape, entropy).map(FrameResult::Call) + } + FrameInput::Create(inputs) => { + self.hit_create_inputs(context, inputs, shape, entropy).map(FrameResult::Create) + } + FrameInput::Empty => None, + } + } + + fn call(&mut self, context: &mut CTX, inputs: &mut CallInputs) -> Option { + let (shape, entropy) = self.pick(&INPUT_SHAPES)?; + self.hit_call_inputs(context, inputs, shape, entropy) + } + + fn create(&mut self, context: &mut CTX, inputs: &mut CreateInputs) -> Option { + let (shape, entropy) = self.pick(&INPUT_SHAPES)?; + self.hit_create_inputs(context, inputs, shape, entropy) + } + + fn call_end(&mut self, context: &mut CTX, _inputs: &CallInputs, outcome: &mut CallOutcome) { + self.settle_pending_refund(&mut outcome.result.gas); + let is_frame_init = context.is_frame_init_result(); + let Some((shape, entropy)) = self.pick(&RESULT_SHAPES) else { return }; + if shape == ChaosShape::MoveOutcomeMetadata { + if shrink_return_range(outcome) { + self.applied(shape); + } + return; + } + self.hit_result(&mut outcome.result, false, is_frame_init, shape, entropy); + } + + fn create_end( + &mut self, + context: &mut CTX, + _inputs: &CreateInputs, + outcome: &mut CreateOutcome, + ) { + self.settle_pending_refund(&mut outcome.result.gas); + let is_frame_init = context.is_frame_init_result(); + let Some((shape, entropy)) = self.pick(&RESULT_SHAPES) else { return }; + if shape == ChaosShape::MoveOutcomeMetadata { + if relabel_deployment(outcome) { + self.applied(shape); + } + return; + } + self.hit_result(&mut outcome.result, true, is_frame_init, shape, entropy); + } + + fn frame_end( + &mut self, + context: &mut CTX, + _frame_input: &FrameInput, + frame_result: &mut FrameResult, + ) { + self.settle_pending_refund(frame_result.gas_mut()); + let is_frame_init = context.is_frame_init_result(); + let Some((shape, entropy)) = self.pick(&RESULT_SHAPES) else { return }; + if shape == ChaosShape::MoveOutcomeMetadata { + let moved = match frame_result { + FrameResult::Call(outcome) => shrink_return_range(outcome), + FrameResult::Create(outcome) => relabel_deployment(outcome), + }; + if moved { + self.applied(shape); + } + return; + } + let is_creation = matches!(frame_result, FrameResult::Create(_)); + self.hit_result( + frame_result.interpreter_result_mut(), + is_creation, + is_frame_init, + shape, + entropy, + ); + } +} + +/// Shrinks a finished call's return range to nothing, returning whether anything moved. +/// +/// Shrunk rather than moved: revm copies the callee's output into the caller's memory at this +/// range and panics if the range is outside what the caller has allocated, so the one edit that is +/// safe on an arbitrary corpus is the one that copies less. The caller then reads whatever was in +/// its memory before the call, which is the same semantic change the moved form makes. +fn shrink_return_range(outcome: &mut CallOutcome) -> bool { + if outcome.memory_offset.is_empty() { + return false; + } + outcome.memory_offset = outcome.memory_offset.start..outcome.memory_offset.start; + true +} + +/// Reports a successful creation at an address it did not deploy to, returning whether anything +/// moved. +fn relabel_deployment(outcome: &mut CreateOutcome) -> bool { + if outcome.address.is_none() || outcome.address == Some(CHAOS_ADDRESS) { + return false; + } + outcome.address = Some(CHAOS_ADDRESS); + true +} + +// --- the sweep ------------------------------------------------------------------------------ + +/// How one vector's three runs came out. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum ChaosClass { + /// The read-only run was identical to the run with no inspector, and the rewriting run + /// completed without tripping anything. + #[default] + Pass, + /// The read-only run differed from the run with no inspector. Observation is not free, which + /// breaks every tracer in production. + ControlDrift, + /// The rewriting run and the reference disagreed about whether the transaction executes at + /// all: one produced a receipt and the other an `EVMError`. No inspector callback runs before + /// validation, so the two cannot legitimately differ here — with the one exception the verdict + /// below names. + ChaosRejected, + /// The rewriting run was declined because the measurement shim *refused* one of its rewrites. + /// + /// The one legitimate way an inspector can stop a transaction that would otherwise execute, + /// and the designed outcome of two rewrite shapes rather than a defect: moving the + /// classification of a result frame init produced, and reviving a failed contract creation. + /// Both leave the caller an answer the state behind it contradicts, so the shim restores the + /// classification and fails the transaction rather than let a receipt be built on it. + /// + /// Counted rather than passed, because the number is worth seeing: it says how much of the + /// corpus the pool's [`ChaosShape::MoveInitResultClass`] draws actually reach. + Refused, + /// The rewriting run applied a mutation the shim is contracted to book unconditionally — see + /// [`ChaosShape::is_always_booked`] — and still ended with an all-zero ledger. + /// + /// The ledger is the inspector term of the conservation law, the backstop the block executor + /// refuses a result over, and the only thing that tells a consumer an execution was + /// inspector-influenced. A rewrite that leaves it zero is invisible to all three. Unlike every + /// other verdict here it is not about the transaction's numbers being wrong — they may be + /// exactly what a rewriting inspector should produce — but about nothing being able to tell + /// that anything happened. + LedgerBlind, + /// Neither run executed the transaction, and the runner declined it identically. + Skipped, + /// A run panicked — which, in a build with debug assertions live, is how a broken conservation + /// law surfaces. + Panic, +} + +impl ChaosClass { + /// Stable upper-case label, for tallies and reports. + pub const fn label(self) -> &'static str { + match self { + Self::Pass => "PASS", + Self::ControlDrift => "CONTROL_DRIFT", + Self::ChaosRejected => "CHAOS_REJECTED", + Self::Refused => "REFUSED", + Self::LedgerBlind => "LEDGER_BLIND", + Self::Skipped => "SKIPPED", + Self::Panic => "PANIC", + } + } + + /// Whether this verdict fails the gate. + pub const fn is_failure(self) -> bool { + matches!(self, Self::ControlDrift | Self::ChaosRejected | Self::LedgerBlind | Self::Panic) + } +} + +/// The verdict on one vector. +#[derive(Debug, Clone)] +pub struct UnitChaos { + /// The unit's key in the fixture's test-suite map, with the vector's indexes when the unit + /// declares more than one. + pub name: String, + /// The fixture file the unit came from. + pub path: String, + /// The seed this vector's rewriting run was driven by — everything needed to re-run exactly + /// it. + pub seed: u64, + /// How the runs came out. + pub class: ChaosClass, + /// Mutations the rewriting run applied. + pub mutations: u32, + /// What went wrong, for a verdict that needs a human. + pub detail: Option, +} + +/// Runs one vector three times — no inspector, a read-only one, a rewriting one — and judges. +/// +/// The reference and the control settle the "observation is free" half. The rewriting run is +/// judged by what it does *not* do: it must not panic (every gas-accounting cross-check is a debug +/// assertion, so a broken law is a panic) and it must not change whether the transaction executes +/// at all. +/// +/// Nothing compares the rewriting run's *numbers* to the reference's. A rewriting inspector is +/// supposed to change them — that is what "supported" means — and the property that they still add +/// up is stated by the conservation law, which the execution checks itself. +pub fn chaos_unit( + unit: &TestUnit, + indexes: TxPartIndices, + spec: &SpecName, + seed: u64, + filter: ShapeFilter, +) -> ChaosVerdict { + let reference = execute_unit_in_mode(unit, indexes, spec, RunMode::Plain); + let control = execute_unit_in_mode(unit, indexes, spec, RunMode::Observe); + + match (&reference, &control) { + (Ok(reference), Ok(control)) => { + let fields = compare(&control.outcome, &reference.outcome); + if !fields.is_empty() { + return ChaosVerdict::failed( + ChaosClass::ControlDrift, + format!( + "an observation-only inspector moved: {}", + fields.iter().map(|f| f.label()).collect::>().join(", ") + ), + ); + } + if !control.ledger.is_zero() { + return ChaosVerdict::failed( + ChaosClass::ControlDrift, + format!( + "an observation-only inspector booked a ledger entry: {:?}", + control.ledger + ), + ); + } + } + (Err(reference), Err(control)) => { + let (reference, control) = (reference.to_string(), control.to_string()); + if reference != control { + return ChaosVerdict::failed( + ChaosClass::ControlDrift, + format!("the runs were declined differently: {reference} != {control}"), + ); + } + } + (Ok(_), Err(e)) | (Err(e), Ok(_)) => { + return ChaosVerdict::failed( + ChaosClass::ControlDrift, + format!("only one of the two read-only runs executed: {e}"), + ) + } + } + + let mut applied = ChaosTally::default(); + let chaos = execute_unit_reporting_chaos( + unit, + indexes, + spec, + RunMode::Chaos { seed, filter }, + &mut applied, + ); + + let (class, detail) = match (reference.is_ok(), &chaos) { + (true, Ok(run)) => match blind_shapes(&applied, run.ledger.is_zero()) { + None => (ChaosClass::Pass, None), + Some(shapes) => ( + ChaosClass::LedgerBlind, + Some(format!( + "the run applied {shapes} and the ledger is still all-zero, so the canonical \ + block path would admit it" + )), + ), + }, + // The runner declined this vector before execution — an intrinsic-gas overrun, an + // unsupported transaction shape — and declined it the same way with the inspector + // attached. Nothing executed, so nothing was tested; counted rather than passed. + (false, Err(_)) => (ChaosClass::Skipped, None), + // A decline the shim itself produced is the designed outcome of a refused rewrite, not a + // disagreement about whether the transaction executes. It is told apart by the reason the + // error carries, which is the shim's own message. + (true, Err(e)) if is_refusal(e) => (ChaosClass::Refused, Some(e.to_string())), + (true, Err(e)) => ( + ChaosClass::ChaosRejected, + Some(format!("the rewriting run was declined where the reference executed: {e}")), + ), + (false, Ok(_)) => ( + ChaosClass::ChaosRejected, + Some("the rewriting run executed where the reference was declined".to_string()), + ), + }; + ChaosVerdict { class, applied, detail } +} + +/// Whether a decline is one the measurement shim produced by refusing a rewrite. +/// +/// Read off the reason the error carries, against the shim's own message constants, so a message +/// that changes changes here too rather than silently reclassifying a whole corpus. +fn is_refusal(error: &TestErrorKind) -> bool { + let rendered = error.to_string(); + rendered.contains(FORBIDDEN_FRAME_INIT_REWRITE) || rendered.contains(FORBIDDEN_CREATE_REVIVAL) +} + +/// The shapes a run applied that the shim must have booked, when its ledger says it booked +/// nothing at all. +/// +/// `None` when the ledger is non-zero, or when every shape the run applied is one whose booking is +/// conditional on something the run may not have reached — gas written into a counter the +/// interpreter is about to stop reading, a result's gas edited on a frame that hands nothing back, +/// a pool a later frame overwrote. Those non-bookings are correct, and a gate stated over them +/// would fail on a working shim. +fn blind_shapes(applied: &ChaosTally, ledger_is_zero: bool) -> Option { + if !ledger_is_zero { + return None; + } + let blind: Vec<&str> = applied + .applied + .keys() + .copied() + .filter(|label| ChaosShape::parse(label).is_ok_and(ChaosShape::is_always_booked)) + .collect(); + (!blind.is_empty()).then(|| blind.join(", ")) +} + +/// What one vector's three runs produced. +#[derive(Debug, Clone, Default)] +pub struct ChaosVerdict { + /// How the runs came out. + pub class: ChaosClass, + /// What the rewriting run mutated. + pub applied: ChaosTally, + /// What went wrong, for a verdict that needs a human. + pub detail: Option, +} + +impl ChaosVerdict { + /// A verdict that failed before the rewriting run was reached, so it mutated nothing. + fn failed(class: ChaosClass, detail: String) -> Self { + Self { class, applied: ChaosTally::default(), detail: Some(detail) } + } +} + +/// The per-shape aggregate a whole sweep applied, plus the verdict counts. +#[derive(Debug, Clone, Default)] +pub struct ChaosSweepTally { + /// Vectors per [`ChaosClass`], keyed by [`ChaosClass::label`]. + pub classes: BTreeMap<&'static str, usize>, + /// Mutations applied over the whole sweep, per shape. + pub shapes: ChaosTally, + /// Every vector that needs a human. + pub flagged: Vec, + /// Files the runner could not read or parse at all, as rendered errors. + pub file_errors: Vec, + /// Files validation skips by filename, and which the sweep therefore judged no vector of. + pub skipped_files: usize, +} + +impl ChaosSweepTally { + /// Number of vectors in a class. + pub fn count(&self, class: ChaosClass) -> usize { + self.classes.get(class.label()).copied().unwrap_or(0) + } + + /// Total number of vectors judged. + pub fn total(&self) -> usize { + self.classes.values().sum() + } + + /// Whether the run should fail its gate. + /// + /// The two content conditions are a failing verdict and a file the sweep could not read. The + /// other two are what make those mean something: a sweep that judged no vector reaches the + /// gate with every count truthfully zero, and so does one whose inspector never mutated + /// anything — a corpus that never arrived and a chaos run that was not chaotic both look + /// exactly like a clean sweep from the counts alone. + pub fn is_failure(&self) -> bool { + self.total() == 0 || + self.shapes.total() == 0 || + !self.flagged.is_empty() || + !self.file_errors.is_empty() + } + + /// Records one vector's verdict. + pub fn record(&mut self, verdict: UnitChaos) { + *self.classes.entry(verdict.class.label()).or_insert(0) += 1; + if verdict.class.is_failure() { + self.flagged.push(verdict); + } + } + + /// Merges another tally into this one. + pub fn merge(&mut self, other: Self) { + for (label, count) in other.classes { + *self.classes.entry(label).or_insert(0) += count; + } + self.shapes.merge(&other.shapes); + self.flagged.extend(other.flagged); + self.file_errors.extend(other.file_errors); + self.skipped_files += other.skipped_files; + } +} + +/// Runs the chaos comparison over every transaction vector of every unit of one fixture file. +pub fn chaos_test_suite( + path: &Path, + spec: &SpecName, + global_seed: u64, + filter: ShapeFilter, +) -> Result<(Vec, ChaosTally), TestError> { + let path_str = path.to_string_lossy().into_owned(); + if skip_test(path) { + return Ok((vec![], ChaosTally::default())); + } + + let fixture_err = |msg: String| TestError { + name: "chaos".to_string(), + path: path_str.clone(), + kind: TestErrorKind::FixtureError(msg), + }; + let source = std::fs::read_to_string(path).map_err(|e| fixture_err(format!("read: {e}")))?; + let suite: TestSuite = serde_json::from_str(&source).map_err(|e| TestError { + name: "Unknown".to_string(), + path: path_str.clone(), + kind: e.into(), + })?; + + let mut verdicts = Vec::with_capacity(suite.0.len()); + let mut shapes = ChaosTally::default(); + for (name, unit) in suite.0 { + let vectors = unit.vectors(); + let multi = vectors.len() > 1; + for indexes in vectors { + let label = if multi { vector_label(&name, indexes) } else { name.clone() }; + let seed = vector_seed(global_seed, path, &label, indexes); + let verdict = + match panic_capture::catch(|| chaos_unit(&unit, indexes, spec, seed, filter)) { + Ok(verdict) => { + shapes.merge(&verdict.applied); + UnitChaos { + name: label, + path: path_str.clone(), + seed, + class: verdict.class, + mutations: verdict.applied.total(), + detail: verdict.detail, + } + } + // A vector that panicked has no tally to report and still has to be counted. + Err(report) => UnitChaos { + name: label, + path: path_str.clone(), + seed, + class: ChaosClass::Panic, + mutations: 0, + detail: Some(report), + }, + }; + verdicts.push(verdict); + } + } + Ok((verdicts, shapes)) +} + +/// How a corpus-wide chaos run behaves. +#[derive(Debug, Clone, Copy)] +pub struct ChaosRunConfig { + /// The spec every run executes under. + pub spec: SpecName, + /// The global seed every vector's own seed is derived from. + pub seed: u64, + /// Which mutations the rewriting run is allowed to make. + pub filter: ShapeFilter, + /// Run every file on one thread. + pub single_thread: bool, + /// Draw a progress bar. + pub progress: bool, +} + +/// Runs the chaos comparison over every fixture file, in parallel. +/// +/// Installs the panic capture hook, for the same reason the differential sweep does: a +/// `debug_assert!` one vector trips becomes that vector's verdict instead of taking down a worker +/// thread, which is what makes a single-process full-corpus sweep possible at all. +pub fn run_chaos(scan: FixtureScan, config: ChaosRunConfig) -> ChaosSweepTally { + panic_capture::install_capture_hook(); + + let FixtureScan { files, errors } = scan; + let n_files = files.len(); + let bar = Arc::new(ProgressBar::with_draw_target( + Some(n_files as u64), + if config.progress { ProgressDrawTarget::stdout() } else { ProgressDrawTarget::hidden() }, + )); + let queue = Arc::new(Mutex::new(files)); + let next = Arc::new(AtomicUsize::new(0)); + let threads = if config.single_thread { + 1 + } else { + std::thread::available_parallelism().map_or(1, |n| n.get().min(n_files.max(1))) + }; + + let mut handles = Vec::with_capacity(threads); + for i in 0..threads { + let (queue, next, bar) = (queue.clone(), next.clone(), bar.clone()); + handles.push( + std::thread::Builder::new() + .name(format!("chaos-{i}")) + .spawn(move || { + let mut tally = ChaosSweepTally::default(); + loop { + let index = next.fetch_add(1, Ordering::SeqCst); + let Some(path) = queue.lock().unwrap().get(index).cloned() else { + return tally; + }; + if is_skipped_fixture(&path) { + tally.skipped_files += 1; + bar.inc(1); + continue; + } + match chaos_test_suite(&path, &config.spec, config.seed, config.filter) { + Ok((verdicts, shapes)) => { + tally.shapes.merge(&shapes); + for verdict in verdicts { + tally.record(verdict); + } + } + Err(e) => tally.file_errors.push(e.to_string()), + } + bar.inc(1); + } + }) + .expect("spawn chaos worker"), + ); + } + + let mut tally = ChaosSweepTally { file_errors: errors, ..ChaosSweepTally::default() }; + for handle in handles { + match handle.join() { + Ok(worker) => tally.merge(worker), + Err(_) => tally + .file_errors + .push("a chaos worker thread panicked; its files were not judged".to_string()), + } + } + bar.finish_and_clear(); + tally +} diff --git a/crates/mega-state-test/src/diff.rs b/crates/mega-state-test/src/diff.rs new file mode 100644 index 00000000..8d029be1 --- /dev/null +++ b/crates/mega-state-test/src/diff.rs @@ -0,0 +1,1995 @@ +//! Differential execution: run one fixture under two specs and judge any disagreement. +//! +//! A state-test fixture pins what a transaction must produce, but only for a spec someone has +//! already computed an expectation for. For an unstable spec there is no such expectation, so a +//! corpus sweep can only check that execution stays self-consistent — that no invariant trips. +//! This module supplies the missing half: it executes the same fixture under the unstable spec +//! and under the frozen spec it inherits from, and asks whether the two agree. +//! +//! Disagreement is not by itself a defect — the new spec is new precisely because it changes +//! something. What makes the question decidable is that Rex7 states the conditions under which it +//! may *not* differ (`docs/spec/upgrades/rex7.md`, "Precision invariant"): +//! +//! > For every transaction that stays within every runtime resource limit, in which no frame ends +//! > in an exceptional halt, and in which no `disableVolatileDataAccess` guard rejects an opcode, +//! > a node MUST produce the same recorded compute-gas total, the same four-dimension resource +//! > usage, the same receipt `gas_used`, the same execution result, and the same state under Rex7 +//! > as under Rex6. +//! +//! Read as a contrapositive, that sentence is a classifier. The quantities after "MUST produce" +//! are what [`SpecOutcome`] compares; the three clauses before it are the hypotheses +//! [`Hypothesis`] enumerates. Two specs that disagree on a compared quantity must therefore show +//! that one of the three hypotheses is false — and the classifier demands positive evidence of +//! that from the execution itself, never a list of fixtures allowed to differ. +//! +//! A disagreement with no such evidence is [`DiffClass::Unexplained`]: either the implementation +//! deviates from the spec, or the spec's invariant is wrong. Both are findings. +//! +//! # What counts as evidence +//! +//! The fixture is the input under test, so nothing the fixture authors may license a difference — +//! otherwise a contract that writes four chosen bytes to its revert buffer would buy itself an +//! exemption from the whole comparison. Every observation the classifier makes therefore carries +//! a [`Provenance`], and only [`Provenance::Execution`] observations — the EVM's own verdict on a +//! frame, the typed halt reason, the `MegaETH` trackers' own counters — can falsify a hypothesis. +//! Observations read out of revert-payload bytes are [`Provenance::Payload`]: they are reported, +//! because they are what a human triaging a flagged unit wants to see, and they license nothing. +//! `test_only_execution_provenance_licenses` holds that line for mechanisms added later. +//! +//! One hypothesis has no producer under that rule. A `disableVolatileDataAccess` rejection is +//! visible only as revert-payload bytes: `MegaETH` writes the guard's payload into the frame +//! result, and a contract can write the same bytes with a plain `REVERT`. Telling the two apart +//! needs a signal the runner cannot read — the trackers' latch is `pub(crate)`, and the one +//! public entry point that reports it (`AdditionalLimit::check_limit`) latches as a side effect +//! and would change the execution under observation. So a difference that only a guard rejection +//! explains is reported for a human rather than licensed, which is the safe direction: the gate +//! over-reports instead of granting an exemption on the strength of bytes the fixture chose. +//! +//! Frame-level evidence is collected by a second, inspected pair of runs, and carries one further +//! condition: each rerun must reproduce the plain run it stands in for, quantity by quantity, +//! before its frames may decide anything. An inspector that moved the execution produced frames +//! that describe a different transaction, and letting those frames license the plain pair's +//! difference is how an observation-path regression explains itself. + +use crate::{ + chaos::{CallbackCounter, ChaosInspector, ChaosTally, ShapeFilter}, + panic_capture, + runner::{ + configure_max_blobs, execution_status, external_envs_for, find_all_json_tests, halt_reason, + inject_block_hashes, prune_base_fee_vault_changes, resolve_chain_id, + set_cfg_spec_and_mainnet_gas_params, skip_test, vector_label, FixtureScan, TestError, + TestErrorKind, UnitStatus, + }, + types::{tx_env_at, SpecName, TestSuite, TestUnit, TxPartIndices}, + utils::{log_rlp_hash, state_merkle_trie_root}, +}; +use indicatif::{ProgressBar, ProgressDrawTarget}; +use mega_evm::{ + alloy_sol_types::SolError, + revm::{ + context::{cfg::CfgEnv, result::ExecutionResult}, + database, + database_interface::DatabaseCommit, + handler::FrameResult, + inspector::Inspector, + interpreter::{interpreter::EthInterpreter, interpreter_action::FrameInput}, + primitives::{Bytes, B256}, + }, + InspectorLedger, MegaContext, MegaEvm, MegaHaltReason, MegaLimitExceeded, MegaTransaction, + MegaTransactionNew as _, VOLATILE_DATA_ACCESS_DISABLED_SELECTOR, +}; +use std::{ + collections::BTreeMap, + path::{Path, PathBuf}, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, Mutex, + }, +}; + +/// A hypothesis of the Rex7 precision invariant. +/// +/// The invariant holds the two specs to identical output only while all three are true, so +/// evidence that one is false is what licenses a difference. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Hypothesis { + /// "stays within every runtime resource limit". + WithinLimits, + /// "no frame ends in an exceptional halt". + NoExceptionalHalt, + /// "no `disableVolatileDataAccess` guard rejects an opcode". + NoDisabledVolatileReject, +} + +/// Where an observation came from, and therefore whether the fixture could have authored it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Provenance { + /// Read from the execution itself: the EVM's verdict on a frame, the typed halt reason, or a + /// `MegaETH` tracker's own counter. A fixture can cause such an observation — that is what + /// running it means — but it cannot fabricate one without the machinery actually firing. + Execution, + /// Inferred from revert-payload bytes, which any contract can write with a plain `REVERT`. + Payload, +} + +/// A `MegaETH` mechanism observed in a differential run. +/// +/// Each variant is a fact read off an execution, not an interpretation of one. A variant that +/// falsifies a hypothesis of the precision invariant reports it through +/// [`Mechanism::falsifies`]; the rest are recorded for the mechanism distribution but never +/// explain a difference on their own. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Mechanism { + /// The transaction halted on a `MegaETH` resource limit (compute gas, data size, KV + /// updates, state growth). + ResourceLimitHalt, + /// The transaction halted on the detained compute-gas limit. + DetentionHalt, + /// A resource-limit exceed rescued the transaction's remaining gas for the sender. + /// + /// The rescue is the tell for a limit exceed whose halt the outer accounting has already + /// rewritten — a failed deposit, for instance, reports its whole gas limit. + GasRescued, + /// A frame reverted carrying the `MegaLimitExceeded` selector. + /// + /// Reported, never licensing: `MegaETH` writes that payload into a frame result, and so can + /// any contract. See the module docs on what counts as evidence. + LimitRevertPayload, + /// A frame ended in an exceptional halt. + /// + /// Counted at the frame the EVM finished, so it covers an inner frame the caller absorbed, a + /// precompile that failed, and a call or creation refused before a frame opened — none of + /// which the transaction's own result shows. + ExceptionalHalt, + /// Rex7 booked a destroyed compute-gas remainder: an envelope was lost without being + /// executed. + /// + /// Recorded, never licensing. The remainder is not observed but *derived*, from a + /// conservation law over the transaction's whole envelope, so a missing term in that law + /// produces a non-zero remainder with no halt behind it. Letting it license the difference it + /// causes would make the one number a defect would move into that defect's own alibi. The + /// halt it claims has an independent witness — the frame the EVM finished — and that witness + /// is what licenses. + DestroyedComputeGas, + /// A frame reverted carrying the `VolatileDataAccessDisabled` selector. + /// + /// Reported, never licensing, for the same reason as [`Mechanism::LimitRevertPayload`]. + VolatileDisabledPayload, + /// The two specs recorded different volatile-data access marks. + /// + /// Rex7 moves the beneficiary / oracle mark to the point where the target account is loaded, + /// so a frame that cannot afford the fees charged before that load marks under Rex6 and not + /// under Rex7. On its own this changes nothing observable — it changes the *limit*, and only + /// crossing that limit changes an outcome — so it is recorded, not accepted as an + /// explanation. + DetentionMarkDiff, + /// A detention cap was in force at the end of the transaction. + /// + /// Informational for the same reason as [`Mechanism::DetentionMarkDiff`]: a cap nobody + /// reached explains nothing. + DetentionInForce, +} + +impl Mechanism { + /// The invariant hypothesis this mechanism falsifies, if any. + /// + /// Only an [`Provenance::Execution`] observation may return `Some`; see + /// [`Mechanism::provenance`] and the module docs. + pub const fn falsifies(self) -> Option { + match self { + Self::ResourceLimitHalt | Self::DetentionHalt | Self::GasRescued => { + Some(Hypothesis::WithinLimits) + } + Self::ExceptionalHalt => Some(Hypothesis::NoExceptionalHalt), + Self::LimitRevertPayload | + Self::VolatileDisabledPayload | + Self::DestroyedComputeGas | + Self::DetentionMarkDiff | + Self::DetentionInForce => None, + } + } + + /// Whether this observation is read off the execution or off bytes the fixture chose. + pub const fn provenance(self) -> Provenance { + match self { + Self::ResourceLimitHalt | + Self::DetentionHalt | + Self::GasRescued | + Self::ExceptionalHalt | + Self::DestroyedComputeGas | + Self::DetentionMarkDiff | + Self::DetentionInForce => Provenance::Execution, + Self::LimitRevertPayload | Self::VolatileDisabledPayload => Provenance::Payload, + } + } + + /// Stable lower-case label, for tallies and reports. + pub const fn label(self) -> &'static str { + match self { + Self::ResourceLimitHalt => "resource_limit_halt", + Self::DetentionHalt => "detention_halt", + Self::GasRescued => "gas_rescued", + Self::LimitRevertPayload => "limit_revert_payload", + Self::ExceptionalHalt => "exceptional_halt", + Self::DestroyedComputeGas => "destroyed_compute_gas", + Self::VolatileDisabledPayload => "volatile_disabled_payload", + Self::DetentionMarkDiff => "detention_mark_diff", + Self::DetentionInForce => "detention_in_force", + } + } +} + +/// What kind of halt a `MegaHaltReason` is. +/// +/// Classified by matching the reason's own variants, with no catch-all arm: a `MegaHaltReason` +/// added later fails to compile here until someone decides what it means for the invariant. +/// The rule it replaces — "a halt whose `Debug` form does not start with `Base` is a resource +/// limit" — granted every future variant, and today's `SystemTxInvalidCallee`, the standing of a +/// crossed resource limit, which licenses a difference on any quantity at all. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum HaltKind { + /// One of the four `MegaETH` per-transaction resource limits. + ResourceLimit, + /// The detained compute-gas limit. + Detention, + /// A halt that is not a resource limit: the inherited EVM's own halts, and the + /// `MegaETH`-specific halts that are not metering failures. + Other, +} + +/// Classifies a halt reason for the [`Mechanism`] it produces. +pub const fn halt_kind(reason: &MegaHaltReason) -> HaltKind { + match reason { + MegaHaltReason::DataLimitExceeded { .. } | + MegaHaltReason::KVUpdateLimitExceeded { .. } | + MegaHaltReason::ComputeGasLimitExceeded { .. } | + MegaHaltReason::StateGrowthLimitExceeded { .. } => HaltKind::ResourceLimit, + MegaHaltReason::VolatileDataAccessOutOfGas { .. } => HaltKind::Detention, + MegaHaltReason::Base(_) | MegaHaltReason::SystemTxInvalidCallee { .. } => HaltKind::Other, + } +} + +/// A quantity the precision invariant requires the two specs to agree on. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum DiffField { + /// Post-state trie root over the fixture's account closure. + StateRoot, + /// RLP hash of the emitted logs. + LogsRoot, + /// Receipt `gas_used`. + GasUsed, + /// `success` / `revert` / `halt`. + Status, + /// Halt reason, when the status is `halt`. + HaltReason, + /// Transaction output bytes. + Output, + /// Reported compute-gas total. + ComputeGasUsed, + /// Data-size dimension usage. + DataSize, + /// KV-update dimension usage. + KvUpdates, + /// State-growth dimension usage. + StateGrowth, +} + +impl DiffField { + /// Stable lower-case label, for reports. + pub const fn label(self) -> &'static str { + match self { + Self::StateRoot => "state_root", + Self::LogsRoot => "logs_root", + Self::GasUsed => "gas_used", + Self::Status => "status", + Self::HaltReason => "halt_reason", + Self::Output => "output", + Self::ComputeGasUsed => "compute_gas_used", + Self::DataSize => "data_size", + Self::KvUpdates => "kv_updates", + Self::StateGrowth => "state_growth", + } + } + + /// Whether an exceptional halt, on its own, may move this field. + /// + /// The exceptional-halt carve-out settles a halted frame's whole budget as compute gas, which + /// raises the *reported* compute total and nothing else: the receipt, the state and the other + /// three dimensions are explicitly unchanged by it. Every other field needs the transaction + /// to have taken a different path, which under Rex7 means a resource limit was crossed or a + /// guard rejected an opcode. + const fn movable_by_halt_alone(self) -> bool { + matches!(self, Self::ComputeGasUsed) + } +} + +/// One side of a differential run. +/// +/// The first group is what the precision invariant compares; the rest is the evidence a +/// disagreement is judged against. +#[derive(Debug, Clone)] +pub struct SpecOutcome { + /// Post-state trie root over the fixture's account closure. + pub state_root: B256, + /// RLP hash of the emitted logs. + pub logs_root: B256, + /// Receipt `gas_used`. + pub gas_used: u64, + /// `success` / `revert` / `halt`. + pub status: String, + /// Halt reason (`Debug` form) when the status is `halt`. + /// + /// Compared as a quantity and printed in reports. What the halt *means* to the classifier is + /// [`SpecOutcome::halt_kind`], read off the typed reason rather than off this rendering. + pub halt_reason: Option, + /// What kind of halt the transaction ended in, when it halted. + pub halt_kind: Option, + /// Transaction output bytes, if any. + pub output: Option, + /// Reported compute-gas total. + pub compute_gas_used: u64, + /// Data-size dimension usage. + pub data_size: u64, + /// KV-update dimension usage. + pub kv_updates: u64, + /// State-growth dimension usage. + pub state_growth: u64, + + /// The part of the reported compute total that was destroyed rather than performed (Rex7+). + pub compute_gas_destroyed: u64, + /// The part of the reported compute total that every compute-gas limit is evaluated against. + pub compute_gas_enforced: u64, + /// Gas a resource-limit exceed rescued for the sender. + pub rescued_gas: u64, + /// The detained compute-gas limit in force at the end of the transaction, if any. + pub detained_limit: Option, + /// Bitmap of the volatile data the transaction accessed. + pub volatile_access: u16, + /// Per-frame evidence, when the run collected it (see [`FrameEvidence`]). + pub frames: Option, +} + +impl SpecOutcome { + /// Mechanisms visible in this single execution. + fn mechanisms(&self) -> Vec { + let mut found = Vec::new(); + match self.halt_kind { + Some(HaltKind::ResourceLimit) => found.push(Mechanism::ResourceLimitHalt), + Some(HaltKind::Detention) => found.push(Mechanism::DetentionHalt), + Some(HaltKind::Other) | None => {} + } + if self.rescued_gas > 0 { + found.push(Mechanism::GasRescued); + } + if self.compute_gas_destroyed > 0 { + found.push(Mechanism::DestroyedComputeGas); + } + if self.status == "halt" { + found.push(Mechanism::ExceptionalHalt); + } + if self.detained_limit.is_some() { + found.push(Mechanism::DetentionInForce); + } + if let Some(frames) = &self.frames { + if frames.halted > 0 { + found.push(Mechanism::ExceptionalHalt); + } + if frames.limit_revert_payloads > 0 { + found.push(Mechanism::LimitRevertPayload); + } + if frames.volatile_disabled_payloads > 0 { + found.push(Mechanism::VolatileDisabledPayload); + } + } + found + } +} + +/// Per-frame facts collected by [`FrameEvidenceInspector`]. +/// +/// A transaction's own result hides most of what happens below it: an inner frame that halted and +/// was absorbed by its caller, a precompile that failed, a call refused before a frame opened. +/// All three falsify a hypothesis of the precision invariant and none of them is visible from the +/// outside, so the classifier collects them from the frames themselves when the cheap evidence +/// runs out. +/// +/// The three counters do not carry equal weight. [`FrameEvidence::halted`] is the EVM's own +/// verdict on the frame; the other two are what the frame put in its revert buffer, which a +/// contract writes as freely as `MegaETH` does. They are counted for the report and classified as +/// [`Provenance::Payload`], so they never license a difference. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct FrameEvidence { + /// Frames whose final result was an exceptional halt. + pub halted: u32, + /// Frames that reverted carrying the `MegaLimitExceeded` selector, whoever wrote it. + pub limit_revert_payloads: u32, + /// Frames that reverted carrying the `VolatileDataAccessDisabled` selector, whoever wrote it. + pub volatile_disabled_payloads: u32, +} + +/// Read-only inspector that records each frame's final result. +/// +/// It implements `frame_end` and nothing else, so it neither rewrites a frame result nor touches +/// the interpreter's gas counter — the two things the Rex7 accounting notes call out as making an +/// inspected execution diverge from an uninspected one. +#[derive(Debug, Default)] +pub struct FrameEvidenceInspector { + evidence: FrameEvidence, +} + +impl FrameEvidenceInspector { + /// The facts collected so far. + pub const fn evidence(&self) -> FrameEvidence { + self.evidence + } +} + +impl Inspector for FrameEvidenceInspector { + fn frame_end( + &mut self, + _context: &mut CTX, + _frame_input: &FrameInput, + frame_result: &mut FrameResult, + ) { + // `frame_end` is the one hook revm calls for *every* frame outcome — an interpreter frame + // that ran, a precompile answered without a frame, and a frame init the EVM refused — + // and it runs after the create-return processing that can still turn a successful + // constructor into a halt. + let result = frame_result.interpreter_result(); + if result.result.is_halt() { + self.evidence.halted += 1; + return; + } + if !result.result.is_revert() { + return; + } + // A selector match says what the frame's revert buffer starts with and nothing more: + // `REVERT` copies whatever memory the contract points it at. Both counters are recorded + // as claims, for a human reading a flagged unit, and are never treated as evidence. + match result.output.get(..4) { + Some(s) if s == MegaLimitExceeded::SELECTOR => { + self.evidence.limit_revert_payloads += 1; + } + Some(s) if s == VOLATILE_DATA_ACCESS_DISABLED_SELECTOR => { + self.evidence.volatile_disabled_payloads += 1; + } + _ => {} + } + } +} + +/// How a fixture unit's two executions compare. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DiffClass { + /// Both specs agreed on every compared quantity. + Pass, + /// The specs disagreed, and the disagreement carries evidence that a hypothesis of the + /// precision invariant does not hold. + Explained, + /// The specs disagreed with no such evidence. + /// + /// Either the implementation deviates from the spec, or the spec's invariant is wrong. + Unexplained, + /// Neither spec executed the transaction, and both declined it the same way. + /// + /// A fixture the runner rejects before execution (an intrinsic-gas overrun, an unsupported + /// transaction shape, a fixture defect) says nothing about either spec's semantics as long as + /// both sides reject it identically — validation is spec-independent here, so an *asymmetric* + /// rejection is a difference and is classified as one. + Skipped, + /// Executing the unit panicked on at least one side. + Panic, +} + +impl DiffClass { + /// Stable upper-case label, for tallies and reports. + pub const fn label(self) -> &'static str { + match self { + Self::Pass => "PASS", + Self::Explained => "EXPLAINED", + Self::Unexplained => "UNEXPLAINED", + Self::Skipped => "SKIPPED", + Self::Panic => "PANIC", + } + } +} + +/// The verdict on one fixture unit. +#[derive(Debug, Clone)] +pub struct UnitDiff { + /// The unit's key in the fixture's test-suite map. + pub name: String, + /// The fixture file the unit came from. + pub path: String, + /// How the two executions compare. + pub class: DiffClass, + /// Quantities the two specs disagreed on. + pub fields: Vec, + /// Mechanisms observed on either side. + pub mechanisms: Vec, + /// Why the unit is [`DiffClass::Skipped`] or [`DiffClass::Panic`], or what the two sides + /// disagreed on in detail. + pub detail: Option, +} + +/// Which specs a differential run compares. +/// +/// Only [`DiffSpecs::SUPPORTED`] can be constructed. The classifier is not a general-purpose +/// two-spec comparator: every rule in it is a reading of one sentence, the Rex7 precision +/// invariant, which relates Rex7 to Rex6 and says nothing about any other pair. Pointed at +/// Rex5-against-Rex4 it would apply Rex7's licence to a pair that never had one — deciding, from +/// mechanisms that are not evidence for anything there, that a difference is fine. +/// +/// [`DiffSpecs::new`] is that restriction, so the fields it validates are private: a public field +/// is a second way to build the value, and the classifier cannot tell a pair that came through the +/// check from one that was assembled around it. +/// +/// ``` +/// use state_test::{diff::DiffSpecs, types::SpecName}; +/// +/// let (target, base) = DiffSpecs::SUPPORTED; +/// let specs = DiffSpecs::new(target, base).expect("the supported pair"); +/// assert_eq!((specs.target(), specs.base()), (SpecName::Rex7, SpecName::Rex6)); +/// assert!(DiffSpecs::new(SpecName::Rex6, SpecName::Rex5).is_err()); +/// ``` +/// +/// The same pair the constructor refuses, assembled directly, does not compile: +/// +/// ```compile_fail +/// use state_test::{diff::DiffSpecs, types::SpecName}; +/// +/// let specs = DiffSpecs { target: SpecName::Rex6, base: SpecName::Rex5 }; +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DiffSpecs { + /// The spec under test, normally the unstable one. + target: SpecName, + /// The frozen spec the target inherits from. + base: SpecName, +} + +impl DiffSpecs { + /// The one pair the classifier has an invariant for: Rex7 against Rex6. + pub const SUPPORTED: (SpecName, SpecName) = (SpecName::Rex7, SpecName::Rex6); + + /// Builds the spec pair, rejecting any pair the classifier has no invariant for. + /// + /// # Errors + /// + /// Returns a message naming the supported pair when `target` / `base` is not it. + pub fn new(target: SpecName, base: SpecName) -> Result { + let (want_target, want_base) = Self::SUPPORTED; + if (target, base) != (want_target, want_base) { + return Err(format!( + "a differential run is only defined for {want_target:?} against {want_base:?}, \ + not {target:?} against {base:?}: the precision invariant that decides when a \ + difference is licensed is {want_target:?}'s, and no other pair states one" + )); + } + Ok(Self { target, base }) + } + + /// The spec under test. + pub const fn target(&self) -> SpecName { + self.target + } + + /// The frozen spec the target is judged against. + pub const fn base(&self) -> SpecName { + self.base + } +} + +/// Runs one unit's transaction vector under both specs and classifies the result. +/// +/// `collect_evidence` decides whether the second, inspected pass runs when the cheap evidence does +/// not settle the case; see the body for why it is staged. +pub fn diff_unit( + unit: &TestUnit, + indexes: TxPartIndices, + specs: DiffSpecs, + collect_evidence: bool, +) -> UnitDiffOutcome { + let (target_spec, base_spec) = (specs.target(), specs.base()); + let target = execute_unit_outcome(unit, indexes, &target_spec, false); + let base = execute_unit_outcome(unit, indexes, &base_spec, false); + + let (target, base) = match (target, base) { + (Ok(t), Ok(b)) => (t, b), + (Err(t), Err(b)) => { + let (t, b) = (t.to_string(), b.to_string()); + return if t == b { + UnitDiffOutcome::skipped(t) + } else { + // Validation is the same code on both specs, so two different rejections are a + // difference in their own right — and one no execution evidence can explain, + // because neither side executed anything. + UnitDiffOutcome::unexplained( + vec![], + vec![], + format!("both specs rejected the transaction, differently: {t} != {b}"), + ) + }; + } + (Ok(_), Err(e)) => { + return UnitDiffOutcome::unexplained( + vec![], + vec![], + format!( + "{} executed but {} rejected the transaction: {e}", + label(target_spec), + label(base_spec) + ), + ) + } + (Err(e), Ok(_)) => { + return UnitDiffOutcome::unexplained( + vec![], + vec![], + format!( + "{} rejected the transaction but {} executed it: {e}", + label(target_spec), + label(base_spec) + ), + ) + } + }; + + let fields = compare(&target, &base); + if fields.is_empty() { + return UnitDiffOutcome::pass(collect_mechanisms(&target, &base)); + } + + let verdict = judge(&fields, &target, &base); + if verdict.class != DiffClass::Unexplained || !collect_evidence { + return verdict; + } + + // Stage two. The cheap evidence found nothing, so re-run both sides with the frame inspector, + // which sees the frames the transaction's own result hides. It costs an inspected execution + // only for the units that reach here, instead of on every unit in the corpus. The plain + // outcomes stay alive: they are what each rerun has to reproduce before its frames count. + let reruns = ( + execute_unit_outcome(unit, indexes, &target_spec, true), + execute_unit_outcome(unit, indexes, &base_spec, true), + ); + judge_with_frame_evidence(verdict, &target, &base, reruns) +} + +/// Re-decides an unexplained difference on two inspected reruns' frame evidence — but only once +/// each rerun has shown that it reproduced the plain run it stands in for. +/// +/// The inspector attached to a rerun is supposed to observe and change nothing, and the one this +/// crate attaches implements a single read-only callback. "Supposed to" is not a check, though, +/// and a rerun that executed differently answers a different question: its frames describe an +/// execution that did not happen, and can license a difference that execution never produced. An +/// observation-path regression that introduces an inner exceptional halt on the target while the +/// compute-gas difference survives is exactly that shape — the regression would explain itself +/// and clear the nightly gate, hiding both itself and the difference it was called in to judge. +/// +/// So each side's rerun is compared against its own plain outcome first, over every quantity but +/// the frames the rerun exists to collect ([`rerun_drift`]). Any movement discards the evidence +/// and leaves the plain verdict standing, which keeps the difference flagged: +/// +/// | target rerun | base rerun | verdict | +/// | --------------- | ---------- | ------------------------------------------------------------- | +/// | did not execute | either | the plain verdict, unchanged: no evidence was collected | +/// | reproduced | reproduced | judged on the inspected pair, whose frames are admissible | +/// | moved | reproduced | the plain verdict, detail naming what moved on the target | +/// | reproduced | moved | the plain verdict, detail naming what moved on the base | +/// | moved | moved | the plain verdict, detail naming both sides' moved quantities | +fn judge_with_frame_evidence( + verdict: UnitDiffOutcome, + plain_target: &SpecOutcome, + plain_base: &SpecOutcome, + reruns: (Result, Result), +) -> UnitDiffOutcome { + // A rerun that did not execute collected nothing; there is no evidence to admit or refuse. + let (Ok(target), Ok(base)) = reruns else { + return verdict; + }; + + let drifted: Vec = + [("target", rerun_drift(plain_target, &target)), ("base", rerun_drift(plain_base, &base))] + .into_iter() + .filter(|(_, moved)| !moved.is_empty()) + .map(|(side, moved)| { + format!("frame inspector moved the {side} execution: {}", moved.join(", ")) + }) + .collect(); + if !drifted.is_empty() { + let mut detail: Vec = verdict.detail.iter().cloned().collect(); + detail.extend(drifted); + detail.push("frame evidence discarded".to_string()); + return UnitDiffOutcome { detail: Some(detail.join("; ")), ..verdict }; + } + + let inspected_fields = compare(&target, &base); + // Each rerun equals its plain run quantity by quantity, so the inspected pair disagrees on + // exactly the quantities the plain pair did — the set the caller already judged, and never + // an empty one. What the reruns add is the frame evidence the outcomes now carry. + debug_assert_eq!( + inspected_fields, verdict.fields, + "reruns that reproduced both plain outcomes must disagree on the same quantities" + ); + judge(&inspected_fields, &target, &base) +} + +/// The quantities on which an inspected rerun departed from the plain run it stands in for. +/// +/// Naming every field of the outcome rather than defaulting is deliberate: a quantity added to +/// [`SpecOutcome`] later is a compile error here instead of a silent hole in the check. Two +/// groups are compared for two reasons — [`compare`]'s ten are what the precision invariant +/// holds the specs to, and the rest are what [`judge`] reads off an outcome to decide whether a +/// mechanism was observed, which a rerun could move while leaving the compared ten alone. +/// [`SpecOutcome::frames`] is the single exclusion: collecting it is what the rerun is for. +fn rerun_drift(plain: &SpecOutcome, inspected: &SpecOutcome) -> Vec<&'static str> { + let SpecOutcome { + // Decided by `compare`. + state_root: _, + logs_root: _, + gas_used: _, + status: _, + halt_reason: _, + output: _, + compute_gas_used: _, + data_size: _, + kv_updates: _, + state_growth: _, + // Evidence read straight off the outcome. + halt_kind, + compute_gas_destroyed, + compute_gas_enforced, + rescued_gas, + detained_limit, + volatile_access, + // What the rerun exists to collect. + frames: _, + } = inspected; + + let mut moved: Vec<&'static str> = + compare(plain, inspected).iter().map(|f| f.label()).collect(); + let mut push = |differs: bool, label: &'static str| { + if differs { + moved.push(label); + } + }; + push(*halt_kind != plain.halt_kind, "halt_kind"); + push(*compute_gas_destroyed != plain.compute_gas_destroyed, "compute_gas_destroyed"); + push(*compute_gas_enforced != plain.compute_gas_enforced, "compute_gas_enforced"); + push(*rescued_gas != plain.rescued_gas, "rescued_gas"); + push(*detained_limit != plain.detained_limit, "detained_limit"); + push(*volatile_access != plain.volatile_access, "volatile_access"); + moved +} + +/// The verdict body of [`UnitDiff`], before the unit's name and path are attached. +#[derive(Debug, Clone)] +pub struct UnitDiffOutcome { + /// How the two executions compare. + pub class: DiffClass, + /// Quantities the two specs disagreed on. + pub fields: Vec, + /// Mechanisms observed on either side. + pub mechanisms: Vec, + /// Supporting detail for the verdict. + pub detail: Option, +} + +impl UnitDiffOutcome { + fn pass(mechanisms: Vec) -> Self { + Self { class: DiffClass::Pass, fields: vec![], mechanisms, detail: None } + } + + fn skipped(detail: String) -> Self { + Self { class: DiffClass::Skipped, fields: vec![], mechanisms: vec![], detail: Some(detail) } + } + + fn unexplained(fields: Vec, mechanisms: Vec, detail: String) -> Self { + Self { class: DiffClass::Unexplained, fields, mechanisms, detail: Some(detail) } + } + + /// Attaches the unit's identity to the verdict. + pub fn named(self, name: String, path: String) -> UnitDiff { + UnitDiff { + name, + path, + class: self.class, + fields: self.fields, + mechanisms: self.mechanisms, + detail: self.detail, + } + } +} + +/// Human-facing name of a spec, for report text. +fn label(spec: SpecName) -> String { + format!("{spec:?}") +} + +/// Mechanisms visible on either side, deduplicated and ordered. +fn collect_mechanisms(target: &SpecOutcome, base: &SpecOutcome) -> Vec { + let mut found = target.mechanisms(); + found.extend(base.mechanisms()); + if target.volatile_access != base.volatile_access { + found.push(Mechanism::DetentionMarkDiff); + } + found.sort_unstable(); + found.dedup(); + found +} + +/// The quantities on which the two sides disagree. +/// +/// Public together with [`judge`] so the classifier's verdict can be exercised against outcomes +/// taken from real executions, rather than only against hand-built ones. +pub fn compare(target: &SpecOutcome, base: &SpecOutcome) -> Vec { + let mut fields = Vec::new(); + let mut push = |differs: bool, field: DiffField| { + if differs { + fields.push(field); + } + }; + push(target.state_root != base.state_root, DiffField::StateRoot); + push(target.logs_root != base.logs_root, DiffField::LogsRoot); + push(target.gas_used != base.gas_used, DiffField::GasUsed); + push(target.status != base.status, DiffField::Status); + push(target.halt_reason != base.halt_reason, DiffField::HaltReason); + push(target.output != base.output, DiffField::Output); + push(target.compute_gas_used != base.compute_gas_used, DiffField::ComputeGasUsed); + push(target.data_size != base.data_size, DiffField::DataSize); + push(target.kv_updates != base.kv_updates, DiffField::KvUpdates); + push(target.state_growth != base.state_growth, DiffField::StateGrowth); + fields +} + +/// Decides whether the observed mechanisms license the observed differences. +/// +/// The two tiers come straight from what each hypothesis can move. Falsifying "within every +/// resource limit" or "no guard rejected an opcode" changes which opcodes ran, so it can move any +/// compared quantity. Falsifying "no frame ended in an exceptional halt" only re-attributes a +/// halted frame's budget, which the spec confines to the reported compute total: "The receipt +/// `gas_used`, the halt or revert reported, and the execution success or failure of the outer +/// transaction are unchanged by the destroyed half of that carve-out." An exceptional halt is +/// therefore not accepted as the explanation for a state-root or receipt difference. +pub fn judge(fields: &[DiffField], target: &SpecOutcome, base: &SpecOutcome) -> UnitDiffOutcome { + let mechanisms = collect_mechanisms(target, base); + let falsified: Vec = { + let mut h: Vec<_> = mechanisms.iter().filter_map(|m| m.falsifies()).collect(); + h.sort_unstable(); + h.dedup(); + h + }; + let path_changed = falsified + .iter() + .any(|h| matches!(h, Hypothesis::WithinLimits | Hypothesis::NoDisabledVolatileReject)); + let halted = falsified.contains(&Hypothesis::NoExceptionalHalt); + + let unexplained: Vec = fields + .iter() + .copied() + .filter(|f| !(path_changed || (halted && f.movable_by_halt_alone()))) + .collect(); + + if unexplained.is_empty() { + return UnitDiffOutcome { + class: DiffClass::Explained, + fields: fields.to_vec(), + mechanisms, + detail: None, + }; + } + let detail = format!( + "no evidence licenses a difference on: {}", + unexplained.iter().map(|f| f.label()).collect::>().join(", ") + ); + UnitDiffOutcome { + class: DiffClass::Unexplained, + fields: fields.to_vec(), + mechanisms, + detail: Some(detail), + } +} + +/// Executes one unit's given transaction vector under `spec` and collects its outcome and +/// evidence — the differential classifier's entry point into +/// [`execute_unit_in_mode`](execute_unit_in_mode). +/// +/// `collect_evidence` runs the execution under [`FrameEvidenceInspector`], which is what makes an +/// inner frame's outcome visible; it costs an inspected interpreter loop, so the differential +/// classifier turns it on only for the units it cannot settle without it. +pub fn execute_unit_outcome( + unit: &TestUnit, + indexes: TxPartIndices, + spec: &SpecName, + collect_evidence: bool, +) -> Result { + let mode = if collect_evidence { RunMode::Evidence } else { RunMode::Plain }; + execute_unit_in_mode(unit, indexes, spec, mode).map(|run| run.outcome) +} + +/// Which inspector, if any, drives a unit's execution. +/// +/// Every mode runs the same setup — the same config, block environment, external environment, +/// block hashes and `BaseFeeVault` pruning — so that what a mode changes is the inspector and +/// nothing else. That is what lets a rewriting run be compared against a plain one and the +/// difference be attributed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RunMode { + /// No inspector at all: revm's plain frame loops. + Plain, + /// The read-only [`FrameEvidenceInspector`]. + Evidence, + /// A read-only inspector that counts the callbacks it is handed and changes nothing — the + /// control a rewriting run is judged against. + Observe, + /// The same control, declared `TrustedObserver`, so the measurement shim delegates to it + /// without taking any of its readings. + /// + /// A third run of one vector rather than a variant of the second: what it is for is to be + /// compared against both of the others, since the declaration's whole claim is that skipping + /// the measurement changes nothing an execution produces. + ObserveTrusted, + /// [`ChaosInspector`](crate::chaos::ChaosInspector), seeded with `seed` and restricted to + /// what `filter` allows. + Chaos { + /// The stream this run's decisions come from. + seed: u64, + /// Which mutations the run may make. + filter: ShapeFilter, + }, +} + +/// One unit's execution, with whatever the mode's inspector collected alongside it. +#[derive(Debug, Clone)] +pub struct UnitExecution { + /// The quantities the differential classifier compares. + pub outcome: SpecOutcome, + /// What the chaos inspector did, in [`RunMode::Chaos`]. + pub chaos: Option, + /// How many callbacks the observing inspector was handed, in [`RunMode::Observe`] and + /// [`RunMode::ObserveTrusted`]. + pub observed: u64, + /// What the measurement shim booked for the transaction. + /// + /// Empty for every mode but [`RunMode::Chaos`] — which is itself an assertion the chaos sweep + /// makes, since a read-only inspector that moved a lane would not be read-only. + pub ledger: InspectorLedger, +} + +/// Executes one unit's given transaction vector under `spec`, with `mode`'s inspector attached. +/// +/// Mirrors the validation path exactly — the same config, block environment, external +/// environment, block hashes and `BaseFeeVault` pruning — so the roots it computes are the roots +/// validation would check. +pub fn execute_unit_in_mode( + unit: &TestUnit, + indexes: TxPartIndices, + spec: &SpecName, + mode: RunMode, +) -> Result { + execute_unit_reporting_chaos(unit, indexes, spec, mode, &mut ChaosTally::default()) +} + +/// [`execute_unit_in_mode`], with the chaos run's tally written to `chaos_out` whether or not the +/// run produced a receipt. +/// +/// The ordinary return carries the tally inside [`UnitExecution`], which a run that did not +/// execute never reaches — and a run the measurement shim refused is exactly such a run. What it +/// mutated is the whole of what it has to report, so it cannot travel on the success path. +pub fn execute_unit_reporting_chaos( + unit: &TestUnit, + indexes: TxPartIndices, + spec: &SpecName, + mode: RunMode, + chaos_out: &mut ChaosTally, +) -> Result { + let mut cfg = CfgEnv::default(); + // See `execute_test_suite`: revm-27 chain-id gate-off (revm 40 default is true). + cfg.tx_chain_id_check = false; + cfg.chain_id = resolve_chain_id(&unit.env)?; + set_cfg_spec_and_mainnet_gas_params( + &mut cfg, + spec.to_spec_id().map_err(|e| TestErrorKind::FixtureError(format!("spec: {e}")))?, + ); + configure_max_blobs(&mut cfg); + + let block = unit.block_env(&cfg); + let tx = tx_env_at(unit, indexes)?; + + let cache = unit.state(); + let mut state = + database::State::builder().with_cached_prestate(cache).with_bundle_update().build(); + inject_block_hashes(&mut state, unit)?; + + let evm_context = MegaContext::default() + .with_db(&mut state) + .with_cfg(cfg) + .with_block(block) + .with_external_envs(external_envs_for(unit)?.into()); + let mut megatx = MegaTransaction::new(tx); + megatx.enveloped_tx = Some(Bytes::default()); + + let mut chaos_tally = None; + let mut observed = 0; + let (executed, frames, ctx) = match mode { + RunMode::Evidence => { + let mut evm = + MegaEvm::new(evm_context).with_inspector(FrameEvidenceInspector::default()); + let executed = evm.execute_transaction(megatx); + let inner = evm.into_inner(); + let frames = Some(inner.inspector.evidence()); + (executed, frames, inner.ctx) + } + RunMode::Observe => { + let mut evm = MegaEvm::new(evm_context).with_inspector(CallbackCounter::default()); + let executed = evm.execute_transaction(megatx); + let inner = evm.into_inner(); + observed = inner.inspector.callbacks(); + (executed, None, inner.ctx) + } + RunMode::ObserveTrusted => { + let mut evm = + MegaEvm::new(evm_context).with_trusted_inspector(CallbackCounter::default()); + let executed = evm.execute_transaction(megatx); + let inner = evm.into_inner(); + observed = inner.inspector.callbacks(); + (executed, None, inner.ctx) + } + RunMode::Chaos { seed, filter } => { + let mut evm = + MegaEvm::new(evm_context).with_inspector(ChaosInspector::new(seed, filter)); + let executed = evm.execute_transaction(megatx); + let inner = evm.into_inner(); + let tally = inner.inspector.tally(); + *chaos_out = tally.clone(); + chaos_tally = Some(tally); + (executed, None, inner.ctx) + } + RunMode::Plain => { + let mut evm = MegaEvm::new(evm_context); + let executed = evm.execute_transaction(megatx); + let inner = evm.into_inner(); + (executed, None, inner.ctx) + } + }; + + // Read the trackers before the context is dismantled: they carry the transaction's final + // limit and detention state, which no execution result exposes. + let rescued_gas = ctx.additional_limit.borrow().rescued_gas; + let (detained_limit, volatile_access) = { + let tracker = ctx.volatile_data_tracker.borrow(); + (tracker.get_compute_gas_limit(), tracker.get_volatile_data_accessed().bits()) + }; + let db = ctx.into_inner().journaled_state.database; + + let outcome = executed.map_err(|e| TestErrorKind::FixtureError(e.to_string()))?; + let ledger = outcome.inspector_ledger; + let compute_gas_used = outcome.compute_gas_used; + let compute_gas_destroyed = outcome.compute_gas_destroyed; + let compute_gas_enforced = outcome.compute_gas_enforced; + let data_size = outcome.data_size; + let kv_updates = outcome.kv_updates; + let state_growth = outcome.state_growth_used; + let result = outcome.result_and_state.result; + + // `execute_transaction` finalizes but does not commit; the roots are taken over the committed + // cache, exactly as the `transact_commit` validation path does. + db.commit(outcome.result_and_state.state); + prune_base_fee_vault_changes(db); + + let outcome = SpecOutcome { + state_root: state_merkle_trie_root(db.cache.trie_account()), + logs_root: log_rlp_hash(result.logs()), + gas_used: result.tx_gas_used(), + status: execution_status(&result).to_string(), + halt_reason: halt_reason(&result), + halt_kind: match &result { + ExecutionResult::Halt { reason, .. } => Some(halt_kind(reason)), + _ => None, + }, + output: result.output().cloned(), + compute_gas_used, + data_size, + kv_updates, + state_growth, + compute_gas_destroyed, + compute_gas_enforced, + rescued_gas, + detained_limit, + volatile_access, + frames, + }; + Ok(UnitExecution { outcome, chaos: chaos_tally, observed, ledger }) +} + +/// Runs the differential comparison over every transaction vector of every unit of one fixture +/// file. +/// +/// A unit that panics is recorded as [`DiffClass::Panic`] and the rest of the file still runs; +/// see [`panic_capture`] for why that matters at corpus scale. +pub fn diff_test_suite( + path: &Path, + specs: DiffSpecs, + collect_evidence: bool, +) -> Result, TestError> { + let path_str = path.to_string_lossy().into_owned(); + if skip_test(path) { + return Ok(vec![]); + } + + let fixture_err = |msg: String| TestError { + name: "diff".to_string(), + path: path_str.clone(), + kind: TestErrorKind::FixtureError(msg), + }; + let s = std::fs::read_to_string(path).map_err(|e| fixture_err(format!("read: {e}")))?; + let suite: TestSuite = serde_json::from_str(&s).map_err(|e| TestError { + name: "Unknown".to_string(), + path: path_str.clone(), + kind: e.into(), + })?; + + let mut diffs = Vec::with_capacity(suite.0.len()); + for (name, unit) in suite.0 { + // One verdict per vector the unit declares: `post` entries at different `indexes` are + // different transactions over the same pre-state, and judging only index `{0,0,0}` would + // report a green unit while never running the rest. + let vectors = unit.vectors(); + let multi = vectors.len() > 1; + for indexes in vectors { + let outcome = + match panic_capture::catch(|| diff_unit(&unit, indexes, specs, collect_evidence)) { + Ok(outcome) => outcome, + Err(report) => UnitDiffOutcome { + class: DiffClass::Panic, + fields: vec![], + mechanisms: vec![], + detail: Some(report), + }, + }; + let name = if multi { vector_label(&name, indexes) } else { name.clone() }; + diffs.push(outcome.named(name, path_str.clone())); + } + } + Ok(diffs) +} + +/// Counts of every verdict and mechanism seen over a corpus. +#[derive(Debug, Clone, Default)] +pub struct DiffTally { + /// Units per [`DiffClass`], keyed by [`DiffClass::label`]. + pub classes: BTreeMap<&'static str, usize>, + /// Units per [`Mechanism`] over the explained differences, keyed by [`Mechanism::label`]. + pub mechanisms: BTreeMap<&'static str, usize>, + /// Units per set of disagreeing quantities over the explained differences, keyed by the + /// comma-joined [`DiffField::label`]s. + /// + /// The shape of an explained difference is what a reviewer reads to see whether the corpus is + /// exercising the deviations the spec describes — one entry per distinct shape, rather than + /// one line per unit, which at corpus scale is tens of thousands of identical lines. + pub explained_fields: BTreeMap, + /// Every unit that needs a human: an unexplained difference or a panic. + pub flagged: Vec, + /// Files the runner could not read or parse at all, as rendered errors. + pub file_errors: Vec, + /// Files validation skips by filename, and which the sweep therefore judged no unit of. + /// + /// Counted rather than ignored: it is the difference between the number of units this sweep + /// reports and the number a driver that splits the corpus into one file per unit would, and + /// leaving it implicit turns every comparison against such a run into a manual subtraction. + pub skipped_files: usize, +} + +impl DiffTally { + /// Number of units in a class. + pub fn count(&self, class: DiffClass) -> usize { + self.classes.get(class.label()).copied().unwrap_or(0) + } + + /// Total number of units judged. + pub fn total(&self) -> usize { + self.classes.values().sum() + } + + /// Whether the run should fail its gate: a panic, an unexplained difference, a file the sweep + /// could not read, or a run that judged nothing at all. + /// + /// The last one is what makes the other three mean something. A sweep whose corpus never + /// arrived, or whose discovery walked into an unreadable directory, reaches the gate with an + /// empty tally — zero panics, zero unexplained differences — and every count it prints is + /// truthful. Reading that as a pass is how a broken corpus becomes a green nightly. + pub fn is_failure(&self) -> bool { + self.total() == 0 || + self.count(DiffClass::Panic) > 0 || + self.count(DiffClass::Unexplained) > 0 || + !self.file_errors.is_empty() + } + + /// Records one unit's verdict. + pub fn record(&mut self, diff: UnitDiff) { + *self.classes.entry(diff.class.label()).or_insert(0) += 1; + if diff.class == DiffClass::Explained { + for m in &diff.mechanisms { + *self.mechanisms.entry(m.label()).or_insert(0) += 1; + } + let shape = diff.fields.iter().map(|f| f.label()).collect::>().join(","); + *self.explained_fields.entry(shape).or_insert(0) += 1; + } + if matches!(diff.class, DiffClass::Unexplained | DiffClass::Panic) { + self.flagged.push(diff); + } + } + + /// Merges another tally into this one. + pub fn merge(&mut self, other: Self) { + for (k, v) in other.classes { + *self.classes.entry(k).or_insert(0) += v; + } + for (k, v) in other.mechanisms { + *self.mechanisms.entry(k).or_insert(0) += v; + } + for (k, v) in other.explained_fields { + *self.explained_fields.entry(k).or_insert(0) += v; + } + self.flagged.extend(other.flagged); + self.file_errors.extend(other.file_errors); + self.skipped_files += other.skipped_files; + } +} + +/// How a corpus-wide differential run behaves. +#[derive(Debug, Clone, Copy)] +pub struct DiffRunConfig { + /// The specs to compare. + pub specs: DiffSpecs, + /// Run every file on one thread. + pub single_thread: bool, + /// Re-run an otherwise unexplained difference with the frame inspector. + pub collect_evidence: bool, + /// Draw a progress bar. + pub progress: bool, +} + +/// Runs the differential comparison over every fixture file, in parallel. +/// +/// Installs the panic capture hook: a `debug_assert!` one fixture trips becomes that fixture's +/// verdict instead of taking down a worker thread, which is what makes a single-process +/// full-corpus sweep possible. +/// +/// `scan.errors` — anything the discovery walk could not read — is seeded into the tally's file +/// errors before a single fixture runs, so a corpus the sweep only partly reached fails the gate +/// however well the part it did reach behaves. +pub fn run_diff(scan: FixtureScan, config: DiffRunConfig) -> DiffTally { + panic_capture::install_capture_hook(); + + let FixtureScan { files, errors } = scan; + let n_files = files.len(); + let bar = Arc::new(ProgressBar::with_draw_target( + Some(n_files as u64), + if config.progress { ProgressDrawTarget::stdout() } else { ProgressDrawTarget::hidden() }, + )); + let queue = Arc::new(Mutex::new(files)); + let next = Arc::new(AtomicUsize::new(0)); + let threads = if config.single_thread { + 1 + } else { + std::thread::available_parallelism().map_or(1, |n| n.get().min(n_files.max(1))) + }; + + let mut handles = Vec::with_capacity(threads); + for i in 0..threads { + let (queue, next, bar) = (queue.clone(), next.clone(), bar.clone()); + handles.push( + std::thread::Builder::new() + .name(format!("diff-{i}")) + .spawn(move || { + let mut tally = DiffTally::default(); + loop { + let idx = next.fetch_add(1, Ordering::SeqCst); + let Some(path) = queue.lock().unwrap().get(idx).cloned() else { + return tally; + }; + if crate::runner::is_skipped_fixture(&path) { + tally.skipped_files += 1; + bar.inc(1); + continue; + } + match diff_test_suite(&path, config.specs, config.collect_evidence) { + Ok(diffs) => { + for diff in diffs { + tally.record(diff); + } + } + Err(e) => tally.file_errors.push(e.to_string()), + } + bar.inc(1); + } + }) + .expect("spawn diff worker"), + ); + } + + let mut tally = DiffTally { file_errors: errors, ..DiffTally::default() }; + for handle in handles { + match handle.join() { + Ok(worker) => tally.merge(worker), + // A worker thread that unwound past `diff_test_suite` lost the files it had taken; + // surface that rather than reporting a short tally as a clean run. + Err(_) => tally + .file_errors + .push("a diff worker thread panicked; its files were not judged".to_string()), + } + } + bar.finish_and_clear(); + tally +} + +/// Collects every JSON fixture under each path, rejecting a path that does not exist. +/// +/// Directories the walk could not read come back in [`FixtureScan::errors`] rather than as a +/// quietly shorter file list; [`run_diff`] carries them into the tally, where they fail the gate. +pub fn collect_fixture_files(paths: &[PathBuf]) -> Result { + let mut scan = FixtureScan::default(); + for path in paths { + if !path.exists() { + return Err(TestError { + name: "Path validation".to_string(), + path: path.display().to_string(), + kind: TestErrorKind::InvalidPath, + }); + } + let found = find_all_json_tests(path); + scan.files.extend(found.files); + scan.errors.extend(found.errors); + } + if scan.files.is_empty() { + return Err(TestError { + name: "Path validation".to_string(), + path: paths.iter().map(|p| p.display().to_string()).collect::>().join(", "), + kind: TestErrorKind::NoJsonFiles, + }); + } + Ok(scan) +} + +/// Bridges a keep-going fill's per-unit status into the sweep's own vocabulary. +/// +/// A fill sweep and a differential sweep count the same corpus in the same three buckets, so they +/// report through one mapping rather than two that can drift. +pub const fn fill_status_class(status: &UnitStatus) -> DiffClass { + match status { + UnitStatus::Ok => DiffClass::Pass, + UnitStatus::Error(_) => DiffClass::Skipped, + UnitStatus::Panic(_) => DiffClass::Panic, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// An outcome that agrees with itself on every compared quantity and shows no mechanism. + fn quiet() -> SpecOutcome { + SpecOutcome { + state_root: B256::ZERO, + logs_root: B256::ZERO, + gas_used: 21_000, + status: "success".to_string(), + halt_reason: None, + halt_kind: None, + output: None, + compute_gas_used: 1_000, + data_size: 10, + kv_updates: 1, + state_growth: 0, + compute_gas_destroyed: 0, + compute_gas_enforced: 1_000, + rescued_gas: 0, + detained_limit: None, + volatile_access: 0, + frames: None, + } + } + + /// Every mechanism, with the hypothesis it falsifies and where the observation comes from. + /// + /// Exhaustive by construction: `test_mechanism_table_is_exhaustive` fails if a variant is + /// added without a row here. + const MECHANISM_TABLE: [(Mechanism, Option, Provenance); 9] = [ + (Mechanism::ResourceLimitHalt, Some(Hypothesis::WithinLimits), Provenance::Execution), + (Mechanism::DetentionHalt, Some(Hypothesis::WithinLimits), Provenance::Execution), + (Mechanism::GasRescued, Some(Hypothesis::WithinLimits), Provenance::Execution), + (Mechanism::ExceptionalHalt, Some(Hypothesis::NoExceptionalHalt), Provenance::Execution), + (Mechanism::DestroyedComputeGas, None, Provenance::Execution), + (Mechanism::DetentionMarkDiff, None, Provenance::Execution), + (Mechanism::DetentionInForce, None, Provenance::Execution), + (Mechanism::LimitRevertPayload, None, Provenance::Payload), + (Mechanism::VolatileDisabledPayload, None, Provenance::Payload), + ]; + + // Every mechanism maps to exactly the hypothesis it observes. A mechanism silently gaining a + // hypothesis would let it explain a difference it is not evidence for. + #[test] + fn test_mechanism_hypothesis_table() { + for (mechanism, expected, _) in MECHANISM_TABLE { + assert_eq!(mechanism.falsifies(), expected, "{}", mechanism.label()); + } + } + + // The rule that makes the classifier un-gameable by a fixture: an observation read out of + // revert-payload bytes never licenses anything, because a contract writes those bytes as + // freely as MegaETH does. A future mechanism that sniffs a payload and claims a hypothesis + // fails here rather than in a corpus sweep that quietly stops flagging. + #[test] + fn test_only_execution_provenance_licenses() { + for (mechanism, _, provenance) in MECHANISM_TABLE { + assert_eq!(mechanism.provenance(), provenance, "{}", mechanism.label()); + if provenance == Provenance::Payload { + assert_eq!( + mechanism.falsifies(), + None, + "{} is read off fixture-authored bytes and must license nothing", + mechanism.label() + ); + } + } + } + + // The table above is the test's own claim to completeness, so it has to cover every variant. + // Labels are distinct and stable, which is what makes them usable as tally keys. + #[test] + fn test_mechanism_table_is_exhaustive() { + let mut labels: Vec<&str> = MECHANISM_TABLE.iter().map(|(m, _, _)| m.label()).collect(); + labels.sort_unstable(); + assert_eq!( + labels, + [ + "destroyed_compute_gas", + "detention_halt", + "detention_in_force", + "detention_mark_diff", + "exceptional_halt", + "gas_rescued", + "limit_revert_payload", + "resource_limit_halt", + "volatile_disabled_payload", + ], + "every Mechanism variant needs a row in MECHANISM_TABLE, with a distinct label" + ); + } + + /// A quantity to disturb, and how to disturb it. + type FieldProbe = (DiffField, fn(&mut SpecOutcome)); + + // `compare` covers every quantity the precision invariant names; a field left out of the + // comparison is a difference the sweep can never see. + #[test] + fn test_compare_detects_every_field() { + let base = quiet(); + let cases: [FieldProbe; 10] = [ + (DiffField::StateRoot, |o| o.state_root = B256::repeat_byte(1)), + (DiffField::LogsRoot, |o| o.logs_root = B256::repeat_byte(2)), + (DiffField::GasUsed, |o| o.gas_used += 1), + (DiffField::Status, |o| o.status = "revert".to_string()), + (DiffField::HaltReason, |o| o.halt_reason = Some("Base(OutOfGas)".to_string())), + (DiffField::Output, |o| o.output = Some(Bytes::from_static(b"\x01"))), + (DiffField::ComputeGasUsed, |o| o.compute_gas_used += 1), + (DiffField::DataSize, |o| o.data_size += 1), + (DiffField::KvUpdates, |o| o.kv_updates += 1), + (DiffField::StateGrowth, |o| o.state_growth += 1), + ]; + assert!(compare(&base, &base).is_empty(), "an outcome must agree with itself"); + for (field, mutate) in cases { + let mut target = base.clone(); + mutate(&mut target); + assert_eq!(compare(&target, &base), vec![field], "{}", field.label()); + } + } + + /// Frame evidence holding `n` halted frames and no revert payloads. + fn halted_frames(n: u32) -> Option { + Some(FrameEvidence { halted: n, limit_revert_payloads: 0, volatile_disabled_payloads: 0 }) + } + + // The exceptional-halt carve-out raises the reported compute total and is explicitly + // forbidden from moving the receipt or the state, so it licenses one field and not the other. + #[test] + fn test_exceptional_halt_explains_only_the_reported_compute_total() { + let base = quiet(); + let mut target = base.clone(); + target.frames = halted_frames(1); + target.compute_gas_destroyed = 5_000; + target.compute_gas_used += 5_000; + let verdict = judge(&compare(&target, &base), &target, &base); + assert_eq!(verdict.class, DiffClass::Explained); + assert!(verdict.mechanisms.contains(&Mechanism::DestroyedComputeGas)); + + // Same evidence, a state-root difference: not licensed. + let mut target = base.clone(); + target.frames = halted_frames(1); + target.compute_gas_destroyed = 5_000; + target.state_root = B256::repeat_byte(9); + let verdict = judge(&compare(&target, &base), &target, &base); + assert_eq!(verdict.class, DiffClass::Unexplained); + assert!( + verdict.detail.as_deref().is_some_and(|d| d.contains("state_root")), + "detail should name the unlicensed field: {:?}", + verdict.detail + ); + } + + // A destroyed remainder is derived from a conservation law over the envelope, not observed. + // A defect in that law shows up as a non-zero remainder with no halt behind it, and if the + // remainder licensed the compute-total difference it causes, that defect would be exactly the + // shape the sweep stops reporting. The halt it claims must come from the frame the EVM + // finished; here it is booked with no frame that halted, and the difference stays a finding. + #[test] + fn test_destroyed_compute_gas_needs_an_independent_halted_frame() { + let base = quiet(); + let mut target = base.clone(); + target.compute_gas_destroyed = 5_000; + target.compute_gas_used += 5_000; + + // No frame pass at all: the remainder is the only thing on the table. + let verdict = judge(&compare(&target, &base), &target, &base); + assert_eq!(verdict.class, DiffClass::Unexplained, "{verdict:?}"); + assert!(verdict.mechanisms.contains(&Mechanism::DestroyedComputeGas)); + + // The frame pass ran and found no halted frame: the remainder is still unexplained, and + // now it is a live contradiction — something destroyed an envelope that no frame lost. + target.frames = halted_frames(0); + let verdict = judge(&compare(&target, &base), &target, &base); + assert_eq!(verdict.class, DiffClass::Unexplained, "{verdict:?}"); + + // With the independent witness, the same difference is licensed. + target.frames = halted_frames(1); + let verdict = judge(&compare(&target, &base), &target, &base); + assert_eq!(verdict.class, DiffClass::Explained, "{verdict:?}"); + assert!(verdict.mechanisms.contains(&Mechanism::ExceptionalHalt)); + } + + // A crossed resource limit changes which opcodes ran, so it licenses any quantity — + // including the consensus-visible ones. + #[test] + fn test_resource_limit_evidence_explains_a_consensus_difference() { + let base = quiet(); + let mut target = base.clone(); + target.status = "halt".to_string(); + target.halt_reason = Some("ComputeGasLimitExceeded { limit: 1, actual: 2 }".to_string()); + target.halt_kind = Some(HaltKind::ResourceLimit); + target.state_root = B256::repeat_byte(9); + target.gas_used += 5; + let verdict = judge(&compare(&target, &base), &target, &base); + assert_eq!(verdict.class, DiffClass::Explained); + assert!(verdict.mechanisms.contains(&Mechanism::ResourceLimitHalt)); + } + + // The evidence may sit on the *base* side: Rex7 relaxes enforcement on a failing precompile, + // so the frozen spec is the one that halts and the unstable one that survives. + #[test] + fn test_evidence_on_the_base_side_explains_the_difference() { + let target = quiet(); + let mut base = target.clone(); + base.status = "halt".to_string(); + base.halt_reason = Some("ComputeGasLimitExceeded { limit: 1, actual: 2 }".to_string()); + base.halt_kind = Some(HaltKind::ResourceLimit); + base.state_root = B256::repeat_byte(9); + let verdict = judge(&compare(&target, &base), &target, &base); + assert_eq!(verdict.class, DiffClass::Explained); + } + + // Detention labels describe the setting, not a crossing: a cap nobody reached, or a mark that + // moved without changing an outcome, must not license anything. + #[test] + fn test_detention_labels_alone_do_not_explain() { + let base = quiet(); + let mut target = base.clone(); + target.detained_limit = Some(100_000); + target.volatile_access = 0b100; + target.compute_gas_used += 1; + let verdict = judge(&compare(&target, &base), &target, &base); + assert_eq!(verdict.class, DiffClass::Unexplained); + assert!(verdict.mechanisms.contains(&Mechanism::DetentionInForce)); + assert!(verdict.mechanisms.contains(&Mechanism::DetentionMarkDiff)); + } + + // Both revert-payload claims are reported and neither licenses. A frame that reverts with + // MegaETH's selectors is indistinguishable from a contract that wrote the same four bytes, so + // treating the bytes as evidence would let any fixture buy an exemption for any difference — + // and `MegaLimitExceeded` in particular claims the hypothesis that licenses *every* quantity. + #[test] + fn test_revert_payload_claims_never_license() { + let base = quiet(); + for (payload, expected) in [ + ( + FrameEvidence { + halted: 0, + limit_revert_payloads: 1, + volatile_disabled_payloads: 0, + }, + Mechanism::LimitRevertPayload, + ), + ( + FrameEvidence { + halted: 0, + limit_revert_payloads: 0, + volatile_disabled_payloads: 1, + }, + Mechanism::VolatileDisabledPayload, + ), + ] { + let mut target = base.clone(); + target.frames = Some(payload); + target.gas_used += 3; + let verdict = judge(&compare(&target, &base), &target, &base); + assert_eq!(verdict.class, DiffClass::Unexplained, "{verdict:?}"); + assert!( + verdict.mechanisms.contains(&expected), + "the claim is still reported for a human: {:?}", + verdict.mechanisms + ); + } + } + + // An inner frame that halted is invisible in the transaction's own result and leaves no + // destroyed remainder when the interpreter zeroed its counter. Frame evidence is the only + // thing that sees it. + #[test] + fn test_frame_evidence_supplies_the_halt_the_result_hides() { + let base = quiet(); + let mut target = base.clone(); + target.compute_gas_used += 700; + assert_eq!( + judge(&compare(&target, &base), &target, &base).class, + DiffClass::Unexplained, + "without frame evidence there is nothing to license the difference" + ); + + target.frames = halted_frames(1); + let verdict = judge(&compare(&target, &base), &target, &base); + assert_eq!(verdict.class, DiffClass::Explained); + assert!(verdict.mechanisms.contains(&Mechanism::ExceptionalHalt)); + } + + /// The plain pair a stage-two rerun is called in to settle: the target reports 700 more + /// compute gas than the base, with nothing in either outcome to license it. + fn unexplained_pair() -> (SpecOutcome, SpecOutcome, UnitDiffOutcome) { + let base = quiet(); + let mut target = base.clone(); + target.compute_gas_used += 700; + let verdict = judge(&compare(&target, &base), &target, &base); + assert_eq!(verdict.class, DiffClass::Unexplained); + (target, base, verdict) + } + + // The admissible case: both reruns reproduce their plain run, so the inner halt only the + // frames can see is the execution's own and settles the difference. + #[test] + fn test_reruns_that_reproduce_their_plain_run_supply_admissible_evidence() { + let (target, base, verdict) = unexplained_pair(); + let inspected_target = SpecOutcome { frames: halted_frames(1), ..target.clone() }; + let inspected_base = SpecOutcome { frames: Some(FrameEvidence::default()), ..base.clone() }; + + let settled = judge_with_frame_evidence( + verdict, + &target, + &base, + (Ok(inspected_target), Ok(inspected_base)), + ); + assert_eq!(settled.class, DiffClass::Explained); + assert!(settled.mechanisms.contains(&Mechanism::ExceptionalHalt)); + assert_eq!(settled.fields, vec![DiffField::ComputeGasUsed]); + } + + // The regression this gate exists for: the inspector itself introduces the inner halt and + // moves the target's numbers. The frames now describe an execution that did not happen, so + // they explain nothing and the plain verdict stands. + #[test] + fn test_a_target_rerun_that_moved_has_its_frame_evidence_discarded() { + let (target, base, verdict) = unexplained_pair(); + let mut inspected_target = target.clone(); + inspected_target.frames = halted_frames(1); + inspected_target.compute_gas_used += 5_000; + inspected_target.compute_gas_destroyed = 5_000; + let inspected_base = SpecOutcome { frames: Some(FrameEvidence::default()), ..base.clone() }; + + let settled = judge_with_frame_evidence( + verdict, + &target, + &base, + (Ok(inspected_target), Ok(inspected_base)), + ); + assert_eq!(settled.class, DiffClass::Unexplained); + assert!( + !settled.mechanisms.contains(&Mechanism::ExceptionalHalt), + "a discarded rerun contributes no mechanism: {:?}", + settled.mechanisms + ); + let detail = settled.detail.unwrap_or_default(); + assert!( + detail.contains("frame inspector moved the target execution: compute_gas_used"), + "detail should name the side that moved: {detail}" + ); + assert!( + detail.contains("compute_gas_used, compute_gas_destroyed"), + "detail should list every quantity that moved: {detail}" + ); + assert!(!detail.contains("base execution"), "the base rerun did not move: {detail}"); + assert!(detail.ends_with("frame evidence discarded"), "{detail}"); + } + + // The same rule on the other side, and over a quantity `compare` does not look at: a rerun + // that only moved the evidence `judge` reads is still a rerun of a different execution. + #[test] + fn test_a_base_rerun_that_moved_has_its_frame_evidence_discarded() { + let (target, base, verdict) = unexplained_pair(); + let inspected_target = SpecOutcome { frames: halted_frames(1), ..target.clone() }; + let mut inspected_base = base.clone(); + inspected_base.frames = Some(FrameEvidence::default()); + inspected_base.rescued_gas = 4_200; + + let settled = judge_with_frame_evidence( + verdict, + &target, + &base, + (Ok(inspected_target), Ok(inspected_base)), + ); + assert_eq!(settled.class, DiffClass::Unexplained); + assert!( + !settled.mechanisms.contains(&Mechanism::GasRescued), + "a discarded rerun contributes no mechanism: {:?}", + settled.mechanisms + ); + let detail = settled.detail.unwrap_or_default(); + assert!( + detail.contains("frame inspector moved the base execution: rescued_gas"), + "detail should name the side and its moved quantity: {detail}" + ); + assert!(!detail.contains("target execution"), "the target rerun did not move: {detail}"); + } + + // Both sides moving is reported as both, not as whichever was checked first. + #[test] + fn test_both_reruns_moving_names_both_sides() { + let (target, base, verdict) = unexplained_pair(); + let mut inspected_target = target.clone(); + inspected_target.frames = halted_frames(1); + inspected_target.state_root = B256::repeat_byte(7); + let mut inspected_base = base.clone(); + inspected_base.frames = Some(FrameEvidence::default()); + inspected_base.detained_limit = Some(50_000); + + let settled = judge_with_frame_evidence( + verdict, + &target, + &base, + (Ok(inspected_target), Ok(inspected_base)), + ); + assert_eq!(settled.class, DiffClass::Unexplained); + let detail = settled.detail.unwrap_or_default(); + assert!( + detail.contains("frame inspector moved the target execution: state_root") && + detail.contains("frame inspector moved the base execution: detained_limit"), + "both sides moved and both should be named: {detail}" + ); + } + + // A rerun that never executed collected no frames at all, which is not evidence of drift and + // not evidence of anything else: the plain verdict is returned untouched. + #[test] + fn test_a_rerun_that_did_not_execute_leaves_the_plain_verdict_alone() { + let (target, base, verdict) = unexplained_pair(); + let inspected_base = SpecOutcome { frames: Some(FrameEvidence::default()), ..base.clone() }; + let settled = judge_with_frame_evidence( + verdict.clone(), + &target, + &base, + (Err(TestErrorKind::FixtureError("rerun declined".to_string())), Ok(inspected_base)), + ); + assert_eq!(settled.class, DiffClass::Unexplained); + assert_eq!(settled.fields, verdict.fields); + assert_eq!(settled.detail, verdict.detail); + } + + /// A quantity outside `compare`'s ten to disturb in a rerun, and its drift label. + type DriftProbe = (&'static str, fn(&mut SpecOutcome)); + + // The drift check covers the whole outcome but the frames, so a rerun that differs only by + // the evidence it was run to collect reads as a faithful rerun. + #[test] + fn test_only_the_collected_frames_may_differ_between_a_run_and_its_rerun() { + let plain = quiet(); + assert!(rerun_drift(&plain, &plain).is_empty(), "a run must reproduce itself"); + let inspected = SpecOutcome { frames: halted_frames(3), ..plain.clone() }; + assert!( + rerun_drift(&plain, &inspected).is_empty(), + "collecting frame evidence is what the rerun is for" + ); + + let probes: [DriftProbe; 6] = [ + ("halt_kind", |o| o.halt_kind = Some(HaltKind::Other)), + ("compute_gas_destroyed", |o| o.compute_gas_destroyed += 1), + ("compute_gas_enforced", |o| o.compute_gas_enforced += 1), + ("rescued_gas", |o| o.rescued_gas += 1), + ("detained_limit", |o| o.detained_limit = Some(1)), + ("volatile_access", |o| o.volatile_access = 1), + ]; + for (label, mutate) in probes { + let mut moved = plain.clone(); + mutate(&mut moved); + assert_eq!(rerun_drift(&plain, &moved), vec![label]); + } + // And the ten `compare` decides, reported under their own labels. + let mut moved = plain.clone(); + moved.gas_used += 1; + moved.kv_updates += 1; + assert_eq!(rerun_drift(&plain, &moved), vec!["gas_used", "kv_updates"]); + } + + // Which halts count as a crossed resource limit is read off the typed reason, variant by + // variant. Every `MegaHaltReason` gets a row: the four metering halts and the detention halt + // are limits, the inherited EVM's halts and `SystemTxInvalidCallee` are not. The rule this + // replaced — "not `Base(..)` means resource limit" — put `SystemTxInvalidCallee`, and every + // variant a later spec adds, on the licensing side by default. + #[test] + fn test_halt_kind_covers_every_halt_reason() { + use mega_evm::{ + revm::{ + context::result::{HaltReason as EthHaltReason, OutOfGasError}, + primitives::Address, + }, + VolatileDataAccess, + }; + for (reason, expected) in [ + (MegaHaltReason::DataLimitExceeded { limit: 1, actual: 2 }, HaltKind::ResourceLimit), + ( + MegaHaltReason::KVUpdateLimitExceeded { limit: 1, actual: 2 }, + HaltKind::ResourceLimit, + ), + ( + MegaHaltReason::ComputeGasLimitExceeded { limit: 1, actual: 2 }, + HaltKind::ResourceLimit, + ), + ( + MegaHaltReason::StateGrowthLimitExceeded { limit: 1, actual: 2 }, + HaltKind::ResourceLimit, + ), + ( + MegaHaltReason::VolatileDataAccessOutOfGas { + access_type: VolatileDataAccess::empty(), + limit: 1, + actual: 2, + }, + HaltKind::Detention, + ), + (MegaHaltReason::from(EthHaltReason::OutOfGas(OutOfGasError::Basic)), HaltKind::Other), + (MegaHaltReason::SystemTxInvalidCallee { callee: Address::ZERO }, HaltKind::Other), + ] { + assert_eq!(halt_kind(&reason), expected, "{reason:?}"); + } + } + + // A halted transaction is always an exceptional halt; whether it is *also* a crossed resource + // limit is what the kind decides. + #[test] + fn test_halt_kind_drives_the_mechanism() { + let mut outcome = quiet(); + outcome.status = "halt".to_string(); + + outcome.halt_kind = Some(HaltKind::Other); + let m = outcome.mechanisms(); + assert!(m.contains(&Mechanism::ExceptionalHalt)); + assert!(!m.contains(&Mechanism::ResourceLimitHalt)); + assert!(!m.contains(&Mechanism::DetentionHalt)); + + outcome.halt_kind = Some(HaltKind::ResourceLimit); + assert!(outcome.mechanisms().contains(&Mechanism::ResourceLimitHalt)); + + outcome.halt_kind = Some(HaltKind::Detention); + let m = outcome.mechanisms(); + assert!(m.contains(&Mechanism::DetentionHalt)); + assert!(!m.contains(&Mechanism::ResourceLimitHalt)); + } + + // Rescued gas is the tell for a limit exceed whose halt the outer accounting rewrote. + #[test] + fn test_rescued_gas_is_limit_evidence() { + let mut outcome = quiet(); + outcome.rescued_gas = 1; + assert!(outcome.mechanisms().contains(&Mechanism::GasRescued)); + } + + fn diff_of(class: DiffClass, fields: Vec, mechanisms: Vec) -> UnitDiff { + UnitDiff { + name: "u".to_string(), + path: "p".to_string(), + class, + fields, + mechanisms, + detail: None, + } + } + + // The tally counts every class, keeps only what a human must look at, and fails the gate on + // exactly the two classes the sweep exists to catch. + #[test] + fn test_tally_accounting_and_gate() { + let mut tally = DiffTally::default(); + tally.record(diff_of(DiffClass::Pass, vec![], vec![])); + tally.record(diff_of(DiffClass::Skipped, vec![], vec![])); + tally.record(diff_of( + DiffClass::Explained, + vec![DiffField::ComputeGasUsed], + vec![Mechanism::ExceptionalHalt], + )); + assert_eq!(tally.total(), 3); + assert_eq!(tally.count(DiffClass::Explained), 1); + assert_eq!(tally.mechanisms.get("exceptional_halt"), Some(&1)); + assert_eq!(tally.explained_fields.get("compute_gas_used"), Some(&1)); + assert!(tally.flagged.is_empty(), "pass/skip/explained need no human"); + assert!(!tally.is_failure()); + + let mut other = DiffTally::default(); + other.record(diff_of(DiffClass::Unexplained, vec![DiffField::StateRoot], vec![])); + tally.merge(other); + assert_eq!(tally.count(DiffClass::Unexplained), 1); + assert_eq!(tally.flagged.len(), 1); + assert!(tally.is_failure()); + } + + // A file the runner could not read at all is a hole in the sweep's coverage, not a pass. + #[test] + fn test_file_error_fails_the_gate() { + let mut tally = DiffTally::default(); + tally.record(diff_of(DiffClass::Pass, vec![], vec![])); + assert!(!tally.is_failure()); + tally.file_errors.push("unreadable".to_string()); + assert!(tally.is_failure()); + } + + // A sweep that judged nothing has nothing to say, and every count it prints is a truthful + // zero. Reading that as a pass is how a corpus that never arrived becomes a green nightly. + #[test] + fn test_a_run_that_judged_nothing_fails_the_gate() { + let mut tally = DiffTally::default(); + assert!(tally.is_failure(), "an empty tally is not a pass"); + + // Skipped-by-filename files are not units judged, so a corpus of nothing but those is + // still a run that judged nothing. + tally.skipped_files = 12; + assert!(tally.is_failure()); + + tally.record(diff_of(DiffClass::Pass, vec![], vec![])); + assert!(!tally.is_failure()); + } + + // The classifier reads one sentence — Rex7's precision invariant — and that sentence relates + // exactly one pair of specs. Any other pair would be judged by a licence it was never given. + // + // That the constructor is the *only* way in is a property of the crate's boundary, which this + // module is inside of; it is pinned by the `compile_fail` example on [`DiffSpecs`] and by + // `tests/diff_mode.rs`, which are compiled as consumers. + #[test] + fn test_only_the_rex7_rex6_pair_can_be_constructed() { + let (target, base) = DiffSpecs::SUPPORTED; + let specs = DiffSpecs::new(target, base).expect("the supported pair"); + assert_eq!((specs.target(), specs.base()), (target, base)); + for (t, b) in [ + (SpecName::Rex7, SpecName::Equivalence), + (SpecName::Rex6, SpecName::Rex5), + (SpecName::Rex6, SpecName::Rex7), + (SpecName::Rex7, SpecName::Rex7), + ] { + let err = DiffSpecs::new(t, b).expect_err("{t:?} vs {b:?} has no invariant"); + assert!(err.contains("Rex7") && err.contains("Rex6"), "name the supported pair: {err}"); + } + } + + #[test] + fn test_fill_status_class_mapping() { + assert_eq!(fill_status_class(&UnitStatus::Ok), DiffClass::Pass); + assert_eq!(fill_status_class(&UnitStatus::Error(String::new())), DiffClass::Skipped); + assert_eq!(fill_status_class(&UnitStatus::Panic(String::new())), DiffClass::Panic); + } +} diff --git a/crates/mega-state-test/src/lib.rs b/crates/mega-state-test/src/lib.rs index 7af297a0..5b5865a0 100644 --- a/crates/mega-state-test/src/lib.rs +++ b/crates/mega-state-test/src/lib.rs @@ -3,6 +3,12 @@ #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] +pub mod chaos; + +pub mod diff; + +pub mod panic_capture; + pub mod types; pub mod runner; diff --git a/crates/mega-state-test/src/panic_capture.rs b/crates/mega-state-test/src/panic_capture.rs new file mode 100644 index 00000000..a8b5d9b6 --- /dev/null +++ b/crates/mega-state-test/src/panic_capture.rs @@ -0,0 +1,110 @@ +//! Turning a panic inside one fixture unit into a recorded result. +//! +//! A sweep over tens of thousands of fixtures is only useful if a single fixture that trips a +//! `debug_assert!` costs that one fixture rather than the whole run. The alternative used before +//! this module — one process per fixture — isolates perfectly but pays a process spawn and a +//! fixture parse per case, which is what made a full-corpus sweep an overnight job. +//! +//! Two pieces are needed. [`catch`] contains the unwind, and the hook installed by +//! [`install_capture_hook`] records the panic's location and message, which the payload alone does +//! not carry. + +use std::{ + cell::RefCell, + panic::{self, AssertUnwindSafe}, + string::{String, ToString}, + sync::atomic::{AtomicBool, Ordering}, +}; + +thread_local! { + /// The report of the most recent panic on this thread, written by the capture hook and taken + /// by [`catch`]. Thread-local because worker threads panic independently. + static LAST_PANIC: RefCell> = const { RefCell::new(None) }; +} + +/// Whether [`install_capture_hook`] has run. Read by [`catch`] to decide whether a taken-empty +/// report means "no hook" or "hook installed but the panic carried no location". +static HOOK_INSTALLED: AtomicBool = AtomicBool::new(false); + +/// Replaces the process-wide panic hook with one that records each panic instead of printing it. +/// +/// This is process-wide and permanent: it silences the default hook (message, location and +/// backtrace) for every panic in the process, caught or not. Call it only from a driver that +/// reports the captured reports itself — a caught panic that nobody prints is a panic nobody +/// sees. +/// +/// Idempotent: a second call is a no-op, so several drivers in one process cannot stack hooks. +pub fn install_capture_hook() { + if HOOK_INSTALLED.swap(true, Ordering::SeqCst) { + return; + } + panic::set_hook(Box::new(|info| { + // The `Display` form is `panicked at :::\n` — the same shape the + // default hook prints, minus the backtrace. + let report = info.to_string(); + LAST_PANIC.with(|slot| *slot.borrow_mut() = Some(report)); + })); +} + +/// Runs `f`, converting a panic into `Err()`. +/// +/// The report is the one the capture hook recorded when [`install_capture_hook`] has run; +/// otherwise it falls back to the panic payload, which carries the message but not the location. +/// +/// `f` is treated as unwind-safe. Every caller in this crate builds the state it touches from +/// scratch for each fixture unit, so a half-updated value cannot outlive the panic; do not use +/// this to wrap work that mutates state shared with the next unit. +pub fn catch(f: impl FnOnce() -> T) -> Result { + // Clear first: a report left by an earlier panic on this thread must not be attributed to + // this call. + LAST_PANIC.with(|slot| *slot.borrow_mut() = None); + panic::catch_unwind(AssertUnwindSafe(f)).map_err(|payload| { + LAST_PANIC + .with(|slot| slot.borrow_mut().take()) + .unwrap_or_else(|| payload_message(&payload)) + }) +} + +/// Best-effort message from a panic payload, for the no-hook path. +fn payload_message(payload: &Box) -> String { + if let Some(s) = payload.downcast_ref::<&'static str>() { + (*s).to_string() + } else if let Some(s) = payload.downcast_ref::() { + s.clone() + } else { + "panicked with a non-string payload".to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_catch_returns_the_value_when_nothing_panics() { + assert_eq!(catch(|| 41 + 1).expect("no panic"), 42); + } + + #[test] + fn test_catch_converts_a_panic_into_an_error() { + let err = catch(|| panic!("boom {}", 7)).expect_err("panic must be caught"); + assert!(err.contains("boom 7"), "report should carry the message: {err}"); + } + + #[test] + fn test_catch_reports_a_panic_after_the_hook_is_installed() { + // The hook adds the location, which the payload alone does not carry. Installing it is + // process-wide, so this test also fixes what the other tests in this module observe — + // both accept either report shape. + install_capture_hook(); + let err = catch(|| panic!("located")).expect_err("panic must be caught"); + assert!(err.contains("located"), "report should carry the message: {err}"); + assert!(err.contains("panicked at"), "hook report should carry the location: {err}"); + } + + #[test] + fn test_catch_does_not_attribute_an_earlier_panic_to_a_later_call() { + let _ = catch(|| panic!("first")); + assert_eq!(catch(|| "second").expect("no panic"), "second"); + } +} diff --git a/crates/mega-state-test/src/runner.rs b/crates/mega-state-test/src/runner.rs index ad787e20..1b97d602 100644 --- a/crates/mega-state-test/src/runner.rs +++ b/crates/mega-state-test/src/runner.rs @@ -1,6 +1,7 @@ #![allow(missing_docs)] use crate::{ + panic_capture, types::{ tx_env_at, Env, SpecName, Test, TestError as TxBuildError, TestSuite, TestUnit, TxPartIndices, @@ -94,27 +95,61 @@ impl From for TestErrorKind { } } +/// What walking a path for JSON fixtures found — and what it could not read. +/// +/// The two halves are reported together on purpose. A directory the walk cannot descend into +/// yields no fixtures, which is indistinguishable from a directory that holds none, so a walk +/// that only returns file names turns a permission error or a broken symlink into a smaller +/// corpus and a green run. +#[derive(Debug, Clone, Default)] +pub struct FixtureScan { + /// Every `.json` file the walk reached. + pub files: Vec, + /// Entries the walk could not read, as rendered errors. + pub errors: Vec, +} + /// Find all JSON test files in the given path /// If path is a file, returns it in a vector /// If path is a directory, recursively finds all .json files -pub fn find_all_json_tests(path: &Path) -> Vec { +/// +/// Entries the walk cannot read are collected in [`FixtureScan::errors`] rather than dropped; +/// every caller has to decide what an unreadable part of the corpus means for its own verdict. +pub fn find_all_json_tests(path: &Path) -> FixtureScan { if path.is_file() { - vec![path.to_path_buf()] - } else { - WalkDir::new(path) - .into_iter() - .filter_map(Result::ok) - .filter(|e| e.path().extension() == Some("json".as_ref())) - .map(DirEntry::into_path) - .collect() + return FixtureScan { files: vec![path.to_path_buf()], errors: vec![] }; + } + let mut scan = FixtureScan::default(); + for entry in WalkDir::new(path) { + match entry { + Ok(e) if e.path().extension() == Some("json".as_ref()) => { + scan.files.push(DirEntry::into_path(e)); + } + Ok(_) => {} + // Name the entry the walk tripped on, not the root it started from: at corpus scale + // "something under state_tests/ was unreadable" is not an actionable report. + Err(e) => { + let at = e.path().unwrap_or(path).display().to_string(); + scan.errors.push(format!("walk {at}: {e}")); + } + } } + scan +} + +/// Whether validation skips this fixture file entirely, by filename. +/// +/// The skip list is a policy, not a failure: a driver that walks a corpus needs to tell a file it +/// is not meant to run from one it could not run, because only the second is a hole in coverage. +pub fn is_skipped_fixture(path: &Path) -> bool { + skip_test(path) } /// Check if a test should be skipped based on its filename /// Some tests are known to be problematic or take too long /// /// These tests are skipped by `revm`, so we also skip them. -fn skip_test(path: &Path) -> bool { +pub(crate) fn skip_test(path: &Path) -> bool { // A path with no file name or a non-UTF-8 name cannot match any entry on // the skip list, so it is simply not skipped (and must not panic). let Some(name) = path.file_name().and_then(|n| n.to_str()) else { @@ -401,7 +436,7 @@ fn check_evm_execution( /// revm 40 stores per-spec gas params in [`CfgEnv`]. Assigning `cfg.spec` alone /// leaves the previous params in place and drifts gas accounting. Shared by /// [`execute_test_suite`] and single-unit execution. -fn set_cfg_spec_and_mainnet_gas_params(cfg: &mut CfgEnv, spec: MegaSpecId) { +pub(crate) fn set_cfg_spec_and_mainnet_gas_params(cfg: &mut CfgEnv, spec: MegaSpecId) { cfg.set_spec_and_mainnet_gas_params(spec); } @@ -409,7 +444,7 @@ fn set_cfg_spec_and_mainnet_gas_params(cfg: &mut CfgEnv, spec: MegaS /// /// Single source of truth shared by [`execute_test_suite`] and single-unit /// execution so the validation and dump paths stay byte-identical. -fn configure_max_blobs(cfg: &mut CfgEnv) { +pub(crate) fn configure_max_blobs(cfg: &mut CfgEnv) { // OSAKA (which implies PRAGUE) caps blobs back at 6, while the PRAGUE-only // window allows 9 — so the OSAKA arm must be checked first and is distinct // from the pre-PRAGUE default of 6 despite the same value. @@ -427,7 +462,7 @@ fn configure_max_blobs(cfg: &mut CfgEnv) { /// An absent field defaults to `MegaETH`'s 6342 (intentional EEST behavior), /// but a present value that does not fit in a `u64` is a fixture error rather /// than a silent fallback to the default chain. -fn resolve_chain_id(env: &Env) -> Result { +pub(crate) fn resolve_chain_id(env: &Env) -> Result { match env.current_chain_id { None => Ok(6342), Some(id) => id.try_into().map_err(|_| { @@ -438,6 +473,12 @@ fn resolve_chain_id(env: &Env) -> Result { /// Execute a single test suite file containing multiple tests /// +/// Returns the number of *expectations judged*: one per `post` entry the file's units declare and +/// this run actually checked. A unit is not itself a judgement — a `post` of `{}`, a `post` whose +/// vectors are all empty, and a `post` naming only the skipped Constantinople spec each leave the +/// unit walked and nothing about it verified. Counting units instead would report such a file as +/// covered, which is the shape an empty corpus and a truncated one both have. +/// /// # Arguments /// * `path` - Path to the JSON test file /// * `elapsed` - Shared counter for total execution time @@ -448,9 +489,9 @@ pub fn execute_test_suite( elapsed: &Arc>, trace: bool, print_json_outcome: bool, -) -> Result<(), TestError> { +) -> Result { if skip_test(path) { - return Ok(()); + return Ok(0); } let path = path.to_string_lossy().into_owned(); @@ -465,6 +506,7 @@ pub fn execute_test_suite( kind: e.into(), })?; + let mut judged = 0usize; for (name, unit) in suite.0 { // Prepare initial state let cache_state = unit.state(); @@ -519,7 +561,12 @@ pub fn execute_test_suite( Err( TxBuildError::InvalidTransactionType | TxBuildError::UnexpectedException { .. }, - ) if test.expect_exception.is_some() => continue, + ) if test.expect_exception.is_some() => { + // The fixture asked for this failure and got it: the expectation was + // checked, even though nothing executed. + judged += 1; + continue; + } // Propagate the real underlying cause instead of masking // every failure as an unknown private key. Err(e) => { @@ -564,10 +611,11 @@ pub fn execute_test_suite( return Err(TestError { path, name, kind: e }); } + judged += 1; } } } - Ok(()) + Ok(judged) } /// Build the `MegaETH` external environment for a test unit, reproducing the @@ -577,7 +625,7 @@ pub fn execute_test_suite( /// Uses [`AHashBucketHasher`] so that bucket IDs match those recorded during /// `mega-evme replay` — a different hasher would map keys to different buckets /// and reproduce different gas. -fn external_envs_for( +pub(crate) fn external_envs_for( unit: &TestUnit, ) -> Result, TestErrorKind> { let mega_env = unit.mega_env.clone().unwrap_or_default(); @@ -592,7 +640,10 @@ fn external_envs_for( /// A key that does not fit in a `u64` could never be requested by the EVM /// (block numbers are `u64` on the `BLOCKHASH` path), so it is a fixture error /// rather than a silently dropped entry. -fn inject_block_hashes(state: &mut State, unit: &TestUnit) -> Result<(), TestErrorKind> { +pub(crate) fn inject_block_hashes( + state: &mut State, + unit: &TestUnit, +) -> Result<(), TestErrorKind> { let Some(hashes) = &unit.env.block_hashes else { return Ok(()); }; @@ -675,8 +726,8 @@ pub struct ExecutedUnit { pub output: Option, } -/// Execute a single [`TestUnit`] at transaction index 0 for the given spec, in -/// isolation, timing only the EVM `transact` call. +/// Execute a single [`TestUnit`] at the given transaction vector for the given +/// spec, in isolation, timing only the EVM `transact` call. /// /// This runs the same `MegaEVM` pipeline as [`execute_test_suite`] — including the /// reproduced external environment and the Optimism `BaseFeeVault` pruning. When @@ -684,6 +735,7 @@ pub struct ExecutedUnit { /// timed region); otherwise they are skipped for leaner repeated benchmarking. fn run_unit_once( unit: &TestUnit, + indexes: TxPartIndices, spec: &SpecName, compute_roots: bool, ) -> Result<(Duration, ExecutionResult, Option), TestErrorKind> @@ -699,7 +751,7 @@ fn run_unit_once( configure_max_blobs(&mut cfg); let block = unit.block_env(&cfg); - let tx = tx_env_at(unit, TxPartIndices { data: 0, gas: 0, value: 0 })?; + let tx = tx_env_at(unit, indexes)?; let cache = unit.state(); let mut state = @@ -727,16 +779,17 @@ fn run_unit_once( Ok((elapsed, result, validation)) } -/// Execute a single [`TestUnit`] at transaction index 0 for the given spec and -/// collect its canonical post-execution roots, gas, status, and output. +/// Execute a single [`TestUnit`] at the given transaction vector for the given +/// spec and collect its canonical post-execution roots, gas, status, and output. /// /// Returns the computed values instead of comparing them against an expectation; /// it is the dump-time counterpart to validation. pub fn execute_unit_collect( unit: &TestUnit, + indexes: TxPartIndices, spec: &SpecName, ) -> Result { - let (_elapsed, result, validation) = run_unit_once(unit, spec, true)?; + let (_elapsed, result, validation) = run_unit_once(unit, indexes, spec, true)?; let validation = validation.expect("roots requested"); Ok(ExecutedUnit { state_root: validation.state_root, @@ -748,16 +801,17 @@ pub fn execute_unit_collect( }) } -/// Execute a single [`TestUnit`] at transaction index 0 once, returning the time -/// spent in the EVM `transact` call together with the gas used and status. +/// Execute a single [`TestUnit`] at the given transaction vector once, returning +/// the time spent in the EVM `transact` call together with the gas used and status. /// /// The primitive behind [`bench_test_suite`] / `state-test --bench`: it measures /// EVM throughput in isolation (excluding root computation). pub fn time_unit_execution( unit: &TestUnit, + indexes: TxPartIndices, spec: &SpecName, ) -> Result<(Duration, u64, String), TestErrorKind> { - let (elapsed, result, _validation) = run_unit_once(unit, spec, false)?; + let (elapsed, result, _validation) = run_unit_once(unit, indexes, spec, false)?; Ok((elapsed, result.tx_gas_used(), execution_status(&result).to_string())) } @@ -855,57 +909,177 @@ pub fn bench_test_suite( ))); } - for _ in 0..warmup { - time_unit_execution(&unit, &spec) - .map_err(|e| fixture_err(format!("warmup {name}: {e}")))?; - } - let mut durations = Vec::with_capacity(runs as usize); - let mut gas_used = 0u64; - let mut status = String::new(); - for _ in 0..runs { - let (elapsed, gas, st) = time_unit_execution(&unit, &spec) - .map_err(|e| fixture_err(format!("run {name}: {e}")))?; - durations.push(elapsed); - gas_used = gas; - status = st; + // A unit is a family of transactions, one per vector its `post` names; benchmarking only + // index `{0,0,0}` would report a multi-vector fixture as though the other vectors were + // not there. Single-vector fixtures — every replay dump, and the whole EEST state-test + // corpus — keep their bare unit name and their one result. + let vectors = unit.vectors(); + let multi = vectors.len() > 1; + for indexes in vectors { + let name = if multi { vector_label(&name, indexes) } else { name.clone() }; + for _ in 0..warmup { + time_unit_execution(&unit, indexes, &spec) + .map_err(|e| fixture_err(format!("warmup {name}: {e}")))?; + } + let mut durations = Vec::with_capacity(runs as usize); + let mut gas_used = 0u64; + let mut status = String::new(); + for _ in 0..runs { + let (elapsed, gas, st) = time_unit_execution(&unit, indexes, &spec) + .map_err(|e| fixture_err(format!("run {name}: {e}")))?; + durations.push(elapsed); + gas_used = gas; + status = st; + } + durations.sort_unstable(); + let median = durations[durations.len() / 2]; + let min = durations[0]; + let mean = durations.iter().sum::() / durations.len() as u32; + results.push(UnitBench { + name, + gas_used, + success: status == "success", + runs, + min, + median, + mean, + }); } - durations.sort_unstable(); - let median = durations[durations.len() / 2]; - let min = durations[0]; - let mean = durations.iter().sum::() / durations.len() as u32; - results.push(UnitBench { - name, - gas_used, - success: status == "success", - runs, - min, - median, - mean, - }); } Ok(results) } +/// Names one vector of a multi-vector unit, for a per-vector report line. +/// +/// Only used when a unit actually has more than one vector, so a single-vector fixture's +/// reported name stays exactly its key in the suite. +pub fn vector_label(name: &str, indexes: TxPartIndices) -> String { + format!("{name}[d={},g={},v={}]", indexes.data, indexes.gas, indexes.value) +} + +/// What a keep-going driver observed for one fixture unit. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UnitStatus { + /// The unit ran to completion. + Ok, + /// The unit failed with a structured error, before or during execution. + Error(String), + /// Executing the unit panicked; the payload is the captured panic report. + /// + /// Distinct from [`UnitStatus::Error`] on purpose: an error is a fixture the runner declined + /// to execute, a panic is an internal invariant (a `debug_assert!`, an overflow check) that + /// the fixture broke. Only the second is a defect in the code under test. + Panic(String), +} + +impl UnitStatus { + /// Whether the unit ran to completion. + pub const fn is_ok(&self) -> bool { + matches!(self, Self::Ok) + } + + /// The failure report, or `None` when the unit ran to completion. + pub fn message(&self) -> Option<&str> { + match self { + Self::Ok => None, + Self::Error(m) | Self::Panic(m) => Some(m), + } + } +} + +/// What happened to one transaction vector of a fixture unit under a keep-going fill. +#[derive(Debug, Clone)] +pub struct UnitFillResult { + /// The unit's key in the fixture's test-suite map, suffixed with this vector's indexes when + /// the unit declares more than one (see [`vector_label`]). + pub name: String, + /// The transaction vector this entry reports on. + pub indexes: TxPartIndices, + /// Whether the vector filled, failed, or panicked. + pub status: UnitStatus, +} + +/// Per-vector outcome of filling one fixture file with `keep_going` set. +#[derive(Debug, Clone, Default)] +pub struct FillReport { + /// One entry per transaction vector of every unit in the file, in the file's own order. + /// + /// A vector rather than a unit, because a unit is a family of transactions and every other + /// mode judges each of them separately; counting units here would make a fill sweep and a + /// differential sweep report different totals over the same corpus. + pub vectors: Vec, +} + +impl FillReport { + /// Number of transaction vectors whose expectation was recomputed and written. + pub fn filled(&self) -> usize { + self.vectors.iter().filter(|v| v.status.is_ok()).count() + } + + /// Number of vectors that failed or panicked and kept their original expectation. + pub fn failed(&self) -> usize { + self.vectors.len() - self.filled() + } +} + /// Compute and write the `post` expectation for every unit in a fixture file, /// in place — the offline analog of `--dump-fixture`'s post-fill step, for a /// fixture that has no `post` yet (a hand-built or `prestateTracer`-snapshot /// case). It re-uses the same `execute_unit_collect` + [`Test::for_dump`] the /// dump path uses, so the result is a self-validating fixture that -/// [`execute_test_suite`] checks like any other. Returns the number of units -/// filled. +/// [`execute_test_suite`] checks like any other. Returns the number of +/// transaction vectors filled. /// /// `spec_override` selects the spec to execute/record under; when `None`, the /// unit's single existing `post` spec is used (so a fixture with an empty `post` /// must pass a spec). /// -/// Filling replaces the unit's entire `post` map (single spec, single index -/// `{0,0,0}`) with circularly-derived expectations, so a unit that already has a -/// non-empty `post` is refused unless `force` is set. +/// Filling replaces the unit's entire `post` map (a single spec, one entry per +/// transaction vector the unit declares) with circularly-derived expectations, +/// so a unit that already has a non-empty `post` is refused unless `force` is +/// set. +/// +/// The first unit that fails aborts the whole file. Use +/// [`fill_test_suite_keep_going`] to fill the rest of a file whose units fail +/// independently. pub fn fill_test_suite( path: &Path, spec_override: Option, force: bool, ) -> Result { + let report = fill_suite(path, spec_override, force, false)?; + Ok(report.filled()) +} + +/// Fill every unit of a fixture file, recording each unit's failure instead of +/// aborting the file at the first one. +/// +/// A unit that fails or panics keeps its original `post` and is reported in the +/// returned [`FillReport`], one entry per transaction vector; the units that +/// succeeded are still written. This is what lets a corpus sweep run a +/// multi-unit fixture without splitting it into one file per unit first: an EEST +/// fixture holds one unit per (test, fork) pair, and under a spec override the +/// ones that the runner declines are exactly the ones a split sweep would have +/// counted separately. +/// +/// Errors that belong to the *file* rather than to a unit — an unreadable or +/// unparseable fixture, a filename on the validation skip list, a failed write — +/// are still returned as errors: there is no per-unit result to record them on. +pub fn fill_test_suite_keep_going( + path: &Path, + spec_override: Option, + force: bool, +) -> Result { + fill_suite(path, spec_override, force, true) +} + +/// Shared body of [`fill_test_suite`] and [`fill_test_suite_keep_going`]. +fn fill_suite( + path: &Path, + spec_override: Option, + force: bool, + keep_going: bool, +) -> Result { let path_str = path.to_string_lossy().into_owned(); let fixture_err = |msg: String| TestError { name: "fill".to_string(), @@ -930,67 +1104,160 @@ pub fn fill_test_suite( kind: e.into(), })?; - let mut filled = std::collections::BTreeMap::new(); + let mut report = FillReport::default(); + let mut out = std::collections::BTreeMap::new(); + let mut any_filled = false; for (name, mut unit) in suite.0 { - if !force && unit.post.values().any(|tests| !tests.is_empty()) { - return Err(fixture_err(format!( - "unit {name} already has a post expectation; pass --force to overwrite" - ))); - } - let spec = match spec_override { - Some(s) => s, - None => { - let mut specs = unit.post.keys(); - match (specs.next(), specs.next()) { - (Some(s), None) => *s, - _ => { - return Err(fixture_err(format!( - "unit {name} has no single post spec; pass --bench-spec to fill" - ))) - } - } + let results = fill_unit(&mut unit, spec_override, force); + // A unit's `post` is rewritten as a whole, so it either filled for every vector it + // declares or for none of them. + let multi = results.len() > 1; + any_filled |= results.iter().all(|(_, status)| status.is_ok()); + for (indexes, status) in results { + if !keep_going && !status.is_ok() { + let detail = status.message().unwrap_or("failed"); + return Err(fixture_err(format!("unit {name}: {detail}"))); } - }; - // Reject an unmapped spec at selection time, so the error names the - // unit instead of surfacing from deep inside execution. - if spec == SpecName::Unknown { - return Err(fixture_err(format!( - "unit {name} selects an unknown spec; pass a valid --bench-spec" - ))); - } - // Validation skips Constantinople (mirroring upstream revme), so a post - // recorded under it would never be checked. - if spec == SpecName::Constantinople { - return Err(fixture_err(format!( - "unit {name}: validation skips Constantinople; a post filled under it \ - would never be checked" - ))); + let label = if multi { vector_label(&name, indexes) } else { name.clone() }; + report.vectors.push(UnitFillResult { name: label, indexes, status }); } - let executed = execute_unit_collect(&unit, &spec) - .map_err(|e| fixture_err(format!("execute {name}: {e}")))?; - unit.out = executed.output.clone(); - let test = Test::for_dump( - executed.state_root, - executed.logs_root, - executed.gas_used, - executed.status, - ); - unit.post = std::collections::BTreeMap::from([(spec, vec![test])]); - filled.insert(name, unit); + out.insert(name, unit); + } + + // Nothing changed: leave the file's bytes (and mtime) alone rather than + // rewriting it with a re-serialization of what it already held. + if !any_filled { + return Ok(report); } - let count = filled.len(); - let json = serde_json::to_string_pretty(&TestSuite(filled)) + let json = serde_json::to_string_pretty(&TestSuite(out)) .map_err(|e| fixture_err(format!("serialize: {e}")))?; // Write to a sibling temp file and rename so an interrupted write cannot // truncate the original fixture. let tmp = path.with_extension("json.tmp"); std::fs::write(&tmp, json).map_err(|e| fixture_err(format!("write: {e}")))?; std::fs::rename(&tmp, path).map_err(|e| fixture_err(format!("rename: {e}")))?; - Ok(count) + Ok(report) } -fn prune_base_fee_vault_changes(db: &mut State) { +/// Recompute one unit's `post` in place, or report why each of its vectors could not be filled. +/// +/// Returns one entry per transaction vector the unit declares, in ascending vector order, so a +/// caller's tally counts what every other mode counts. A unit's `post` map is rewritten in one +/// step — writing only the vectors that succeeded would delete the expectations of the ones that +/// did not — so a failure anywhere in the unit leaves all of its vectors unfilled, and each of +/// them says so. +/// +/// Execution runs under [`panic_capture::catch`] so that a `debug_assert!` a +/// single fixture trips is that fixture's result rather than the run's. +fn fill_unit( + unit: &mut TestUnit, + spec_override: Option, + force: bool, +) -> Vec<(TxPartIndices, UnitStatus)> { + let vectors = unit.vectors(); + // A reason the whole unit cannot be filled is a reason none of its vectors can. + let reject_all = |msg: &str| -> Vec<(TxPartIndices, UnitStatus)> { + vectors.iter().map(|&i| (i, UnitStatus::Error(msg.to_string()))).collect() + }; + + if !force && unit.post.values().any(|tests| !tests.is_empty()) { + return reject_all("already has a post expectation; pass --force to overwrite"); + } + let spec = match spec_override { + Some(s) => s, + None => { + let mut specs = unit.post.keys(); + match (specs.next(), specs.next()) { + (Some(s), None) => *s, + _ => return reject_all("has no single post spec; pass --bench-spec to fill"), + } + } + }; + // Reject an unmapped spec at selection time, so the error names the unit + // instead of surfacing from deep inside execution. + if spec == SpecName::Unknown { + return reject_all("selects an unknown spec; pass a valid --bench-spec"); + } + // Validation skips Constantinople (mirroring upstream revme), so a post + // recorded under it would never be checked. + if spec == SpecName::Constantinople { + return reject_all( + "validation skips Constantinople; a post filled under it would never be checked", + ); + } + + // One expectation per vector the unit declares, each carrying its own `indexes`. Recording a + // single `{0,0,0}` entry would delete the other vectors' expectations along with the tests + // that used them, and `--force` — which exists to overwrite a stale expectation — would be + // the one flag that makes the loss silent. + // + // Every vector is executed even once one has failed: the verdict is per vector, and stopping + // at the first failure would leave the rest of them with none. + let mut runs = Vec::with_capacity(vectors.len()); + for &indexes in &vectors { + let result = panic_capture::catch(|| execute_unit_collect(unit, indexes, &spec)) + .map_err(UnitStatus::Panic) + .and_then(|r| { + r.map_err(|e| { + UnitStatus::Error(format!( + "execute [d={},g={},v={}]: {e}", + indexes.data, indexes.gas, indexes.value + )) + }) + }); + runs.push((indexes, result)); + } + if runs.iter().any(|(_, r)| r.is_err()) { + return runs + .into_iter() + .map(|(indexes, result)| match result { + Err(status) => (indexes, status), + Ok(_) => ( + indexes, + UnitStatus::Error( + "not filled: another vector of this unit failed, and a unit's post is \ + written as a whole" + .to_string(), + ), + ), + }) + .collect(); + } + + let mut tests = Vec::with_capacity(runs.len()); + let mut outputs = Vec::with_capacity(runs.len()); + for (indexes, result) in runs { + let executed = result.expect("every vector succeeded"); + outputs.push(executed.output); + tests.push(Test::for_dump( + indexes, + executed.state_root, + executed.logs_root, + executed.gas_used, + executed.status, + )); + } + + // `out` is one field for the whole unit, so it can only describe an output every vector + // produced. Vectors that agree — the single-vector case, and a multi-vector unit whose + // variations do not change what the transaction returns — keep it. Vectors that disagree have + // no `out` this schema can express: recording one vector's output would assert it for all, + // and clearing the field would drop an expectation the fixture is entitled to. Per-vector + // output is a schema change, so the unit is refused instead. + let (first, rest) = outputs.split_first().expect("a unit declares at least one vector"); + if !rest.iter().all(|output| output == first) { + return reject_all( + "vectors return different output, and `out` is one field for the whole unit; this \ + fixture schema has no per-vector output to record", + ); + } + unit.out = first.clone(); + unit.post = std::collections::BTreeMap::from([(spec, tests)]); + vectors.iter().map(|&i| (i, UnitStatus::Ok)).collect() +} + +pub(crate) fn prune_base_fee_vault_changes(db: &mut State) { let base_fee_vault = address!("0x4200000000000000000000000000000000000019"); db.cache.accounts.remove(&base_fee_vault); } @@ -1063,6 +1330,9 @@ impl TestRunnerConfig { #[derive(Clone)] struct TestRunnerState { n_errors: Arc, + /// Expectations the workers actually checked, one per `post` entry judged. A run that checked + /// none validated nothing, however many files it walked and however many units they held. + n_judged: Arc, console_bar: Arc, queue: Arc)>>, elapsed: Arc>, @@ -1073,6 +1343,7 @@ impl TestRunnerState { let n_files = test_files.len(); Self { n_errors: Arc::new(AtomicUsize::new(0)), + n_judged: Arc::new(AtomicUsize::new(0)), console_bar: Arc::new(ProgressBar::with_draw_target( Some(n_files as u64), ProgressDrawTarget::stdout(), @@ -1106,10 +1377,15 @@ fn run_test_worker(state: TestRunnerState, config: TestRunnerConfig) -> Result<( state.console_bar.inc(1); - if let Err(err) = result { - state.n_errors.fetch_add(1, Ordering::SeqCst); - if !config.keep_going { - return Err(err); + match result { + Ok(judged) => { + state.n_judged.fetch_add(judged, Ordering::SeqCst); + } + Err(err) => { + state.n_errors.fetch_add(1, Ordering::SeqCst); + if !config.keep_going { + return Err(err); + } } } } @@ -1179,6 +1455,22 @@ pub fn run( let n_errors = state.n_errors.load(Ordering::SeqCst); let n_thread_errors = thread_errors.len(); + let n_judged = state.n_judged.load(Ordering::SeqCst); + + // A run that checked no expectation validated nothing, and "0 errors out of 0" is a truthful + // report of that. An empty corpus, one whose every file is on the skip list, and one whose + // units declare no `post` to check all reach this point the same way, and none of them may + // print "All tests passed!". + if n_errors == 0 && n_thread_errors == 0 && n_judged == 0 { + return Err(TestError { + name: "summary".to_string(), + path: String::new(), + kind: TestErrorKind::FixtureError(format!( + "no fixture expectation was validated across {n_files} file(s); the corpus is \ + empty, entirely skipped, or its units declare no post expectation" + )), + }); + } if n_errors == 0 && n_thread_errors == 0 { println!("All tests passed!"); diff --git a/crates/mega-state-test/src/types/test.rs b/crates/mega-state-test/src/types/test.rs index ec1e7eb7..54ed9116 100644 --- a/crates/mega-state-test/src/types/test.rs +++ b/crates/mega-state-test/src/types/test.rs @@ -62,13 +62,19 @@ impl Test { /// Construct a `post` expectation for a dumped replay fixture. /// /// Records the canonical state/logs roots plus the explicit `MegaETH` gas and - /// status expectations, at transaction index 0. `expect_exception`, - /// `post_state`, `state`, and `txbytes` are left empty/`None` — they are not - /// part of a replay-derived fixture. - pub fn for_dump(hash: B256, logs: B256, mega_gas_used: u64, mega_status: String) -> Self { + /// status expectations, for the transaction vector the caller executed. + /// `expect_exception`, `post_state`, `state`, and `txbytes` are left + /// empty/`None` — they are not part of a replay-derived fixture. + pub fn for_dump( + indexes: TxPartIndices, + hash: B256, + logs: B256, + mega_gas_used: u64, + mega_status: String, + ) -> Self { Self { expect_exception: None, - indexes: TxPartIndices { data: 0, gas: 0, value: 0 }, + indexes, hash, post_state: HashMap::default(), logs, diff --git a/crates/mega-state-test/src/types/test_unit.rs b/crates/mega-state-test/src/types/test_unit.rs index 8e1a65e6..58b1d9a8 100644 --- a/crates/mega-state-test/src/types/test_unit.rs +++ b/crates/mega-state-test/src/types/test_unit.rs @@ -2,7 +2,7 @@ use mega_evm::{revm::primitives::eip4844, MegaSpecId}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; -use super::{AccountInfo, Env, MegaEnv, SpecName, Test, TransactionParts}; +use super::{AccountInfo, Env, MegaEnv, SpecName, Test, TransactionParts, TxPartIndices}; use mega_evm::revm::{ context::{block::BlockEnv, cfg::CfgEnv}, database::CacheState, @@ -74,6 +74,28 @@ pub struct TestUnit { } impl TestUnit { + /// Every transaction vector this unit defines, ascending and deduplicated. + /// + /// A state-test unit is not one transaction but a family of them: `transaction` holds arrays + /// of `data`, `gasLimit` and `value`, and each `post` entry names the combination it pins + /// through its `indexes`. Validation runs every one of those entries, so any other consumer + /// that judges or rewrites a unit has to enumerate the same set — taking index `{0,0,0}` and + /// calling it "the unit" silently drops whatever the other vectors would have shown. + /// + /// A unit with no `post` at all (a hand-built or snapshot-derived fixture) declares no + /// vector; index `{0,0,0}` is the only one such a fixture can mean, and it is what a fill + /// records. + pub fn vectors(&self) -> Vec { + let mut vectors: Vec = + self.post.values().flatten().map(|test| test.indexes).collect(); + vectors.sort_unstable(); + vectors.dedup(); + if vectors.is_empty() { + vectors.push(TxPartIndices { data: 0, gas: 0, value: 0 }); + } + vectors + } + /// Prepare the state from the test unit. /// /// This function uses [`TestUnit::pre`] to prepare the pre-state from the test unit. diff --git a/crates/mega-state-test/src/types/transaction.rs b/crates/mega-state-test/src/types/transaction.rs index 238669ff..7d0b27ee 100644 --- a/crates/mega-state-test/src/types/transaction.rs +++ b/crates/mega-state-test/src/types/transaction.rs @@ -102,7 +102,7 @@ impl TransactionParts { } /// Transaction part indices. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct TxPartIndices { /// Index into the data array diff --git a/crates/mega-state-test/tests/chaos_mode.rs b/crates/mega-state-test/tests/chaos_mode.rs new file mode 100644 index 00000000..1e92cbfa --- /dev/null +++ b/crates/mega-state-test/tests/chaos_mode.rs @@ -0,0 +1,512 @@ +//! End-to-end tests for the chaos sweep. +//! +//! The corpus run is what the mode is for, and it cannot be a unit test: it needs the EEST corpus +//! and takes seconds. What can be pinned here is everything the corpus run's conclusions rest on — +//! that the same seed reproduces the same run, that different seeds are actually different, that +//! narrowing the shape filter narrows and nothing else, that the read-only control is read-only, +//! and that a sweep which mutated nothing fails its own gate rather than reporting a clean corpus. + +use mega_evm::FORBIDDEN_FRAME_INIT_REWRITE; +use state_test::{ + chaos::{ + chaos_test_suite, chaos_unit, run_chaos, vector_seed, ChaosClass, ChaosRunConfig, + ChaosShape, ChaosTally, ShapeFilter, + }, + diff::{execute_unit_in_mode, execute_unit_reporting_chaos, RunMode}, + runner::FixtureScan, + types::{SpecName, TestUnit, TxPartIndices}, +}; +use std::path::{Path, PathBuf}; + +const SENDER: &str = "0x1000000000000000000000000000000000000001"; +const CALLEE: &str = "0x2000000000000000000000000000000000000002"; +const INNER: &str = "0x3000000000000000000000000000000000000003"; +/// An address with no code and no `pre` entry, so a `CALL` to it comes back out of frame init +/// without a frame ever being built. +const EMPTY: &str = "0x4000000000000000000000000000000000000004"; + +/// The single transaction vector these hand-built fixtures declare. +const VECTOR_0: TxPartIndices = TxPartIndices { data: 0, gas: 0, value: 0 }; + +/// `SSTORE(1, 1); CALL(0x2710 gas, INNER, no value, no args, no return); POP; LOG0(0, 0); STOP`. +/// +/// One of everything a callback can be handed: a storage write, a child frame, a log, and enough +/// plain opcodes between them for the stream to reach every callback family. +fn callee_code() -> String { + format!("0x600160015560006000600060006000 73{} 612710 f1 50 60006000a000", &INNER[2..]) + .replace(' ', "") +} + +/// `SSTORE(2, 2); CREATE(0, 0, 0); POP; STOP` — a child frame the create callbacks see. +const INNER_CODE: &str = "0x60026002556000600060006000f05000"; + +/// [`callee_code`] with two things the shape pool needs and the plain fixture does not offer: a +/// slot set and cleared again, so every frame ends holding a refund the EVM produced, and a `CALL` +/// that asks for a word of return data, so a finished call outcome has a range to rewrite. +fn refunding_callee_code() -> String { + format!( + "0x6001600155 6000600155 6020 6000 6000 6000 6000 73{} 612710 f1 50 60006000a000", + &INNER[2..] + ) + .replace(' ', "") +} + +fn unit_json() -> serde_json::Value { + unit_json_with(callee_code()) +} + +fn unit_json_with(callee: String) -> serde_json::Value { + serde_json::json!({ + "env": { + "currentChainID": "0x18c6", + "currentCoinbase": "0x3000000000000000000000000000000000000009", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x10", + "currentTimestamp": "0x3e8", + "currentBaseFee": "0x0", + "currentRandom": "0x0000000000000000000000000000000000000000000000000000000000000001", + "currentExcessBlobGas": "0x0" + }, + "pre": { + SENDER: { "balance": "0xde0b6b3a7640000", "code": "0x", "nonce": "0x0", "storage": {} }, + CALLEE: { "balance": "0x0", "code": callee, "nonce": "0x0", "storage": {} }, + INNER: { "balance": "0x0", "code": INNER_CODE, "nonce": "0x0", "storage": {} }, + }, + "transaction": { + "type": 0, + "data": ["0x"], + "gasLimit": ["0x1e8480"], + "gasPrice": "0x0", + "nonce": "0x0", + "secretKey": "0x0000000000000000000000000000000000000000000000000000000000000000", + "sender": SENDER, + "to": CALLEE, + "value": ["0x0"] + }, + "post": {} + }) +} + +fn unit() -> TestUnit { + serde_json::from_value(unit_json()).expect("valid unit json") +} + +/// The fixture the ledger-gate test uses — see [`refunding_callee_code`]. +fn refunding_unit() -> TestUnit { + serde_json::from_value(unit_json_with(refunding_callee_code())).expect("valid unit json") +} + +/// [`callee_code`] pointed at an account with no code, so its `CALL` returns out of frame init +/// with no child frame ever built. +fn empty_target_callee_code() -> String { + format!("0x600160015560006000600060006000 73{} 612710 f1 50 60006000a000", &EMPTY[2..]) + .replace(' ', "") +} + +/// The fixture the refused-shape test uses: the cheapest way to reach a result frame init +/// produced, which is the only kind [`ChaosShape::MoveInitResultClass`] fires on. +fn init_result_unit() -> TestUnit { + serde_json::from_value(unit_json_with(empty_target_callee_code())).expect("valid unit json") +} + +/// The chaos run's tally for `unit` under `seed` and `filter`. +fn mutations(seed: u64, filter: ShapeFilter) -> Vec<(String, u32)> { + let run = + execute_unit_in_mode(&unit(), VECTOR_0, &SpecName::Rex7, RunMode::Chaos { seed, filter }) + .expect("the fixture executes"); + let tally = run.chaos.expect("a chaos run reports its tally"); + let mut applied: Vec<(String, u32)> = + tally.applied.iter().map(|(k, v)| ((*k).to_string(), *v)).collect(); + applied.sort(); + applied +} + +/// The same seed produces the same run, mutation for mutation. +/// +/// Everything else this mode claims rests on this: a flagged vector's report line is only a +/// reproduction if re-running it reproduces. +#[test] +fn test_a_seed_reproduces_its_own_run() { + let first = mutations(0xC0FFEE, ShapeFilter::default()); + let second = mutations(0xC0FFEE, ShapeFilter::default()); + assert!(!first.is_empty(), "the fixture must reach enough callbacks to mutate something"); + assert_eq!(first, second, "the same seed must produce the same mutations"); +} + +/// Different seeds produce different runs. +/// +/// The mirror of the test above, and the one that fails if the seed stops reaching the decision +/// stream at all — a generator wired to ignore its seed would pass reproducibility perfectly. +#[test] +fn test_different_seeds_produce_different_runs() { + let seeds = [1u64, 2, 3, 4, 5, 6, 7, 8]; + let runs: Vec<_> = seeds.iter().map(|s| mutations(*s, ShapeFilter::default())).collect(); + assert!( + runs.iter().any(|run| *run != runs[0]), + "eight seeds that all mutate identically means the seed reaches nothing: {runs:?}", + ); +} + +/// A vector's seed depends on every part of its identity, and on the global seed. +#[test] +fn test_a_vector_seed_separates_every_part_of_the_identity() { + let a = Path::new("a.json"); + let base = vector_seed(7, a, "unit", VECTOR_0); + let others = [ + vector_seed(8, a, "unit", VECTOR_0), + vector_seed(7, Path::new("b.json"), "unit", VECTOR_0), + vector_seed(7, a, "other", VECTOR_0), + vector_seed(7, a, "unit", TxPartIndices { data: 1, gas: 0, value: 0 }), + vector_seed(7, a, "unit", TxPartIndices { data: 0, gas: 1, value: 0 }), + vector_seed(7, a, "unit", TxPartIndices { data: 0, gas: 0, value: 1 }), + ]; + for (i, other) in others.iter().enumerate() { + assert_ne!(base, *other, "identity component {i} does not reach the seed"); + } + assert_eq!(base, vector_seed(7, a, "unit", VECTOR_0), "and the seed is a function"); +} + +/// The directory a fixture is reached through does not reach the seed. +/// +/// A seed is only worth reporting if the machine that reads it can re-run what produced it. The +/// same corpus is checked out at a different root on every machine, `--corpus-dir` names whatever +/// the caller likes, a sweep can fall back to a private copy, and a fixture under triage is passed +/// on its own rather than walked to. Every one of those changes the path and none of them changes +/// the fixture. +#[test] +fn test_a_vector_seed_ignores_the_directory_the_fixture_was_reached_through() { + let roots = [ + Path::new("/checkout-a/tests/GeneralStateTests/x.json"), + Path::new("/somewhere/else/entirely/x.json"), + Path::new("/tmp/.private.4928/x.json"), + Path::new("x.json"), + ]; + let seeds: Vec = roots.iter().map(|p| vector_seed(7, p, "unit", VECTOR_0)).collect(); + for (root, seed) in roots.iter().zip(&seeds) { + assert_eq!( + *seed, + seeds[0], + "{}: the path above the file name reached the seed", + root.display() + ); + } +} + +/// The same, through the sweep that actually derives the seeds rather than through the function. +/// +/// `vector_seed` taking a stable identity is only half of it; the other half is that the sweep +/// hands it one. This writes one fixture into two different directories and runs the real entry +/// point over each. +#[test] +fn test_the_sweep_derives_the_same_seeds_from_two_different_roots() { + let seeds = |dir: &str| -> Vec { + let path = write_suite_under(dir, "chaos_mode_same_seed.json"); + let (verdicts, _) = chaos_test_suite(&path, &SpecName::Rex7, 11, ShapeFilter::default()) + .expect("the fixture must be readable"); + assert!(!verdicts.is_empty(), "the fixture must produce at least one vector"); + verdicts.iter().map(|v| v.seed).collect() + }; + + assert_eq!( + seeds("chaos_mode_root_a"), + seeds("chaos_mode_root_b/nested"), + "the same fixture under two roots must be mutated the same way", + ); +} + +/// Narrowing the filter keeps every surviving mutation where the full run put it. +/// +/// This is what makes narrowing a triage tool rather than a different experiment: the shapes that +/// remain are applied at the same callbacks, so a flagged mutation is still there to be found. +#[test] +fn test_narrowing_the_filter_keeps_the_surviving_mutations() { + // A seed whose full run draws both of the shapes the narrowed one keeps; which seeds those are + // is a function of the pool's size, so a shape added to the pool can move it. + const SEED: u64 = 2; + let full = mutations(SEED, ShapeFilter::default()); + let only = [ChaosShape::InjectGas, ChaosShape::DrainGas]; + let narrowed = mutations(SEED, ShapeFilter::only(&only)); + let kept: Vec<_> = only.iter().map(|s| s.label()).collect(); + + assert!(!narrowed.is_empty(), "the narrowed run must still mutate something"); + for (shape, _) in &narrowed { + assert!(kept.contains(&shape.as_str()), "{shape} is not in the filter"); + } + for (shape, count) in &full { + if !kept.contains(&shape.as_str()) { + continue; + } + let narrowed_count = narrowed.iter().find(|(s, _)| s == shape).map_or(0, |(_, c)| *c); + assert!( + narrowed_count >= *count, + "{shape}: narrowing dropped a mutation the full run made ({count} -> {narrowed_count})", + ); + } +} + +/// ★ Every shape the ledger gate is stated over really does move the ledger, run on its own. +/// +/// [`ChaosClass::LedgerBlind`] fires when a run applied one of these and the ledger is still +/// all-zero. That verdict is only meaningful if the premise holds — so this checks the premise +/// directly, shape by shape, rather than trusting the partition. Five of the shapes here +/// (`grow_memory_free`, `move_outcome_metadata`, `cancel_refund_edit`, `skip_opcode`, +/// `rewrite_return_data`) were added because the shim did *not* book them, and this is the test +/// that would have said so. +#[test] +fn test_every_always_booked_shape_moves_the_ledger() { + let always_booked: Vec = ChaosShape::ALL + .into_iter() + .filter(|s| s.is_always_booked() && *s != ChaosShape::MoveInitResultClass) + .collect(); + assert!(!always_booked.is_empty(), "the gate must be stated over something"); + + let unit = refunding_unit(); + for shape in always_booked { + let filter = ShapeFilter::only(&[shape]); + let mut reached = 0u32; + // A narrow filter only lands where the stream happens to draw that shape, so sweep seeds + // until enough of them do. A shape no seed reaches is a hole in the pool, not a pass. + for seed in 0u64..1_024 { + let run = execute_unit_in_mode( + &unit, + VECTOR_0, + &SpecName::Rex7, + RunMode::Chaos { seed, filter }, + ) + .expect("the fixture executes"); + if run.chaos.as_ref().expect("a chaos run reports its tally").total() == 0 { + continue; + } + reached += 1; + assert!( + !run.ledger.is_zero(), + "{} applied under seed {seed} and the shim booked nothing: {:?}", + shape.label(), + run.ledger, + ); + if reached == 3 { + break; + } + } + assert_eq!(reached, 3, "{}: too few seeds in the sweep applied it", shape.label()); + } +} + +/// The one always-booked shape a successful run cannot be measured on, and the stronger premise +/// that stands in for it. +/// +/// The shim answers [`ChaosShape::MoveInitResultClass`] by declining the transaction, so there is +/// no receipt and no ledger to read — and nothing for the ledger gate to be stated over. What +/// replaces it is stronger than an all-zero-ledger check: a run that never executed cannot reach a +/// block at all. What this pins is that the decline really is the shim's refusal, that the sweep +/// classifies it as the designed outcome rather than as a defect, and that the tally still reports +/// what such a run mutated even though it produced nothing. +#[test] +fn test_the_refused_shape_is_declined_and_counted() { + let unit = init_result_unit(); + let filter = ShapeFilter::only(&[ChaosShape::MoveInitResultClass]); + let mut reached = 0u32; + for seed in 0u64..1_024 { + let mut applied = ChaosTally::default(); + let run = execute_unit_reporting_chaos( + &unit, + VECTOR_0, + &SpecName::Rex7, + RunMode::Chaos { seed, filter }, + &mut applied, + ); + if applied.total() == 0 { + assert!(run.is_ok(), "a run that mutated nothing must execute: {:?}", run.err()); + continue; + } + reached += 1; + let error = run.expect_err("a refused rewrite declines the transaction").to_string(); + assert!( + error.contains(FORBIDDEN_FRAME_INIT_REWRITE), + "seed {seed} was declined for something other than the refusal: {error}", + ); + let verdict = chaos_unit(&unit, VECTOR_0, &SpecName::Rex7, seed, filter); + assert_eq!( + verdict.class, + ChaosClass::Refused, + "a refusal is the designed outcome, not a disagreement about whether the \ + transaction executes", + ); + assert!(!verdict.class.is_failure(), "a refusal must not fail the sweep"); + assert_eq!( + verdict.applied.total(), + applied.total(), + "a declined run still has to report what it mutated", + ); + if reached == 3 { + break; + } + } + assert_eq!(reached, 3, "too few seeds in the sweep reached a result frame init produced"); +} + +/// The partition the gate rests on is not vacuous in either direction. +/// +/// A gate stated over every shape would fail on a working shim — several shapes are booked only +/// when what they moved still reaches something — and one stated over none would never fire. +#[test] +fn test_the_always_booked_partition_is_not_vacuous() { + let (booked, conditional): (Vec<_>, Vec<_>) = + ChaosShape::ALL.into_iter().partition(|s| s.is_always_booked()); + assert!(!booked.is_empty(), "the gate must have shapes to fire on"); + assert!( + !conditional.is_empty(), + "a shape whose booking is conditional must stay out of the gate; if none is left, the \ + gate should be stated over the whole pool instead", + ); + for shape in [ChaosShape::InjectGas, ChaosShape::RaiseResultGas, ChaosShape::WriteReservoir] { + assert!( + !shape.is_always_booked(), + "{} is booked only when what it moved still reaches something", + shape.label(), + ); + } +} + +/// Every shape label round-trips, and an unknown one is refused with a message that lists them. +#[test] +fn test_every_shape_label_parses_and_an_unknown_one_does_not() { + for shape in ChaosShape::ALL { + assert_eq!(ChaosShape::parse(shape.label()), Ok(shape)); + } + let error = ChaosShape::parse("not_a_shape").expect_err("an unknown label must be refused"); + assert!(error.contains("inject_gas"), "the message must list the known shapes: {error}"); +} + +/// The read-only control leaves the execution exactly as it found it. +/// +/// Checked here on one fixture and over the whole corpus by the sweep itself; this is the version +/// that fails in a unit test run rather than only in a five-second corpus sweep. +#[test] +fn test_the_control_inspector_changes_nothing() { + let unit = unit(); + let plain = execute_unit_in_mode(&unit, VECTOR_0, &SpecName::Rex7, RunMode::Plain) + .expect("the fixture executes"); + let observed = execute_unit_in_mode(&unit, VECTOR_0, &SpecName::Rex7, RunMode::Observe) + .expect("the fixture executes"); + + assert!(observed.observed > 0, "the control must actually be handed callbacks"); + assert!(observed.ledger.is_zero(), "and must book nothing: {:?}", observed.ledger); + assert!( + state_test::diff::compare(&observed.outcome, &plain.outcome).is_empty(), + "an observation-only inspector moved something", + ); +} + +/// The declared control produces the run the other two produce. +/// +/// Three runs of one vector: no inspector, the control measured, and the control declared +/// `TrustedObserver` so the shim delegates without measuring. The declaration's whole claim is +/// that the third is the first, and the field-by-field comparison is what says so — the receipt, +/// the four resource dimensions, the roots, and everything else `SpecOutcome` carries. +/// +/// The callback count is asserted equal too, because every other assertion here would also pass +/// for a fast path that skipped the inspector rather than the measurement. +#[test] +fn test_the_declared_control_changes_nothing_either() { + let unit = unit(); + let plain = execute_unit_in_mode(&unit, VECTOR_0, &SpecName::Rex7, RunMode::Plain) + .expect("the fixture executes"); + let observed = execute_unit_in_mode(&unit, VECTOR_0, &SpecName::Rex7, RunMode::Observe) + .expect("the fixture executes"); + let trusted = execute_unit_in_mode(&unit, VECTOR_0, &SpecName::Rex7, RunMode::ObserveTrusted) + .expect("the fixture executes"); + + assert!(trusted.observed > 0, "the declared control must still be handed callbacks"); + assert_eq!( + trusted.observed, observed.observed, + "and the same ones the measured control was handed", + ); + assert!(trusted.ledger.is_zero(), "the fast path books nothing: {:?}", trusted.ledger); + assert!( + state_test::diff::compare(&trusted.outcome, &plain.outcome).is_empty(), + "a declared observation-only inspector moved something", + ); + assert!( + state_test::diff::compare(&trusted.outcome, &observed.outcome).is_empty(), + "declaring the control changed what its run produced", + ); +} + +/// A vector the rewriting run leaves executable comes back `Pass`, with mutations to show for it. +#[test] +fn test_a_mutated_vector_passes_with_mutations_recorded() { + let verdict = chaos_unit(&unit(), VECTOR_0, &SpecName::Rex7, 0xC0FFEE, ShapeFilter::default()); + assert_eq!(verdict.class, ChaosClass::Pass, "{:?}", verdict.detail); + assert!(verdict.applied.total() > 0, "the fixture must be mutated: {:?}", verdict.applied); + assert!(verdict.applied.callbacks > 0, "and the callbacks must be counted"); +} + +/// A sweep whose inspector mutated nothing fails its own gate. +/// +/// Every count such a run prints is truthful and every one of them is zero, which is exactly what +/// a clean sweep looks like. Reading it as a pass is how a chaos mode that stopped being chaotic +/// becomes a green nightly. +#[test] +fn test_a_sweep_that_mutated_nothing_is_a_failure() { + let path = write_suite("chaos_mode_no_mutations.json"); + let scan = FixtureScan { files: vec![path], errors: vec![] }; + // An empty allow-list: every draw is rejected, so the run executes the whole corpus and + // changes nothing. + let tally = run_chaos( + scan, + ChaosRunConfig { + spec: SpecName::Rex7, + seed: 1, + filter: ShapeFilter::only(&[]), + single_thread: true, + progress: false, + }, + ); + + assert_eq!(tally.count(ChaosClass::Pass), 1, "the vector must still be judged"); + assert_eq!(tally.count(ChaosClass::Panic), 0); + assert_eq!(tally.shapes.total(), 0, "and must have been mutated in no way at all"); + assert!(tally.is_failure(), "a sweep that tested nothing must not report success"); +} + +/// A sweep that did mutate, over the same fixture, passes. +#[test] +fn test_a_sweep_that_mutated_passes() { + let path = write_suite("chaos_mode_mutations.json"); + let scan = FixtureScan { files: vec![path], errors: vec![] }; + let tally = run_chaos( + scan, + ChaosRunConfig { + spec: SpecName::Rex7, + seed: 1, + filter: ShapeFilter::default(), + single_thread: true, + progress: false, + }, + ); + + assert_eq!(tally.count(ChaosClass::Pass), 1); + assert!(tally.shapes.total() > 0, "the fixture must be mutated"); + assert!(tally.flagged.is_empty(), "{:?}", tally.flagged); + assert!(!tally.is_failure(), "the sweep must pass"); +} + +/// Writes the fixture to a unique temp file and returns its path. +fn write_suite(file_name: &str) -> PathBuf { + write_suite_under("mega_state_test_chaos_mode", file_name) +} + +/// Writes the fixture under a named directory of the temp root, so two copies of one fixture can +/// be reached through two different paths. +fn write_suite_under(dir: &str, file_name: &str) -> PathBuf { + let suite: serde_json::Map = + std::iter::once(("chaos_unit".to_string(), unit_json())).collect(); + let dir = std::env::temp_dir().join(dir); + std::fs::create_dir_all(&dir).expect("mkdir"); + let path = dir.join(file_name); + std::fs::write(&path, serde_json::to_string_pretty(&suite).expect("serialize")) + .expect("write fixture"); + path +} diff --git a/crates/mega-state-test/tests/diff_mode.rs b/crates/mega-state-test/tests/diff_mode.rs new file mode 100644 index 00000000..60d96b55 --- /dev/null +++ b/crates/mega-state-test/tests/diff_mode.rs @@ -0,0 +1,608 @@ +//! End-to-end tests for the differential runner and the keep-going fill. +//! +//! The classifier's decision table is unit-tested in `src/diff.rs`; these tests drive real +//! executions, so they cover the parts the table cannot: that the two specs are actually executed +//! and committed the way validation does, that the staged frame evidence reaches a halt no +//! transaction result exposes, that evidence a fixture authored itself buys it nothing, and that +//! a keep-going fill isolates one unit's failure from the rest of its file. + +use mega_evm::{alloy_sol_types::SolError, revm::primitives::B256, MegaLimitExceeded}; +use state_test::{ + diff::{ + collect_fixture_files, compare, diff_test_suite, diff_unit, execute_unit_outcome, judge, + run_diff, DiffClass, DiffRunConfig, DiffSpecs, Mechanism, SpecOutcome, + }, + runner::{ + execute_test_suite, fill_test_suite, fill_test_suite_keep_going, FixtureScan, UnitStatus, + }, + types::{SpecName, TestUnit, TxPartIndices}, +}; +use std::{ + path::{Path, PathBuf}, + sync::{Arc, Mutex}, + time::Duration, +}; + +const SENDER: &str = "0x1000000000000000000000000000000000000001"; +const CALLEE: &str = "0x2000000000000000000000000000000000000002"; +const INNER: &str = "0x3000000000000000000000000000000000000003"; + +/// The single transaction vector these hand-built fixtures declare. +const VECTOR_0: TxPartIndices = TxPartIndices { data: 0, gas: 0, value: 0 }; + +/// `CALL(0 gas, 0x40..04, no value, no args, no return); POP` — runs out of gas partway. +/// +/// Given a small enough allowance this frame halts, and it halts somewhere the two specs account +/// for differently: the opcode that crosses the frame's gas records nothing under Rex6, while +/// Rex7 settles the whole open segment at frame exit. +const INNER_RUNS_OUT: &str = + "0x600060006000600060007340000000000000000000000000000000000000046000f150"; + +/// `CALL( gas, INNER, no value, no args, no return); POP` — no trailing `STOP`. +/// +/// `CALL` pushes 0 on failure and the caller carries on, so nothing about the child's halt +/// reaches the transaction's own result. +fn call_inner_frag(gas: u16) -> String { + format!("6000600060006000600073{}61{gas:04x}f150", &INNER[2..]) +} + +/// `CALL( gas, 0x08, no value, 1 byte of args, no return); POP` — no trailing `STOP`. +/// +/// `0x08` is the bn128 pairing precompile, which rejects any input whose length is not a multiple +/// of 192. It never becomes an EVM frame, so the whole forwarded envelope is lost without being +/// executed: Rex7 books it as a destroyed remainder, and the caller absorbs the failure. +fn call_precompile_frag(gas: u16) -> String { + format!("60006000600160006000600861{gas:04x}f150") +} + +/// Wraps code fragments into a contract that runs them and stops. +fn contract(frags: &[String]) -> String { + format!("0x{}00", frags.concat()) +} + +/// `MSTORE(0, ); REVERT(28, 4)` — a plain revert carrying four chosen bytes. +/// +/// Nothing about this contract is a `MegaETH` mechanism. It writes the same four bytes `MegaETH` +/// writes when a frame-local resource limit is exceeded, which is all a classifier that reads +/// revert payloads as evidence would need to see. +fn revert_with_selector(selector: [u8; 4]) -> String { + let word = u32::from_be_bytes(selector); + format!("0x63{word:08x}6000526004601cfd") +} + +/// A unit whose transaction calls `CALLEE`, with an optional third account at `INNER`. +fn unit_json(callee_code: &str, inner_code: Option<&str>) -> serde_json::Value { + let mut pre = serde_json::json!({ + SENDER: { "balance": "0xde0b6b3a7640000", "code": "0x", "nonce": "0x0", "storage": {} }, + CALLEE: { "balance": "0x0", "code": callee_code, "nonce": "0x0", "storage": {} }, + }); + if let Some(code) = inner_code { + pre[INNER] = + serde_json::json!({ "balance": "0x0", "code": code, "nonce": "0x0", "storage": {} }); + } + serde_json::json!({ + "env": { + "currentChainID": "0x18c6", + "currentCoinbase": "0x3000000000000000000000000000000000000009", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x10", + "currentTimestamp": "0x3e8", + "currentBaseFee": "0x0", + "currentRandom": "0x0000000000000000000000000000000000000000000000000000000000000001", + "currentExcessBlobGas": "0x0" + }, + "pre": pre, + "transaction": { + "type": 0, + "data": ["0x"], + "gasLimit": ["0x30d40"], + "gasPrice": "0x0", + "nonce": "0x0", + "secretKey": "0x0000000000000000000000000000000000000000000000000000000000000000", + "sender": SENDER, + "to": CALLEE, + "value": ["0x0"] + }, + "post": {} + }) +} + +fn parse_unit(json: &serde_json::Value) -> TestUnit { + serde_json::from_value(json.clone()).expect("valid unit json") +} + +fn rex7_over_rex6() -> DiffSpecs { + let (target, base) = DiffSpecs::SUPPORTED; + DiffSpecs::new(target, base).expect("the supported pair") +} + +fn rex7(unit: &TestUnit, collect_evidence: bool) -> SpecOutcome { + execute_unit_outcome(unit, VECTOR_0, &SpecName::Rex7, collect_evidence).expect("rex7 executes") +} + +fn rex6(unit: &TestUnit, collect_evidence: bool) -> SpecOutcome { + execute_unit_outcome(unit, VECTOR_0, &SpecName::Rex6, collect_evidence).expect("rex6 executes") +} + +/// Writes a suite of named units to a unique temp file and returns its path. +fn write_suite(file_name: &str, units: &[(&str, serde_json::Value)]) -> PathBuf { + let suite: serde_json::Map = + units.iter().map(|(n, u)| ((*n).to_string(), u.clone())).collect(); + let dir = std::env::temp_dir().join("mega_state_test_diff_mode"); + std::fs::create_dir_all(&dir).expect("mkdir"); + let path = dir.join(file_name); + std::fs::write(&path, serde_json::to_string_pretty(&suite).expect("serialize")) + .expect("write fixture"); + path +} + +/// A unit whose inner frame runs out of gas somewhere the two specs account for differently. +/// +/// The exact allowance that lands on such an opcode is a function of the gas schedule, so it is +/// searched for rather than hard-coded: the property under test is that *some* inner halt moves +/// the reported compute total while leaving the transaction's own result untouched, not that a +/// particular gas number does. +fn inner_halt_json() -> serde_json::Value { + for gas in 1..=64u16 { + let json = unit_json(&contract(&[call_inner_frag(gas)]), Some(INNER_RUNS_OUT)); + let unit = parse_unit(&json); + let (target, base) = (rex7(&unit, false), rex6(&unit, false)); + let hidden = target.status == "success" && target.compute_gas_destroyed == 0; + if hidden && target.compute_gas_used != base.compute_gas_used { + return json; + } + } + panic!("no forwarded-gas amount produced an inner halt that moves the reported compute total") +} + +fn inner_halt_unit() -> TestUnit { + parse_unit(&inner_halt_json()) +} + +/// A unit whose two specs disagree on the reported compute total, that books a Rex7 destroyed +/// remainder, and whose own transaction result is a plain success. +/// +/// Two independent inner calls: a failing precompile, which loses its whole envelope without +/// executing it and so books the remainder, and a gas-starved inner frame, which is what actually +/// moves the reported total. Neither is visible from the transaction's own result, so before the +/// frame pass the remainder is the only thing on the table — exactly the situation in which a +/// derived number must not be allowed to certify itself. +/// +/// Searched over the inner allowance for the same reason as [`inner_halt_json`]: the property is +/// that the shape exists, not that a particular gas number produces it. +fn destroyed_without_visible_halt_unit() -> TestUnit { + for gas in 1..=64u16 { + let code = contract(&[call_precompile_frag(2_000), call_inner_frag(gas)]); + let unit = parse_unit(&unit_json(&code, Some(INNER_RUNS_OUT))); + let (target, base) = (rex7(&unit, false), rex6(&unit, false)); + if target.status == "success" && + target.compute_gas_destroyed > 0 && + target.compute_gas_used != base.compute_gas_used + { + return unit; + } + } + panic!("no forwarded-gas amount produced a destroyed remainder under a successful transaction") +} + +// A transaction that stays inside every limit, ends no frame in an exceptional halt and trips no +// guard is bit-identical under Rex7 and Rex6 — the precision invariant's own statement, executed. +#[test] +fn test_within_limit_transaction_is_identical_under_both_specs() { + let unit = parse_unit(&unit_json("0x", None)); + let outcome = diff_unit(&unit, VECTOR_0, rex7_over_rex6(), true); + assert_eq!(outcome.class, DiffClass::Pass, "{outcome:?}"); + assert!(outcome.fields.is_empty()); +} + +// The staged evidence pass exists for exactly this shape: an inner frame runs out of gas, its +// caller absorbs the failure and returns normally, and the interpreter left nothing to destroy. +// The transaction's own result is a plain success, so only the frame the EVM finished shows the +// halt — and without that, a real and correct Rex7 deviation reads as a defect. +#[test] +fn test_inner_frame_halt_is_explained_only_with_frame_evidence() { + let unit = inner_halt_unit(); + + let without = diff_unit(&unit, VECTOR_0, rex7_over_rex6(), false); + assert_eq!( + without.class, + DiffClass::Unexplained, + "the transaction's own result hides the inner halt: {without:?}" + ); + + let with = diff_unit(&unit, VECTOR_0, rex7_over_rex6(), true); + assert_eq!(with.class, DiffClass::Explained, "{with:?}"); + assert!( + with.mechanisms.contains(&Mechanism::ExceptionalHalt), + "frame evidence should name the halt: {:?}", + with.mechanisms + ); +} + +// A destroyed remainder is derived from a conservation law over the transaction's envelope, not +// observed. A missing term in that law produces a non-zero remainder with no halt behind it, so +// letting the remainder license the compute-total difference it causes would make that defect its +// own alibi. Here a real transaction books one and ends in a plain success: the remainder alone +// leaves the difference unexplained, and only the halted frame the inspector finds licenses it. +#[test] +fn test_destroyed_remainder_is_licensed_by_the_frame_not_by_itself() { + let unit = destroyed_without_visible_halt_unit(); + + let without = diff_unit(&unit, VECTOR_0, rex7_over_rex6(), false); + assert_eq!( + without.class, + DiffClass::Unexplained, + "a destroyed remainder must not certify the halt it claims: {without:?}" + ); + assert!( + without.mechanisms.contains(&Mechanism::DestroyedComputeGas), + "the remainder is still reported: {:?}", + without.mechanisms + ); + + let with = diff_unit(&unit, VECTOR_0, rex7_over_rex6(), true); + assert_eq!(with.class, DiffClass::Explained, "{with:?}"); + assert!( + with.mechanisms.contains(&Mechanism::ExceptionalHalt), + "the independent witness is the frame the EVM finished: {:?}", + with.mechanisms + ); +} + +// Anti-vacuity control, and the reason evidence has to be bound to the execution. A contract that +// writes MegaETH's `MegaLimitExceeded` selector into its revert buffer is observed doing so — the +// claim is real and reported — but it claims the hypothesis that licenses *every* compared +// quantity, and it is four bytes any fixture can write. An unrelated difference laid over that +// execution stays UNEXPLAINED, so the sweep cannot be talked out of a finding by its own input. +#[test] +fn test_a_forged_limit_selector_buys_no_exemption() { + let selector: [u8; 4] = MegaLimitExceeded::SELECTOR; + let unit = parse_unit(&unit_json(&revert_with_selector(selector), None)); + + // The forgery is a plain `REVERT`, and the inspector does see the four bytes. + let observed = rex7(&unit, true); + let frames = observed.frames.expect("the inspected pass collects frame evidence"); + assert!( + frames.limit_revert_payloads > 0, + "the forged selector should be observed and reported: {frames:?}" + ); + assert_eq!(frames.halted, 0, "a plain revert is not a halt"); + + // On its own the fixture is identical under both specs — the forgery changes nothing. + let outcome = diff_unit(&unit, VECTOR_0, rex7_over_rex6(), true); + assert_eq!(outcome.class, DiffClass::Pass, "{outcome:?}"); + + // Lay an unrelated difference over the same real execution — one on a quantity only a changed + // execution path can move. The forged claim is the only thing on the table, and it licenses + // nothing, so the difference is still a finding. + let mut target = observed.clone(); + target.state_root = B256::repeat_byte(9); + target.gas_used += 1; + let verdict = judge(&compare(&target, &observed), &target, &observed); + assert_eq!( + verdict.class, + DiffClass::Unexplained, + "bytes the fixture chose must not license a difference: {verdict:?}" + ); + assert!( + verdict.mechanisms.contains(&Mechanism::LimitRevertPayload), + "the claim is still reported for a human triaging the finding: {:?}", + verdict.mechanisms + ); +} + +// The whole file is judged, one verdict per unit, and a unit's verdict is attributed to its own +// name — a sweep that mislabels which fixture differed is unusable for triage. +#[test] +fn test_diff_test_suite_reports_one_verdict_per_unit() { + let path = write_suite( + "two_units.json", + &[("quiet", unit_json("0x", None)), ("inner_halt", inner_halt_json())], + ); + let diffs = diff_test_suite(&path, rex7_over_rex6(), true).expect("diff suite"); + assert_eq!(diffs.len(), 2); + let quiet = diffs.iter().find(|d| d.name == "quiet").expect("quiet unit"); + let halting = diffs.iter().find(|d| d.name == "inner_halt").expect("halting unit"); + assert_eq!(quiet.class, DiffClass::Pass); + assert_eq!(halting.class, DiffClass::Explained); +} + +// A fixture the runner declines on both sides says nothing about either spec: the gas limit here +// is below the intrinsic cost both specs charge, so neither executes anything. +#[test] +fn test_transaction_rejected_by_both_specs_is_skipped() { + let mut json = unit_json("0x", None); + json["transaction"]["gasLimit"] = serde_json::json!(["0x1"]); + let unit = parse_unit(&json); + let outcome = diff_unit(&unit, VECTOR_0, rex7_over_rex6(), true); + assert_eq!(outcome.class, DiffClass::Skipped, "{outcome:?}"); +} + +/// A unit with two transaction vectors: `data[1]` is a non-empty calldata, and the two `post` +/// entries name index 0 and index 1. +fn two_vector_json() -> serde_json::Value { + let mut json = unit_json("0x", None); + json["transaction"]["data"] = serde_json::json!(["0x", "0xdeadbeef"]); + let entry = |data: usize| { + serde_json::json!({ + "indexes": { "data": data, "gas": 0, "value": 0 }, + "hash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "logs": "0x0000000000000000000000000000000000000000000000000000000000000000" + }) + }; + json["post"] = serde_json::json!({ "Rex6": [entry(0), entry(1)] }); + json +} + +// A unit is a family of transactions, one per vector its `post` names. Judging only index +// `{0,0,0}` would report a green unit while never running the rest of it, and every count the +// sweep prints would be short by the vectors it skipped. +#[test] +fn test_every_declared_vector_is_judged() { + let unit = parse_unit(&two_vector_json()); + assert_eq!(unit.vectors().len(), 2, "the fixture declares two vectors"); + + let path = write_suite("two_vectors.json", &[("family", two_vector_json())]); + let diffs = diff_test_suite(&path, rex7_over_rex6(), true).expect("diff suite"); + assert_eq!(diffs.len(), 2, "one verdict per vector: {diffs:?}"); + let mut names: Vec<&str> = diffs.iter().map(|d| d.name.as_str()).collect(); + names.sort_unstable(); + assert_eq!(names, ["family[d=0,g=0,v=0]", "family[d=1,g=0,v=0]"]); + + // The two vectors send different calldata, so they are genuinely different transactions and + // not the same one counted twice. + let gas: Vec = unit + .vectors() + .into_iter() + .map(|v| execute_unit_outcome(&unit, v, &SpecName::Rex7, false).expect("executes").gas_used) + .collect(); + assert_ne!(gas[0], gas[1], "calldata cost should differ between the vectors"); +} + +// `--fill --force` exists to overwrite a stale expectation. Collapsing the `post` map to a single +// `{0,0,0}` entry would make it delete the other vectors' expectations too, and silently: the +// file still parses, still validates, and covers less than it did. +#[test] +fn test_fill_records_one_expectation_per_vector() { + let path = write_suite("fill_two_vectors.json", &[("family", two_vector_json())]); + let report = + fill_test_suite_keep_going(&path, Some(SpecName::Rex7), true).expect("fill the file"); + assert_eq!(report.filled(), 2, "the tally counts vectors, and this unit declares two"); + + let written: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).expect("read")).expect("json"); + let post = &written["family"]["post"]["Rex7"]; + assert_eq!(post.as_array().map(Vec::len), Some(2), "both vectors kept: {post}"); + assert_eq!(post[0]["indexes"], serde_json::json!({ "data": 0, "gas": 0, "value": 0 })); + assert_eq!(post[1]["indexes"], serde_json::json!({ "data": 1, "gas": 0, "value": 0 })); + assert_ne!( + post[0]["megaGasUsed"], post[1]["megaGasUsed"], + "each entry records its own vector's execution" + ); +} + +/// `CALLDATACOPY(0, 0, CALLDATASIZE); RETURN(0, CALLDATASIZE)` — returns whatever it was called +/// with, so two vectors that send different calldata produce different output. +const ECHO_CALLDATA: &str = "0x366000600037366000f3"; + +/// `MSTORE(0, 42); RETURN(0, 32)` — returns the same word whatever it was called with. +const RETURN_CONSTANT: &str = "0x602a60005260206000f3"; + +/// Re-runs validation over a fixture and returns how many expectations it checked. +fn validate(path: &Path) -> usize { + let elapsed = Arc::new(Mutex::new(Duration::ZERO)); + execute_test_suite(path, &elapsed, false, false).expect("the filled fixture self-validates") +} + +/// [`two_vector_json`] with a chosen callee, so the two vectors' outputs can be made to agree or +/// to differ. +fn two_vector_json_with_callee(code: &str) -> serde_json::Value { + let mut json = two_vector_json(); + json["pre"][CALLEE]["code"] = serde_json::json!(code); + json +} + +// `out` is one field for the whole unit, and a multi-vector unit only has an output to record +// when its vectors agree on one. Clearing it unconditionally drops an expectation the fixture was +// entitled to — quietly, since the result still parses and still validates. +#[test] +fn test_fill_keeps_a_multi_vector_out_when_the_vectors_agree() { + let path = write_suite( + "fill_out_agree.json", + &[("family", two_vector_json_with_callee(RETURN_CONSTANT))], + ); + let report = + fill_test_suite_keep_going(&path, Some(SpecName::Rex7), true).expect("fill the file"); + assert_eq!(report.filled(), 2); + + let written: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).expect("read")).expect("json"); + assert_eq!( + written["family"]["out"], + serde_json::json!("0x000000000000000000000000000000000000000000000000000000000000002a"), + "both vectors return that word, so the unit has one output: {written}" + ); + // And the recorded output is checked, rather than merely stored. + assert_eq!(validate(&path), 2); +} + +// The other half: vectors that return different outputs have no `out` this schema can express. +// Recording one vector's output would assert it for every vector, and per-vector output is a +// schema change, so the unit is refused with a reason instead of filled with a claim. +#[test] +fn test_fill_refuses_a_multi_vector_unit_whose_outputs_disagree() { + let unit = two_vector_json_with_callee(ECHO_CALLDATA); + let path = write_suite("fill_out_disagree.json", &[("family", unit)]); + let before = std::fs::read_to_string(&path).expect("read"); + + let report = + fill_test_suite_keep_going(&path, Some(SpecName::Rex7), true).expect("fill the file"); + assert_eq!(report.filled(), 0, "{:?}", report.vectors); + assert_eq!(report.failed(), 2, "the refusal covers every vector of the unit"); + for vector in &report.vectors { + let message = vector.status.message().expect("a refused vector carries its reason"); + assert!( + message.contains("different output"), + "the reason should name what could not be recorded: {message}" + ); + } + assert_eq!(std::fs::read_to_string(&path).expect("read"), before, "file must be untouched"); +} + +// Fill and diff sweep the same corpus and print a total each. Counting units in one and vectors in +// the other makes those totals disagree over any multi-vector fixture — and a tally that cannot be +// compared against the other mode's, or against a baseline taken under the other mode, is a number +// nobody can act on. +#[test] +fn test_fill_and_diff_count_the_same_vectors() { + let units = [ + ("family", two_vector_json()), + ("single", unit_json("0x", None)), + ("another_family", two_vector_json()), + ]; + let path = write_suite("count_parity.json", &units); + + let diffs = diff_test_suite(&path, rex7_over_rex6(), true).expect("diff suite"); + let report = + fill_test_suite_keep_going(&path, Some(SpecName::Rex7), true).expect("fill the file"); + + assert_eq!(report.vectors.len(), 5, "two units of two vectors and one of one"); + assert_eq!(report.vectors.len(), diffs.len(), "the two modes count the same things"); + + let mut filled: Vec<&str> = report.vectors.iter().map(|v| v.name.as_str()).collect(); + let mut judged: Vec<&str> = diffs.iter().map(|d| d.name.as_str()).collect(); + filled.sort_unstable(); + judged.sort_unstable(); + assert_eq!(filled, judged, "and name them the same way"); +} + +// Every pair but one is refused at construction, and the constructor is the only way to build the +// value: the fields it validates are private, which this file — compiled as a consumer of the +// crate — can only observe through the accessors. The compile-time half of that is pinned by the +// `compile_fail` example on `DiffSpecs`. +#[test] +fn test_diff_specs_is_only_reachable_through_its_constructor() { + let (target, base) = DiffSpecs::SUPPORTED; + let specs = DiffSpecs::new(target, base).expect("the supported pair"); + assert_eq!((specs.target(), specs.base()), (SpecName::Rex7, SpecName::Rex6)); + + for (t, b) in [ + (SpecName::Rex7, SpecName::Equivalence), + (SpecName::Rex6, SpecName::Rex5), + (SpecName::Rex6, SpecName::Rex7), + (SpecName::Rex7, SpecName::Rex7), + ] { + let err = DiffSpecs::new(t, b).expect_err("only one pair has an invariant"); + assert!(err.contains("Rex7") && err.contains("Rex6"), "name the supported pair: {err}"); + } +} + +// Keep-going fill: one unit's failure must cost that unit, not the units after it in the same +// file. Without this, a corpus sweep has to split every multi-unit fixture first. +#[test] +fn test_keep_going_fill_isolates_one_unit_failure() { + // The middle unit's gas limit is below the intrinsic cost, so filling it fails. + let mut broken = unit_json("0x", None); + broken["transaction"]["gasLimit"] = serde_json::json!(["0x1"]); + let path = write_suite( + "keep_going.json", + &[("a_ok", unit_json("0x", None)), ("b_broken", broken), ("c_ok", unit_json("0x", None))], + ); + + // Without keep-going the whole file aborts at the broken unit and nothing is written. + let before = std::fs::read_to_string(&path).expect("read"); + let err = fill_test_suite(&path, Some(SpecName::Rex7), true).expect_err("must abort"); + assert!(err.to_string().contains("b_broken"), "error should name the unit: {err}"); + assert_eq!(std::fs::read_to_string(&path).expect("read"), before, "file must be untouched"); + + let report = fill_test_suite_keep_going(&path, Some(SpecName::Rex7), true).expect("fill"); + assert_eq!(report.filled(), 2); + assert_eq!(report.failed(), 1); + let failed = report.vectors.iter().find(|v| !v.status.is_ok()).expect("one vector failed"); + assert_eq!(failed.name, "b_broken"); + assert!(matches!(failed.status, UnitStatus::Error(_)), "{:?}", failed.status); + + // The two good units carry a freshly recorded Rex7 expectation; the failed one keeps its + // original (empty) post rather than a half-written one. + let written: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).expect("read")).expect("json"); + assert!(written["a_ok"]["post"]["Rex7"].is_array()); + assert!(written["c_ok"]["post"]["Rex7"].is_array()); + assert_eq!(written["b_broken"]["post"], serde_json::json!({})); +} + +// A part of the corpus the discovery walk could not read is a hole in coverage, and it reaches +// the gate as a smaller file list — every count still truthful, every count short. `run_diff` +// carries those errors into the tally so the run fails instead of grading the part it reached. +#[test] +fn test_an_unreadable_part_of_the_corpus_fails_the_run() { + let path = write_suite("scan_errors.json", &[("quiet", unit_json("0x", None))]); + let clean = FixtureScan { files: vec![path.clone()], errors: vec![] }; + let config = DiffRunConfig { + specs: rex7_over_rex6(), + single_thread: true, + collect_evidence: true, + progress: false, + }; + + let tally = run_diff(clean, config); + assert_eq!(tally.count(DiffClass::Pass), 1); + assert!(!tally.is_failure(), "a corpus the sweep read in full and passed"); + + let partial = + FixtureScan { files: vec![path], errors: vec!["walk /corpus/sub: denied".to_string()] }; + let tally = run_diff(partial, config); + assert_eq!(tally.count(DiffClass::Pass), 1, "the readable part still runs"); + assert_eq!(tally.file_errors.len(), 1, "and the unreadable part is reported"); + assert!(tally.is_failure(), "a partly-read corpus is not a pass"); +} + +// A directory whose contents cannot be listed yields no fixtures, which is indistinguishable from +// a directory that holds none. The walk has to report it. +#[test] +#[cfg(unix)] +fn test_discovery_reports_a_directory_it_cannot_read() { + use std::os::unix::fs::PermissionsExt; + + let root = std::env::temp_dir().join("mega_state_test_unreadable_scan"); + let _ = std::fs::remove_dir_all(&root); + let locked = root.join("locked"); + std::fs::create_dir_all(&locked).expect("mkdir"); + std::fs::write(locked.join("hidden.json"), "{}").expect("write"); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).expect("chmod"); + + let unreadable = std::fs::read_dir(&locked).is_err(); + let scan = state_test::runner::find_all_json_tests(&root); + + // Restore before asserting, so a failure does not leave an unreadable directory behind. + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).expect("chmod back"); + let _ = std::fs::remove_dir_all(&root); + + // Running as root defeats the permission bits; then there is nothing to detect and the walk + // legitimately finds the file. + if unreadable { + assert!(scan.files.is_empty(), "the fixture is behind the locked directory"); + assert!(!scan.errors.is_empty(), "the unreadable directory must be reported"); + } else { + assert_eq!(scan.files.len(), 1, "readable after all (running as root?)"); + } +} + +// The path-level guards the differential run relies on before it judges anything. +#[test] +fn test_collect_fixture_files_rejects_a_corpus_with_nothing_in_it() { + let missing = std::env::temp_dir().join("mega_state_test_no_such_corpus_4928"); + let _ = std::fs::remove_dir_all(&missing); + assert!( + collect_fixture_files(std::slice::from_ref(&missing)).is_err(), + "a path that does not exist" + ); + + std::fs::create_dir_all(&missing).expect("mkdir"); + assert!( + collect_fixture_files(std::slice::from_ref(&missing)).is_err(), + "a directory with no fixtures" + ); + let _ = std::fs::remove_dir_all(&missing); +} diff --git a/crates/mega-state-test/tests/dump_roundtrip.rs b/crates/mega-state-test/tests/dump_roundtrip.rs index 77dac481..e3dfd485 100644 --- a/crates/mega-state-test/tests/dump_roundtrip.rs +++ b/crates/mega-state-test/tests/dump_roundtrip.rs @@ -12,9 +12,12 @@ use std::{ use state_test::{ runner::{execute_test_suite, execute_unit_collect, fill_test_suite}, - types::{SpecName, Test, TestSuite, TestUnit}, + types::{SpecName, Test, TestSuite, TestUnit, TxPartIndices}, }; +/// The only transaction vector these fixtures declare. +const VECTOR_0: TxPartIndices = TxPartIndices { data: 0, gas: 0, value: 0 }; + /// A minimal `MegaETH` unit: a funded sender transfers value to a pre-existing /// recipient, under a `megaEnv` carrying a non-default SALT bucket capacity. fn sample_unit_json() -> &'static str { @@ -69,12 +72,13 @@ fn dump_fixture_json() -> (String, state_test::runner::ExecutedUnit) { let mut unit: TestUnit = serde_json::from_str(sample_unit_json()).expect("parse unit"); let spec = SpecName::Rex5; - let executed = execute_unit_collect(&unit, &spec).expect("execute unit"); + let executed = execute_unit_collect(&unit, VECTOR_0, &spec).expect("execute unit"); unit.out = executed.output.clone(); unit.post = std::collections::BTreeMap::from([( spec, vec![Test::for_dump( + VECTOR_0, executed.state_root, executed.logs_root, executed.gas_used, @@ -150,7 +154,7 @@ fn test_undersized_bucket_capacity_fails_instead_of_panicking() { assert_ne!(bad, sample_unit_json(), "capacity replacement applied"); let unit: TestUnit = serde_json::from_str(&bad).expect("parse unit"); - let err = execute_unit_collect(&unit, &SpecName::Rex5) + let err = execute_unit_collect(&unit, VECTOR_0, &SpecName::Rex5) .expect_err("undersized capacity must fail execution"); assert!(format!("{err}").contains("MIN_BUCKET_SIZE"), "unexpected error: {err}"); } diff --git a/crates/mega-state-test/tests/hardening.rs b/crates/mega-state-test/tests/hardening.rs index bf6c2b09..5c560e24 100644 --- a/crates/mega-state-test/tests/hardening.rs +++ b/crates/mega-state-test/tests/hardening.rs @@ -15,9 +15,12 @@ use state_test::{ bench_test_suite, execute_test_suite, execute_unit_collect, fill_test_suite, run, TestError, TestErrorKind, }, - types::{SpecName, TestUnit}, + types::{SpecName, TestUnit, TxPartIndices}, }; +/// The only transaction vector these fixtures declare. +const VECTOR_0: TxPartIndices = TxPartIndices { data: 0, gas: 0, value: 0 }; + /// Minimal valid unit JSON: a funded sender sends a legacy transaction to a /// recipient whose code is `code` (use `"0x"` for a plain transfer). fn unit_json(code: &str) -> serde_json::Value { @@ -83,14 +86,14 @@ fn write_suite(file_name: &str, unit: &serde_json::Value) -> PathBuf { path } -fn run_suite(path: &Path) -> Result<(), TestError> { +fn run_suite(path: &Path) -> Result { let elapsed = Arc::new(Mutex::new(Duration::ZERO)); // `print_json_outcome: true` keeps the failure path single-shot (no debug // re-run with tracing), so error assertions stay quiet and fast. execute_test_suite(path, &elapsed, false, true) } -fn expect_fixture_error(result: Result<(), TestError>, needle: &str) { +fn expect_fixture_error(result: Result, needle: &str) { let err = result.expect_err("suite must fail"); match &err.kind { TestErrorKind::FixtureError(msg) => { @@ -269,8 +272,10 @@ fn block_hashes_are_injected_into_execution() { "0xf": "0x2222222222222222222222222222222222222222222222222222222222222222" }); - let run1 = execute_unit_collect(&blockhash_unit(Some(h1)), &SpecName::Rex5).expect("run h1"); - let run2 = execute_unit_collect(&blockhash_unit(Some(h2)), &SpecName::Rex5).expect("run h2"); + let run1 = + execute_unit_collect(&blockhash_unit(Some(h1)), VECTOR_0, &SpecName::Rex5).expect("run h1"); + let run2 = + execute_unit_collect(&blockhash_unit(Some(h2)), VECTOR_0, &SpecName::Rex5).expect("run h2"); assert_eq!(run1.status, "success"); assert_eq!(run2.status, "success"); assert_ne!( @@ -279,7 +284,7 @@ fn block_hashes_are_injected_into_execution() { ); // Absent blockHashes: execution still works on the synthetic default. - let synthetic = execute_unit_collect(&blockhash_unit(None), &SpecName::Rex5) + let synthetic = execute_unit_collect(&blockhash_unit(None), VECTOR_0, &SpecName::Rex5) .expect("run without blockHashes"); assert_eq!(synthetic.status, "success"); } @@ -291,7 +296,7 @@ fn block_hashes_key_overflow_is_fixture_error() { "0x10000000000000000": "0x1111111111111111111111111111111111111111111111111111111111111111" }); - let err = execute_unit_collect(&blockhash_unit(Some(overflow)), &SpecName::Rex5) + let err = execute_unit_collect(&blockhash_unit(Some(overflow)), VECTOR_0, &SpecName::Rex5) .expect_err("overflowing blockHashes key must fail"); assert!(err.to_string().contains("blockHashes"), "unexpected error: {err}"); } diff --git a/crates/mega-state-test/tests/replay_corpus.rs b/crates/mega-state-test/tests/replay_corpus.rs index 468a83f4..26555f89 100644 --- a/crates/mega-state-test/tests/replay_corpus.rs +++ b/crates/mega-state-test/tests/replay_corpus.rs @@ -29,17 +29,18 @@ use state_test::runner::{execute_test_suite, find_all_json_tests}; #[test] fn test_replay_corpus_self_validates() { let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../bench/replay/fixtures"); - let fixtures = find_all_json_tests(std::path::Path::new(dir)); + let scan = find_all_json_tests(std::path::Path::new(dir)); - assert!(!fixtures.is_empty(), "replay corpus is empty at {dir}"); + assert!(scan.errors.is_empty(), "replay corpus is not fully readable: {:?}", scan.errors); + assert!(!scan.files.is_empty(), "replay corpus is empty at {dir}"); let elapsed = Arc::new(Mutex::new(Duration::ZERO)); let mut passed = 0usize; - for path in &fixtures { + for path in &scan.files { execute_test_suite(path, &elapsed, false, false).unwrap_or_else(|e| { panic!("replay fixture {} failed to validate: {e}", path.display()) }); passed += 1; } - assert_eq!(passed, fixtures.len(), "all corpus fixtures must validate"); + assert_eq!(passed, scan.files.len(), "all corpus fixtures must validate"); } diff --git a/crates/state-test/README.md b/crates/state-test/README.md index 58ee76f8..bf49285b 100644 --- a/crates/state-test/README.md +++ b/crates/state-test/README.md @@ -13,6 +13,16 @@ Every mode operates on self-contained EEST fixtures (`TestUnit { env, pre, trans - **Validate** (default) — `state-test ` executes each fixture and checks its recorded `post` (state root, logs root, gas, status). This is how the official Ethereum tests and the replay corpus (`bench/replay/fixtures/`, via `replay_corpus.rs`) are checked. - **`--bench`** — `state-test --bench [--bench-runs N] [--bench-warmup W] [--bench-spec SPEC] ` times each fixture's isolated EVM execution and prints `{ gas_used, success, bench: { min/median/mean, mgasPerSec } }` as JSON instead of validating. This is the only EVM-throughput benchmark entry point; the replay-throughput benchmark (`bench/replay/run.py`) drives it. -- **`--fill`** — `state-test --fill --bench-spec SPEC ` computes each fixture's `post` and writes it back in place (atomically, via a temp file). This is the offline analog of `mega-evme replay --dump-fixture`'s post-fill step, for a fixture that has no on-chain origin (a hand-built case, or a `prestateTracer` snapshot such as `bench/replay/fixtures/attack_deploy.json`). After filling, the fixture is self-validating like any dumped one. A fixture that already has a non-empty `post` is refused unless `--force` is passed — filling replaces the whole `post` map with circularly-derived expectations, so an accidental run against real expectations (e.g. the official test suites) would destroy them. Filenames on the validation skip list and the Constantinople spec are refused outright, since validation would never check the result. +- **`--fill`** — `state-test --fill --bench-spec SPEC ` computes each fixture's `post` and writes it back in place (atomically, via a temp file). This is the offline analog of `mega-evme replay --dump-fixture`'s post-fill step, for a fixture that has no on-chain origin (a hand-built case, or a `prestateTracer` snapshot such as `bench/replay/fixtures/attack_deploy.json`). After filling, the fixture is self-validating like any dumped one. One expectation is recorded per transaction vector the unit declares, each keeping its own `indexes`, and a unit is written as a whole — a vector that cannot be executed leaves the unit's `post` untouched rather than half-rewritten. The unit-wide `out` field is recorded when every vector produces the same output and the unit is refused when they differ, since one field cannot state a per-vector expectation. A fixture that already has a non-empty `post` is refused unless `--force` is passed — filling replaces the whole `post` map with circularly-derived expectations, so an accidental run against real expectations (e.g. the official test suites) would destroy them. Filenames on the validation skip list and the Constantinople spec are refused outright, since validation would never check the result. +- **`--diff-spec`** — `state-test --bench-spec Rex7 --diff-spec Rex6 ` executes each fixture under both specs and classifies how they differ. Nothing is written and no recorded `post` is consulted: the two executions are compared against each other. This is the only check available for a spec nobody has computed expectations for yet — the frozen spec it inherits from is the oracle, and that spec's precision invariant is what says when the two are allowed to disagree. That invariant is Rex7's and relates Rex7 to Rex6, so Rex7-against-Rex6 is the only pair accepted; any other is refused rather than judged by a licence it was never granted. See `crates/mega-state-test/src/diff.rs` for the classification and `tools/eest-sweep/` for the corpus driver built on it. +- **`--keep-going`** — with `--fill`, records each vector's failure (or panic) and carries on with the rest of its file instead of aborting at the first one, then prints a `Fill tally:` line. Without it, one bad unit ends the whole run, which is why a corpus sweep used to have to split every multi-unit fixture into one file per unit first. The tally counts transaction vectors, the same unit of work `--diff-spec` counts, so the two modes' totals over one corpus can be compared with each other and against a baseline. -`--bench-spec` selects the spec to run under; without it, the fixture's single `post` spec is used (so `--fill` needs it when the `post` is still empty). \ No newline at end of file +`--bench-spec` selects the spec to run under; without it, the fixture's single `post` spec is used (so `--fill` needs it when the `post` is still empty, and `--diff-spec` requires it outright). + +## Transaction vectors + +A state-test unit is a family of transactions, not one: `transaction` holds arrays of `data`, `gasLimit` and `value`, and each `post` entry names the combination it pins through its `indexes`. Validate, `--bench`, `--fill` and `--diff-spec` all enumerate that same set, so a multi-vector unit yields one result, one benchmark, one filled expectation and one verdict per vector. A unit with no `post` declares no vector and is run at `{0,0,0}`, which is the only transaction such a fixture can mean. Per-vector results of a multi-vector unit are reported under `name[d=..,g=..,v=..]`; a single-vector unit keeps its bare name. + +## Exit codes + +Every mode exits 1 on failure and 0 otherwise, and "judged nothing" counts as a failure in all of them, with or without `--keep-going`: a run whose corpus was empty, unreachable, or entirely unreadable reports zeroes that are truthful and meaningless, and must not read as a pass. Validation counts the expectations it checked rather than the units it walked, so a unit whose `post` is empty — nothing to check, nothing checked — fails the same way an empty corpus does. A `--diff-spec` run additionally fails on a panic, on an unexplained difference, and on any file it could not read or parse. \ No newline at end of file diff --git a/crates/state-test/src/main.rs b/crates/state-test/src/main.rs index 0c7e0b1d..ca14bca1 100644 --- a/crates/state-test/src/main.rs +++ b/crates/state-test/src/main.rs @@ -5,9 +5,11 @@ use clap::Parser; use state_test::{ + chaos::{run_chaos, ChaosClass, ChaosRunConfig, ChaosShape, ChaosSweepTally, ShapeFilter}, + diff::{collect_fixture_files, run_diff, DiffClass, DiffRunConfig, DiffSpecs, DiffTally}, runner::{ - bench_test_suite, fill_test_suite, find_all_json_tests, run, TestError, TestErrorKind, - UnitBench, + bench_test_suite, fill_test_suite, fill_test_suite_keep_going, find_all_json_tests, + is_skipped_fixture, run, TestError, TestErrorKind, UnitBench, UnitStatus, }, types::SpecName, }; @@ -71,11 +73,58 @@ pub struct Cmd { /// Overwrite an existing non-empty `post` when filling with `--fill`. #[arg(long, requires = "fill")] force: bool, + /// Execute each fixture under this spec as well and report how the two differ. + /// + /// The spec under test is `--bench-spec`, which is therefore required: a differential run + /// compares two named specs, and taking the target from each fixture's own `post` would make + /// the comparison mean something different from one unit to the next. Nothing is written — + /// the comparison is between the two executions, not against a recorded expectation, which is + /// what lets an unstable spec with no expectations be checked at all. + #[arg(long, value_name = "SPEC", requires = "bench_spec", conflicts_with_all = ["bench", "fill"])] + diff_spec: Option, + /// Write the differential run's tally and every flagged unit to this file, as JSON. + #[arg(long, value_name = "FILE", requires = "diff_spec")] + diff_report: Option, + /// Skip the inspected second pass that collects per-frame evidence for a difference the + /// cheap evidence did not explain. + /// + /// Only useful for measuring the cost of that pass: without it, a difference caused by an + /// inner frame the transaction's own result hides is reported as unexplained. + #[arg(long, requires = "diff_spec")] + diff_no_frame_evidence: bool, + /// Execute each fixture three times — with no inspector, with a read-only one, and with a + /// deterministic rewriting one seeded from this value and the vector's own identity — and + /// report how the three came out. + /// + /// The spec every run executes under is `--bench-spec`, which is therefore required. Nothing + /// is written and nothing is compared against a recorded expectation: the read-only run is + /// judged against the run with no inspector, and the rewriting run is judged by whether the + /// execution's own gas-accounting cross-checks survive it. Those cross-checks are debug + /// assertions, so this mode is only meaningful in a build that keeps them. + #[arg(long, value_name = "SEED", requires = "bench_spec", conflicts_with_all = ["bench", "fill", "diff_spec"])] + chaos_seed: Option, + /// Write the chaos run's tally and every flagged vector to this file, as JSON. + #[arg(long, value_name = "FILE", requires = "chaos_seed")] + chaos_report: Option, + /// Restrict the rewriting run to these shapes (comma-separated labels). + /// + /// For triage: a flagged vector is re-run with the list narrowed until the smallest set that + /// still reproduces it is found. Narrowing does not reshuffle the decision stream, so each + /// surviving mutation stays where the full run put it; it does leave the mutation budget + /// unspent on rejected draws, so a narrowed run can reach further into a transaction. + #[arg(long, value_name = "SHAPES", value_delimiter = ',', requires = "chaos_seed")] + chaos_shapes: Vec, } impl Cmd { /// Runs `statetest` command. pub fn run(&self) -> Result<(), TestError> { + if self.diff_spec.is_some() { + return self.run_diff(); + } + if self.chaos_seed.is_some() { + return self.run_chaos(); + } if self.fill { return self.run_fill(); } @@ -92,9 +141,22 @@ impl Cmd { } println!("\nRunning tests in {}...", path.display()); - let test_files = find_all_json_tests(path); + let scan = find_all_json_tests(path); + // A directory the walk could not descend into contributes no fixtures, which looks + // exactly like a directory that holds none. Fail rather than run the part that was + // readable and report it as the whole. + if let Some(err) = scan.errors.first() { + return Err(TestError { + name: "Path validation".to_string(), + path: path.display().to_string(), + kind: TestErrorKind::FixtureError(format!( + "{} path(s) could not be read; first: {err}", + scan.errors.len() + )), + }); + } - if test_files.is_empty() { + if scan.files.is_empty() { return Err(TestError { name: "Path validation".to_string(), path: path.display().to_string(), @@ -102,52 +164,26 @@ impl Cmd { }); } - run(test_files, self.single_thread, self.json, self.json_outcome, self.keep_going)? + run(scan.files, self.single_thread, self.json, self.json_outcome, self.keep_going)? } Ok(()) } /// Parse `--bench-spec` into a [`SpecName`], if given. fn resolve_spec(&self) -> Result, TestError> { - self.bench_spec - .as_deref() - .map(|s| { - let invalid_spec = || TestError { - name: "spec".to_string(), - path: s.to_string(), - kind: TestErrorKind::FixtureError(format!( - "invalid --bench-spec {s:?}; expected one of: {}", - [ - mega_evm::name::EQUIVALENCE, - mega_evm::name::MINI_REX, - mega_evm::name::REX, - mega_evm::name::REX1, - mega_evm::name::REX2, - mega_evm::name::REX3, - mega_evm::name::REX4, - mega_evm::name::REX5, - ] - .join(", ") - )), - }; - let spec = MegaSpecId::from_str(s) - .map(SpecName::from_mega_spec) - .map_err(|_| invalid_spec())?; - // A spec id that parses but has no fixture-facing name (a - // future `MegaSpecId` this crate does not map yet) would - // otherwise fail much later, deep inside execution — reject it - // here with the same actionable message. - if spec == SpecName::Unknown { - return Err(invalid_spec()); - } - Ok(spec) - }) - .transpose() + self.bench_spec.as_deref().map(|s| parse_spec("--bench-spec", s)).transpose() + } + + /// Parse `--diff-spec` into a [`SpecName`], if given. + fn resolve_diff_spec(&self) -> Result, TestError> { + self.diff_spec.as_deref().map(|s| parse_spec("--diff-spec", s)).transpose() } /// Fill every fixture's `post` expectation in place (see `--fill`). fn run_fill(&self) -> Result<(), TestError> { let spec_override = self.resolve_spec()?; + let (mut filled, mut errors, mut panics) = (0usize, 0usize, 0usize); + let (mut file_errors, mut skipped_files) = (0usize, 0usize); for path in &self.paths { if !path.exists() { return Err(TestError { @@ -156,12 +192,203 @@ impl Cmd { kind: TestErrorKind::InvalidPath, }); } - for file in find_all_json_tests(path) { - let n = fill_test_suite(&file, spec_override, self.force)?; - println!("Filled post for {n} unit(s) in {}", file.display()); + let scan = find_all_json_tests(path); + // Same hole as in the other modes, reported the way this mode reports a file it could + // not read: as a FILE_ERR that the tally's gate counts. + for err in &scan.errors { + println!("FILE_ERR\t{}\t{}", path.display(), err.replace('\n', " ")); + file_errors += 1; + } + if !self.keep_going && !scan.errors.is_empty() { + return Err(TestError { + name: "Path validation".to_string(), + path: path.display().to_string(), + kind: TestErrorKind::FixtureError(format!( + "{} path(s) could not be read", + scan.errors.len() + )), + }); + } + for file in scan.files { + if self.keep_going { + // A file the runner declines as a whole (an unreadable fixture, a filename on + // the validation skip list) must not end the sweep either: record it and move + // on, the same way a declined unit is recorded. + if is_skipped_fixture(&file) { + println!("SKIP_FILE\t{}", file.display()); + skipped_files += 1; + continue; + } + let report = match fill_test_suite_keep_going(&file, spec_override, self.force) + { + Ok(report) => report, + Err(e) => { + println!( + "FILE_ERR\t{}\t{}", + file.display(), + e.to_string().replace('\n', " ") + ); + file_errors += 1; + continue; + } + }; + for vector in &report.vectors { + match &vector.status { + UnitStatus::Ok => {} + UnitStatus::Error(m) => { + println!("ERR\t{}::{}\t{m}", file.display(), vector.name); + errors += 1; + } + UnitStatus::Panic(m) => { + println!( + "PANIC\t{}::{}\t{}", + file.display(), + vector.name, + m.replace('\n', " ") + ); + panics += 1; + } + } + } + filled += report.filled(); + } else { + let n = fill_test_suite(&file, spec_override, self.force)?; + println!("Filled post for {n} transaction vector(s) in {}", file.display()); + filled += n; + } } } - Ok(()) + // A sweep that filled and declined nothing reached no transaction vector at all: an empty + // corpus, or one whose every file was unreadable or skipped. Its zeroes are truthful and + // meaningless, so they must not read as a pass — in either mode. Without `--keep-going` + // the run stops at the first failure, which says nothing about the case where there was + // no work to fail at. + let total = filled + errors + panics; + if self.keep_going { + println!( + "Fill tally: OK={filled} ERR={errors} PANIC={panics} FILE_ERR={file_errors} \ + SKIP_FILE={skipped_files} TOTAL={total}" + ); + } + if total == 0 { + return Err(TestError { + name: "fill summary".to_string(), + path: String::new(), + kind: TestErrorKind::FixtureError( + "no transaction vector was filled; the corpus is empty or unreachable" + .to_string(), + ), + }); + } + if !self.keep_going { + return Ok(()); + } + if errors + panics + file_errors == 0 { + return Ok(()); + } + // `--keep-going` changes when the run stops, not whether it failed: the CLI's exit-code + // contract still reports a unit that did not fill. + Err(TestError { + name: "fill summary".to_string(), + path: String::new(), + kind: TestErrorKind::TestsFailed { failed: errors + panics + file_errors, total }, + }) + } + + /// Execute every fixture under both specs and report how they differ (see `--diff-spec`). + fn run_diff(&self) -> Result<(), TestError> { + let base = self.resolve_diff_spec()?.expect("run_diff is only reached with --diff-spec"); + // Clap's `requires = "bench_spec"` makes the target explicit before this point. + let target = self.resolve_spec()?.expect("--diff-spec requires --bench-spec"); + // The comparison is decided by Rex7's precision invariant, which relates Rex7 to Rex6 and + // states nothing about any other pair; running it over one would apply that licence where + // none was granted. + let specs = DiffSpecs::new(target, base).map_err(|detail| TestError { + name: "spec pair".to_string(), + path: String::new(), + kind: TestErrorKind::FixtureError(detail), + })?; + let scan = collect_fixture_files(&self.paths)?; + + let tally = run_diff( + scan, + DiffRunConfig { + specs, + single_thread: self.single_thread, + collect_evidence: !self.diff_no_frame_evidence, + progress: !self.json, + }, + ); + + print_diff_tally(&tally, target, base); + if let Some(report) = &self.diff_report { + let json = serde_json::to_string_pretty(&diff_report_json(&tally, target, base)) + .expect("serialize diff report"); + std::fs::write(report, json).map_err(|e| TestError { + name: "diff report".to_string(), + path: report.display().to_string(), + kind: TestErrorKind::FixtureError(format!("write: {e}")), + })?; + } + + if !tally.is_failure() { + return Ok(()); + } + Err(diff_summary_error(&tally)) + } + + /// Build the chaos run's shape filter from `--chaos-shapes`. + fn resolve_chaos_filter(&self) -> Result { + if self.chaos_shapes.is_empty() { + return Ok(ShapeFilter::default()); + } + let shapes = self + .chaos_shapes + .iter() + .map(|label| ChaosShape::parse(label)) + .collect::, _>>() + .map_err(|detail| TestError { + name: "--chaos-shapes".to_string(), + path: String::new(), + kind: TestErrorKind::FixtureError(detail), + })?; + Ok(ShapeFilter::only(&shapes)) + } + + /// Sweep the corpus under a deterministic rewriting inspector (see `--chaos-seed`). + fn run_chaos(&self) -> Result<(), TestError> { + let seed = self.chaos_seed.expect("run_chaos is only reached with --chaos-seed"); + // Clap's `requires = "bench_spec"` makes the spec explicit before this point. + let spec = self.resolve_spec()?.expect("--chaos-seed requires --bench-spec"); + let filter = self.resolve_chaos_filter()?; + let scan = collect_fixture_files(&self.paths)?; + + let tally = run_chaos( + scan, + ChaosRunConfig { + spec, + seed, + filter, + single_thread: self.single_thread, + progress: !self.json, + }, + ); + + print_chaos_tally(&tally, spec, seed, filter); + if let Some(report) = &self.chaos_report { + let json = serde_json::to_string_pretty(&chaos_report_json(&tally, spec, seed, filter)) + .expect("serialize chaos report"); + std::fs::write(report, json).map_err(|e| TestError { + name: "chaos report".to_string(), + path: report.display().to_string(), + kind: TestErrorKind::FixtureError(format!("write: {e}")), + })?; + } + + if !tally.is_failure() { + return Ok(()); + } + Err(chaos_summary_error(&tally)) } /// Benchmark every fixture under the given paths and print the results as JSON. @@ -181,7 +408,18 @@ impl Cmd { kind: TestErrorKind::InvalidPath, }); } - for file in find_all_json_tests(path) { + let scan = find_all_json_tests(path); + if let Some(err) = scan.errors.first() { + return Err(TestError { + name: "Path validation".to_string(), + path: path.display().to_string(), + kind: TestErrorKind::FixtureError(format!( + "{} path(s) could not be read; first: {err}", + scan.errors.len() + )), + }); + } + for file in scan.files { all.extend(bench_test_suite( &file, self.bench_runs, @@ -191,6 +429,21 @@ impl Cmd { } } + if all.is_empty() { + return Err(TestError { + name: "bench".to_string(), + path: self + .paths + .iter() + .map(|p| p.display().to_string()) + .collect::>() + .join(", "), + kind: TestErrorKind::FixtureError( + "no unit was benchmarked; the corpus is empty or unreachable".to_string(), + ), + }); + } + let bench_json = |u: &UnitBench| { json!({ "runs": u.runs, @@ -220,6 +473,288 @@ impl Cmd { } } +/// Parse a spec-name flag value into a [`SpecName`]. +/// +/// Rejects both an unparseable string and a `MegaSpecId` this crate has no fixture-facing name +/// for; either would otherwise fail much later, deep inside execution. +fn parse_spec(flag: &str, value: &str) -> Result { + let invalid_spec = || TestError { + name: "spec".to_string(), + path: value.to_string(), + kind: TestErrorKind::FixtureError(format!( + "invalid {flag} {value:?}; expected one of: {}", + [ + mega_evm::name::EQUIVALENCE, + mega_evm::name::MINI_REX, + mega_evm::name::REX, + mega_evm::name::REX1, + mega_evm::name::REX2, + mega_evm::name::REX3, + mega_evm::name::REX4, + mega_evm::name::REX5, + mega_evm::name::REX6, + mega_evm::name::REX7, + ] + .join(", ") + )), + }; + let spec = + MegaSpecId::from_str(value).map(SpecName::from_mega_spec).map_err(|_| invalid_spec())?; + if spec == SpecName::Unknown { + return Err(invalid_spec()); + } + Ok(spec) +} + +/// Why a sweep that judged nothing failed, as a [`TestErrorKind::FixtureError`] detail. +fn no_work_detail(unit: &str, file_errors: usize) -> String { + if file_errors == 0 { + format!("no {unit} was judged; the corpus is empty, entirely skipped, or unreachable") + } else { + format!( + "no {unit} was judged ({file_errors} fixtures unreadable); \ + the corpus is empty, entirely skipped, or unreachable" + ) + } +} + +/// Maps every [`DiffTally::is_failure`] condition onto the CLI's summary error. +/// +/// Unexplained differences, panics, and unreadable fixtures share [`TestErrorKind::TestsFailed`] +/// so the `failed` count includes all three. A run that judged nothing uses +/// [`TestErrorKind::FixtureError`] instead of claiming `0 tests failed out of 0`. +fn diff_summary_error(tally: &DiffTally) -> TestError { + let unexplained = tally.count(DiffClass::Unexplained); + let panics = tally.count(DiffClass::Panic); + let file_errors = tally.file_errors.len(); + let total = tally.total(); + TestError { + name: "diff summary".to_string(), + path: String::new(), + kind: if total == 0 { + TestErrorKind::FixtureError(no_work_detail("fixture", file_errors)) + } else { + TestErrorKind::TestsFailed { failed: unexplained + panics + file_errors, total } + }, + } +} + +/// Maps every [`ChaosSweepTally::is_failure`] condition onto the CLI's summary error. +/// +/// Flagged verdicts and unreadable fixtures share [`TestErrorKind::TestsFailed`]. A run that +/// judged nothing, or that applied no mutation, uses [`TestErrorKind::FixtureError`] so it cannot +/// read as `0 tests failed`. +fn chaos_summary_error(tally: &ChaosSweepTally) -> TestError { + let file_errors = tally.file_errors.len(); + let total = tally.total(); + TestError { + name: "chaos summary".to_string(), + path: String::new(), + kind: if total == 0 { + TestErrorKind::FixtureError(no_work_detail("vector", file_errors)) + } else if tally.flagged.is_empty() && file_errors == 0 { + TestErrorKind::FixtureError( + "no mutation was applied; the run tested nothing".to_string(), + ) + } else { + TestErrorKind::TestsFailed { failed: tally.flagged.len() + file_errors, total } + }, + } +} + +/// Every class a differential run can produce, in report order. +const DIFF_CLASSES: [DiffClass; 5] = [ + DiffClass::Pass, + DiffClass::Explained, + DiffClass::Unexplained, + DiffClass::Skipped, + DiffClass::Panic, +]; + +/// Prints the differential run's tally, mechanism distribution, and every flagged unit. +fn print_diff_tally(tally: &DiffTally, target: SpecName, base: SpecName) { + println!("\nDifferential run: {target:?} vs {base:?} over {} unit(s)", tally.total()); + for class in DIFF_CLASSES { + println!(" {:<12} {}", class.label(), tally.count(class)); + } + if !tally.file_errors.is_empty() { + println!(" {:<12} {}", "FILE_ERROR", tally.file_errors.len()); + } + if tally.skipped_files > 0 { + println!(" ({} file(s) skipped by filename, no unit of them judged)", tally.skipped_files); + } + if !tally.mechanisms.is_empty() { + println!("Mechanisms over explained differences:"); + for (label, count) in &tally.mechanisms { + println!(" {label:<28} {count}"); + } + } + if !tally.explained_fields.is_empty() { + println!("Shapes of explained differences (disagreeing quantities):"); + for (shape, count) in &tally.explained_fields { + println!(" {shape:<48} {count}"); + } + } + for diff in &tally.flagged { + println!( + "{}\t{}::{}\t{}\t{}", + diff.class.label(), + diff.path, + diff.name, + diff.fields.iter().map(|f| f.label()).collect::>().join(","), + diff.detail.as_deref().unwrap_or("-").replace('\n', " ") + ); + } + for error in &tally.file_errors { + println!("FILE_ERROR\t{}", error.replace('\n', " ")); + } + if tally.is_failure() { + println!( + "Diff failure: {} unexplained, {} panics, {} fixtures unreadable ({} judged)", + tally.count(DiffClass::Unexplained), + tally.count(DiffClass::Panic), + tally.file_errors.len(), + tally.total(), + ); + } +} + +/// Every chaos verdict, in the order a reader wants them. +const CHAOS_CLASSES: [ChaosClass; 7] = [ + ChaosClass::Pass, + ChaosClass::Refused, + ChaosClass::ControlDrift, + ChaosClass::ChaosRejected, + ChaosClass::LedgerBlind, + ChaosClass::Skipped, + ChaosClass::Panic, +]; + +/// Prints the chaos run's tally, plus every vector that needs a human. +fn print_chaos_tally(tally: &ChaosSweepTally, spec: SpecName, seed: u64, filter: ShapeFilter) { + println!("\nChaos run: {spec:?} under seed {seed} over {} vector(s)", tally.total()); + if !filter.is_complete() { + println!(" (shape filter: {})", chaos_filter_label(filter)); + } + for class in CHAOS_CLASSES { + println!(" {:<16} {}", class.label(), tally.count(class)); + } + if !tally.file_errors.is_empty() { + println!(" {:<16} {}", "FILE_ERROR", tally.file_errors.len()); + } + if tally.skipped_files > 0 { + println!( + " ({} file(s) skipped by filename, no vector of them judged)", + tally.skipped_files + ); + } + println!( + "Mutations applied: {} over {} callback(s)", + tally.shapes.total(), + tally.shapes.callbacks + ); + for (shape, count) in &tally.shapes.applied { + println!(" {shape:<20} {count}"); + } + for verdict in &tally.flagged { + println!( + "{}\t{}::{}\tseed={}\t{}", + verdict.class.label(), + verdict.path, + verdict.name, + verdict.seed, + verdict.detail.as_deref().unwrap_or("-").replace('\n', " ") + ); + } + for error in &tally.file_errors { + println!("FILE_ERROR\t{}", error.replace('\n', " ")); + } + if tally.is_failure() { + println!( + "Chaos failure: {} flagged, {} fixtures unreadable, {} mutations ({} judged)", + tally.flagged.len(), + tally.file_errors.len(), + tally.shapes.total(), + tally.total(), + ); + } +} + +/// The machine-readable form of [`print_chaos_tally`], for `--chaos-report`. +fn chaos_report_json( + tally: &ChaosSweepTally, + spec: SpecName, + seed: u64, + filter: ShapeFilter, +) -> serde_json::Value { + json!({ + "spec": format!("{spec:?}"), + "seed": seed, + "shapeFilter": chaos_filter_label(filter), + "total": tally.total(), + "classes": CHAOS_CLASSES + .iter() + .map(|c| (c.label().to_string(), json!(tally.count(*c)))) + .collect::>(), + "callbacks": tally.shapes.callbacks, + "mutations": tally.shapes.total(), + "mutationsByShape": tally.shapes.applied, + "fileErrors": tally.file_errors, + "skippedFiles": tally.skipped_files, + "flagged": tally + .flagged + .iter() + .map(|v| json!({ + "class": v.class.label(), + "path": v.path, + "name": v.name, + "seed": v.seed, + "mutations": v.mutations, + "detail": v.detail, + })) + .collect::>(), + }) +} + +/// What a chaos run's shape filter allows, as one line. +fn chaos_filter_label(filter: ShapeFilter) -> String { + ChaosShape::ALL + .into_iter() + .filter(|shape| filter.allows(*shape)) + .map(|shape| shape.label().to_string()) + .collect::>() + .join(",") +} + +/// The machine-readable form of [`print_diff_tally`], for `--diff-report`. +fn diff_report_json(tally: &DiffTally, target: SpecName, base: SpecName) -> serde_json::Value { + json!({ + "targetSpec": format!("{target:?}"), + "baseSpec": format!("{base:?}"), + "total": tally.total(), + "classes": DIFF_CLASSES + .iter() + .map(|c| (c.label().to_string(), json!(tally.count(*c)))) + .collect::>(), + "mechanisms": tally.mechanisms, + "explainedFields": tally.explained_fields, + "fileErrors": tally.file_errors, + "skippedFiles": tally.skipped_files, + "flagged": tally + .flagged + .iter() + .map(|d| json!({ + "class": d.class.label(), + "path": d.path, + "name": d.name, + "fields": d.fields.iter().map(|f| f.label()).collect::>(), + "mechanisms": d.mechanisms.iter().map(|m| m.label()).collect::>(), + "detail": d.detail, + })) + .collect::>(), + }) +} + fn main() { let cmd = Cmd::parse(); // CI exit-code contract: any error — including `TestsFailed` when tests @@ -273,4 +808,45 @@ mod tests { "error should be actionable: {err}" ); } + + #[test] + fn test_diff_summary_counts_file_errors_as_failures() { + let mut tally = DiffTally::default(); + *tally.classes.entry(DiffClass::Pass.label()).or_insert(0) += 1; + tally.file_errors.push("broken.json".to_string()); + let err = diff_summary_error(&tally); + match err.kind { + TestErrorKind::TestsFailed { failed, total } => { + assert_eq!((failed, total), (1, 1), "the unreadable fixture is a failure"); + } + other => panic!("expected TestsFailed, got {other:?}"), + } + assert!( + !err.to_string().contains("Error: 0 tests failed"), + "must not claim zero failures: {err}" + ); + } + + #[test] + fn test_diff_summary_reports_a_run_that_judged_nothing() { + let err = diff_summary_error(&DiffTally::default()); + let text = err.to_string(); + assert!(text.contains("no fixture was judged"), "{text}"); + assert!(!text.contains("0 tests failed"), "{text}"); + + let mut tally = DiffTally::default(); + tally.file_errors.push("broken.json".to_string()); + let err = diff_summary_error(&tally); + let text = err.to_string(); + assert!(text.contains("no fixture was judged"), "{text}"); + assert!(text.contains("1 fixtures unreadable"), "{text}"); + } + + #[test] + fn test_chaos_summary_reports_a_run_that_judged_nothing() { + let err = chaos_summary_error(&ChaosSweepTally::default()); + let text = err.to_string(); + assert!(text.contains("no vector was judged"), "{text}"); + assert!(!text.contains("0 tests failed"), "{text}"); + } } diff --git a/crates/state-test/tests/cli_exit.rs b/crates/state-test/tests/cli_exit.rs index e3b20cc5..9b438eaf 100644 --- a/crates/state-test/tests/cli_exit.rs +++ b/crates/state-test/tests/cli_exit.rs @@ -62,8 +62,20 @@ fn run_cli(args: &[&str]) -> std::process::Output { Command::new(env!("CARGO_BIN_EXE_state-test")).args(args).output().expect("spawn state-test") } +fn stderr(out: &std::process::Output) -> String { + String::from_utf8_lossy(&out.stderr).into_owned() +} + +fn stdout(out: &std::process::Output) -> String { + String::from_utf8_lossy(&out.stdout).into_owned() +} + +fn combined(out: &std::process::Output) -> String { + format!("{}\n{}", stdout(out), stderr(out)) +} + #[test] -fn failing_tests_exit_with_code_1() { +fn test_failing_tests_exit_with_code_1() { let path = write_fixture("failing.json", FAILING_SUITE); let path = path.to_str().expect("utf8 path"); @@ -81,21 +93,291 @@ fn failing_tests_exit_with_code_1() { } #[test] -fn invalid_path_exits_with_code_1() { +fn test_invalid_path_exits_with_code_1() { let out = run_cli(&["/nonexistent/state_test_cli_exit_4928"]); assert_eq!(out.status.code(), Some(1)); assert!(!out.stderr.is_empty(), "stderr should carry the error message"); } #[test] -fn passing_run_exits_with_code_0() { - // The same unit with an empty `post` validates trivially: the run completes - // with zero errors and must keep exiting 0. +fn test_passing_run_exits_with_code_0() { + // A fixture whose recorded roots are the ones its execution produces. `--fill` computes them, + // which is also what makes this a run with something in it to pass: the expectation exists and + // is checked. let mut suite: serde_json::Value = serde_json::from_str(FAILING_SUITE).expect("parse"); suite["exit_code_test"]["post"] = serde_json::json!({}); - let passing = serde_json::to_string(&suite).expect("serialize"); + let path = write_fixture("passing.json", &serde_json::to_string(&suite).expect("serialize")); + let path = path.to_str().expect("utf8 path"); - let path = write_fixture("passing.json", &passing); + let out = run_cli(&[path, "--fill", "--bench-spec", "Rex5"]); + assert_eq!(out.status.code(), Some(0), "fill must succeed: {}", stderr(&out)); + + let out = run_cli(&[path]); + assert_eq!(out.status.code(), Some(0), "passing run must exit 0: {}", stderr(&out)); + assert!( + String::from_utf8_lossy(&out.stdout).contains("All tests passed!"), + "and say so: {}", + String::from_utf8_lossy(&out.stdout) + ); +} + +#[test] +fn test_validate_run_over_a_unit_with_no_expectation_exits_1() { + // A unit whose `post` is empty is walked, executed against nothing, and counted by nothing. + // Reading that as a pass makes "the runner checked this file" true of a file that pins no + // behavior at all — and `--fill --force` writing an empty `post` is one bug away. + let mut suite: serde_json::Value = serde_json::from_str(FAILING_SUITE).expect("parse"); + suite["exit_code_test"]["post"] = serde_json::json!({}); + let path = write_fixture("no_expectation.json", &serde_json::to_string(&suite).expect("ser")); + + let out = run_cli(&[path.to_str().expect("utf8 path")]); + assert_eq!(out.status.code(), Some(1), "a unit that pins nothing is not a passing run"); + assert!( + stderr(&out).contains("no fixture expectation was validated"), + "stderr should say what was missing: {}", + stderr(&out) + ); + + // The same file with a `post` that holds an empty vector list: a unit, a spec, and still no + // expectation to check. + let mut suite: serde_json::Value = serde_json::from_str(FAILING_SUITE).expect("parse"); + suite["exit_code_test"]["post"] = serde_json::json!({ "Rex5": [] }); + let path = write_fixture("empty_vector_list.json", &serde_json::to_string(&suite).expect("s")); let out = run_cli(&[path.to_str().expect("utf8 path")]); - assert_eq!(out.status.code(), Some(0), "passing run must exit 0"); + assert_eq!(out.status.code(), Some(1), "an empty vector list judges nothing either"); +} + +#[test] +fn test_fill_that_filled_nothing_exits_1() { + // `--keep-going` decides when a run stops, not whether an empty one counts. Without it the + // fill loop simply has nothing to fail at, so a corpus that never arrived walks no file, + // writes no fixture, and used to exit 0 — the one report that must never read as a pass. + let dir = std::env::temp_dir().join("state_test_cli_exit_empty_fill"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("mkdir"); + let dir_arg = dir.to_str().expect("utf8 path"); + + for args in [ + vec![dir_arg, "--fill", "--bench-spec", "Rex7"], + vec![dir_arg, "--fill", "--keep-going", "--bench-spec", "Rex7"], + ] { + let out = run_cli(&args); + assert_eq!(out.status.code(), Some(1), "a fill that filled nothing must fail: {args:?}"); + assert!( + stderr(&out).contains("no transaction vector was filled"), + "stderr should say what was missing: {}", + stderr(&out) + ); + } + + // A corpus of nothing but files the runner skips by name reaches the fill loop and still + // fills nothing. + std::fs::write(dir.join("ValueOverflow.json"), FAILING_SUITE).expect("write"); + let out = run_cli(&[dir_arg, "--fill", "--keep-going", "--force", "--bench-spec", "Rex7"]); + assert_eq!(out.status.code(), Some(1), "a skipped-only corpus fills nothing"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn test_fill_tally_counts_transaction_vectors() { + // The tally a sweep gates on has to count what the differential sweep counts, or the two + // numbers cannot be compared with each other or against a baseline recorded under the other + // mode. A unit is a family of transactions; the vector is the unit both modes agree on. + let mut suite: serde_json::Value = serde_json::from_str(FAILING_SUITE).expect("parse"); + suite["exit_code_test"]["transaction"]["data"] = serde_json::json!(["0x", "0xdeadbeef"]); + let entry = |data: usize| { + serde_json::json!({ + "indexes": { "data": data, "gas": 0, "value": 0 }, + "hash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "logs": "0x0000000000000000000000000000000000000000000000000000000000000000" + }) + }; + suite["exit_code_test"]["post"] = serde_json::json!({ "Rex5": [entry(0), entry(1)] }); + let path = write_fixture("tally_vectors.json", &serde_json::to_string(&suite).expect("ser")); + let path = path.to_str().expect("utf8 path"); + + let out = run_cli(&[path, "--fill", "--force", "--keep-going", "--bench-spec", "Rex7"]); + assert_eq!(out.status.code(), Some(0), "{}", stderr(&out)); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("Fill tally: OK=2 ERR=0 PANIC=0 FILE_ERR=0 SKIP_FILE=0 TOTAL=2"), + "one unit, two vectors, two filled: {stdout}" + ); + + // The differential sweep over the same fixture reports the same total. + let out = run_cli(&[path, "--bench-spec", "Rex7", "--diff-spec", "Rex6"]); + assert_eq!(out.status.code(), Some(0), "{}", stderr(&out)); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("over 2 unit(s)"), "the same two vectors: {stdout}"); +} + +#[test] +fn test_diff_run_with_no_unexplained_difference_exits_with_code_0() { + let mut suite: serde_json::Value = serde_json::from_str(FAILING_SUITE).expect("parse"); + // The differential run computes both sides itself; the recorded `post` is irrelevant, and an + // empty one keeps the fixture honest about that. + suite["exit_code_test"]["post"] = serde_json::json!({}); + let path = write_fixture("diff_pass.json", &serde_json::to_string(&suite).expect("serialize")); + let report = write_fixture("diff_pass_report.json", ""); + + let out = run_cli(&[ + path.to_str().expect("utf8 path"), + "--bench-spec", + "Rex7", + "--diff-spec", + "Rex6", + "--diff-report", + report.to_str().expect("utf8 path"), + ]); + assert_eq!(out.status.code(), Some(0), "a run with no unexplained difference must exit 0"); + + let written: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&report).expect("read report")) + .expect("report is json"); + assert_eq!(written["targetSpec"], "Rex7"); + assert_eq!(written["baseSpec"], "Rex6"); + assert_eq!(written["classes"]["PASS"], 1); + assert_eq!(written["classes"]["UNEXPLAINED"], 0); +} + +#[test] +fn test_diff_run_over_an_unauthorized_spec_pair_is_refused() { + // Every rule in the classifier is a reading of one sentence, the Rex7 precision invariant, + // which relates Rex7 to Rex6 and states nothing about any other pair. Pointed at another pair + // it would grant a licence that pair never had — deciding, from mechanisms that are evidence + // for nothing there, that a difference is fine. It refuses instead of judging. + let mut suite: serde_json::Value = serde_json::from_str(FAILING_SUITE).expect("parse"); + suite["exit_code_test"]["post"] = serde_json::json!({}); + let path = write_fixture("diff_pair.json", &serde_json::to_string(&suite).expect("serialize")); + let path = path.to_str().expect("utf8 path"); + + for (target, base) in [("Rex7", "Equivalence"), ("Rex6", "Rex5"), ("Rex6", "Rex7")] { + let out = run_cli(&[path, "--bench-spec", target, "--diff-spec", base]); + assert_eq!(out.status.code(), Some(1), "{target} vs {base} must be refused"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("Rex7") && stderr.contains("Rex6"), + "the error should name the one supported pair: {stderr}" + ); + } +} + +#[test] +fn test_validate_run_that_judged_nothing_exits_1() { + // Same hole as in the differential mode, one mode over: a corpus whose every file is on the + // validation skip list walks files, reaches no unit, and reports zero errors. + let dir = std::env::temp_dir().join("state_test_cli_exit_all_skipped"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("mkdir"); + std::fs::write(dir.join("ValueOverflow.json"), FAILING_SUITE).expect("write"); + + let out = run_cli(&[dir.to_str().expect("utf8 path")]); + assert_eq!(out.status.code(), Some(1), "a run that validated nothing must fail"); + assert!( + stderr(&out).contains("no fixture expectation was validated"), + "stderr should say what was missing: {}", + stderr(&out) + ); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn test_diff_run_that_judged_nothing_exits_1() { + // A sweep whose corpus never arrived reaches the gate with an empty tally: zero panics, zero + // unexplained differences, every count truthful and meaningless. It must not read as a pass. + let dir = std::env::temp_dir().join("state_test_cli_exit_empty_corpus"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("mkdir"); + + let out = + run_cli(&[dir.to_str().expect("utf8 path"), "--bench-spec", "Rex7", "--diff-spec", "Rex6"]); + assert_eq!(out.status.code(), Some(1), "a corpus with no fixture in it must fail"); + let empty = combined(&out); + assert!( + empty.contains("no JSON test files found") || empty.contains("no fixture was judged"), + "an empty corpus must say nothing was judged: {empty}" + ); + assert!( + !empty.contains("Error: 0 tests failed"), + "must not claim zero failures while exiting 1: {empty}" + ); + + // A directory holding only fixtures on the validation skip list reaches the runner but judges + // no unit, which is the same hole one step further in. + std::fs::write(dir.join("ValueOverflow.json"), FAILING_SUITE).expect("write"); + let out = + run_cli(&[dir.to_str().expect("utf8 path"), "--bench-spec", "Rex7", "--diff-spec", "Rex6"]); + assert_eq!(out.status.code(), Some(1), "a sweep that judged no unit must fail"); + let skipped = combined(&out); + assert!( + skipped.contains("no fixture was judged"), + "a sweep that judged no unit must say so: {skipped}" + ); + assert!( + !skipped.contains("Error: 0 tests failed"), + "must not claim zero failures while exiting 1: {skipped}" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn test_diff_run_with_an_unparseable_fixture_exits_1() { + // A file the sweep cannot parse is a fixture it did not judge. Skipping it quietly is how a + // corpus shrinks without anyone noticing. + let dir = std::env::temp_dir().join("state_test_cli_exit_bad_fixture"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("mkdir"); + let mut suite: serde_json::Value = serde_json::from_str(FAILING_SUITE).expect("parse"); + suite["exit_code_test"]["post"] = serde_json::json!({}); + std::fs::write(dir.join("good.json"), serde_json::to_string(&suite).expect("serialize")) + .expect("write"); + std::fs::write(dir.join("broken.json"), "{ not json").expect("write"); + + let out = + run_cli(&[dir.to_str().expect("utf8 path"), "--bench-spec", "Rex7", "--diff-spec", "Rex6"]); + let stdout = stdout(&out); + let report = combined(&out); + assert!( + stdout.lines().any(|l| l.split_whitespace().eq(["PASS", "1"])), + "the readable fixture still runs: {stdout}" + ); + assert!(stdout.contains("FILE_ERROR"), "the unreadable one is reported: {stdout}"); + assert!( + report.contains("1 fixtures unreadable"), + "the summary must count unreadable fixtures: {report}" + ); + assert!( + !report.contains("Error: 0 tests failed"), + "must not claim zero failures while exiting 1: {report}" + ); + assert_eq!(out.status.code(), Some(1), "a corpus the sweep only partly read is not a pass"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn test_diff_spec_requires_an_explicit_target_spec() { + let path = write_fixture("diff_needs_target.json", FAILING_SUITE); + let out = run_cli(&[path.to_str().expect("utf8 path"), "--diff-spec", "Rex6"]); + assert_eq!(out.status.code(), Some(2), "clap rejects the incomplete flag combination"); + assert!( + String::from_utf8_lossy(&out.stderr).contains("bench-spec"), + "the error should name the missing flag" + ); +} + +#[test] +fn test_diff_spec_rejects_an_unknown_spec_name() { + let path = write_fixture("diff_bad_spec.json", FAILING_SUITE); + let out = run_cli(&[ + path.to_str().expect("utf8 path"), + "--bench-spec", + "Rex7", + "--diff-spec", + "FutureFork9000", + ]); + assert_eq!(out.status.code(), Some(1)); + assert!( + String::from_utf8_lossy(&out.stderr).contains("--diff-spec"), + "the error should name the offending flag" + ); } diff --git a/docs/spec/evm/compute-gas.md b/docs/spec/evm/compute-gas.md index 5a1069d9..50d93cf5 100644 --- a/docs/spec/evm/compute-gas.md +++ b/docs/spec/evm/compute-gas.md @@ -334,20 +334,36 @@ These conditions apply on every spec; only the point at which the recording happ | Spec | Recording point | | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Rex5+ | Atomically with the deployment commit: recorded when the deployment's pre-commit success conditions hold, at the same point the EVM charges the code-deposit gas and commits the created contract. | +| Rex5–Rex6 | Atomically with the deployment commit: recorded when the deployment's pre-commit success conditions hold, at the same point the EVM charges the code-deposit gas and commits the created contract. | | MiniRex–Rex4 | During frame-return processing, in the window covering the EVM's code-deposit charge. | A node MUST NOT record this amount twice. -The recording itself can latch a compute-gas exceed, and the two recording points then produce different deployment outcomes: +The recording interacts with the compute-gas limit, and the two recording points then produce different outcomes: -- From Rex5, the recording precedes the commit: the frame fails as specified in [Exceed Behavior](#exceed-behavior) and the deployment commits nothing, but the recorded amount stands — recording precedes exceed evaluation, and compute gas is never reverted. +- Under Rex5 and Rex6, the recording precedes the commit: the frame fails as specified in [Exceed Behavior](#exceed-behavior) and the deployment commits nothing, but the recorded amount stands — recording precedes exceed evaluation, and compute gas is never reverted. + The amount stands on every path that fails the frame after it, not only on a compute-gas exceed. - Under Rex4, the only earlier spec with a per-frame budget, the recording happens after the EVM has already charged the deposit and committed the created contract. A frame-budget exceed latched by this recording therefore produces a split outcome: the frame's result is the frame-local revert, while the deployed code remains committed. A node MUST NOT roll the deployment back on this path. The code-deposit _storage_ gas is charged before this window opens and therefore falls outside it, consistent with the [storage gas exclusion](#storage-gas-exclusion). +
+Rex7 (unstable): the code-deposit amount is weighed before it is recorded + +Rex7 replaces the Rex5 recording point with a conditional one: the amount is evaluated once the frame's own accounting for the exit is complete, and recorded only if it fits the budgets it is weighed against. +The full previous/new pairing is on the [Rex7 Network Upgrade](../upgrades/rex7.md) page; the normative rules for implementers follow. + +A node MUST evaluate the frame-local and transaction-level compute budgets against the frame's usage plus this amount, and MUST NOT record the amount when either would be exceeded. +That evaluation MUST happen once the frame's own accounting for the exit is complete — its final segment settled and its frame-exit resource usage merged — so the amount is weighed against the frame's whole usage rather than a total still missing part of it. +A frame that failed on any dimension before this point never reaches the evaluation: the EVM does not charge a deposit such a frame will not make, so there is nothing to record. +When the amount does not fit, the frame fails as specified in [Exceed Behavior](#exceed-behavior) and the deployment commits nothing — the same outcome Rex5 and Rex6 produce — but the transaction's compute total reports only what it spent. +A frame-local exceed on this path MUST NOT be latched: with the amount unrecorded the transaction is within every limit, and the frames above it MAY continue. +A transaction-level exceed MUST be latched and MUST halt the transaction with the usual gas rescue, and MUST carry the same detention attribution it would have carried had the amount been recorded. + +
+ #### Keyless Deploy Sandbox From Rex3 onward, a node MUST record the [KeylessDeploy](../system-contracts/keyless-deploy.md) fixed dispatch overhead (`KEYLESS_DEPLOY_OVERHEAD_GAS`) as compute gas when that overhead is charged. @@ -434,6 +450,19 @@ The rule admits no exception: the keyless-deploy dispatch path rescues on the sa Rescue is specific to a transaction-level exceed. A frame-local exceed needs none: the frame reverts and its unspent gas returns to the parent through ordinary frame accounting. +Through Rex6, the frame's state does not follow that revert. +A node commits or reverts a frame's journal checkpoint from the frame's instruction result when the frame's action is processed, which is before the frame-local rewrite reaches the result; a frame that ran to a successful exit therefore reports the revert over state that stays committed. + +
+Rex7 (unstable): the frame's state follows its final result + +Under Rex7, a node MUST decide a frame's journal outcome from the frame's final result — the result after every settlement and every rewrite the node applies at that frame's exit — so a frame that reports a revert has reverted. + +The rule reaches every exceed the frame itself latched while it ran. +It reaches one first detected on the way out to the caller too — that one weighs the frame's usage against the caller's budget after the merge, so under Rex7 a node determines it before the merge and rewrites the frame's result first; see [Per-Call-Frame Runtime Budgets](resource-limits.md#per-call-frame-runtime-budgets). + +
+ When a `CALL`-family or `CREATE` / `CREATE2` opcode fails on a compute-gas exceed — the frame-local revert and the transaction-level halt alike — its pending child frame is discarded before the child runs. A node MUST return the gas already forwarded to that discarded child to the frame before it terminates, so that gas is not charged as consumed: on a frame-local revert it returns to the parent frame, and on a transaction-level halt it is excluded from the transaction's `gas_used`. @@ -444,6 +473,188 @@ The transaction's standard EVM `gas_limit` remains the only bound that can halt A node MUST record compute gas before evaluating any exceed, including an exceed already latched on another resource dimension. The compute work was performed, and the recorded total feeds the transaction outcome and the block-level compute accounting even for a transaction halted on a different dimension. +
+Rex7 (unstable): checkpoint settlement and gas-clamp enforcement + +Rex7 replaces per-opcode recording for plain opcodes with checkpoint settlement, and enforces compute-gas and detention limits inside plain segments by clamping interpreter-visible gas. +The full previous/new pairing is on the [Rex7 Network Upgrade](../upgrades/rex7.md) page; the normative rules for implementers follow. + +#### Checkpoint set + +A node MUST settle compute gas at each of the following **checkpoints**, and MUST NOT open a per-opcode measurement window for any other opcode: + +- storage-gas opcodes: `SSTORE`, `LOG0`–`LOG4`, `SELFDESTRUCT`; +- call-family opcodes: `CALL`, `CALLCODE`, `DELEGATECALL`, `STATICCALL`; +- create opcodes: `CREATE`, `CREATE2`; +- volatile / detention-guarded opcodes: the unconditional block-environment set, the beneficiary-conditional set, and oracle-conditional `SLOAD` (same membership as the Volatile class and the call-family / `SELFDESTRUCT` beneficiary guards above); +- the `GAS` opcode; +- frame entry, frame resume after a child returns, and frame exit. + +Plain opcodes between checkpoints MUST run without recording compute gas when they finish. + +#### Segment settlement + +At each checkpoint a node MUST: + +1. Settle the open plain-opcode segment as the interpreter-gas delta since the previous checkpoint or frame open/resume, applying the same storage-gas and forwarded-child exclusions as the checkpoint opcode's measurement window under this page's stable rules. +2. Record that segment amount as compute gas and evaluate the compute-gas limit (and any latched non-compute resource-limit exceed) at that checkpoint — the latch-surface point is the next checkpoint rather than the next per-opcode recording site. +3. Record the checkpoint opcode's own body under the measurement-window rules for its metering class, then re-open the settlement window. + +Of the non-opcode recording sites on this page, intrinsic gas, successful or reverting precompiles and KeylessDeploy are unchanged. +Code deposit is not: Rex7 weighs the amount against the frame-local and transaction-level compute budgets before recording it, and records nothing when it does not fit or when the frame had already failed, as specified under [Contract Creation Code Deposit](#contract-creation-code-deposit). +A precompile that fails is split under the exceptional-halt carve-out below. + +For every transaction that stays within every runtime resource limit, in which no frame ends in an exceptional halt, and in which no `disableVolatileDataAccess` guard rejects an opcode, a node MUST produce the same recorded compute-gas total, the same four-dimension usage, the same receipt `gas_used`, the same execution result, and the same state as under Rex6. + +#### Gas-clamp enforcement + +After settlement and body recording at a checkpoint (and at frame entry and resume), a node MUST clamp the interpreter-visible remaining gas to the remaining compute headroom — the minimum of the current frame's remaining per-frame compute budget and the transaction-level remaining budget under the effective limit (including detention) — and MUST restore the hidden amount before the next checkpoint body, before `GAS` is observed, before call-gas forwarding, and before storage-gas charges. + +The clamp is in force for the segment that follows whenever the true remaining gas is at or above the headroom, and a node MUST remember which constraint bound it along with that constraint's own limit value. +An exact equality is a binding clamp that hides nothing, not the absence of a clamp. +When the true remaining gas is below the headroom, no clamp is in force and an out-of-gas inside the segment is the inherited EVM's own. + +Inside a plain-opcode segment: + +- An opcode that would cost more than the clamped visible remainder MUST NOT execute. +- The frame's final result MUST restore the hidden gas. +- The node MUST reclassify that out-of-gas as the resource-limit exceed the clamp stood for: frame-local budget → frame revert with `MegaLimitExceeded`; transaction-level compute → transaction halt with `OutOfGas` and rescued remaining gas; detained limit → transaction halt with `VolatileDataAccessOutOfGas` and rescued remaining gas. + +The `limit` reported by either shape MUST be the constraint that bound the clamp — the frame's own compute budget for a frame-local binding, the effective transaction-level limit otherwise — matching what the per-opcode check path on this page reports. + +Because the crossing opcode never executes, a node MUST NOT include its cost in recorded compute-gas usage. +The `actual` a transaction-level clamp halt reports MUST be the transaction's final compute usage, after the frame-exit settlement has closed the partial segment the crossing opcode stopped inside. + +A checkpoint that still carries a non-zero static fee — `GAS` and `LOG0` through `LOG4` — MAY itself be the crossing opcode of the preceding plain-opcode segment. +When the clamped visible remainder is less than that fee, the inherited per-opcode check stops the opcode before the body runs, and a node MUST treat that stop as a plain-segment crossing. +The CALL family is the same stop: its static fee is charged before the body, so a clamped remainder below that fee stops the opcode before the target account is read. +`CREATE` and `CREATE2` charge their inherited creation fee inside the body, after the true remaining gas has been restored, so a compute headroom below that fee MUST NOT stop them before the body. + +When the current frame's remaining per-frame compute budget equals the transaction-level remaining budget, a node MUST bind the clamp to the transaction-level constraint (including detention when detention is the effective transaction-level bound). +A clamp-induced exceed under that binding MUST halt the transaction with gas rescue; a node MUST NOT classify the equality as frame-local. +Through Rex6, the same equality is classified by the per-opcode check as a frame-local exceed; at the top-level frame that surfaces as a revert rather than a halt. + +When the crossing opcode would exhaust both the true remaining EVM gas and the compute headroom, a node MUST attribute the halt to the compute-gas or detention limit (with rescue) rather than to ordinary EVM out-of-gas. + +#### Exceptional-halt frame carve-out + +A frame that ends in an exceptional halt — ordinary out-of-gas, memory out-of-gas, stack underflow or overflow, invalid jump, unknown opcode, and every other error result — returns none of its remaining budget. +A node MUST settle that budget as compute gas, apart from any MegaETH storage gas a checkpoint body charged before aborting: that charge was taken on the storage-gas lane, stays there, and belongs to neither part below. +A node MUST split what remains into two parts that are accounted differently: + +- **Executed** — the open plain-opcode segment, measured as the interpreter-gas delta since the previous checkpoint, net of that storage charge. + A checkpoint opcode that halts inside its own body never reaches the recording that closes its measurement window, so the EVM gas the body had already charged — the value-transfer surcharge and the argument / return-range memory expansion a call-family body takes before it loads the target account — is still inside that segment when the frame exits, and belongs to this part. + This is work the network performed, and a node MUST record it through the ordinary path: it counts toward the transaction's reported total **and** toward the usage every resource limit is evaluated against, exactly as the same opcodes would if the frame had returned normally. +- **Destroyed** — the budget the frame never spent and never handed back. + A node MUST record it in the reported compute-gas total and in block-level compute accounting, and MUST NOT evaluate any resource limit against it, at transaction level or at block level (see [Resource Limits](resource-limits.md)). + +The destroyed part is bounded by the sender's gas envelope rather than by the compute limit, and halting on it would rescue gas the EVM already destroyed and change the receipt this carve-out requires to stay identical. +The executed part carries no such problem: it is work, and leaving it out of enforcement would let a frame that keeps executing after absorbing a failed child spend the same compute headroom a second time. + +#### Destroyed compute gas + +The destroyed part of a transaction is defined by a conservation law over the gas the transaction spent, not by an enumeration of the places that can destroy an envelope. + +Every unit of EVM gas a transaction spends is exactly one of three things: compute work its frames performed, MegaETH storage gas, or budget that was lost without anything being executed for it. +Two of those three are recorded as they happen, so a node MUST derive the third: + +`destroyed = spent + minted_stipends − storage_gas − executed_compute` + +- `spent` — the EVM gas the transaction's envelope burnt, read once, at the moment the envelope is final: after the transaction's gas accounting has settled and any resource-limit gas rescue has been returned to the sender, and before the EIP-3529 refund and the EIP-7623 floor are applied. + Those two move the number the receipt reports without anything having been burnt, so a node MUST NOT read `spent` after them. + Gas rescued for the sender, and gas the clamp was hiding, are both out of the envelope by this point and MUST NOT be added back. + A failed deposit transaction, whose result is rebuilt after that point, is the one exception; the rule for it is below. +- `minted_stipends` — the sum of `CALL_STIPEND` over the transaction's value-transferring `CALL` and `CALLCODE` invocations, counted once per stipend the EVM mints. + The inherited EVM grants that stipend to the child's frame budget without debiting the caller's gas counter, so the frames between them record one stipend more work than the envelope funded, per such call, whatever becomes of the stipend afterwards. + The mint is created when the invocation is handed to the EVM, before the child is entered, and a node MUST count it from that point rather than from the child frame running: an invocation turned away at frame entry — for want of balance, or at the call-depth limit — hands the whole child budget back to the caller with the stipend inside it, which shrinks the envelope against recorded work by exactly as much as a child that ran and returned it would. + An invocation a node halts before handing it to the EVM, which is what a compute-gas limit reached at the call site does, mints nothing and a node MUST NOT count it. + A node MUST add the total back; without it the two sides of the law disagree by exactly that amount. +- `storage_gas` — the MegaETH storage gas the transaction was charged: the storage-gas share of intrinsic gas, the in-frame storage-gas surcharges, the code-deposit charge, and the charges a system contract invocation takes outside an EVM frame. + At a nested-execution boundary this term takes the **difference** between what the nested execution cost the outer gas counter and what it recorded as compute, which can be negative when the nested execution's own EIP-3529 refund outgrew its storage gas; a node MUST NOT clamp that contribution at zero. +- `executed_compute` — the compute gas the transaction is recorded as having performed, fixed site by site by the rules below, and the quantity every resource limit is evaluated against. + It equals the reported total less the destroyed part, but that identity is a consequence of the law rather than a definition of either side. + +The result is the number a node MUST report as the transaction's destroyed compute gas. +A node MUST NOT report a negative result: the law cannot produce one on this spec, and a node that computes one MUST report zero rather than a wrapped value. + +The law defines a reported quantity, and nothing else. +Enforcement — the transaction's own compute-gas limit, and the block's enforced compute counter, which accumulates each transaction's `executed_compute` — runs on the recorded work at every level, never on this remainder or on a total with it subtracted back out. +The two readings agree by construction of the law; keeping enforcement on the recorded side is what confines an error in the derivation to the number it reports. + +The rules that follow fix `executed_compute` at each site that can leave budget unspent, which is what makes the law's remainder well defined; they are not themselves the definition of the destroyed total. + +A node MUST record each producer at the site the table names, and MUST NOT record it at any other site. + +| Producer | Recording site | +| --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| A frame that ends in an exceptional halt, including a creation rejected at code deposit | The frame's final-result settlement | +| A call or creation the inherited EVM refuses before it opens a frame | The same settlement, classified by whether the refusal swallows the child budget or hands it back | +| A precompile invocation that fails | The same settlement, against the numbers the precompile's own recording site fixed; a node MUST NOT also record it as a refusal | +| A system-contract invocation answered without an EVM frame, when the answer is a halt that keeps the call's gas | The site that produces the answer | +| A failed-deposit receipt rebuild | The rebuild of the envelope, as the gap between that envelope and every earlier recording | +| An ordinary transaction rejected during validation because intrinsic gas outgrew the sender's gas limit | Nowhere: the transaction produces no receipt | + +The classification that decides whether a result swallows its remaining budget or hands it back MUST be exhaustive over the inherited instruction-result space. +Every result the inherited EVM can produce MUST be assigned swallowed, returned, or unreachable. +A newly introduced result MUST NOT be assigned by a default arm. + +A precompile invocation that fails is the same split. +A precompile never becomes a child EVM frame, so the frame-exit settlement cannot see it; a node MUST take the split from the classification the call returns to its caller, which is what decides whether the caller reclaims the remainder. + +- **Executed** — the work the precompile performed: the KZG point-evaluation fixed cost when that precompile reached verification and returned a non-out-of-gas error, and zero when the invocation was rejected before any work. + For KZG the dividing line is its own input-length check, which runs before the commitment is read: an input whose length is not `KZG_POINT_EVALUATION_INPUT_LENGTH` is turned away before any work, while every other non-out-of-gas failure is raised once verification is under way and is priced at the whole fixed cost regardless of how far it got. + A node MUST price an unrecognised non-out-of-gas KZG failure as verification under way, so an unfamiliar failure can only over-charge. + A node MUST record the executed part through the ordinary enforcing path. +- **Destroyed** — the rest of the call's gas limit: the caller-supplied envelope minus the executed part. + On a value-transferring call the envelope includes the protocol-granted call stipend, so it can exceed what the parent itself funded. + That loss is the uncapped forwarded envelope, not the Rex5-capped effective gas limit; when the cap binds, the gap belongs to the destroyed part. + A node MUST record it in the reported total and MUST NOT evaluate any resource limit against it. + +Through Rex6 the generic error arm recorded the effective gas limit as enforcing usage. +Under Rex7 that arm enforces nothing, which is a deliberate enforcement difference. +The Rex5 forwarded-gas cap is unchanged: a precompile still MUST NOT perform more work than the remaining compute budget. + +A [system contract](../system-contracts/overview.md) invocation a node answers without opening an EVM frame takes the same split, at the site that produces the answer. +It applies only when the answer is a halt that keeps the call's gas: the part the invocation performed before failing is executed, and the rest of the call's gas limit is destroyed. +An answer that returns or reverts hands the gas back to the caller, and a halt whose remaining gas is rescued for the sender is a refund; a node MUST NOT record either as destroyed, because that gas was not lost. + +A call or creation an inherited EVM refuses before it opens a frame takes the same split, at the site that produces the refusal. +The refusal hands back a result carrying the whole child budget, and the classification decides that budget's fate. +A creation onto an address that already holds code or a nonce, and a value transfer that overflows the recipient's balance, are exceptional halts whose budget the caller never sees again; the frame never ran, so nothing was executed and a node MUST record the whole budget as destroyed. +A refusal classified as a success or a revert — a call or creation past the call-stack limit, a creation whose value exceeds the caller's balance, a creation from an account whose nonce cannot be bumped, a call into an account with no code — hands the budget straight back to the caller, and a node MUST NOT record any of it as destroyed. +A precompile invocation is answered on this same path and is covered by its own rule above; a node MUST NOT book it a second time here. + +An ordinary transaction a node rejects during validation has no envelope to split. +Since [Rex5](../upgrades/rex5.md) a transaction whose intrinsic gas requirement outgrows the gas limit its sender supplied is rejected during validation — after every MegaETH storage-gas contribution has been folded into the intrinsic total and before the sender is debited — so it produces no receipt. +A node MUST NOT record such a transaction's gas limit as a destroyed remainder. + +A deposit transaction is not allowed to fail, and that is where the exception lies. +A deposit a node would otherwise reject during validation, and a deposit that halts during execution, are both rebuilt into a receipt reporting the transaction's whole gas limit, with state rolled back to the sender's nonce bump and the deposit's mint. +The rebuild runs after every recording and settlement site, so it is the last thing that decides the envelope: a node MUST derive the law against the rebuilt envelope rather than against the one the transaction reached on its own. +The difference between the two is destroyed compute gas, because the receipt burns it and nothing was executed for it. +The two shapes arrive from opposite positions and the law covers both without distinguishing them — a rejected deposit has recorded only the standard-EVM share of its intrinsic gas and settled nothing, while a halted deposit has already settled against the smaller envelope its resource-limit gas rescue left behind. +A node MUST NOT let the rebuild change `executed_compute`: nothing was executed for the difference between the rebuilt envelope and the one the transaction reached on its own, so that difference MUST NOT consume compute capacity at transaction or block level. +What each shape recorded before the rebuild stands, and is enforced. +A rejected deposit therefore enforces the standard-EVM share of its intrinsic gas — the amount recorded before validation returned the error — and a halted deposit enforces everything it had settled; on both shapes that is exactly what Rex6 records for the same transaction, and the destroyed remainder is an addition to the reported total rather than a change to the enforced one. + +The split MUST be driven by the halt classification rather than by the interpreter's own counter, which an inherited EVM zeroes for ordinary out-of-gas only. +That zeroing has one consequence a node MUST accept: for an ordinary out-of-gas taken with no clamp in force, the counter is already zero when the frame exits, so the whole segment measures as executed and is enforced in full. +A node MUST NOT try to recover the split in that case. +It is the one shape where Rex7 enforcement is stricter than per-opcode enforcement through Rex6, which attributes the failing opcode to neither part. + +A node MUST take the split from the frame's **final** result, after the create-return processing that can still turn a successful constructor into a canonical code-deposit out-of-gas, an EIP-3541 reject or a runtime code-size reject. +Each of those destroys the frame's remainder just as a halt from the interpreter loop does. + +Under per-opcode recording through Rex6 neither the failing opcode nor the destroyed remainder is attributed to compute gas, so a transaction that halts exceptionally, or that contains an inner call frame which does, MAY report a strictly higher compute-gas total under Rex7 while EVM gas and the receipt remain identical. + +A clamp-induced out-of-gas is not an exceptional halt for this rule — the crossing opcode never executed and the remaining gas is rescued rather than destroyed. +A frame whose exit latches a resource-limit exceed destroys nothing either: it reverts to its parent (frame-local) or halts the transaction with its gas rescued (transaction-level). + +When a nested execution merges its usage into an outer one — the [KeylessDeploy](../system-contracts/keyless-deploy.md) sandbox is the only such boundary — a node MUST carry the split across it, reporting the inner total in full while enforcing only the executed part. +The outer transaction's destroyed total is still derived once, from its own envelope, after the merge. + +
+ #### Keyless Deploy Exceed When recording the [KeylessDeploy](../system-contracts/keyless-deploy.md) dispatch overhead exceeds a compute gas limit, the outcome follows the frame-local / transaction-level split above, but the two branches are not observably the same: @@ -466,16 +677,17 @@ See [Resource Accounting](resource-accounting.md#revert-behavior). ## Constants -| Constant | Value | Spec | Description | -| ------------------------------- | ------------- | -------------- | ---------------------------------------------------------------------------------------------------------------- | -| `TX_COMPUTE_GAS_LIMIT` | 200,000,000 | Rex onward | Maximum compute gas per transaction from Rex onward | -| `TX_COMPUTE_GAS_LIMIT` | 1,000,000,000 | MiniRex | Maximum compute gas per transaction under MiniRex | -| `FRAME_LIMIT_NUMERATOR` | 98 | Rex4 onward | Numerator of the per-call-frame budget forwarding fraction | -| `FRAME_LIMIT_DENOMINATOR` | 100 | Rex4 onward | Denominator of the per-call-frame budget forwarding fraction | -| `CALL_STIPEND` | 2,300 | All | Standard EVM value-transfer call stipend, inherited unchanged | -| `CODEDEPOSIT` | 200 | All | Standard EVM per-byte code-deposit gas, inherited unchanged | -| `KEYLESS_DEPLOY_OVERHEAD_GAS` | 100,000 | Rex2 onward | Fixed dispatch overhead for a keyless deploy | -| `KZG_POINT_EVALUATION_GAS_COST` | 100,000 | MiniRex onward | MegaETH's fixed-cost override for the KZG point-evaluation precompile (defined in [Precompiles](precompiles.md)) | +| Constant | Value | Spec | Description | +| ----------------------------------- | ------------- | -------------- | ---------------------------------------------------------------------------------------------------------------- | +| `TX_COMPUTE_GAS_LIMIT` | 200,000,000 | Rex onward | Maximum compute gas per transaction from Rex onward | +| `TX_COMPUTE_GAS_LIMIT` | 1,000,000,000 | MiniRex | Maximum compute gas per transaction under MiniRex | +| `FRAME_LIMIT_NUMERATOR` | 98 | Rex4 onward | Numerator of the per-call-frame budget forwarding fraction | +| `FRAME_LIMIT_DENOMINATOR` | 100 | Rex4 onward | Denominator of the per-call-frame budget forwarding fraction | +| `CALL_STIPEND` | 2,300 | All | Standard EVM value-transfer call stipend, inherited unchanged | +| `CODEDEPOSIT` | 200 | All | Standard EVM per-byte code-deposit gas, inherited unchanged | +| `KEYLESS_DEPLOY_OVERHEAD_GAS` | 100,000 | Rex2 onward | Fixed dispatch overhead for a keyless deploy | +| `KZG_POINT_EVALUATION_GAS_COST` | 100,000 | MiniRex onward | MegaETH's fixed-cost override for the KZG point-evaluation precompile (defined in [Precompiles](precompiles.md)) | +| `KZG_POINT_EVALUATION_INPUT_LENGTH` | 192 | All | Required input length in bytes of the KZG point-evaluation precompile, inherited unchanged | The gas detention caps that lower the effective compute gas limit are defined in [Gas Detention](gas-detention.md). @@ -503,6 +715,16 @@ Permitting both lets an implementation choose whichever is cheaper at a given si For a value-transferring `CALL` or `CALLCODE`, the inherited EVM adds `CALL_STIPEND` to the child's gas limit without deducting it from the parent's remaining gas. Treating the child's full gas limit as forwarded would therefore subtract gas the parent never contributed, under-counting the parent's compute gas by the stipend. +
+Rex7 (unstable): why destroyed compute gas is defined by a conservation law + +A definition that enumerates the sites which can destroy an envelope is only as complete as the enumeration, and its completeness is not checkable — a site added later, or one an implementation reaches by a path the list did not anticipate, silently under-reports with nothing to notice it. +The conservation law has no such failure mode: it is stated over quantities a node already tracks for other reasons, so any envelope lost anywhere shows up in the remainder whether or not the loss was foreseen. +It also gives the site rules something to be checked against, since the two are computed independently and must agree. +The cost is one correction term — the inherited EVM's minted `CALL_STIPEND`, which makes recorded work exceed the envelope — and one ordering obligation on where the envelope is read. + +
+ **Why is the first `CALL`-family touch of a preload-warm address charged cold?** MegaETH's storage-gas pricing inspects the callee account before the opcode's own access, and that inspection materializes the account without inheriting its preloaded warmth, so the opcode's subsequent access observes a cold account. @@ -572,3 +794,4 @@ System-granted gas leaks to the sender, who recovers gas that was never theirs t - [Rex4](../upgrades/rex4.md) — introduced the per-call-frame compute gas budget; made gas detention caps relative to usage at the access point; added beneficiary volatile-access guards to the `CALL` family, `SELFDESTRUCT`, and `SELFBALANCE`. - [Rex5](../upgrades/rex5.md) — excluded the `CALL_STIPEND` from the forwarded-gas deduction; moved `CREATE2` memory-expansion recording ahead of the storage-gas charge; made contract-creation code-deposit compute gas atomic with the deployment commit; refined precompile compute-gas recording and bounded it by the remaining compute budget; added the `SELFDESTRUCT` empty-beneficiary storage-gas charge; removed `CALLCODE` from the cold first-touch charge and added `SELFDESTRUCT`'s beneficiary to it; stopped following EIP-7702 delegation in the pre-execution inspection, restoring inherited warmth for delegates. - [Rex6](../upgrades/rex6.md) — unified the measurement window across all storage-affecting opcodes and folded `CREATE2` memory expansion into it, ending the two-window exception; returned forwarded gas to the failing frame on a compute-gas exceed; rescued the unused envelope on a keyless-deploy dispatch exceed; made beneficiary detection delegation-aware, returning `CALLCODE` call targets to the cold first-touch charge; exempted system-originated transactions from the compute gas limit and gas detention. +- [Rex7](../upgrades/rex7.md) _(unstable)_ — settles compute gas at checkpoints rather than after every plain opcode; enforces compute and detention limits inside plain segments by clamping interpreter-visible gas so a crossing opcode does not execute; records an exceptional-halt frame's burned remainder as compute gas at frame exit; splits a failing precompile the same way, from the classification its caller is handed; weighs a contract creation's code-deposit compute gas against the compute budgets before recording it, rather than recording it ahead of the evaluation. diff --git a/docs/spec/evm/dual-gas-model.md b/docs/spec/evm/dual-gas-model.md index 374eb057..b92c615c 100644 --- a/docs/spec/evm/dual-gas-model.md +++ b/docs/spec/evm/dual-gas-model.md @@ -99,6 +99,18 @@ When more than one dimension is over its limit on that opcode, the reported dime A node MUST record an opcode's compute gas in exactly one step, after the opcode body has fully executed — with no `CREATE2` exception. The no-record rule when the body does not run to completion is specified in [Single-Record Rule](compute-gas.md#single-record-rule). +
+Rex7 (unstable): checkpoint settlement of compute gas + +Under Rex7, the metering order above continues to govern every **checkpoint** opcode that has a storage-gas component — the storage-affecting set listed in this section. +Those checkpoints still charge storage gas before the body and record compute gas after it. +Volatile / detention-guarded checkpoints and `GAS` record compute gas after the body and charge no storage gas. +Plain opcodes between checkpoints MUST NOT record compute gas when they finish; their compute gas settles as an interpreter-gas segment delta at the next checkpoint or at frame entry, resume, or exit. +Limit enforcement inside a plain-opcode segment uses gas clamping rather than a post-opcode record step: a crossing opcode is stopped before it executes, and its cost is excluded from recorded usage. +See [Compute Gas Accounting](compute-gas.md) and the [Rex7 Network Upgrade](../upgrades/rex7.md) for the full checkpoint set, clamp rules, and the exceptional-halt frame carve-out. + +
+ ### Storage Gas [Storage gas](../glossary.md#storage-gas) is an additional charge for operations that impose persistent storage burden on nodes. @@ -305,3 +317,4 @@ For the historical evolution of storage gas formulas and constants across specs: - [Rex4](../upgrades/rex4.md) — storage gas stipend for value transfers - [Rex5](../upgrades/rex5.md) — reworked the storage gas stipend into a separated-allowance model, derived the top-level contract-creation storage-gas address from the sender's current state nonce, and made contract-creation code-deposit compute gas atomic with the deployment commit - [Rex6](../upgrades/rex6.md) — unified per-opcode gas metering order (compute gas recorded once, after the opcode body, with no `CREATE2` exception); system-originated transactions charge dynamic storage gas at minimum bucket capacity; forwarded gas and the KeylessDeploy envelope are returned on a compute-gas exceed rather than spent +- [Rex7](../upgrades/rex7.md) _(unstable)_ — settles compute gas at checkpoints rather than after every plain opcode; clamps interpreter-visible gas between checkpoints so compute and detention limits stop a crossing opcode before it executes diff --git a/docs/spec/evm/gas-detention.md b/docs/spec/evm/gas-detention.md index 5217179b..e3ee277e 100644 --- a/docs/spec/evm/gas-detention.md +++ b/docs/spec/evm/gas-detention.md @@ -113,6 +113,15 @@ When a volatile-data trigger occurs, the node MUST perform the following steps i After detention has been applied, any subsequent execution step that would cause `compute_gas_used` to exceed the effective detained limit MUST halt the transaction with `VolatileDataAccessOutOfGas`. +
+Rex7 (unstable): clamp-based detention enforcement inside plain segments + +Under Rex7, after a detention cap has been installed the remaining compute headroom includes that detained limit, and the gas clamp applied at checkpoints and frame boundaries restricts interpreter-visible gas to that headroom. +A plain-opcode segment that would cross the detained limit is therefore stopped at the clamp boundary before the crossing opcode executes, reclassified as `VolatileDataAccessOutOfGas`, with remaining gas rescued for the sender — the same halt reason and refund shape as through Rex6, but without executing the crossing opcode or recording its cost. +See [Compute Gas Accounting](compute-gas.md) and the [Rex7 Network Upgrade](../upgrades/rex7.md). + +
+ The detained compute-gas limit MUST NOT halt a [system-originated transaction](../system-contracts/system-tx.md#system-originated-transaction-metering-exemption). Volatile-data accesses by such a transaction are still tracked, but the detention cap is not enforced against it; its standard EVM `gas_limit` remains the only halting bound. @@ -156,6 +165,28 @@ The CALL-family opcodes are excluded from this registration guarantee: their bas `EXTCODECOPY` is excluded for the same reason: its copy cost is charged before the target account is read, so only a frame that affords the copy cost registers the access. An access blocked by [`disableVolatileDataAccess()`](../system-contracts/mega-access-control.md) is the exception: the blocked opcode never runs, so it reads nothing and triggers nothing. +
+Rex7 (unstable): detention mark at account load + +Under Rex7 the CALL-family / `EXTCODECOPY` charge-before-load order is specified, not a frozen replay window. +A node MUST produce the beneficiary (or oracle) mark when the target account or slot is loaded, and MUST NOT produce that mark from a frame that cannot afford the fees charged before the load. +A CALL that exhausts the frame on its static fee or value-transfer fee, and an `EXTCODECOPY` that exhausts the frame on its copy fee, therefore halt without detaining the rest of the transaction. +See the [Rex7 Network Upgrade](../upgrades/rex7.md). + +
+ +
+Rex7 (unstable): charge-on-reject for disabled volatile access + +Under Rex7 a node MUST still revert a `disableVolatileDataAccess` rejection with `VolatileDataAccessDisabled` and MUST still leave the tracker unmarked. +A node MUST charge the rejected opcode's static fee before that revert. +The fee is ordinary EVM gas, is not refunded by the synthetic revert, and is recorded as compute gas when the open segment is settled. +A frame that cannot afford the static fee MUST halt out of gas instead of reaching the disable revert. +Through Rex6 the same reject charges nothing. +See the [Rex7 Network Upgrade](../upgrades/rex7.md). + +
+ ## Constants | Constant | Value | Description | @@ -204,3 +235,4 @@ Gas detention semantics evolved across specs: - [Rex3](../upgrades/rex3.md) — raised oracle cap to 20M and changed oracle detection from CALL-based to SLOAD-based - [Rex4](../upgrades/rex4.md) — changes absolute detention to relative detention and adds additional beneficiary-triggered behavior - [Rex6](../upgrades/rex6.md) — adds a beneficiary-detention trigger for an applied EIP-7702 authorization whose authority equals the block beneficiary; resolves a CALL-family target's EIP-7702 delegation one hop before the beneficiary comparison, so a call through a delegator whose delegate is the beneficiary triggers detention (through Rex5 only the raw target is compared); and stops enforcing the detention cap against system-originated transactions, whose volatile accesses are still tracked +- [Rex7](../upgrades/rex7.md) _(unstable)_ — enforces the detained limit inside plain-opcode segments by gas clamping, stopping a crossing opcode before it executes while preserving `VolatileDataAccessOutOfGas` and gas rescue; specifies that a detention mark is produced when the target account is loaded, so a frame that cannot afford the pre-load fees produces no mark; and charges the static fee of an opcode rejected by `disableVolatileDataAccess` diff --git a/docs/spec/evm/resource-accounting.md b/docs/spec/evm/resource-accounting.md index 354006bd..6a63ae2c 100644 --- a/docs/spec/evm/resource-accounting.md +++ b/docs/spec/evm/resource-accounting.md @@ -273,3 +273,4 @@ This page describes the current accounting behavior. - [Rex6](../upgrades/rex6.md) — counted the account-info write of a `SELFDESTRUCT` balance credit to an already-existing beneficiary: through Rex5 only a `SELFDESTRUCT` that created a new beneficiary was metered, so a balance credit to an existing beneficiary (which does not flow through the frame-initialization or caller-dedup path) recorded nothing. - [Rex6](../upgrades/rex6.md) — added a per-log data-size base: through Rex5, an empty `LOG0` contributed zero data size because the log address was not counted. - [Rex6](../upgrades/rex6.md) — deduplicated the value self-transfer account-info write: when a value-transferring call's target equals its caller, the caller-side and target-side writes refer to the same account, but through Rex5 the data-size and KV-update charges were recorded for both, over-counting the one account (it never under-charges). This extends the Rex5 caller-account deduplication above to the self-transfer case. +- [Rex7](../upgrades/rex7.md) _(unstable)_ — does not change data-size, KV-update, or state-growth counting; compute-gas settlement moves to checkpoints (see [Compute Gas Accounting](compute-gas.md)). diff --git a/docs/spec/evm/resource-limits.md b/docs/spec/evm/resource-limits.md index 1ceedc5c..ae04d29e 100644 --- a/docs/spec/evm/resource-limits.md +++ b/docs/spec/evm/resource-limits.md @@ -131,6 +131,17 @@ Subsequent candidate transactions MUST be skipped before execution once the bloc Although block compute gas usage MAY be tracked, the protocol does not impose a separate block-level compute gas cap. +
+Rex7 (unstable): two readings of cumulative block compute gas + +From [Rex7](../upgrades/rex7.md) onward, a node that tracks cumulative block compute gas MUST track it as two readings, because the [exceptional-halt frame carve-out](compute-gas.md#exceptional-halt-frame-carve-out) makes them differ. +The **reported** reading accumulates each transaction's full compute-gas total, destroyed remainders included; it is the block's compute-gas statistic. +The **enforced** reading accumulates only the part each transaction performed — its [`executed_compute`](compute-gas.md#destroyed-compute-gas), taken from the recordings the transaction enforced its own compute limit against rather than by subtracting the transaction's reported destroyed total — and is the only one a node MAY compare against a configured block compute-gas ceiling, and the only one such a ceiling's rejection MUST report as the block's usage. +Comparing the reported reading instead would let a transaction that destroyed a large gas envelope while performing almost no work close the block's compute capacity for every transaction behind it. +Before Rex7 nothing is destroyed, so the two readings coincide. + +
+ ### Two-Phase Block Building Workflow When constructing a block, a node or sequencer MUST process candidate transactions in the following order: @@ -162,6 +173,17 @@ Only total gas (the standard EVM gas parameter in CALL-like opcodes) remains und If a child call frame exceeds its local budget, it MUST revert with `MegaLimitExceeded(uint8 kind, uint64 limit)`. The parent call frame MAY continue execution. +A frame's own budget is not the only one it can overrun: its usage is merged into its caller's when it returns, and that merge can put the caller past its budget even though the frame stayed inside its own. +Through [Rex6](../upgrades/rex6.md), a node detects that after the merge — the frame is told to revert, its usage is carried up as a successful frame's is, and the caller is failed by it at the caller's next resource check. + +
+Rex7 (unstable): the exceed is determined before the merge + +Under Rex7, a node MUST determine such an exceed before merging, over the reading the merge would produce, and MUST rewrite the frame's result to the same frame-local revert before merging. +The merge then discards the frame's usage as it discards any reverting frame's, the frame's state is rolled back with it, and the caller MUST be free to continue. + +
+ The top-level call frame's budget MUST equal the transaction limit minus any resource usage already recorded before the first frame begins. These deductions include transaction-only intrinsic usage and any DB-dependent pre-execution usage that is resolved before the first frame starts. Each resource dimension deducts only the pre-frame items relevant to it: @@ -246,3 +268,4 @@ Including failed transactions ensures the sender always pays for consumed resour - [Rex4](../upgrades/rex4.md) — added per-call-frame runtime budgets; intrinsic resource costs (always deducted before execution) are now reflected in the top-level frame budget before it is forwarded to child frames. - [Rex5](../upgrades/rex5.md) — bounded a precompile invocation's compute-gas consumption by the remaining compute-gas budget, failing the precompile with `PrecompileOOG` rather than letting it overshoot the budget. - [Rex6](../upgrades/rex6.md) — moved EIP-7702 authority state-growth resolution from pre-execution (after the caller nonce bump) to validation, and added dynamic SALT account-creation gas for each net-new applied authority to the pre-frame intrinsic gas deduction; removed the keyless-deploy exception to gas preservation, so remaining gas is now rescued on every transaction-level exceed; and stopped enforcing the four runtime transaction-level limits against system-originated transactions, whose usage is still recorded. +- [Rex7](../upgrades/rex7.md) _(unstable)_ — does not change the limit ceilings or the success/failed/skipped/rejected outcomes; a compute-gas or detention exceed inside a plain-opcode segment is stopped before the crossing opcode executes, and cumulative block compute gas splits into a reported and an enforced reading (see [Compute Gas Accounting](compute-gas.md)). diff --git a/docs/spec/hardfork-spec.md b/docs/spec/hardfork-spec.md index ff10aee4..84015532 100644 --- a/docs/spec/hardfork-spec.md +++ b/docs/spec/hardfork-spec.md @@ -150,7 +150,11 @@ _See [Rex6 Network Upgrade](upgrades/rex6.md) for full details._ ### REX7 -REX7 is the current **unstable** spec under active development. -It introduces no behavioral change over REX6 yet; its semantics may change at any time before it is frozen. +REX7 is the current **unstable** spec under active development; its semantics may change at any time before it is frozen. -_See [Rex7 Network Upgrade](upgrades/rex7.md) for the current state._ +- **Checkpoint-settled compute gas** — Plain opcodes record no compute gas between checkpoints; settlement runs at storage-gas opcodes, the CALL / CREATE family, volatile opcodes, `GAS`, and frame entry / resume / exit. +- **Gas-clamp enforcement** — Between checkpoints the interpreter-visible remaining gas is clamped to the remaining compute headroom, so a compute-gas or detention exceed stops the crossing opcode before it executes (zero overshoot; crossing cost excluded from recorded usage). +- **Exceptional-halt frame carve-out** — A frame that ends in an exceptional halt (including out-of-gas) settles its burned remainder as compute gas, so nested out-of-gas calls may report higher compute usage than REX6 while EVM gas and the receipt stay the same. + A failing precompile is split the same way: executed work enforces, the unused forwarded envelope is reported only. + +_See [Rex7 Network Upgrade](upgrades/rex7.md) for the full previous/new pairing._ diff --git a/docs/spec/overview.md b/docs/spec/overview.md index bd84fd7e..f0718b74 100644 --- a/docs/spec/overview.md +++ b/docs/spec/overview.md @@ -62,7 +62,8 @@ A new spec may add behavior, but it never changes what an existing frozen spec d Contracts deployed under a given spec will continue to behave identically, regardless of future upgrades. {% endhint %} -- **EQUIVALENCE** — Baseline. Full Optimism Isthmus compatibility with block environment access tracking for parallel execution. +- **EQUIVALENCE** — Baseline. + Full Optimism Isthmus compatibility with block environment access tracking for parallel execution. - **MINI_REX** — Dual gas model, multidimensional resource limits, gas detention, 98/100 gas forwarding, SELFDESTRUCT disabled, Oracle and Timestamp system contracts. - **REX** — Revised storage gas economics (`base × (multiplier − 1)`), transaction intrinsic storage gas, state growth tracking, consistent CALL-like opcode behavior. - **REX1** — Fix: compute gas limit reset between transactions. @@ -71,7 +72,8 @@ Contracts deployed under a given spec will continue to behave identically, regar - **REX4** — Per-call-frame resource budgets, relative gas detention, [storage gas stipend](glossary.md#storage-gas-stipend), MegaAccessControl and MegaLimitControl system contracts. - **REX5** — SequencerRegistry system contract, Oracle v2.0.0 with dynamic system address, caller-account update deduplication, storage-gas-stipend separated-allowance model, value-transfer CALL/CALLCODE parent compute-gas attribution, CREATE code-deposit compute-gas atomicity, EIP-2935/EIP-4788 pre-block gas floor with fail-closed block rejection, CREATE2 empty-initcode short-circuit, KeylessDeploy trailing-bytes rejection and empty-code log forwarding. - **REX6** — Unified per-opcode gas metering order, consolidated EIP-7702 authorization accounting, CREATE-frame accounting corrections, KeylessDeploy sandbox hardening, post-execution fee-reward accounting, system-originated transaction metering exemption, extended beneficiary detention coverage, and SequencerRegistry v2.0.0 rotation hardening. -- **REX7** — The **unstable** spec, currently open for development. No behavioral change over REX6 yet. +- **REX7** — The **unstable** spec, currently open for development. + Checkpoint-settled compute gas accounting with gas-clamp enforcement; plain opcodes record no compute gas between checkpoints. See [Hardforks and Specs](hardfork-spec.md) for full details. diff --git a/docs/spec/upgrades/overview.md b/docs/spec/upgrades/overview.md index d544ca07..7d2a67bf 100644 --- a/docs/spec/upgrades/overview.md +++ b/docs/spec/upgrades/overview.md @@ -153,7 +153,8 @@ Not yet scheduled {% endtabs %} Unstable; under active development. -No behavioral change over Rex6 yet. +Checkpoint-settled [compute gas](../glossary.md#compute-gas) accounting with gas-clamp enforcement: plain opcodes record nothing between checkpoints; within-limit transactions that never end a frame in an exceptional halt and never trip a `disableVolatileDataAccess` guard stay bit-identical to Rex6; a compute-gas or detention exceed inside a plain segment stops the crossing opcode before it executes. +A disabled-volatile rejection charges the opcode's static fee; a detention mark is produced when the target account is loaded. ## How to Read These Pages diff --git a/docs/spec/upgrades/rex7.md b/docs/spec/upgrades/rex7.md index dcdaba79..8ddaf7c1 100644 --- a/docs/spec/upgrades/rex7.md +++ b/docs/spec/upgrades/rex7.md @@ -1,5 +1,5 @@ --- -description: Rex7 network upgrade — the current unstable spec, open for development and carrying no behavioral change over Rex6 yet. +description: Rex7 network upgrade — checkpoint-settled compute gas accounting with gas-clamp enforcement; plain opcodes record no compute gas between checkpoints, within-limit transactions that never end a frame in an exceptional halt and never trip a disableVolatileDataAccess guard stay bit-identical to Rex6, a disabled-volatile rejection charges the opcode's static fee, and a detention mark is produced when the target account is loaded. --- # Rex7 Network Upgrade @@ -14,34 +14,379 @@ Anything recorded on this page may change before Rex7 is frozen, and nothing her ## Summary -Rex7 is the spec currently open for development. -It inherits every [Rex6](rex6.md) behavior and, as of this page, changes none of them: a transaction executed under Rex7 produces the same result as the same transaction executed under Rex6. +Rex7 changes how a node records and enforces [compute gas](../glossary.md#compute-gas) during execution. -Rex7 exists so that new behavior has somewhere to land. -[Rex6](rex6.md) is frozen — its semantics are fixed and may no longer be modified — so any change to gas costs, opcode behavior, resource accounting, or a system contract must be introduced under Rex7. +Through [Rex6](rex6.md), every metered opcode records its own compute gas after it finishes, and a compute-limit exceed is evaluated at that opcode. +Rex7 replaces that per-opcode recording for ordinary opcodes with **checkpoint settlement**: plain opcodes run without a compute-gas recording step, and the node settles the compute gas of an entire segment when it reaches a checkpoint. + +Rex7 also introduces **gas-clamp enforcement**: between checkpoints the node restricts the interpreter-visible remaining gas to the remaining compute headroom, so the inherited EVM's own per-opcode gas check stops a limit-crossing opcode before that opcode executes. + +For a transaction that never crosses a compute-gas, detention, or other resource limit, in which no frame ends in an exceptional halt, and in which no `disableVolatileDataAccess` guard rejects an opcode, Rex7 is bit-identical to Rex6: the same gas, the same receipt, the same state, and the same `GAS` opcode readings. +For a transaction that does cross a compute-gas or detention limit inside a plain-opcode segment, the halt lands before the crossing opcode rather than after it, the crossing opcode's cost is excluded from recorded compute usage, and remaining gas remains refundable under the same rescue rules as other transaction-level compute-limit halts. + +Rex7 also makes two guard- and detention-related choices that Rex6 does not: + +- A `disableVolatileDataAccess` rejection still charges the rejected opcode's static fee. +- A detention mark is produced when the target account is loaded, so a frame that cannot afford the fees charged before that load produces no mark. +- A contract creation's code-deposit compute gas is weighed before it is recorded, so a creation that fails at its frame exit is no longer charged compute gas for a deposit it never made. + +Two deliberate accounting carve-outs remain. +A frame that ends in an exceptional halt (including ordinary out-of-gas) settles its whole EVM-gas budget as compute gas, apart from storage gas it had already been charged, so a transaction that contains an inner out-of-gas call can report higher compute usage under Rex7 than under Rex6 even though EVM gas and the receipt are unchanged. +That budget is split — the work the frame performed enforces like any other work, while the remainder it destroyed is reported but never enforced. +The reported destroyed total is derived from a conservation law over what the transaction spent rather than summed from the sites that destroyed it, so an envelope lost anywhere lands in it whether or not a site was written to book it. +A precompile that fails is split the same way, from the classification its caller is handed: executed work (the KZG fixed fee when the call reached verification; zero when the input was rejected before any work, KZG's own input-length check included) enforces, and the unused caller-supplied envelope is destroyed. +The generic error arm therefore stops enforcing the whole forwarded amount, which is an intentional enforcement difference from Rex6; the Rex5 forwarded-gas cap still prevents the precompile from performing more work than the remaining compute budget. ## What Changed -Nothing yet. +### Checkpoint-Settled Compute Gas Accounting + +#### Previous behavior + +From [MiniRex](minirex.md) through [Rex6](rex6.md), a node records compute gas at every metered opcode: + +- Each opcode belongs to a metering class defined in [Compute Gas Accounting](../evm/compute-gas.md). +- After the opcode body completes (or at the equivalent single measurement window for storage-affecting opcodes), the node records `(gas_before − gas_after)` less any storage-gas and forwarded-child exclusions, and evaluates the compute-gas limit. +- Plain opcodes (arithmetic, stack, memory, jumps, and similar) each open and close their own measurement window. +- A compute-gas or detention exceed is evaluated after the opcode that crossed the limit has finished, so that opcode's cost is included in recorded usage and the recorded total can land strictly above the limit. + +Frame entry, frame resume, and frame exit do not themselves settle a multi-opcode segment; they only participate in per-frame budget push/pop and in the non-opcode recording sites listed in [Compute Gas Accounting](../evm/compute-gas.md#non-opcode-recording-sites). + +#### New behavior + +Under Rex7, a node MUST settle compute gas at **checkpoints** rather than after every plain opcode. + +A **checkpoint** is any of the following: + +1. A storage-gas opcode: `SSTORE`, `LOG0`–`LOG4`, `SELFDESTRUCT`. +2. A call-family opcode: `CALL`, `CALLCODE`, `DELEGATECALL`, `STATICCALL`. +3. A create opcode: `CREATE`, `CREATE2`. +4. A volatile / detention-guarded opcode: the unconditional block-environment set (`BLOCKHASH`, `COINBASE`, `TIMESTAMP`, `NUMBER`, `DIFFICULTY` / `PREVRANDAO`, `GASLIMIT`, `BASEFEE`, `BLOBBASEFEE`, `BLOBHASH`), the beneficiary-conditional set (`BALANCE`, `EXTCODESIZE`, `EXTCODECOPY`, `EXTCODEHASH`, `SELFBALANCE`), and oracle-conditional `SLOAD`. +5. The `GAS` opcode. +6. Frame entry, frame resume after a child returns, and frame exit. + +Every other opcode is a **plain opcode** for settlement purposes. +A plain opcode MUST NOT open a compute-gas measurement window of its own and MUST NOT record compute gas when it finishes. + +At each checkpoint a node MUST: + +1. Settle the open plain-opcode segment as the interpreter-gas delta since the previous checkpoint (or since the frame opened / resumed), excluding storage gas and forwarded child gas that the checkpoint itself charges or forwards under the same exclusion rules as [Compute Gas Accounting](../evm/compute-gas.md). +2. Record that segment amount as compute gas and evaluate the compute-gas limit (and any latched non-compute resource-limit exceed) at that checkpoint. +3. Record the checkpoint opcode's own body compute gas under the same measurement-window rules that apply through Rex6 for that opcode class, then re-open the settlement window for the next segment. + +Non-opcode recording sites (transaction intrinsic gas, successful or reverting precompiles, contract-creation code deposit, KeylessDeploy overhead and sandbox merge) are unchanged. +A precompile that **fails** is the exception below. + +**Precision invariant.** +For every transaction that stays within every runtime resource limit, in which no frame ends in an exceptional halt, and in which no `disableVolatileDataAccess` guard rejects an opcode, a node MUST produce the same recorded compute-gas total, the same four-dimension resource usage, the same receipt `gas_used`, the same execution result, and the same state under Rex7 as under Rex6. +The interpreter's gas counter already meters every opcode; settling by segment reproduces the per-opcode sum exactly when no limit is crossed, no frame ends in an exceptional halt, and no rejected guard charges a static fee. + +**Exceptional-halt frame carve-out.** +A frame that ends in an exceptional halt — ordinary out-of-gas, memory out-of-gas, stack underflow or overflow, invalid jump, unknown opcode, and every other error result — returns none of its remaining budget. +The top-level frame's whole envelope is spent by the transaction's final gas accounting, and an inner frame's remainder is never handed back to its caller. +A node MUST settle that budget as compute gas in two parts that are accounted differently, except for any MegaETH storage gas a checkpoint body charged before aborting: that charge stays storage gas and is in neither part. + +The **executed** part is the open plain-opcode segment, measured as the interpreter-gas delta since the previous checkpoint, net of that storage charge. +A checkpoint opcode that halts inside its own body never reaches the recording that closes its measurement window, so the EVM gas the body had already charged — the value-transfer surcharge and the argument / return-range memory expansion a call-family body takes before it loads the target account — is still inside that segment when the frame exits, and a node MUST settle it as executed work rather than dropping it or treating it as destroyed. +A node MUST record it through the ordinary path, so it counts toward the transaction's reported total **and** toward the usage every resource limit is evaluated against — exactly as the same opcodes would if the frame had returned normally. +A parent frame keeps executing after it absorbs a failed child; excluding the child's work from enforcement would let the code that follows spend the same compute headroom a second time. + +The **destroyed** part is the budget the frame never spent and never handed back. +A node MUST record it in the transaction's reported compute-gas total and in block-level compute accounting, and MUST NOT evaluate any resource limit against it — at transaction level or at block level, where a destroyed remainder that counted toward admission would close the block's compute capacity for the transactions behind it (see [Resource Limits](../evm/resource-limits.md)). +It is bounded by the sender's gas envelope rather than by the compute limit, so it can carry the reported total past that limit; halting on it would rescue gas the EVM has already destroyed and change a receipt this carve-out requires to stay identical. + +**The destroyed total is derived, not summed.** +Rex7 does not define a transaction's destroyed compute gas as the sum of what its halted frames, precompiles and system-contract invocations booked. +It defines it as a conservation law over the gas the transaction spent: + +`destroyed = spent + minted_stipends − storage_gas − executed_compute` + +`spent` is the EVM gas the envelope burnt, read once at the moment the envelope is final — after the transaction's gas accounting has settled and any resource-limit rescue has been returned to the sender, and before the EIP-3529 refund and the EIP-7623 floor are applied, since those move the receipt's number without anything having been burnt. +For a deposit transaction whose result is rebuilt into a failed-deposit receipt, that moment is the rebuild; see below. +`minted_stipends` is the sum of `CALL_STIPEND` over the value-transferring `CALL` and `CALLCODE` invocations, counted once per stipend the EVM mints: the inherited EVM grants that stipend to the child's frame budget without debiting the caller, so the frames record one stipend more work than the envelope funded per such call, and a node MUST add it back. +The mint happens when the invocation is handed to the EVM, before the child is entered, and a node MUST count it from there rather than from the child frame running — an invocation turned away at frame entry, for want of balance or at the call-depth limit, returns the whole child budget with the stipend inside it and shrinks the envelope by exactly as much as a child that ran and returned it would. +An invocation a node halts before handing it to the EVM, which is what a compute-gas limit reached at the call site does, mints nothing and a node MUST NOT count it. +`storage_gas` is the MegaETH storage gas the transaction was charged, taken as a signed difference at a nested-execution boundary — negative when the nested execution's own EIP-3529 refund outgrew its storage gas — which a node MUST NOT clamp at zero. +`executed_compute` is the compute gas the transaction is recorded as having performed, fixed site by site by the rules below and by [Compute Gas](../evm/compute-gas.md); it is the quantity every resource limit is evaluated against, and it equals the reported total less the destroyed part as a consequence of the law rather than by definition. +A node MUST NOT report a negative result; the law cannot produce one on this spec, and a node that computes one MUST report zero rather than a wrapped value. + +Enforcement is unaffected, at every level: the work a limit is evaluated against — the transaction's own compute-gas limit and the block's enforced compute counter alike — still comes from the checkpoint and per-opcode recordings, and only the reported destroyed total comes from the law. +The block's enforced counter accumulates each transaction's `executed_compute`; it does not subtract the reported destroyed total from the reported total, which would put the derivation on the admission path. + +The split MUST be driven by the halt classification, not by the interpreter's own counter — an inherited EVM zeroes that counter for ordinary out-of-gas only. +That zeroing has one consequence a node MUST accept rather than work around: for an ordinary out-of-gas taken with no clamp in force, the counter is already zero at frame exit, so the whole segment measures as executed and is enforced in full. +This is the one shape where Rex7 enforcement is stricter than Rex6's, which attributes the failing opcode to neither part. + +A node MUST take the split from the frame's **final** result, after the create-return processing that can still turn a successful constructor into a canonical code-deposit out-of-gas, an EIP-3541 reject or a runtime code-size reject — each of which destroys the frame's remainder just as a halt from the interpreter loop does. +When a nested execution merges its usage into an outer one, which today is only the `KeylessDeploy` sandbox boundary, a node MUST carry the split across that boundary: the outer transaction reports the inner total in full and enforces only its executed part. + +A precompile invocation that fails is the same split, taken from the classification the call returns to its caller rather than at interpreter-frame exit — a precompile never becomes a child EVM frame, so the frame-exit settlement cannot see it. +The classification is what decides whether the caller reclaims the remainder, so it is what the split has to be taken from; a node MUST NOT fix the split earlier, while the call's outcome can still change. +The **executed** part is the work the precompile performed: the KZG point-evaluation fixed cost when that precompile reached verification and returned a non-out-of-gas error, and zero when the invocation was rejected before any work (malformed input, or a wrapper out-of-gas that never reached verification). +For KZG the boundary is its own input-length check, which runs before the commitment is read: an input whose length is not `KZG_POINT_EVALUATION_INPUT_LENGTH` is a rejection before any work, while every other non-out-of-gas failure is raised once verification is under way and is priced at the whole fixed cost, however far it got. +A node MUST price an unrecognised non-out-of-gas KZG failure as verification under way, so that an unfamiliar failure can only over-charge. +A node MUST record the executed part through the ordinary enforcing path. +The **destroyed** part is the rest of the call's gas limit — the caller-supplied envelope, not the Rex5-capped effective gas limit. +On a value-transferring call that envelope includes the protocol-granted call stipend, so it can exceed what the parent itself funded. +When the cap binds, the gap between the envelope and the effective limit is destroyed budget rather than work, and a node MUST include it in the destroyed part. +Through Rex6 the generic error arm recorded the effective gas limit as enforcing usage; under Rex7 that arm enforces nothing. +That is a deliberate enforcement difference. +The Rex5 forwarded-gas cap is unchanged: a precompile still MUST NOT perform more work than the remaining compute budget. + +A system contract invocation a node answers without opening an EVM frame — the `KeylessDeploy` dispatch is the only one today — takes the same split at the site that produces the answer, and only when that answer is a halt which keeps the call's gas: whatever the invocation performed before failing is executed, and the rest of the call's gas limit is destroyed. +An answer that returns or reverts gives the gas back to the caller, and a halt whose remaining gas is rescued for the sender is a refund. +A node MUST NOT record either as destroyed; that gas was not lost, and counting it would report it twice. + +A call or creation an inherited EVM refuses before it opens a frame takes the same split at the site that produces the refusal, and the classification decides which way it goes. +A creation onto an address that already holds code or a nonce, and a value transfer that overflows the recipient's balance, are exceptional halts whose budget the caller never sees again; the frame never ran, so nothing was executed and a node MUST record the whole budget as destroyed. +A refusal classified as a success or a revert — a call or creation past the call-stack limit, a creation whose value exceeds the caller's balance, a creation from an account whose nonce cannot be bumped, a call into an account with no code — hands the budget straight back, and a node MUST NOT record any of it as destroyed. +A precompile invocation is answered on this same path and is covered by its own rule above; a node MUST NOT book it a second time here. + +Those sites are where a Rex7 transaction is known to lose an envelope without executing it, and they are what fixes `executed_compute` at each one — but they are not what makes the enumeration complete. + +| Producer | Recording site | +| --------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| A frame that ends in an exceptional halt, including a creation rejected at code deposit | The frame's final-result settlement | +| A call or creation the inherited EVM refuses before it opens a frame | The same settlement, classified by whether the refusal swallows the child budget or hands it back | +| A precompile invocation that fails | The same settlement, against the numbers the precompile's own recording site fixed; not also as a refusal | +| A system-contract invocation answered without an EVM frame, when the answer is a halt that keeps the call's gas | The site that produces the answer | +| A failed-deposit receipt rebuild | The rebuild of the envelope | + +The classification that decides whether a result swallows its remaining budget or hands it back MUST be exhaustive over the inherited instruction-result space. +Every result MUST be assigned swallowed, returned, or unreachable. +A newly introduced result MUST NOT be assigned by a default arm. +Completeness of the _reported_ total is a consequence of the law: a lost envelope is gas the transaction spent that neither the compute lanes nor the storage-gas lane accounts for, so it lands in the remainder whether or not a site above anticipated it. +Reading the two independently and requiring them to agree is what turns the list from an assumption into a checkable claim. + +One further shape burns a whole envelope having executed nothing — a transaction whose intrinsic gas requirement outgrows the gas limit its sender supplied — but [Rex5](rex5.md) already rejects that transaction during validation, after every MegaETH storage-gas contribution has been folded into the intrinsic total and before the sender is debited. +An ordinary transaction therefore produces no receipt on Rex7 and there is no envelope to split; a node MUST NOT record such a transaction's gas limit as a destroyed remainder. + +A deposit transaction is the exception, because it is not allowed to fail. +A deposit a node would otherwise reject during validation, and a deposit that halts during execution, are both rebuilt into a receipt reporting the transaction's whole gas limit, with state rolled back to the sender's nonce bump and the deposit's mint. +That rebuild runs after every recording and settlement site, so it is the last thing that decides the envelope: a node MUST derive the law against the rebuilt envelope, which makes the difference between it and what those sites already hold destroyed compute gas. +The two shapes arrive from opposite positions and the law covers both without distinguishing them — a rejected deposit has recorded only the standard-EVM share of its intrinsic gas and settled nothing, while a halted deposit has already settled against the smaller envelope its resource-limit gas rescue left behind. +`executed_compute` MUST NOT move: nothing was executed for the difference, so a deposit rejected before it ran anything consumes no compute capacity at transaction or block level. + +Under per-opcode recording through Rex6, neither the failing opcode nor the destroyed remainder is attributed to compute gas. +Consequently, a transaction that halts exceptionally, or that contains an inner call frame which does, MAY report a **strictly higher** compute-gas total under Rex7 than under Rex6, while EVM gas accounting and the receipt remain identical. + +A clamp-induced out-of-gas is not an exceptional halt for this rule. +The crossing opcode was stopped before it executed and the remaining gas is rescued for the sender rather than destroyed, so the reclassification rules below apply instead. +A frame whose exit latches a resource-limit exceed destroys nothing either: it reverts to its parent, or halts the transaction with its gas rescued. + +### Gas-Clamp Enforcement + +#### Previous behavior + +Through Rex6, the compute-gas limit and the detained compute-gas limit are enforced when an opcode records its compute gas after it has finished. +The crossing opcode therefore executes fully, its cost is recorded, and recorded usage can land strictly above the limit (overshoot of one opcode). +Frame-local budget exceeds become frame reverts with `MegaLimitExceeded`; transaction-level and detention exceeds become transaction halts with remaining gas rescued for the sender. + +#### New behavior + +Under Rex7, a node MUST enforce compute-gas and detention limits inside plain-opcode segments by **clamping** the interpreter-visible remaining gas. + +At each checkpoint, after settlement and after the checkpoint body has recorded its own compute gas (and after any detention cap the checkpoint installs), and again at frame entry and resume, a node MUST: + +1. Compute the remaining compute headroom as the minimum of the current frame's remaining per-frame compute budget and the transaction-level remaining budget under the effective limit (including detention). +2. When the interpreter's true remaining gas is at or above that headroom, put the clamp in force for the segment that follows: hide the excess from the interpreter and remember which constraint bound the clamp — the frame-local budget or the transaction-level / detained limit — together with that constraint's own limit value. + Equality is a binding clamp that hides nothing, not the absence of a clamp. +3. When the true remaining gas is below the headroom, no clamp is in force: the frame's own gas runs out ahead of the compute headroom, and an out-of-gas inside the segment is the inherited EVM's own rather than a resource-limit exceed. +4. Leave the true remaining gas available again before the next checkpoint body runs, before `GAS` is observed, before call-gas forwarding is computed, and before storage-gas charges are taken, so those sites always see the unclamped counter. + +Inside a plain-opcode segment only plain opcodes run, so the inherited EVM's ordinary per-opcode gas check is the enforcement tool: + +- When an opcode would cost more gas than the clamped visible remainder, the opcode MUST NOT execute. +- The frame's final result MUST restore the hidden gas into the gas counter. +- The node MUST reclassify that out-of-gas as the resource-limit exceed that the clamp stood for: + - **Frame-local binding** → the frame reverts with `MegaLimitExceeded(uint8 kind, uint64 limit)`, and unspent gas returns to the parent through ordinary frame accounting. + - **Transaction-level compute binding** → the transaction halts with `OutOfGas`, and remaining gas is rescued and refunded to the sender. + - **Detained-limit binding** → the transaction halts with `VolatileDataAccessOutOfGas`, with the same gas rescue. + +The reported `limit` MUST be the constraint that bound the clamp, not whichever limit is largest or most convenient: the frame's own compute budget for a frame-local binding, the effective transaction-level limit otherwise. +The revert payload is visible to the calling contract, so a frame-local exceed that reported the transaction-level limit would be a different observable return value for the same execution, not merely a different diagnostic. + +Because the crossing opcode never executes, a node MUST NOT include its cost in recorded compute-gas usage. -Each change landed under Rex7 will be recorded here as a **Previous behavior** / **New behavior** pair, in the order it is specified. +A checkpoint that still carries a non-zero static fee — `GAS` and `LOG0` through `LOG4` — MAY itself be the crossing opcode of the preceding plain-opcode segment. +When the clamped visible remainder is less than that fee, the inherited per-opcode check stops the opcode before the body runs, and a node MUST treat that stop as a plain-segment crossing. +The CALL family is the same stop: its static fee is charged before the body, so a clamped remainder below that fee stops the opcode before the target account is read. +`CREATE` and `CREATE2` charge their inherited creation fee inside the body, after the true remaining gas has been restored, so a compute headroom below that fee MUST NOT stop them before the body. + +The usage the clamp **enforces** therefore ends at or below the limit, not strictly above it. +The `actual` a transaction-level clamp halt reports MUST be the transaction's final reported compute usage — the frame-exit settlement closes the partial segment after the exceed is identified, and a node MUST NOT report the usage as it stood before that settlement. +Reported usage is not the same quantity as enforced usage: it also carries the destroyed remainders of any frame that halted exceptionally earlier in the transaction, which are reported and never enforced. +A node MUST NOT assume `actual` is at most `limit`. + +**Top-frame headroom tie-break.** +At the top-level frame the remaining per-frame compute budget equals the transaction-level remaining budget whenever both are still governed by the same base limit. +When those two remaining amounts are equal, a node MUST bind the clamp to the transaction-level constraint (or to the detained limit when detention is the effective transaction-level bound). +A clamp-induced exceed under that binding MUST halt the transaction with gas rescue; a node MUST NOT classify the equality as frame-local. +Through Rex6, the same equality is classified by the per-opcode check as a frame-local exceed, which the top-level frame absorbs into a revert rather than a halt. + +**Double-exceed preference.** +When the crossing opcode would have exhausted both the true remaining EVM gas and the compute headroom at the same point, a node MUST attribute the halt to the compute-gas (or detention) limit rather than to ordinary EVM out-of-gas, so remaining gas stays refundable under the rescue rules. +The two cases are indistinguishable once the frame has already reported out-of-gas, and the compute classification is the one that preserves the sender refund. + +**Within-limit observability.** +For a transaction that never crosses a compute or detention limit, the clamp MUST be unobservable: `GAS` returns the true remaining gas, call forwarding and storage-gas charges see the true counter, and gas, receipt, and state match Rex6. + +### Charge-on-Reject for Disabled Volatile Access + +#### Previous behavior + +Through [Rex6](rex6.md), a node that has `disableVolatileDataAccess` active rejects a volatile-guarded opcode with a revert and the `VolatileDataAccessDisabled` payload, and charges the rejected opcode nothing. +The static gas table zeroes those opcodes so a frame holding less than the static fee still reaches the guard, and the handler charges the entry only after the check declines. +The reverting frame returns every unit of gas it held when it reached the opcode. + +#### New behavior + +Under Rex7, a node MUST still reject the same opcodes with the same revert payload, and MUST still leave the tracker unmarked. +A node MUST charge the rejected opcode's static fee before producing that revert. +The fee is ordinary EVM gas: it is debited from the frame, it is not refunded by the synthetic revert, and it MUST be recorded as compute gas when the open segment is settled at frame exit. +A guard that does not reject MUST charge the opcode's static fee at the same position the body charged it before this rule. +For the CALL family that position remains before the target account is read. +A frame that cannot afford the static fee MUST halt out of gas instead of reaching the disable revert. + +The guarded set is unchanged from Rex6: the unconditional block-environment opcodes, the beneficiary-conditional account reads, `SELFBALANCE`, oracle-conditional `SLOAD`, the CALL family, and `SELFDESTRUCT`. + +### Detention Mark at Account Load + +#### Previous behavior + +Through Rex6, a node documents that `BALANCE`, `EXTCODESIZE`, `EXTCODEHASH`, `SLOAD`, and `SELFDESTRUCT` register a volatile access even when the frame then runs out of gas on that opcode's own cost. +The CALL family and `EXTCODECOPY` are excluded from that guarantee because their implementation charges the base access cost — and, for a value-transferring call, the transfer cost — or the copy cost before the target account is read. +That exclusion is a frozen replay window, not a Rex6 rule: historical executions under the previous interpreter loaded first and marked first. + +#### New behavior + +Under Rex7, a node MUST produce a beneficiary or oracle detention mark when the target account (or oracle slot) is loaded, and MUST NOT produce that mark from a frame that cannot afford the fees charged before the load. +A CALL-family opcode whose static fee or value-transfer fee exhausts the frame, and an `EXTCODECOPY` whose copy cost exhausts the frame, therefore halt without marking, and the rest of the transaction runs undetained unless some other access has already marked. +This is specified behavior, not a replay exception. + +### Conditional Code-Deposit Compute Gas Recording + +#### Previous behavior + +From [Rex5](rex5.md), a node records a contract creation's code-deposit compute gas (`code_length × CODEDEPOSIT`) before the deployment is committed, so that a compute-limit exceed caused by that amount fails the frame while the deployment is still revertible. +The amount is recorded first and the limit is evaluated afterwards, so it stays in the transaction's compute total whichever way the frame then ends. +It stays there on every path that fails the frame after the recording, not only on a compute-gas exceed: a data-size or state-growth exceed detected as the frame exits fails the frame just as effectively, and the EVM then charges no deposit at all. +The recorded amount is therefore compute gas that nothing spent, which both the transaction's reported total and the block's compute accounting carry. + +#### New behavior + +Under Rex7, a node MUST weigh the amount before recording it. + +A node MUST evaluate the frame-local and transaction-level compute budgets against the frame's usage plus the amount, and MUST record the amount only when neither budget would be exceeded. +That evaluation MUST happen once the frame's own accounting for the exit is complete — its final segment settled and its frame-exit resource usage merged — so the amount is weighed against the frame's whole usage. +A frame that has already failed at that point never reaches the evaluation, and nothing is recorded for it. + +When the amount does not fit, the outcome for the deployment is unchanged from Rex5: the frame fails as specified in [Exceed Behavior](../evm/compute-gas.md#exceed-behavior) and the deployment commits nothing. +What changes is what the transaction reports. +A frame-local exceed on this path MUST NOT be latched — with the amount unrecorded the transaction is within every limit, and the frames above it MAY continue. +A transaction-level exceed MUST be latched and MUST halt the transaction with the usual gas rescue, and MUST carry the same detention attribution it would have carried had the amount been recorded. + +### Frame Result and Frame State Agree + +#### Previous behavior + +A frame's journal checkpoint is committed or reverted from the frame's instruction result at the moment the frame's action is processed, which is before the node applies the resource-limit rewrites that decide what the frame finally reports. +A frame that ran to a successful exit and is then rewritten into a frame-local revert therefore reports failure over state that stays committed: a contract creation on that path leaves its deployed code and its constructor's storage writes behind, and the caller is told the frame failed. +[Rex4](rex4.md) documents that split for the code-deposit path, where a node MUST NOT roll the deployment back. + +#### New behavior + +Under Rex7, a node MUST decide a frame's journal outcome from the frame's **final** result — the result after every settlement and every rewrite the node applies at that frame's exit. +A frame that reports a revert MUST have its state reverted; a contract creation that reports anything other than success MUST deposit no code. + +This closes the split for every exceed the frame itself latched while it ran, and — with the rule below — for the one it cannot latch. + +A node MUST NOT deposit code that its create-return predicates rejected, whatever a later rewrite says the result is. +The bytes a deployment writes MUST be the bytes those predicates approved. + +### A Frame-Local Exceed Detected on the Way Out + +#### Previous behavior + +A frame-local budget is the frame's usage weighed against its **caller's** budget once the frame's usage has been merged into it, so a frame can overrun one with nothing having observed it while the frame ran. +Such an exceed is first detectable at the frame return, and a node detects it there — after the merge, and after the journal decision of the previous section. +The frame is told to revert, but its usage was merged into its caller as a successful frame's is, and its state was committed. +The caller then carries the frame's usage against its own budget and is failed by it at its next resource check. + +#### New behavior + +Under Rex7, a node MUST determine a frame-local exceed of this kind **before** it merges the frame's usage into the caller, evaluating it over the state the merge would produce. + +The reading a node evaluates MUST be the post-merge reading: the caller's budget, its usage with the frame's merged into it, and the transaction's usage as the merge would leave it. +What changes is when the answer is taken, not what is asked. + +A node that finds such an exceed MUST rewrite the frame's result to a frame-local revert before merging. +The merge, the journal decision and the result then follow one classification: + +- the caller is told the frame reverted, with the same `MegaLimitExceeded(kind, limit)` payload any frame-local exceed carries; +- the merge MUST discard the frame's usage exactly as it discards a reverting frame's — the reverted lanes of every dimension are dropped, and only usage that survives a revert is carried up; +- the frame's state MUST be rolled back, by the rule of the previous section. + +The caller MUST then be free to continue. +A node MUST NOT fail the caller on the frame's discarded usage. ## Developer Impact -None. +Rex7 is not scheduled on any network. +Its semantics may still change before it is frozen. + +Contracts and tools that assume per-opcode compute-gas attribution for every instruction MUST treat that assumption as false under Rex7: only checkpoints settle compute gas during execution, and a plain-opcode segment has no intermediate recording. + +Contracts that stay within every resource limit, never end a frame in an exceptional halt, and never trip a `disableVolatileDataAccess` guard see no behavioral change relative to Rex6. +Contracts that trip the compute-gas or detention limit inside a plain-opcode segment halt one opcode earlier than under Rex6, with the crossing opcode excluded from recorded compute usage and with remaining gas still refundable on a transaction-level halt. +A contract that disables volatile access and then hits a guarded opcode pays that opcode's static fee under Rex7 and gets the same revert payload; through Rex6 that reject cost nothing. +A CALL or `EXTCODECOPY` that cannot afford the fees charged before the target account is loaded does not detain the rest of the transaction. + +A transaction that halts exceptionally, or that calls into a child frame which does, may report a higher transaction-level compute-gas total under Rex7 than under Rex6 — for any exceptional halt, not just out-of-gas. +The receipt `gas_used`, the halt or revert reported, and the execution success or failure of the outer transaction are unchanged by the destroyed half of that carve-out: it is reported, never enforced. +The executed half does enforce, so a contract that calls into a failing child and keeps working can trip a resource limit at the same point it would under Rex6 — and, for a child that ran out of gas with no clamp in force, marginally earlier. +A contract that calls a precompile which then fails is on the same split: work the precompile performed still binds the remaining compute budget; the unused forwarded envelope does not. +Under Rex6 that unused envelope was enforcing, so the same tail work can survive under Rex7 and starve under Rex6. + +A contract creation that fails at its frame exit reports `code_length × CODEDEPOSIT` less compute gas under Rex7 than under Rex6, and leaves that much more of the transaction's and the block's compute budget for the work that follows. +Whether the creation succeeds, what it deploys, and the receipt it produces are unchanged. -Rex7 is not scheduled on any network, and it is behaviorally identical to Rex6, so no contract, tool, or integration needs to do anything today. +A frame that reports a frame-local revert leaves no state behind under Rex7, where under Rex6 a frame that had already exited successfully kept its writes. +A caller that read state written by such a frame after absorbing its revert sees nothing under Rex7. + +A caller whose child overran the caller's own budget survives under Rex7 and is failed under Rex6. +The child reverts either way; what changes is that its usage no longer follows it up. +Reaching this at all needs the caller's remaining budget in one dimension to be small enough that 2% of it is under one unit of that dimension's charge, so a caller with room to spare cannot be put there by a child that stayed inside its own budget. ## Safety and Compatibility Rex7 changes nothing about how blocks under earlier specs are executed. -Every spec through Rex6 is frozen: a node replaying historical blocks resolves each block's spec from its timestamp and applies that spec's semantics, unaffected by Rex7's existence. +Every spec through Rex6 remains frozen: a node replaying historical blocks resolves each block's spec from its timestamp and applies that spec's semantics. Because Rex7 is unstable, its semantics may change in either direction until it is frozen. Any node, tool, or test fixture pinned to Rex7 must expect its results to move. A deployment that needs stable semantics must select a frozen spec explicitly rather than relying on the latest one. +The gas clamp is strictly tighter than Rex6's post-opcode enforcement on the overshoot axis: the crossing opcode does not run, and enforced usage does not pass the limit by that opcode's cost. +Rex7 can report more compute gas than Rex6 for the same inputs on four paths: the exceptional-halt frame carve-out, which over-reports rather than under-reports; a failing precompile whose unused forwarded envelope is now reported as destroyed; a `disableVolatileDataAccess` rejection, which now includes the rejected opcode's static fee; and a failed deposit, whose rebuilt receipt is now covered by the reported total instead of leaving part of the envelope unaccounted. +The carve-out's enforcing half is never looser than Rex6's on interpreter frames, and is stricter in exactly one shape: an ordinary out-of-gas taken with no clamp in force, whose zeroed counter leaves the whole segment measuring as executed. +On a precompile that fails before performing work, Rex7 enforcement is deliberately looser than Rex6's: the unused envelope does not bind the compute limit. + +Rex7 rolls back state Rex6 keeps on one path: a frame that exited successfully and was then rewritten into a frame-local revert. +The rewrite itself is unchanged; what changes is that the journal follows it. + +Rex7 keeps a caller alive that Rex6 fails, on one path: a frame whose merge would overrun its caller's budget. +Rex6 merges the usage first and fails the caller on it; Rex7 rewrites the frame first, so the merge discards that usage the way it discards any reverting frame's. +The frame's own outcome — a frame-local revert with the same payload — is the same on both. + +Rex7 reports less compute gas than Rex6 on one path: a contract creation that fails at its frame exit, whose code-deposit compute gas Rex6 records and Rex7 does not. +Enforcement is looser there by the same amount, and deliberately so — the EVM charges that amount only for a deposit that happens, so enforcing it against a creation that failed would bind the compute limit with gas nobody spent. + ## References - [Hardforks and Specs](../hardfork-spec.md) — how specs are versioned, frozen, and activated. - [Rex6 Network Upgrade](rex6.md) — the frozen spec Rex7 inherits from. +- [Compute Gas Accounting](../evm/compute-gas.md) — measurement windows, metering classes, and exceed behavior (Rex7 details on that page). +- [Dual Gas Model](../evm/dual-gas-model.md) — total gas, storage gas, and metering order. +- [Gas Detention](../evm/gas-detention.md) — detained compute-gas caps. +- [Multidimensional Resource Limits](../evm/resource-limits.md) — transaction- and frame-level limit outcomes. diff --git a/mutants/suppressions.toml b/mutants/suppressions.toml index cd8d4000..09a7df28 100644 --- a/mutants/suppressions.toml +++ b/mutants/suppressions.toml @@ -290,31 +290,31 @@ kind = "line" category = "equivalent" file = "crates/mega-evm/src/limit/state_growth.rs" mutant = "crates/mega-evm/src/limit/state_growth.rs:121:1: spec-gate if self.spec.is_enabled(MegaSpecId::REX4) { -> if self.spec.is_enabled(MegaSpecId::REX3) {" -justification = "Equivalent (coupled): push_frame shifted to REX3 derives per-frame state-growth limits at REX3, but check_limit (line 206, unmutated) skips the per-frame check pre-REX4 and uses TX-level net_usage only. The derived frame limits are never read, so no observable change." +justification = "Equivalent (coupled): push_frame shifted to REX3 derives per-frame state-growth limits at REX3, but check_limit_on (line 178, unmutated) skips the per-frame check pre-REX4 and uses TX-level net_usage only. The derived frame limits are never read, so no observable change." reviewer = "improve-mutation-score (William Aaron Cheung)" [[suppress]] kind = "line" category = "equivalent" file = "crates/mega-evm/src/limit/state_growth.rs" -mutant = "crates/mega-evm/src/limit/state_growth.rs:206:1: spec-gate if self.spec.is_enabled(MegaSpecId::REX4) { -> if self.spec.is_enabled(MegaSpecId::REX3) {" -justification = "Equivalent (coupled): check_limit shifted to REX3 runs the per-frame check at REX3, but push_frame (line 121, unmutated) pushes frames with u64::MAX limit pre-REX4, so exceeds_current_frame_limit never fires and control falls through to the identical TX-level check." +mutant = "crates/mega-evm/src/limit/state_growth.rs:178:1: spec-gate if self.spec.is_enabled(MegaSpecId::REX4) { -> if self.spec.is_enabled(MegaSpecId::REX3) {" +justification = "Equivalent (coupled): check_limit_on shifted to REX3 runs the per-frame check at REX3, but push_frame (line 121, unmutated) pushes frames with u64::MAX limit pre-REX4, so exceeds_current_frame_limit never fires and control falls through to the identical TX-level check." reviewer = "improve-mutation-score (William Aaron Cheung)" [[suppress]] kind = "line" category = "equivalent" file = "crates/mega-evm/src/limit/state_growth.rs" -mutant = "crates/mega-evm/src/limit/state_growth.rs:169:1: spec-gate if self.spec.is_enabled(MegaSpecId::REX4) { -> if self.spec.is_enabled(MegaSpecId::REX3) {" -justification = "Equivalent: current_call_remaining is only read when building a child sandbox budget (sandbox/execution.rs:1037, KeylessDeploy, REX5+); it is never called at REX3/REX4. And at REX3 frames carry u64::MAX limits (push_frame, line 121), so frame_remaining.min(tx_remaining) == tx_remaining regardless." +mutant = "crates/mega-evm/src/limit/state_growth.rs:205:1: spec-gate if self.spec.is_enabled(MegaSpecId::REX4) { -> if self.spec.is_enabled(MegaSpecId::REX3) {" +justification = "Equivalent: current_call_remaining is only read when building a child sandbox budget (sandbox/execution.rs, KeylessDeploy, REX5+); it is never called at REX3/REX4. And at REX3 frames carry u64::MAX limits (push_frame, line 121), so frame_remaining.min(tx_remaining) == tx_remaining regardless." reviewer = "improve-mutation-score (William Aaron Cheung)" [[suppress]] kind = "line" category = "equivalent" file = "crates/mega-evm/src/limit/state_growth.rs" -mutant = "crates/mega-evm/src/limit/state_growth.rs:169:1: spec-gate if self.spec.is_enabled(MegaSpecId::REX4) { -> if self.spec.is_enabled(MegaSpecId::REX5) {" -justification = "Equivalent: current_call_remaining is only read by the REX5+ KeylessDeploy sandbox (sandbox/execution.rs:1037). The REX4->REX5 shift differs only at exactly REX4, where the function is never called — no observable change." +mutant = "crates/mega-evm/src/limit/state_growth.rs:205:1: spec-gate if self.spec.is_enabled(MegaSpecId::REX4) { -> if self.spec.is_enabled(MegaSpecId::REX5) {" +justification = "Equivalent: current_call_remaining is only read by the REX5+ KeylessDeploy sandbox (sandbox/execution.rs). The REX4->REX5 shift differs only at exactly REX4, where the function is never called — no observable change." reviewer = "improve-mutation-score (William Aaron Cheung)" # --- precompiles.rs:135 legacy Osaka ModExp size check, first `||` (equivalent) --- @@ -335,12 +335,12 @@ reviewer = "improve-mutation-score (William Aaron Cheung)" # equivalent: weakening it lets an over-limit `exp_len` fall through to the short-circuit and be # charged the flat minimum, which `test_modexp_oversized_exp_len_halts_before_short_circuit` # rejects. The two mutants share identical mutation text, so this entry uses the full -# `file:line:col:` form to suppress only the line-135 one. +# `file:line:col:` form to suppress only the line-139 one. [[suppress]] kind = "line" category = "equivalent" file = "crates/mega-evm/src/evm/precompiles.rs" -mutant = "crates/mega-evm/src/evm/precompiles.rs:135:49: replace || with && in modexp::run_osaka_legacy" +mutant = "crates/mega-evm/src/evm/precompiles.rs:139:49: replace || with && in modexp::run_osaka_legacy" justification = "Equivalent: weakening the first || only skips the early halt when exactly one of base_len/mod_len exceeds the EIP-7823 limit while exp_len does not. The zero-base/zero-modulus short-circuit cannot fire there (the offending length is non-zero), so control reaches upstream osaka_run, whose identical EIP-7823 check returns Err(ModexpEip7823LimitSize); the wrapper maps it to the same PrecompileOutput::halt(ModexpEip7823LimitSize, reservoir) the unmutated line returns. Full suite green with the mutant applied." reviewer = "RealiCZ (cz)" @@ -359,3 +359,194 @@ file = "crates/mega-evm/src/evm/host.rs" mutant = "spec-gate self.spec.is_enabled(MegaSpecId::MINI_REX) && address == ORACLE_CONTRACT_ADDRESS, -> self.spec.is_enabled(MegaSpecId::EQUIVALENCE) && address == ORACLE_CONTRACT_ADDRESS," justification = "Equivalent: the mutated conjunct lives in the debug_assert! at the top of oracle_sload, which restates the sole caller's guard (sload_skip_cold_load, host.rs:181-182). is_enabled(EQUIVALENCE) is vacuously true, so the mutant only weakens an assertion that never fires on guarded code, and debug_assert! is compiled out of release builds. The guard's own spec-gate mutants are killed." reviewer = "RealiCZ (cz)" + +# --- checkpoint.rs:128 has_clamp -> false (equivalent: debug_assert only) --------- +# +# `CheckpointTracker::has_clamp` has exactly two call sites, both `debug_assert!(!has_clamp())` +# (limit.rs `checkpoint_clamp_amount` and `before_frame_run`). Those asserts fire only if a +# clamp is already outstanding when a new one is applied or a frame is resumed — an invariant +# violation that no green test reaches. Replacing the predicate with `false` makes +# `debug_assert!(!false)` always pass, so every currently-passing test stays green. Release +# builds compile the asserts out, leaving `has_clamp` with zero call sites. The sibling +# `has_clamp -> true` mutant *does* trip those asserts on every REX7 clamp path and is killed. +# Do not add a `#[should_panic]` test of the debug_assert. +[[suppress]] +kind = "line" +category = "equivalent" +file = "crates/mega-evm/src/limit/checkpoint.rs" +mutant = "replace CheckpointTracker::has_clamp -> bool with false" +justification = "Equivalent: has_clamp is consumed only by two debug_assert!(!has_clamp()) sites (checkpoint_clamp_amount, before_frame_run). Those fire only on an outstanding-clamp invariant violation that no green test reaches, so forcing the predicate to false cannot change any passing test. Release builds compile the asserts out (zero call sites). The has_clamp -> true sibling is not equivalent and is killed by the same asserts." +reviewer = "RealiCZ (cz)" + +# --- instructions.rs:671 += → *= in rex7::instruction_table (dead: non-terminating) - +# +# The loop copies INHERITED_FROM_REX6 into the Rex7 table: `while i < len { ...; i += 1 }`. +# `i *= 1` is a no-op increment, so the loop never advances. The function is `const fn`, so +# the mutant hangs const evaluation / the test build rather than producing a distinguishable +# wrong table — structurally unkillable. The neighbouring `+=` → `-=` and the loop-bound +# `<` / `==` / `>` mutants do terminate and are killed by opcode-set tests. +[[suppress]] +kind = "line" +category = "dead" +file = "crates/mega-evm/src/evm/instructions.rs" +mutant = "replace += with *= in rex7::instruction_table" +justification = "Dead/non-terminating: i *= 1 does not advance the INHERITED_FROM_REX6 copy loop, so instruction_table (a const fn) never finishes evaluating. The mutant times out at build rather than yielding a wrong table that a test could reject. Structurally unkillable; the += → -= and loop-bound mutants on the same loop are caught." +reviewer = "RealiCZ (cz)" + +# --- execution.rs frame-settlement MINI_REX gates, EQUIVALENCE side (equivalent) -- +# +# Two of the three spec-gate survivors on the shared frame-exit body are equivalent; the +# third — the `finalize_frame` gate itself (execution.rs:613) — is a real gap and is killed +# by `tests/equivalence/pre_mini_rex_gates.rs` +# (`test_equivalence_does_not_book_a_frame_result_gas_rewrite`), so it has no entry here. +# +# `execution.rs` carries the same gate text at several sites (the identical +# `let is_mini_rex_enabled = self.ctx()...` line also appears in `init_frame_unsettled`), so +# both entries use the full `file:line:col:` form and suppress exactly one site each. + +# `gas_remaining_before` is read by exactly one consumer, `MegaEvm::settle_post_action_charge`, +# whose own `MINI_REX` gate is a separate (unmutated) site and returns before it looks at the +# argument. Capturing the value under EQUIVALENCE therefore reads a `u64` that is then thrown +# away, and no test can distinguish it. +[[suppress]] +kind = "line" +category = "equivalent" +file = "crates/mega-evm/src/evm/execution.rs" +mutant = "crates/mega-evm/src/evm/execution.rs:632:1: spec-gate let gas_remaining_before = match (&action, ctx.spec.is_enabled(MegaSpecId::MINI_REX)) { -> let gas_remaining_before = match (&action, ctx.spec.is_enabled(MegaSpecId::EQUIVALENCE)) {" +justification = "Equivalent: the captured value's only consumer is MegaEvm::settle_post_action_charge, whose own MINI_REX gate is a different site and is unmutated, so it returns before reading the argument. Nothing else reads the local. Under EQUIVALENCE the mutant only makes a u64 read whose value is discarded. Full mega-evm suite green with the mutant applied." +reviewer = "RealiCZ (cz) via S1 spec-gate triage" + +# The uninspected `frame_init`. Under EQUIVALENCE the mutant runs +# `AdditionalLimit::finalize_frame(result, exit, 0)`, and every branch of it is structurally +# inert there: +# - `absorb_frame_local_exceed`, `settle_exceptional_halt_burn` and +# `settle_frame_init_reject_burn` all return at `checkpoint.rex7_enabled()`; +# - `staged_precompile` is always `None`, because `stage_precompile_envelope` returns at the +# same REX7 gate; +# - the inspector delta is the literal `0` this call site passes (there is no inspector on +# this path, and only the measurement shim stages `staged_action_result_gas`), so +# `book_crossing` adds `0.unsigned_abs()` to the gross and `settle_inspector_result_gas` +# returns immediately without booking; +# - `try_rescue_gas` runs on a `Refused` exit, but `check_limit()` cannot latch: every +# `EvmTxRuntimeLimits::equivalence()` dimension is `u64::MAX`, every frame-local branch is +# `rex4_enabled`-gated off, and no usage is ever recorded because every recorder sits behind +# its own MINI_REX gate. Both of its effects are unreadable pre-MINI_REX anyway — +# `rescued_gas` is consumed only inside `last_frame_result`'s `is_mini_rex` branch, and every +# reader of `has_exceeded_limit` is behind an unmutated MINI_REX gate — so this holds even +# for a caller that installs finite limits on an EQUIVALENCE context via +# `with_tx_runtime_limits`. +# `FrameExit::Ran` is unreachable from frame init, so the `Ran` arm's debug_assert is not a +# `#[should_panic]` candidate. +[[suppress]] +kind = "line" +category = "equivalent" +file = "crates/mega-evm/src/evm/execution.rs" +mutant = "crates/mega-evm/src/evm/execution.rs:1673:1: spec-gate let is_mini_rex_enabled = self.ctx().spec.is_enabled(MegaSpecId::MINI_REX); -> let is_mini_rex_enabled = self.ctx().spec.is_enabled(MegaSpecId::EQUIVALENCE);" +justification = "Equivalent: this is the uninspected frame_init, so the mutant runs AdditionalLimit::finalize_frame(result, exit, 0) under EQUIVALENCE and every branch is inert. absorb_frame_local_exceed / settle_exceptional_halt_burn / settle_frame_init_reject_burn return at checkpoint.rex7_enabled(); staged_precompile is always None because stage_precompile_envelope returns at the same REX7 gate; the inspector delta is the literal 0 this site passes, so book_crossing books nothing and settle_inspector_result_gas returns without booking; try_rescue_gas's check_limit() cannot latch under EQUIVALENCE (every EvmTxRuntimeLimits::equivalence() dimension is u64::MAX, every frame-local branch is rex4_enabled-gated off, and no usage is ever recorded), and both of its effects are unreadable pre-MINI_REX regardless — rescued_gas is consumed only in last_frame_result's is_mini_rex branch and every reader of has_exceeded_limit is behind an unmutated MINI_REX gate. FrameExit::Ran is unreachable from frame init. Full mega-evm suite green with the mutant applied." +reviewer = "RealiCZ (cz) via S1 spec-gate triage" + +# --- classify_create_return: the EIP-8037 state-gas guard (equivalent) ------------- +# +# `if state_gas_for_code > 0 && !gas.record_state_cost(state_gas_for_code)`. The `>` vs `>=` +# boundary can only differ at `state_gas_for_code == 0`, where the mutant additionally evaluates +# `record_state_cost(0)`. That call is a no-op that always succeeds: `reservoir >= 0` holds for +# every `u64`, so it adds 0 to `state_gas_spent`, subtracts 0 from the reservoir and returns +# `true` — making the conjunction `true && !true == false`, the same branch the original takes. +# The gas counter, the reservoir and the state-gas dimension are all left where they were. +# +# The sibling `>` -> `==` and `>` -> `<` mutants are *not* equivalent (they skip a charge that is +# owed) and are killed by +# `test_the_eip8037_split_charges_the_hash_and_the_state_gas_on_top_of_the_deposit`. +[[suppress]] +kind = "line" +category = "equivalent" +file = "crates/mega-evm/src/evm/frame.rs" +mutant = "replace > with >= in classify_create_return" +justification = "Equivalent: > vs >= differs only at state_gas_for_code == 0, where the mutant evaluates record_state_cost(0). That is a total no-op returning true (reservoir >= 0 holds for every u64; it adds 0 to state_gas_spent and subtracts 0 from the reservoir), so the conjunction is false either way and the same branch is taken with the gas counter, reservoir and state-gas dimension unchanged. The > -> == and > -> < siblings are not equivalent and are killed by test_the_eip8037_split_charges_the_hash_and_the_state_gas_on_top_of_the_deposit." +reviewer = "RealiCZ (cz)" + +# --- the two `if hide > 0` clamp guards (equivalent) ------------------------------- +# +# Both sites read `let hide = self.checkpoint_clamp_amount(gas.remaining());` and then guard +# `gas.record_regular_cost(hide)` with `if hide > 0`. `hide` is a `u64`, so `>= 0` is always +# true and the mutant's only extra work is the `hide == 0` case: +# +# - `record_regular_cost(0)` is `remaining.checked_sub(0)`, which always succeeds and leaves +# `remaining` exactly where it was, so the `debug_assert!(clamped, ...)` beside it still +# passes; +# - in `record_inspector_gas_adjustment` the guarded body also re-runs +# `sync_checkpoint_baseline(gas.remaining())`, which is a plain assignment of the value the +# line above the clamp derivation already wrote — nothing between the two moves +# `gas.remaining()`; +# - in `before_frame_run` the baseline sync sits *outside* the guard and runs either way. +# +# The `>` -> `==` and `>` -> `<` siblings at both sites skip a clamp that binds and are killed. +[[suppress]] +kind = "line" +category = "equivalent" +file = "crates/mega-evm/src/limit/limit.rs" +mutant = "replace > with >= in AdditionalLimit::record_inspector_gas_adjustment" +justification = "Equivalent: hide is a u64 so >= 0 is always true, and the hide == 0 body is inert. record_regular_cost(0) is remaining.checked_sub(0) — it always succeeds and leaves remaining unchanged, so the debug_assert beside it still passes — and the sync_checkpoint_baseline(gas.remaining()) it re-runs re-writes the value the line above the clamp derivation already assigned, since nothing in between moves gas.remaining(). The > -> == and > -> < siblings at this site are not equivalent and are killed." +reviewer = "RealiCZ (cz)" + +[[suppress]] +kind = "line" +category = "equivalent" +file = "crates/mega-evm/src/limit/limit.rs" +mutant = "replace > with >= in AdditionalLimit::before_frame_run" +justification = "Equivalent: hide is a u64 so >= 0 is always true, and the hide == 0 body is inert — record_regular_cost(0) is remaining.checked_sub(0), which always succeeds and leaves remaining unchanged, so the debug_assert beside it still passes. The baseline sync at the end of the branch sits outside this guard and runs either way. The > -> == and > -> < siblings at this site are not equivalent and are killed." +reviewer = "RealiCZ (cz)" + +# --- debug-only predicates the mutation gate cannot evaluate ---------------------- +# +# The three entries below share one shape: the mutated expression contains +# `cfg!(debug_assertions)`, and every build the test suite is compiled in has assertions ON. +# Under that constant the mutant and the original reduce to the same value on every input the +# suite can produce, so no test can kill them. They are recorded here rather than left as +# survivors, and the profile scope is stated in each justification rather than glossed as full +# equivalence: with assertions OFF the two forms do differ, which is exactly why they are +# written this way in the first place. +# +# Do not "fix" these by adding a test — there is no assertions-off test build to add one to. +# What guards the release semantics is different in each case, and is named per entry. + +# `measures()` is `!self.trusted || cfg!(debug_assertions)`. In a debug build it is the constant +# `true` — every inspector takes the measuring path — so both mutants below are that same +# constant. The release semantics they would change (a declared `TrustedObserver` delegated to +# unmeasured) is a fast path, not a behavioral one: whichever way `measures` answers, the +# callback still reaches the wrapped inspector, and a declaration that turns out to be false is +# caught by `verify_trusted` after every measured callback and by the transaction-level backstop +# in `evm/mod.rs`, both of which run in exactly the profile that measures. +[[suppress]] +kind = "line" +category = "equivalent" +file = "crates/mega-evm/src/evm/inspector.rs" +mutant = "replace MeasuredInspector::measures -> bool with true" +justification = "Equivalent in every profile the suite builds: measures() is `!trusted || cfg!(debug_assertions)`, which is the constant true under debug_assertions, so the mutant is the same constant and no test can distinguish it. With assertions off the mutant would take the measuring path for a declared TrustedObserver too — a fast-path difference, not a behavioral one, since the callback reaches the wrapped inspector either way and a false declaration is caught by verify_trusted and by the transaction-level backstop in evm/mod.rs, both of which run in the measuring profile." +reviewer = "RealiCZ (cz)" + +[[suppress]] +kind = "line" +category = "equivalent" +file = "crates/mega-evm/src/evm/inspector.rs" +mutant = "delete ! in MeasuredInspector::measures" +justification = "Equivalent in every profile the suite builds: measures() is `!trusted || cfg!(debug_assertions)`, and deleting the ! leaves `trusted || cfg!(debug_assertions)`, which is the same constant true under debug_assertions. With assertions off the mutant inverts which inspectors are measured; that is guarded by verify_trusted and by the transaction-level backstop in evm/mod.rs, which run in the measuring profile. The sibling `measures -> false` is not equivalent and is killed." +reviewer = "RealiCZ (cz)" + +# `let peeked = (!duplicate && (self.rex7_enabled() || cfg!(debug_assertions))).then(...)`. +# Under debug_assertions the original is unconditionally true and the mutant reduces to +# `rex7_enabled()`, so they differ only on a frozen spec — where the peek's sole consumer is the +# `debug_assert!` cross-check twenty lines below, which `is_none_or` satisfies vacuously when the +# peek was not taken. `peek_check_limit_after_pop` takes `&self` and mutates nothing, so skipping +# it has no other effect. The REX7 settlement above reads the peek only inside +# `if self.rex7_enabled()`, where both forms are true. +# +# Making this killable would mean driving the pre-pop peek and the post-pop check to disagree on +# a frozen spec, which is the drift the assertion exists to catch — i.e. it would require the bug. +[[suppress]] +kind = "line" +category = "equivalent" +file = "crates/mega-evm/src/limit/limit.rs" +mutant = "replace || with && in AdditionalLimit::before_frame_return_result" +justification = "Equivalent in every profile the suite builds: under debug_assertions the original condition is unconditionally true and the mutant reduces to rex7_enabled(), so the two differ only on a frozen spec. There the peek's only consumer is the debug_assert cross-check below, which is_none_or satisfies vacuously when no peek was taken; peek_check_limit_after_pop takes &self and mutates nothing, and the REX7 settlement reads the peek only inside `if self.rex7_enabled()`, where both forms are true. Killing it would require driving the pre-pop peek and the post-pop check to disagree on a frozen spec, which is the drift the assertion exists to catch. With assertions off the mutant would disable the REX7 pre-pop settlement, which no build the gate produces can observe." +reviewer = "RealiCZ (cz)" diff --git a/tools/eest-sweep/README.md b/tools/eest-sweep/README.md new file mode 100644 index 00000000..d5f060f1 --- /dev/null +++ b/tools/eest-sweep/README.md @@ -0,0 +1,132 @@ +# EEST corpus sweep + +Runs the whole [execution-spec-tests](https://github.com/ethereum/execution-spec-tests) state-test +corpus through the mega-evm runner, in one command: + +```bash +tools/eest-sweep/run.sh +``` + +That fetches the pinned fixture release, verifies its hash, unpacks the `state_tests` subtree, and +executes every fixture under the unstable spec **and** under the frozen spec it inherits from, +comparing the two. `.github/workflows/eest-nightly.yml` runs the same command nightly. + +## Why a differential sweep + +A state-test fixture pins what a transaction must produce — but only for a spec someone has already +computed an expectation for. The corpus records expectations for Ethereum forks, which mega-evm +maps onto `Equivalence`; for `Rex7` there is nothing to compare against, so executing the corpus +under it can only check that nothing crashes. + +The frozen spec supplies the missing oracle. `Rex7` states the conditions under which it may _not_ +differ from `Rex6` ([`docs/spec/upgrades/rex7.md`](../../docs/spec/upgrades/rex7.md), "Precision +invariant"), and read as a contrapositive that sentence classifies every disagreement: a difference +must come with evidence, read off the execution itself, that one of the invariant's three +hypotheses does not hold. A difference with no such evidence is a defect — in the implementation or +in the invariant. + +## What fails the run + +Two conditions, and only these two: + +- **`PANIC`** — a fixture tripped a debug assertion or an internal invariant. +- **`UNEXPLAINED`** — the two specs disagreed and nothing in either execution licenses it. + +Everything else is reported and does not fail: + +- **`PASS`** — the two specs agreed on every compared quantity. +- **`EXPLAINED`** — they disagreed, with evidence (a crossed resource limit, a frame that ended in + an exceptional halt, a `disableVolatileDataAccess` rejection). +- **`SKIPPED`** — neither spec executed the transaction, and both declined it identically. Most of + this class is the MegaETH intrinsic-gas surcharge putting an Ethereum fixture's gas limit below + what the transaction now costs. + +`baseline.json` records the tally at the time this sweep was written. The nightly compares against +it and warns in the job summary when the coverage numbers move — a corpus that half-unpacked, or a +change that pushed thousands of fixtures out of execution, is worth seeing even though it is not a +defect. Update it deliberately when a move is expected. + +## The cached corpus + +The archive is verified against the hash in `corpus.env` on every run, and the tree unpacked from +it is verified against a manifest the unpack wrote: every file that was extracted, with that file's +hash. Before a cached tree is swept, that manifest is re-derived from the bytes on disk and +compared; a file missing, added or edited discards the tree and unpacks it again. + +The tree is what the sweep actually reads, and a cached one can be short of the corpus in ways +nothing about it announces — an extraction cut off by a cancelled job or a full disk, a CI cache +archived mid-write and restored intact, a stray edit under the cache directory. Each of those +leaves a directory that exists and sweeps clean over a fraction of what the tally claims, which is +the one failure mode a coverage number cannot show. + +Unpacking is serialized by an atomic `mkdir` lock, so two runs sharing a cache directory do not +extract into the same destination at once. A run that finds the lock held waits for it, and if the +wait runs out — the lock's owner died, or is very slow — unpacks a private tree of its own rather +than reaching into a directory another process may still be writing. A lock left behind by a dead +run is cleared by removing `/.unpack.lock`. + +`tests/cache_integrity.sh` drives all of this against a synthetic archive and a stub binary; it +runs per-PR in CI and needs neither the corpus nor a build. + +## Chaos mode + +`--mode chaos` asks a different question of the same corpus: not whether two specs agree, but whether the accounting survives an inspector that rewrites what it is handed. + +Every vector is executed three times under the target spec — with no inspector, with a read-only one, and with a deterministic rewriting one — and three things are checked. + +- **Observation is free.** The read-only run must be identical to the run with no inspector on every quantity the differential classifier compares, and must leave an empty inspector ledger. That is the property every tracer in production depends on, checked against the whole corpus rather than a handful of fixtures. +- **Rewriting does not break the books.** Every gas-accounting cross-check MegaETH has is a debug assertion, so under the default `hivetests` profile a broken conservation law is a panic, and a panic is that vector's verdict rather than a lost worker thread. +- **The ledger can still see the rewrite.** A run that applied a shape the shim is contracted to book unconditionally must not end with an all-zero inspector ledger, because the ledger is the conservation law's inspector term, the backstop the block executor refuses a result over, and the only thing that tells a consumer an execution was inspector-influenced. The gate is stated over a subset of the pool on purpose: most shapes are booked only when what they moved still reaches something — gas written into a counter the interpreter is about to stop reading moves nothing, a result's remaining gas edited on a halting frame is never handed back — and a gate over those would fail on a working shim. `ChaosShape::is_always_booked` is the partition. + +The rewriting inspector's decisions come from a hash of the global seed and the vector's own identity — its fixture path, unit name and transaction indexes. +No clock, no address, no iteration order. +The same seed and the same corpus produce the same mutations on any machine, in any thread count, so a flagged vector's report line carries everything needed to re-run exactly it. + +What fails the run: a `PANIC`, a `CONTROL_DRIFT` (the read-only run moved something), a `CHAOS_REJECTED` (the rewriting run changed whether the transaction executes at all), a `LEDGER_BLIND` (the run rewrote something the guard cannot see), a file the sweep could not read, a run that judged no vector — and a run whose inspector mutated nothing, which would report every count truthfully zero while testing nothing. + +One rewrite shape is deliberately absent from the pool: turning a failed contract creation into a successful one. +The shim refuses that shape and asserts on it, so including it would make the detector's own firing the sweep's dominant result. +`crates/mega-evm/tests/rex7/inspector_cheat_matrix.rs` pins the refusal instead. + +### Narrowing a flagged vector + +One knob, passed through with `--chaos-arg`: + +```bash +tools/eest-sweep/run.sh --mode chaos \ + --chaos-arg --chaos-shapes --chaos-arg inject_gas,drain_gas +``` + +- `--chaos-shapes LIST` restricts the pool to the named shapes. Narrowing does not reshuffle the decision stream, so each surviving mutation stays where the full run put it; it does leave the mutation budget unspent on rejected draws, so a narrowed run can reach further into a transaction. + +## Options + +``` +--target-spec SPEC Spec under test (default: Rex7) +--base-spec SPEC Frozen spec to compare against (default: Rex6) +--mode diff|fill|chaos + diff (default) executes both specs and classifies the differences. + fill executes the target spec only and recomputes each fixture's `post` + on a private copy — the older scan, kept because it exercises the + fixture-writing path that diff mode does not touch. + chaos executes the target spec three times per vector under three + inspectors; see "Chaos mode" above. +--chaos-seed SEED Global seed for chaos mode (default: 1). +--chaos-arg ARG Extra argument passed through to the chaos run; repeatable. +--corpus-dir DIR Use an already-unpacked `state_tests` tree instead of downloading. +--cache-dir DIR Where to keep the downloaded archive (default: .eest-cache). +--report-dir DIR Where to write the report and log (default: .eest-report). +--profile PROFILE Cargo profile (default: hivetests). +--no-build Use an already-built binary. +``` + +The default profile is `hivetests` rather than `release` or `dev` deliberately: it is optimized +_and_ keeps debug assertions live, so the Rex7 gas-conservation cross-checks actually run. A +release build would sweep the corpus without evaluating them; a `dev` build evaluates them at +roughly a tenth of the speed. + +## Bumping the corpus + +Edit `corpus.env` (release, archive name, sha256), re-run the sweep, and update `baseline.json` +from the new report. The hash is verified on every run, so a mismatch — a re-uploaded asset, a +mirror serving something else — fails loudly instead of silently changing what the sweep covers. diff --git a/tools/eest-sweep/baseline.json b/tools/eest-sweep/baseline.json new file mode 100644 index 00000000..a80a8968 --- /dev/null +++ b/tools/eest-sweep/baseline.json @@ -0,0 +1,23 @@ +{ + "targetSpec": "Rex7", + "baseSpec": "Rex6", + "total": 44023, + "classes": { + "PASS": 19611, + "EXPLAINED": 17363, + "UNEXPLAINED": 0, + "SKIPPED": 7049, + "PANIC": 0 + }, + "mechanisms": { + "destroyed_compute_gas": 10698, + "detention_in_force": 610, + "exceptional_halt": 17363 + }, + "explainedFields": { + "compute_gas_used": 17363 + }, + "fileErrors": [], + "skippedFiles": 5, + "flagged": [] +} diff --git a/tools/eest-sweep/corpus.env b/tools/eest-sweep/corpus.env new file mode 100644 index 00000000..c019bf23 --- /dev/null +++ b/tools/eest-sweep/corpus.env @@ -0,0 +1,14 @@ +# The EEST fixture release this sweep runs against. +# +# Pinned by version *and* content hash: a release asset that is re-uploaded, or a mirror that +# serves something else, must fail the sweep rather than silently change what it covers. Bumping +# the corpus is a deliberate edit of these three lines, and the tally it produces is the new +# baseline. +# +# `fixtures_stable` is the build that stops at the latest deployed Ethereum fork, which is what +# MegaETH's Equivalence baseline tracks; `fixtures_develop` would add unshipped forks the +# baseline does not claim to implement. +EEST_RELEASE="v5.4.0" +EEST_ARCHIVE="fixtures_stable.tar.gz" +EEST_SHA256="92cf1b47ad12fb27163261fc3c1cea5df72439cab507983d06b56c94f8741909" +EEST_URL_BASE="https://github.com/ethereum/execution-spec-tests/releases/download" diff --git a/tools/eest-sweep/run.sh b/tools/eest-sweep/run.sh new file mode 100755 index 00000000..07d8a513 --- /dev/null +++ b/tools/eest-sweep/run.sh @@ -0,0 +1,337 @@ +#!/usr/bin/env bash +# +# Run the EEST state-test corpus through the mega-evm state-test runner. +# +# One command: fetch and verify the pinned fixture release, unpack its `state_tests` subtree, and +# execute every fixture. In the default `diff` mode two gates fail the run — a fixture that panics, +# and a difference between the spec under test and its frozen base that no MegaETH mechanism +# accounts for. Everything else (fixtures the runner declines, differences the classifier explains) +# is reported and does not fail. `chaos` mode has its own gates; see `--mode`. +# +# Usage: +# tools/eest-sweep/run.sh [options] +# +# --target-spec SPEC Spec under test (default: Rex7) +# --base-spec SPEC Frozen spec to compare against (default: Rex6) +# --mode diff|fill|chaos +# diff: execute under both specs and classify the differences (default). +# fill: execute under the target spec only and recompute each fixture's +# `post` in place, on a private copy. `diff` runs the target spec through +# the same execution path, so it already covers what `fill` scans for; +# `fill` remains available to exercise the fixture-writing path itself. +# chaos: execute under the target spec three times per vector — with no +# inspector, with a read-only one, and with a deterministic rewriting one — +# and check that observation stays free and that nothing the rewriting run +# does breaks the gas-accounting cross-checks. +# --chaos-seed SEED Global seed for `--mode chaos` (default: 1). Each vector's own seed is +# derived from this and the vector's identity, so a flagged vector +# reproduces exactly. +# --chaos-arg ARG Extra argument passed through to the chaos run; repeatable. Used to +# narrow a flagged vector (`--chaos-shapes`). +# --corpus-dir DIR Use an already-unpacked `state_tests` tree instead of downloading. +# --cache-dir DIR Where to keep the downloaded archive (default: .eest-cache). +# --report-dir DIR Where to write the report and log (default: .eest-report). +# --profile PROFILE Cargo profile to build with (default: hivetests — optimized, with debug +# assertions live, which is what makes the conservation cross-checks fire). +# --no-build Use an already-built binary. +# -h, --help Show this message. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# shellcheck source=tools/eest-sweep/corpus.env +source "$REPO_ROOT/tools/eest-sweep/corpus.env" + +TARGET_SPEC="Rex7" +BASE_SPEC="Rex6" +MODE="diff" +CHAOS_SEED="1" +CHAOS_ARGS=() +CORPUS_DIR="" +CACHE_DIR="$REPO_ROOT/.eest-cache" +REPORT_DIR="$REPO_ROOT/.eest-report" +PROFILE="hivetests" +BUILD=1 + +while [ $# -gt 0 ]; do + case "$1" in + --target-spec) TARGET_SPEC="$2"; shift 2 ;; + --base-spec) BASE_SPEC="$2"; shift 2 ;; + --mode) MODE="$2"; shift 2 ;; + --chaos-seed) CHAOS_SEED="$2"; shift 2 ;; + --chaos-arg) CHAOS_ARGS+=("$2"); shift 2 ;; + --corpus-dir) CORPUS_DIR="$2"; shift 2 ;; + --cache-dir) CACHE_DIR="$2"; shift 2 ;; + --report-dir) REPORT_DIR="$2"; shift 2 ;; + --profile) PROFILE="$2"; shift 2 ;; + --no-build) BUILD=0; shift ;; + # Print the header comment block and stop at the first line that is not one, so the help text + # can never run past it into the script body. + -h|--help) sed -n '2,${/^#/!q;s/^# \{0,1\}//p;}' "${BASH_SOURCE[0]}"; exit 0 ;; + *) echo "unknown option: $1" >&2; exit 2 ;; + esac +done + +case "$MODE" in + diff|fill|chaos) ;; + *) echo "--mode must be 'diff', 'fill' or 'chaos', got '$MODE'" >&2; exit 2 ;; +esac + +# `sha256sum` on Linux, `shasum -a 256` on macOS. +if command -v sha256sum >/dev/null 2>&1; then + SHA256=(sha256sum) +else + SHA256=(shasum -a 256) +fi +sha256_of() { + "${SHA256[@]}" "$1" | cut -d' ' -f1 +} + +# How long to wait for another run that is already unpacking this corpus, before giving up on the +# shared tree and unpacking a private one. A full extraction is well under a minute; the override +# is for the tests, and for a machine whose lock is known to be stale. +LOCK_WAIT_SECS="${EEST_UNPACK_LOCK_WAIT_SECS:-900}" + +# Name of the manifest inside an unpacked tree. Excluded from its own listing. +MANIFEST_NAME=".manifest" + +# Every file of an unpacked tree with its hash, in a byte-stable order. +corpus_manifest() { + (cd "$1" && find . -type f ! -name "$MANIFEST_NAME" -print0 | + LC_ALL=C sort -z | + xargs -0 "${SHA256[@]}") +} + +# The manifest covers regular files, which is everything the fixture archive holds. An entry of +# any other kind is outside what it can speak for — a symlink is not hashed, so swapping its +# target would leave the manifest matching — so such a tree is rejected rather than described. +has_irregular_entry() { + [ -n "$(find "$1" ! -type d ! -type f -print -quit)" ] +} + +# Whether a tree is exactly what this archive unpacks to: the manifest names this archive, and +# every file in the tree still hashes to what the manifest recorded — with no file added, removed +# or rewritten since. This is what the sweep's coverage rests on, so it is re-derived from the +# bytes on every run rather than trusted from a marker a previous run left behind. +corpus_is_intact() { + local root="$1" manifest="$1/$MANIFEST_NAME" actual status + [ -f "$manifest" ] || return 1 + [ "$(head -n 1 "$manifest")" = "archive-sha256 $EEST_SHA256" ] || return 1 + has_irregular_entry "$root" && return 1 + actual="$(mktemp)" + if ! corpus_manifest "$root" >"$actual" 2>/dev/null; then + rm -f "$actual" + return 1 + fi + status=0 + tail -n +2 "$manifest" | cmp -s - "$actual" || status=1 + rm -f "$actual" + return "$status" +} + +# Unpack the archive into $1, manifest and all, via a scratch directory and one rename: the +# destination either does not exist or holds a tree this function extracted whole. +unpack_corpus() { + local dest="$1" stage="$CACHE_DIR/.unpack.$$" + rm -rf "$stage" + mkdir -p "$stage" + # Only `state_tests` is unpacked: the runner reads the state-test format, and the archive's + # blockchain-test subtrees are several times larger. + tar -xzf "$ARCHIVE" -C "$stage" --strip-components=1 fixtures/state_tests + if has_irregular_entry "$stage/state_tests"; then + echo "the archive unpacks to something other than a tree of regular files, which the corpus" >&2 + echo "manifest cannot describe; teach corpus_manifest about it before sweeping." >&2 + exit 1 + fi + { + echo "archive-sha256 $EEST_SHA256" + corpus_manifest "$stage/state_tests" + } >"$stage/state_tests/$MANIFEST_NAME" + mkdir -p "$(dirname "$dest")" + rm -rf "$dest" + mv "$stage/state_tests" "$dest" + rm -rf "$stage" +} + +# The unpack lock and any private tree this run extracted, released however the run ends. +UNPACK_LOCK="" +LOCK_HELD=0 +PRIVATE_ROOT="" +cleanup() { + if [ "$LOCK_HELD" -eq 1 ]; then + rm -rf "$UNPACK_LOCK" + LOCK_HELD=0 + fi + if [ -n "$PRIVATE_ROOT" ]; then + rm -rf "$PRIVATE_ROOT" + fi +} +trap cleanup EXIT + +mkdir -p "$REPORT_DIR" + +# --- corpus ----------------------------------------------------------------------------------- + +if [ -z "$CORPUS_DIR" ]; then + mkdir -p "$CACHE_DIR" + ARCHIVE="$CACHE_DIR/$EEST_RELEASE-$EEST_ARCHIVE" + if [ ! -f "$ARCHIVE" ]; then + echo "==> downloading EEST $EEST_RELEASE / $EEST_ARCHIVE" + # Download beside the target and rename on success, so an interrupted download can never be + # mistaken for a cached archive on the next run. + curl --fail --location --show-error --silent \ + --output "$ARCHIVE.part" \ + "$EEST_URL_BASE/$EEST_RELEASE/$EEST_ARCHIVE" + mv "$ARCHIVE.part" "$ARCHIVE" + fi + + ACTUAL="$(sha256_of "$ARCHIVE")" + if [ "$ACTUAL" != "$EEST_SHA256" ]; then + echo "corpus hash mismatch for $ARCHIVE" >&2 + echo " expected $EEST_SHA256" >&2 + echo " actual $ACTUAL" >&2 + echo "Delete the cached archive and re-run, or update tools/eest-sweep/corpus.env." >&2 + exit 1 + fi + echo "==> corpus hash verified: $EEST_SHA256" + + CORPUS_DIR="$CACHE_DIR/$EEST_RELEASE/state_tests" + # What the sweep reports is a statement about the corpus it read, so the tree it reads has to be + # the whole corpus and nothing else. A cached tree can be short of that in ways nothing about it + # announces: an extraction interrupted by a cancelled job or a full disk, a cache archived + # mid-write and restored intact, a stray edit under the cache directory. Each leaves a directory + # that exists, sweeps clean, and covers a fraction of what the tally claims. + # + # So a cached tree is re-verified against its own manifest — every file, hashed — before it is + # used, and discarded and re-extracted when it does not match. + if corpus_is_intact "$CORPUS_DIR"; then + echo "==> corpus tree verified against its manifest" + else + # Two runs sharing a cache directory would otherwise extract into the same destination at the + # same time, and the loser's rename lands inside the winner's tree. An atomic `mkdir` picks + # one producer; the other waits for it and, if the wait runs out, extracts a private tree + # rather than reaching into a directory a live process may still own. + UNPACK_LOCK="$CACHE_DIR/$EEST_RELEASE.unpack.lock" + mkdir -p "$CACHE_DIR" + if mkdir "$UNPACK_LOCK" 2>/dev/null; then + LOCK_HELD=1 + echo "$$" >"$UNPACK_LOCK/pid" 2>/dev/null || true + echo "==> unpacking state_tests" + unpack_corpus "$CORPUS_DIR" + rm -rf "$UNPACK_LOCK" + LOCK_HELD=0 + else + echo "==> another run is unpacking this corpus; waiting up to ${LOCK_WAIT_SECS}s" + WAITED=0 + while [ -d "$UNPACK_LOCK" ] && [ "$WAITED" -lt "$LOCK_WAIT_SECS" ]; do + sleep 5 + WAITED=$((WAITED + 5)) + done + if ! corpus_is_intact "$CORPUS_DIR"; then + PRIVATE_ROOT="$CACHE_DIR/.private.$$" + echo "==> shared corpus is not usable; unpacking a private tree at $PRIVATE_ROOT" + echo " (a lock left behind by a dead run is cleared by removing $UNPACK_LOCK)" >&2 + unpack_corpus "$PRIVATE_ROOT/state_tests" + CORPUS_DIR="$PRIVATE_ROOT/state_tests" + fi + fi + fi +fi + +if [ ! -d "$CORPUS_DIR" ]; then + echo "corpus directory not found: $CORPUS_DIR" >&2 + exit 1 +fi +FIXTURE_COUNT="$(find "$CORPUS_DIR" -name '*.json' | wc -l | tr -d ' ')" +echo "==> corpus: $CORPUS_DIR ($FIXTURE_COUNT fixture files)" +if [ "$FIXTURE_COUNT" -eq 0 ]; then + echo "corpus holds no fixtures: $CORPUS_DIR" >&2 + exit 1 +fi + +# --- binary ----------------------------------------------------------------------------------- + +BIN="$REPO_ROOT/target/$PROFILE/state-test" +if [ "$BUILD" -eq 1 ]; then + echo "==> building state-test (profile: $PROFILE)" + (cd "$REPO_ROOT" && cargo build --profile "$PROFILE" -p state-test) +fi +if [ ! -x "$BIN" ]; then + echo "state-test binary not found at $BIN" >&2 + exit 1 +fi + +# --- run -------------------------------------------------------------------------------------- + +LOG="$REPORT_DIR/sweep.log" +STATUS=0 +if [ "$MODE" = "diff" ]; then + echo "==> differential sweep: $TARGET_SPEC vs $BASE_SPEC" + "$BIN" \ + --bench-spec "$TARGET_SPEC" \ + --diff-spec "$BASE_SPEC" \ + --diff-report "$REPORT_DIR/diff-report.json" \ + "$CORPUS_DIR" >"$LOG" 2>&1 || STATUS=$? +elif [ "$MODE" = "chaos" ]; then + echo "==> chaos sweep under $TARGET_SPEC, seed $CHAOS_SEED" + "$BIN" \ + --bench-spec "$TARGET_SPEC" \ + --chaos-seed "$CHAOS_SEED" \ + --chaos-report "$REPORT_DIR/chaos-report.json" \ + "${CHAOS_ARGS[@]+"${CHAOS_ARGS[@]}"}" \ + "$CORPUS_DIR" >"$LOG" 2>&1 || STATUS=$? +else + # `--fill` rewrites each fixture in place, so it runs on a private copy and never touches the + # cached corpus other runs share. + WORK="$REPORT_DIR/fill-corpus" + echo "==> fill sweep under $TARGET_SPEC (private copy at $WORK)" + rm -rf "$WORK" + mkdir -p "$WORK" + cp -R "$CORPUS_DIR" "$WORK/" + "$BIN" \ + --fill --force --keep-going \ + --bench-spec "$TARGET_SPEC" \ + "$WORK" >"$LOG" 2>&1 || STATUS=$? +fi + +# The tally, plus anything that needs a human. Per-unit `ERR` lines are the expected noise floor +# (thousands of fixtures the runner declines before execution) and are left in the log only. +grep -vE '^ERR\b' "$LOG" | tail -n 60 +echo "==> full log: $LOG" + +if [ "$MODE" = "diff" ]; then + echo "==> report: $REPORT_DIR/diff-report.json" + # The CLI already fails on a panic or an unexplained difference, and on nothing else — fixtures + # it declines and differences it explains leave it at 0. Pass that verdict straight through + # rather than re-deriving it from parsed output. + exit "$STATUS" +fi + +if [ "$MODE" = "chaos" ]; then + echo "==> report: $REPORT_DIR/chaos-report.json" + # Same reasoning as diff mode: the CLI's own gate is the verdict. + exit "$STATUS" +fi + +# `--fill` has no notion of an expected failure: it exits non-zero for every unit it could not +# fill, and thousands of them are fixtures neither spec would execute. Re-derive the gate from the +# tally so `fill` mode fails on the same two conditions `diff` mode does. +TALLY="$(grep -m1 '^Fill tally:' "$LOG" || true)" +if [ -z "$TALLY" ]; then + echo "fill run produced no tally line; treating as a failure" >&2 + exit 1 +fi +field() { echo "$TALLY" | tr ' ' '\n' | grep "^$1=" | cut -d= -f2; } +PANICS="$(field PANIC)" +FILE_ERRS="$(field FILE_ERR)" +TOTAL="$(field TOTAL)" +# A run that reached no unit at all reports zero panics and zero file errors, truthfully, and +# says nothing. It is the one tally that must never pass. +if [ "${TOTAL:-0}" -eq 0 ]; then + echo "gate failed: the sweep judged no unit (TOTAL=0)" >&2 + exit 1 +fi +if [ "${PANICS:-0}" -gt 0 ] || [ "${FILE_ERRS:-0}" -gt 0 ]; then + echo "gate failed: PANIC=$PANICS FILE_ERR=$FILE_ERRS" >&2 + exit 1 +fi +exit 0 diff --git a/tools/eest-sweep/summarize.py b/tools/eest-sweep/summarize.py new file mode 100755 index 00000000..a221e8f1 --- /dev/null +++ b/tools/eest-sweep/summarize.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Render an EEST sweep report as a markdown summary and compare it to the committed baseline. + +The sweep's own exit code is the gate: it fails on a fixture that panicked and on a difference no +MegaETH mechanism accounts for, and on nothing else. This script never fails a run. What it adds +is drift: the counts of fixtures the runner declined and of differences it explained are the +sweep's coverage, and a silent move in either — a corpus that half-unpacked, a change that pushed +thousands of fixtures out of execution — is worth seeing even though it is not a defect. +""" + +import argparse +import json +import sys + +CLASSES = ["PASS", "EXPLAINED", "UNEXPLAINED", "SKIPPED", "PANIC"] + + +def load(path): + with open(path, encoding="utf-8") as f: + return json.load(f) + + +def delta(current, base): + d = current - base + if d == 0: + return f"{current}" + return f"{current} ({d:+d})" + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("report", help="diff-report.json produced by the sweep") + ap.add_argument("--baseline", help="committed baseline tally to compare against") + ap.add_argument("--out", help="write the markdown summary here (default: stdout)") + args = ap.parse_args() + + report = load(args.report) + base = load(args.baseline) if args.baseline else None + base_classes = (base or {}).get("classes", {}) + + lines = [ + f"## EEST sweep — {report['targetSpec']} vs {report['baseSpec']}", + "", + f"{report['total']} units judged" + + (f", {report['skippedFiles']} file(s) skipped by filename" if report.get("skippedFiles") else "") + + ".", + "", + "| class | units |", + "|---|--:|", + ] + for name in CLASSES: + count = report["classes"].get(name, 0) + cell = delta(count, base_classes[name]) if name in base_classes else str(count) + lines.append(f"| {name} | {cell} |") + + if report.get("mechanisms"): + lines += ["", "| mechanism (explained differences) | units |", "|---|--:|"] + for name, count in sorted(report["mechanisms"].items()): + lines.append(f"| `{name}` | {count} |") + + if report.get("explainedFields"): + lines += ["", "| disagreeing quantities | units |", "|---|--:|"] + for shape, count in sorted(report["explainedFields"].items()): + lines.append(f"| `{shape}` | {count} |") + + flagged = report.get("flagged", []) + if flagged: + lines += ["", "### Flagged units", "", "| class | fixture | quantities | detail |", "|---|---|---|---|"] + # Cap the table: an unexplained class in the thousands is one finding to investigate, not + # thousands of rows to scroll. The full list is in the uploaded report. + for item in flagged[:50]: + fixture = f"{item['path'].split('state_tests/')[-1]}::{item['name']}" + lines.append( + f"| {item['class']} | `{fixture[:160]}` | `{','.join(item['fields'])}` |" + f" {(item.get('detail') or '-')[:160]} |" + ) + if len(flagged) > 50: + lines.append(f"| … | {len(flagged) - 50} more in the uploaded report | | |") + + if report.get("fileErrors"): + lines += ["", "### Files the sweep could not judge", ""] + for err in report["fileErrors"][:20]: + lines.append(f"- `{err[:200]}`") + + if base_classes: + drifted = [n for n in CLASSES if n in base_classes and report["classes"].get(n, 0) != base_classes[n]] + lines += [""] + if drifted: + lines.append( + "> :warning: Coverage drifted from the committed baseline in: " + + ", ".join(f"`{n}`" for n in drifted) + + ". Not a failure — update `tools/eest-sweep/baseline.json` if the move is expected." + ) + else: + lines.append("> Coverage matches the committed baseline exactly.") + + text = "\n".join(lines) + "\n" + if args.out: + with open(args.out, "w", encoding="utf-8") as f: + f.write(text) + else: + sys.stdout.write(text) + + +if __name__ == "__main__": + main() diff --git a/tools/eest-sweep/tests/cache_integrity.sh b/tools/eest-sweep/tests/cache_integrity.sh new file mode 100755 index 00000000..b82515ce --- /dev/null +++ b/tools/eest-sweep/tests/cache_integrity.sh @@ -0,0 +1,226 @@ +#!/usr/bin/env bash +# +# Tests for the corpus-cache guards in `run.sh`. +# +# What a sweep reports is a statement about the corpus it read, so the tree it reads has to be the +# whole corpus and nothing else. These cases drive `run.sh` against a small synthetic archive and +# check that every way a cached tree can be wrong — truncated, edited, added to, left over from a +# different archive — is detected and re-extracted, and that two runs sharing a cache directory do +# not extract into each other. +# +# Usage: tools/eest-sweep/tests/cache_integrity.sh +set -uo pipefail + +SUITE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +RUN_SH="$SUITE_DIR/../run.sh" +WORK="$(mktemp -d)" +FAILURES=0 +CASE="" + +cleanup() { rm -rf "$WORK"; } +trap cleanup EXIT + +fail() { + echo " FAIL: $*" >&2 + FAILURES=$((FAILURES + 1)) +} + +start_case() { + CASE="$1" + echo "==> $CASE" +} + +# --- fake repository ----------------------------------------------------------------------------- + +# A repo root holding just what `run.sh` reads: its own directory next to a `corpus.env` naming a +# synthetic archive, and a binary that stands in for `state-test`. +ROOT="$WORK/repo" +CACHE="$WORK/cache" +REPORT="$WORK/report" +STUB_ARGS_LOG="$WORK/stub-args.log" +export STUB_ARGS_LOG +mkdir -p "$ROOT/tools/eest-sweep" "$ROOT/target/stubprofile" "$CACHE" +cp "$RUN_SH" "$ROOT/tools/eest-sweep/run.sh" + +cat >"$ROOT/target/stubprofile/state-test" <<'STUB' +#!/usr/bin/env bash +# Stands in for the `state-test` binary: records the path it was handed and prints the shape of +# output `run.sh` parses. The corpus guards are what is under test, not the runner. +printf '%s\n' "${@: -1}" >>"$STUB_ARGS_LOG" +case " $* " in + *" --fill "*) echo "Fill tally: OK=2 ERR=0 PANIC=0 FILE_ERR=0 SKIP_FILE=0 TOTAL=2" ;; + *) echo "Differential run: Rex7 vs Rex6 over 2 unit(s)" ;; +esac +exit 0 +STUB +chmod +x "$ROOT/target/stubprofile/state-test" + +# A two-fixture archive shaped like the real one: `fixtures/state_tests/...`. +SRC="$WORK/src" +mkdir -p "$SRC/fixtures/state_tests/a" "$SRC/fixtures/state_tests/b" +echo '{"unit_a": {}}' >"$SRC/fixtures/state_tests/a/one.json" +echo '{"unit_b": {}}' >"$SRC/fixtures/state_tests/b/two.json" +ARCHIVE_NAME="fixtures_test.tar.gz" +RELEASE="vtest" +tar -czf "$WORK/$ARCHIVE_NAME" -C "$SRC" fixtures +if command -v sha256sum >/dev/null 2>&1; then + ARCHIVE_SHA="$(sha256sum "$WORK/$ARCHIVE_NAME" | cut -d' ' -f1)" +else + ARCHIVE_SHA="$(shasum -a 256 "$WORK/$ARCHIVE_NAME" | cut -d' ' -f1)" +fi +cat >"$ROOT/tools/eest-sweep/corpus.env" <"$WORK/last.log" 2>&1 +} + +expect_ok() { + local status="$1" + [ "$status" -eq 0 ] || fail "expected exit 0, got $status: $(tail -n 3 "$WORK/last.log")" +} + +# The directory's identity, which a re-extraction replaces: the tree is moved into place, never +# written into. +tree_id() { + ls -di "$CORPUS" 2>/dev/null | awk '{print $1}' +} + +log_has() { + grep -q "$1" "$WORK/last.log" || fail "log should mention '$1': $(cat "$WORK/last.log")" +} + +log_lacks() { + grep -q "$1" "$WORK/last.log" && fail "log should not mention '$1': $(cat "$WORK/last.log")" +} + +corpus_is_whole() { + [ -f "$CORPUS/a/one.json" ] && [ -f "$CORPUS/b/two.json" ] && [ -f "$CORPUS/.manifest" ] +} + +# --- cases --------------------------------------------------------------------------------------- + +start_case "a cold cache unpacks the corpus and sweeps it" +sweep +expect_ok "$?" +log_has "unpacking state_tests" +corpus_is_whole || fail "the tree is not whole after a cold run" +grep -q "^archive-sha256 $ARCHIVE_SHA$" "$CORPUS/.manifest" || + fail "the manifest should name the archive it came from" +[ "$(grep -c . "$CORPUS/.manifest")" -eq 3 ] || fail "manifest should list both fixtures" +FIRST_ID="$(tree_id)" + +start_case "a warm cache is verified against the manifest, not re-extracted" +sweep +expect_ok "$?" +log_has "verified against its manifest" +log_lacks "unpacking state_tests" +[ "$(tree_id)" = "$FIRST_ID" ] || fail "the tree was replaced despite being intact" + +start_case "a fixture edited under the cache is detected and the tree re-extracted" +echo '{"tampered": true}' >"$CORPUS/a/one.json" +sweep +expect_ok "$?" +log_has "unpacking state_tests" +[ "$(cat "$CORPUS/a/one.json")" = '{"unit_a": {}}' ] || fail "the edit survived the re-extraction" +[ "$(tree_id)" != "$FIRST_ID" ] || fail "the tree should have been replaced" + +start_case "a fixture missing from the cache is detected" +rm "$CORPUS/b/two.json" +sweep +expect_ok "$?" +log_has "unpacking state_tests" +corpus_is_whole || fail "the missing fixture should be back" + +start_case "a file added under the cache is detected" +echo '{"stray": true}' >"$CORPUS/a/stray.json" +sweep +expect_ok "$?" +log_has "unpacking state_tests" +[ -f "$CORPUS/a/stray.json" ] && fail "the stray fixture should be gone" + +start_case "a tree left by a different archive is not reused" +# The manifest describes this tree correctly and names another archive: it is a complete corpus, +# but not the one the sweep is pinned to. +sed -i.bak "1s/.*/archive-sha256 0000000000000000000000000000000000000000000000000000000000000000/" \ + "$CORPUS/.manifest" +rm -f "$CORPUS/.manifest.bak" +sweep +expect_ok "$?" +log_has "unpacking state_tests" +grep -q "^archive-sha256 $ARCHIVE_SHA$" "$CORPUS/.manifest" || + fail "the re-extracted tree should name the pinned archive" + +start_case "a truncated tree cannot be swept as a whole one" +# What an interrupted extraction leaves behind, in the shape a cache restore would preserve. +rm -rf "${CORPUS:?}/b" +sweep +expect_ok "$?" +corpus_is_whole || fail "the truncated tree should have been replaced" + +start_case "a run that finds the lock held falls back to a private tree" +# Nobody holds this lock, but a live producer is indistinguishable from a dead one, and the +# waiter's job is the same either way: never write into a destination another process owns. +rm -rf "$CORPUS" +mkdir -p "$LOCK" +: >"$STUB_ARGS_LOG" +EEST_UNPACK_LOCK_WAIT_SECS=1 sweep +expect_ok "$?" +log_has "unpacking a private tree" +[ -d "$CORPUS" ] && fail "the waiter must not create the shared tree" +SWEPT="$(tail -n 1 "$STUB_ARGS_LOG")" +case "$SWEPT" in + *"/.private."*) ;; + *) fail "the sweep should have run against the private tree, got '$SWEPT'" ;; +esac +[ -e "$SWEPT" ] && fail "the private tree should be removed when the run ends" +rmdir "$LOCK" + +start_case "two runs sharing a cache do not extract into each other" +rm -rf "$CORPUS" +sweep & +FIRST=$! +"$ROOT/tools/eest-sweep/run.sh" --no-build --profile stubprofile \ + --cache-dir "$CACHE" --report-dir "$WORK/report2" >"$WORK/second.log" 2>&1 & +SECOND=$! +wait "$FIRST" +FIRST_STATUS=$? +wait "$SECOND" +SECOND_STATUS=$? +[ "$FIRST_STATUS" -eq 0 ] || fail "the first concurrent run failed: $(tail -n 3 "$WORK/last.log")" +[ "$SECOND_STATUS" -eq 0 ] || fail "the second concurrent run failed: $(tail -n 3 "$WORK/second.log")" +corpus_is_whole || fail "the shared tree is not whole after two concurrent runs" +[ -d "$LOCK" ] && fail "the lock should be released" +ls -d "$CACHE"/.private.* >/dev/null 2>&1 && fail "no private tree should be left behind" +ls -d "$CACHE"/.unpack.* >/dev/null 2>&1 && fail "no scratch directory should be left behind" + +start_case "fill mode runs against a private copy of the verified tree" +: >"$STUB_ARGS_LOG" +sweep --mode fill +expect_ok "$?" +log_has "verified against its manifest" +SWEPT="$(tail -n 1 "$STUB_ARGS_LOG")" +case "$SWEPT" in + "$REPORT/fill-corpus"*) ;; + *) fail "fill mode should sweep its own copy, got '$SWEPT'" ;; +esac + +# --- verdict ------------------------------------------------------------------------------------- + +if [ "$FAILURES" -ne 0 ]; then + echo "$FAILURES check(s) failed" >&2 + exit 1 +fi +echo "all corpus-cache checks passed"