diff --git a/docs/changes/2026-07-28-evm-spp-gas-boundaries/README.md b/docs/changes/2026-07-28-evm-spp-gas-boundaries/README.md new file mode 100644 index 000000000..f24f9b28b --- /dev/null +++ b/docs/changes/2026-07-28-evm-spp-gas-boundaries/README.md @@ -0,0 +1,85 @@ +# Change: Preserve gas semantics at EVM SPP boundaries + +- **Status**: Accepted +- **Date**: 2026-07-28 +- **Tier**: Light +- **PR**: #579 + +This fix closes two unsafe SPP boundary classes with 0% `GasBlock` size growth. +Retain both the unresolved-source and implicit-target guards to preserve gas +semantics. + +## Overview + +The Structured Precharging Pass (SPP) must not rely only on total path cost at +these two boundaries: + +- `SSTORE`, whose EIP-2200 sentry depends on the gas remaining at the + instruction; +- unresolved dynamic `JUMP` and `JUMPI` sources, whose runtime successor set + is incomplete in the explicit cache-build CFG. + +The implementation treats `SSTORE` as a gas-sensitive boundary and rejects +source-side shifts from blocks with omitted dynamic successors. The existing +implicit-predecessor check continues to reject shifts into possible dynamic +targets. + +## Motivation + +SPP may move a successor block's gas charge into its predecessor. This +transformation is valid only when intermediate gas cannot affect execution and +every runtime successor receives the corresponding compensation. + +Precharging later work before `SSTORE` can change a successful execution into +an out-of-gas failure at the EIP-2200 call-stipend threshold. At an unresolved +dynamic `JUMPI`, moving the explicit fallthrough cost onto the source also +charges the taken path even though its omitted target receives no compensating +reduction. + +## Impact + +- `src/evm/evm_cache.cpp` adds the two scheduling guards. +- `GasBlock` stores the omitted-successor flag in existing padding and remains + 32 bytes. +- `src/evm/evm_cache.md` and `docs/modules/evm/cache-build.md` record the + source, successor, and CFG invariants used by SPP scheduling. +- Cache-level regressions cover both failure modes. An + interpreter-versus-multipass regression covers the unresolved dynamic + `JUMPI` case without introducing a new runtime component. +- No API, ABI, configuration, or persisted-data format changes are introduced. +- The guards can reduce SPP scheduling opportunities around `SSTORE` and + unresolved dynamic jumps. Applying the `SSTORE` boundary before Istanbul is + intentionally conservative. No performance claim is made. + +## Verification + +The production implementation at commit +`79540851a852d5eb2fbc1f847c2fc6f95acd7aff` was validated by: + +- tracked-source formatting and a clean Release all-target build, with no + warning diagnostics from changed files; +- focused cache regressions: 2/2; +- focused interpreter-versus-multipass regression: 1/1; +- CTest: 12/12 targets; +- interpreter unit, Cancun state, and EVM assembly suites: 215/215, 2723/2723, + and 209/209; +- multipass unit, Cancun state, and EVM assembly suites: 223/223, 2723/2723, + and 209/209. + +A sealed multipass replay of blocks 21,800,000--21,800,031 covered 32/32 +blocks, 4,993 transactions, and 594,578,894 gas. Its semantic summary matched +the frozen reference, and its exported post-state was byte-identical. The +replay used Git tree `97dd4837b1803f72e3b3c4fc742fec691b09d115` and +`libdtvmapi.so.0.1.0` SHA-256 +`5135b4a4424a92c4e66cce6c86df01e688998468341e3dd0292bbb1f7ff2d73a`. +This evidence covers the production implementation in +`src/evm/evm_cache.cpp` blob +`6ca5d7a55812920d4d0e1de8b258c176c3d3c252`; it does not claim that a later +documentation or test-only head was itself replayed. + +## Checklist + +- [x] Implementation complete +- [x] Tests added/updated +- [x] Module specs in `docs/modules/` updated +- [x] Build and tests pass diff --git a/docs/modules/evm/cache-build.md b/docs/modules/evm/cache-build.md index 23408c797..f0d1b77ec 100644 --- a/docs/modules/evm/cache-build.md +++ b/docs/modules/evm/cache-build.md @@ -40,17 +40,17 @@ straight-line fallback only. |---:|---|---| | 0 | `buildJumpDestMapAndPushCache` | Single bytecode walk: mark valid JUMPDESTs (skipping PUSH-data regions); decode PUSHn immediates into `PushValueMap` | | 1 | `buildGasBlocks` | Single bytecode walk: emit one `GasBlock` per basic block, record `JumpDestBlocks` inline, compute per-block straight-line gas | -| 2 | `buildCFGEdges` | Single sweep: emit Succs/Preds edges into `EdgeTables`; stamp `ImplicitDynamicPredCount` on JUMPDEST blocks reachable by unresolved dynamic JUMP | +| 2 | `buildCFGEdges` | Single sweep: emit resolved and fallthrough Succs/Preds edges into `EdgeTables`; for unresolved dynamic JUMP/JUMPI, mark the source with `HasUnresolvedDynamicSuccessor` and stamp possible JUMPDEST targets with `ImplicitDynamicPredCount` | | 3 | `splitCriticalEdges` | Insert empty synthetic blocks on `multi-succ → multi-pred` edges; appends new entries onto `Blocks` and `EdgeTables` | | 4 | `buildAdjacencyCSR` | Flatten `EdgeTables.Succs` and `.Preds` into two read-only `CSRGraph`s after the graph is frozen | | 5 | `computeReachable` | DFS from block 0 over `SuccsCSR`; produce `Reachable` bitset | | 6 | `computeDomInfo` | Cooper-Harvey-Kennedy fixpoint over `PredsCSR` for `IDom`, then Tarjan DFS over the dominator tree for `Enter`/`Exit` Euler tour stamps; produce `RPO` from the forward DFS | | 7 | `findBackEdgesUsingDominators` | Iterate edges; emit back-edges where successor `dominates(succ, curr)` | | 8 | `computeReverseTopo` | Return `reverse(DomInfo::RPO)` | -| 9 | `buildLoopsUsingDominance` | From dominator-based back-edges, gather natural-loop body sets; returns `true` if every node's back-edge target dominates it (reducible) | -| 10 | `computeInCycle` | **Conditional**: when `UseLinearSPP=true` (reducible result from 9), set `InCycle = union(Loops[].NodeMask)`; when `UseLinearSPP=false`, fall back to Tarjan SCC over `SuccsCSR` for `InCycle` | +| 9 | `buildLoopsUsingDominance` | Gather natural-loop bodies from dominance back-edges whose source is reachable; return `false` if a detected body violates header dominance or detected loops overlap without nesting. A `true` result validates only the detected loop set, not general CFG reducibility | +| 10 | `computeInCycle` | **Conditional**: when `UseLinearSPP=true`, set `InCycle = union(Loops[].NodeMask)`; when `UseLinearSPP=false`, run a two-pass Kosaraju-style SCC traversal over `SuccsCSR` and `PredsCSR`. The traversal therefore runs only for a rejected detected-loop set | | 11 | `meteringInit` | Copy per-block `Cost` into the `Metering` working array used by lemma614 | -| 12 | `lemma614Schedule` | SPP gas-shifting in reverse-topo order: for each block, try to shift its gas charge onto its successors via `lemma614Update`, gated on `effectivePredCount` and `InCycle` | +| 12 | `lemma614Schedule` | SPP gas-shifting in reverse-topo order: for each block, try to shift its gas charge onto its successors via `lemma614Update`, gated on `HasUnresolvedDynamicSuccessor == 0`, `effectivePredCount`, and `InCycle` | | 13 | `writeback` | Project per-block `Metering` back onto `GasChunkEnd` / `GasChunkCost` / `GasChunkCostSPP` | `EVM_PROFILE_BEGIN() / EVM_PROFILE_END()` chrono pairs @@ -72,7 +72,8 @@ Per-block scalars used by every downstream pass. | 16 | `ImplicitDynamicPredCount` | `uint32_t` | Count of dynamic-JUMP blocks that could land on this JUMPDEST (carried separately to avoid `D×J` materialised over-approximation edges) | | 20 | `LastOpcode` | `uint8_t` | Terminator opcode | | 21 | `PrevOpcode` | `uint8_t` | Opcode before terminator | -| 22 | _pad[2]_ | — | Alignment to 8-byte `Cost` | +| 22 | `HasUnresolvedDynamicSuccessor` | `uint8_t` | Nonzero when an unresolved dynamic JUMP/JUMPI has runtime targets omitted from this source's explicit successor list | +| 23 | _pad[1]_ | — | Alignment to 8-byte `Cost` | | 24 | `Cost` | `uint64_t` | Straight-line gas cost of the block | The 32-byte stride is load-bearing for the cache-density gains; the @@ -94,6 +95,13 @@ edge insertion is provided by the `addEdge` helper (linear scan over the per-block vectors). Consumed by `buildAdjacencyCSR` and not read directly by any downstream pass. +Edges for unresolved dynamic jumps are deliberately absent from both +tables and the resulting CSR graphs. `ImplicitDynamicPredCount` +represents those omitted incoming edges on each possible JUMPDEST, +while `HasUnresolvedDynamicSuccessor` marks the source whose runtime +successor set is incomplete. For an unresolved JUMPI, its fallthrough +edge remains explicit; only its taken-target edges are omitted. + ### `CSRGraph` — read-only flat adjacency ```cpp @@ -138,42 +146,93 @@ DFS instead of running their own. ## Invariants -### Reducible-CFG fast-path soundness +### Loop-set selection and per-update soundness + +`UseLinearSPP=true` means that the dominance-based natural loops found +by `buildLoopsUsingDominance` passed its body-dominance and nesting +checks. It does not prove that the CFG is reducible. A multi-entry SCC +with no dominance back-edge produces no `LoopInfo` and can therefore +return `true` with that SCC absent from `InCycle`. The two-pass SCC +traversal runs only when the detected loop set fails validation. -The R2 reviewers established the soundness story explicitly. When -`UseLinearSPP=true` (i.e. `buildLoopsUsingDominance` reported reducible), -`computeInCycle` is a **performance optimisation**, not the safety -mechanism. The actual safety invariant lives in `lemma614Update`'s -multi-predecessor guard: +Soundness does not require complete SCC classification. For updates +that can occur on a runtime path, it follows from these local +conditions: ```cpp +if (Node.HasUnresolvedDynamicSuccessor != 0) { + return false; +} + if (effectivePredCount(Succ, Blocks, PredsCSR) != 1) { - // refuse shift + MinSucc = 0; + continue; } ``` `effectivePredCount` folds `ImplicitDynamicPredCount` into the -structural pred count, so any JUMPDEST that could be reached by an -unresolved dynamic JUMP sees count > 1 and the lemma refuses to shift -gas across that edge. Every node inside any SCC of size ≥ 2 has at -least one in-cycle predecessor on top of any out-of-cycle entry, so -its `effectivePredCount` is ≥ 2 and the shift is refused even on -irreducible CFGs the fast-path filter misses. - -**Future-contributor warning**: do **not** remove the multi-pred guard -on the assumption that `InCycle` covers it. On an irreducible 2-entry -cycle `A ↔ B` where neither node dominates the other, the dominator-based -back-edge set is empty, `buildLoopsUsingDominance` returns `true` with -`Loops` empty, and `InCycle = union(empty) = all-zeros`. Without the -multi-pred guard, lemma614 would mis-charge such a CFG; with the guard, -correctness is preserved. - -### Irreducible-CFG fallback +structural pred count. A possible dynamic target with an explicit +incoming edge therefore has count greater than one, so the lemma +cannot move its cost onto only that explicit predecessor. A target +with only implicit incoming edges is not present in any source's +`SuccsCSR` slice and cannot be selected for a shift. + +Independently, no block with `HasUnresolvedDynamicSuccessor != 0` may +act as a lemma614 shift source. Moving explicit-successor cost onto +such a source would increase the charge on every runtime exit while +compensating only successors represented in `SuccsCSR`; an omitted +taken target would receive no compensating reduction. The guard makes +the represented successor list complete for every accepted source. + +A runtime-reachable source of a recorded dominance back-edge belongs +to the corresponding natural-loop mask and is skipped before +`lemma614Update`. The `BackEdges` branch therefore omits no executable +edge for a reachable source that is actually updated. In particular, +an updated reachable source in an unrecognized SCC has no recorded +outgoing back-edge, so the branch is a no-op. If `AllowedMask` excludes +any other represented successor, the first scan sets `MinSucc` to zero +and rejects the whole update. + +For a successful update on a reachable source, every represented +runtime successor is therefore included, and the source passes the +gas-sensitive boundary check. Each successor has one effective +predecessor, is not the program entry, has no implicit dynamic +predecessor, and passes the gas-chunk boundary check. The update +subtracts the same common cost from every such successor that it adds +to the source. Every source execution selects one compensated +successor, and every execution of that successor arrives from the +source, so the costs balance on complete paths even when an SCC was +not added to `InCycle`. + +`RPO` and the schedule can still contain unreachable blocks, while +`buildLoopsUsingDominance` skips an unreachable back-edge source. +`lemma614Update` may therefore update such a source and skip one of its +recorded back-edges. This does not affect runtime gas because no +executable path reaches the source. The local path-balance claim above +is intentionally limited to runtime-reachable sources. + +**Future-contributor warning**: `InCycle`, the dynamic-edge guards, +`effectivePredCount`, and the schedule masks enforce different parts of +this invariant. None can be removed on the assumption that +`UseLinearSPP=true` proves reducibility. + +### Conditional two-pass SCC fallback When `buildLoopsUsingDominance` returns `false`, `UseLinearSPP=false` -and the Tarjan SCC pass runs over `SuccsCSR` to fill `InCycle`. The -`effectivePredCount` guard remains active in this path too, so Tarjan -SCC is defence-in-depth, not the only safety net. +and a two-pass Kosaraju-style traversal runs over `SuccsCSR` and +`PredsCSR` to fill `InCycle`. A `true` result does not trigger the +fallback and may leave an SCC without dominance back-edges unmarked. +Both paths retain the same local source, successor, and schedule checks +described above. + +### Regression scope + +The permanent cache and execution regressions added for the dynamic-jump +fix directly cover the `HasUnresolvedDynamicSuccessor` behavior. They do +not execute an unrecognized SCC or a synthetic critical edge through the +complete SPP pipeline. Changes to loop discovery, back-edge filtering, or +synthetic-block writeback require dedicated regression coverage for those +paths. ### Block-vector reserve diff --git a/src/evm/evm_cache.cpp b/src/evm/evm_cache.cpp index 0966960be..6ca5d7a55 100644 --- a/src/evm/evm_cache.cpp +++ b/src/evm/evm_cache.cpp @@ -103,6 +103,12 @@ static bool isControlFlowTerminator(uint8_t OpcodeU8) { static bool isGasSensitiveTerminator(uint8_t OpcodeU8) { switch (static_cast(OpcodeU8)) { case evmc_opcode::OP_GAS: + // EIP-2200 makes SSTORE fail when gas left is at or below the call stipend. + // Moving successor cost before SSTORE can therefore change success to OOG + // even when the total path cost is preserved. Applying this barrier before + // Istanbul is an intentional conservative safety policy: it can only reduce + // SPP shifting on revisions without the sentry. + case evmc_opcode::OP_SSTORE: case evmc_opcode::OP_CREATE: case evmc_opcode::OP_CREATE2: case evmc_opcode::OP_CALL: @@ -228,7 +234,8 @@ buildJumpDestMapAndPushCache(const zen::common::Byte *Code, size_t CodeSize, // 16 ImplicitDynamicPredCount uint32 // 20 LastOpcode uint8 // 21 PrevOpcode uint8 -// 22 pad[2] (2 alignment bytes before Cost) +// 22 HasUnresolvedDynamicSuccessor uint8 +// 23 pad[1] (1 alignment byte before Cost) // 24 Cost uint64 // 32 sizeof struct GasBlock { @@ -243,6 +250,8 @@ struct GasBlock { uint32_t ImplicitDynamicPredCount = 0; uint8_t LastOpcode = 0; uint8_t PrevOpcode = 0; + // Runtime can branch to a successor omitted from the explicit CFG. + uint8_t HasUnresolvedDynamicSuccessor = 0; uint64_t Cost = 0; }; static_assert(sizeof(GasBlock) == 32, @@ -502,12 +511,11 @@ static bool resolveConstantJumpTarget(const std::vector &JumpDestMap, // single-target edges. For each unresolved dynamic jump we DO NOT add the // D*|JUMPDEST| explicit over-approximation edges (which previously made the // pass quadratic-to-cubic in pathological contracts). Instead we record on -// every JUMPDEST how many dynamic-jump blocks could land there at runtime -// via `ImplicitDynamicPredCount`, and `effectivePredCount` folds that count -// into its multi-predecessor check. SPP decisions are identical: a JUMPDEST -// that is a potential dynamic-jump target sees `effectivePredCount > 1` and -// `lemma614Update` refuses to shift gas across that edge, exactly as it -// would have done against an explicit over-approximated `Preds` set. +// every JUMPDEST how many dynamic-jump blocks could land there at runtime via +// `ImplicitDynamicPredCount`, and mark each dynamic-jump source with +// `HasUnresolvedDynamicSuccessor`. The former prevents shifts into an implicit +// target; the latter prevents shifts out of a source whose omitted successors +// cannot be compensated. static void buildCFGEdges(std::vector &Blocks, EdgeTables &Edges, const std::vector &BlockAtPc, @@ -523,7 +531,7 @@ buildCFGEdges(std::vector &Blocks, EdgeTables &Edges, // halves the call count and the bytecode rescan it performs. uint32_t DynamicJumpCount = 0; for (size_t BlockId = 0; BlockId < Blocks.size(); ++BlockId) { - const auto &Block = Blocks[BlockId]; + auto &Block = Blocks[BlockId]; const bool IsTerminator = isControlFlowTerminator(Block.LastOpcode); // Add fallthrough edge for non-terminating opcodes (CALL/CREATE/GAS, @@ -546,6 +554,7 @@ buildCFGEdges(std::vector &Blocks, EdgeTables &Edges, // Keep the constant-decode fallback for cases the shared abstract // stack pass intentionally leaves unresolved. } else { + Block.HasUnresolvedDynamicSuccessor = 1; ++DynamicJumpCount; continue; } @@ -1199,9 +1208,9 @@ static bool buildLoopsUsingDominance( // // Blocks with `ImplicitDynamicPredCount > 0` (every JUMPDEST in a contract // that has at least one dynamic jump) carry the over-approximated dynamic -// predecessors as a count instead of explicit edges; folding them in here -// keeps `lemma614Update`'s "shift only into single-pred successors" check -// equivalent to the explicit over-approximation. +// predecessors as a count instead of explicit edges. Folding them in here +// conservatively prevents a shift into any represented successor that could +// also be reached by an omitted dynamic edge. static size_t effectivePredCount(uint32_t NodeId, const std::vector &Blocks, const CSRGraph &PredsCSR) { @@ -1220,7 +1229,8 @@ static bool lemma614Update(uint32_t NodeId, const std::vector &Blocks, const std::vector *AllowedMask, std::vector &Metering) { const auto &Node = Blocks[NodeId]; - if (isGasSensitiveTerminator(Node.LastOpcode)) { + if (isGasSensitiveTerminator(Node.LastOpcode) || + Node.HasUnresolvedDynamicSuccessor != 0) { return false; } @@ -1508,7 +1518,9 @@ static bool buildGasChunksSPP( } // Always build CFG — no early exit for dynamic jumps. - // Unresolved jumps get over-approximated edges to all JUMPDESTs. + // Unresolved dynamic targets remain implicit: possible target blocks carry + // ImplicitDynamicPredCount, and their source blocks carry + // HasUnresolvedDynamicSuccessor. // JumpDestBlocks is now produced inline by buildGasBlocks (one push per // block whose first opcode is OP_JUMPDEST), eliminating the prior bytecode @@ -1516,11 +1528,9 @@ static bool buildGasChunksSPP( // every JUMPDEST byte under EVM semantics starts a new gas block. // Static jumps get precise single-target edges. For unresolved dynamic - // jumps, the CFG over-approximation is encoded as - // ImplicitDynamicPredCount on each JUMPDEST (folded into - // effectivePredCount). Narrowing to partial call-site resolution would - // under-approximate the CFG and let SPP shift gas along non-existent - // edges, producing unsafe metering. + // jumps, ImplicitDynamicPredCount protects possible targets while + // HasUnresolvedDynamicSuccessor prevents source-side shifts that could not + // compensate omitted targets. EdgeTables Edges; Edges.resize(Blocks.size()); @@ -1604,21 +1614,21 @@ static bool buildGasChunksSPP( SuccsCSR, PredsCSR, Dom, Reachable, Loops, LoopOf, ExitLoops, ExitFlags); EVM_PROFILE_END(buildLoopsUsingDominance); - // InCycle is a performance fast-path filter for lemma614Update, NOT the - // soundness mechanism. On reducible CFGs (UseLinearSPP=true) the union of - // natural-loop NodeMasks coincides with the in-cycle set Tarjan SCC would - // produce, so we skip the standalone Tarjan pass. On irreducible CFGs - // (UseLinearSPP=false) buildLoopsUsingDominance can miss multi-entry - // cycles (e.g. an irreducible 2-entry cycle A<->B with no dominator-based - // back-edge), so the Tarjan SCC backstop fills InCycle for those nodes. + // UseLinearSPP means only that the detected dominance-based natural loops + // passed the body-dominance and nesting checks. It is not a general + // reducibility proof: an SCC without a dominance back-edge produces no + // LoopInfo and can still return true. The two-pass SCC fallback runs only + // when the validator returns false; otherwise InCycle is the union of the + // detected loop masks. // - // Soundness on irreducible CFGs ultimately rests on lemma614Update's - // effectivePredCount(Succ) != 1 multi-pred guard at line 1224: every SCC - // node has at least one in-cycle predecessor on top of any out-of-cycle - // entry, so its effectivePredCount is >= 2 and the shift is refused even - // when InCycle is empty. See docs/modules/evm/cache-build.md §Invariants - // -- do NOT remove the multi-pred guard on the assumption that InCycle - // covers it. + // Runtime soundness is local to each successful update on a reachable source. + // Sources with omitted dynamic successors are rejected. A reachable source + // of a recorded dominance back-edge is in its natural-loop mask and is + // skipped by the schedule, so BackEdges omits no executable edge from a + // reachable source that is updated. If AllowedMask excludes another + // successor, lemma614Update cancels the whole update. Unreachable blocks can + // still be scheduled, but their shifted costs occur on no executable path. + // See docs/modules/evm/cache-build.md §Invariants. EVM_PROFILE_BEGIN(computeInCycle); std::vector InCycle; if (UseLinearSPP) { diff --git a/src/evm/evm_cache.md b/src/evm/evm_cache.md index c6a8bb644..93019cbf5 100644 --- a/src/evm/evm_cache.md +++ b/src/evm/evm_cache.md @@ -2,6 +2,11 @@ This document describes the bytecode cache built by `buildBytecodeCache()` in `src/evm/evm_cache.cpp` and used by `BaseInterpreter::interpret()` in `src/evm/interpreter.cpp` as well as the EVM JIT compiler in `src/compiler/evm_compiler.cpp`. +The current SPP design fixes two unsafe gas-shifting boundary classes with 0% +`GasBlock` size growth; the structure remains 32 bytes. Retain both the +unresolved-source guard and the implicit-target predecessor guard because +removing either can make gas placement unsound. + ## Layout - `JumpDestMap[pc]` (`uint8_t`): `1` if `Code[pc]` is `OP_JUMPDEST` and this byte is an opcode byte (not inside PUSH data). @@ -32,6 +37,7 @@ We still partition the bytecode into straight-line "gas blocks": - `INVALID` - `JUMP`, `JUMPI` - `GAS` + - `SSTORE` - `CREATE`, `CREATE2` - `CALL`, `CALLCODE`, `DELEGATECALL`, `STATICCALL` @@ -51,9 +57,12 @@ every execution path. We build a CFG of gas blocks and compute a *shifted* metering function `m` using a linear-time SPP pass: -- Edges: fallthrough edges (including `JUMPI` fallthrough) and constant-jump - edges (validated by `JumpDestMap`). Dynamic jumps are conservatively - over-approximated to all `JUMPDEST` blocks. +- Edges: fallthrough edges, including `JUMPI` fallthrough, are explicit. + Resolved jump-target edges are also explicit after validation by + `JumpDestMap`. Target edges for an unresolved dynamic `JUMP` or `JUMPI` are + omitted. The source is marked with `HasUnresolvedDynamicSuccessor`, and each + possible `JUMPDEST` target carries the corresponding + `ImplicitDynamicPredCount`. - Critical edges are split before SPP to preserve the local update rules. - Dominators are computed by the Cooper-Harvey-Kennedy (CHK) algorithm (`computeDomInfo` in `evm_cache.cpp`): iterate `IDom[b] = NCA(p, IDom[b])` @@ -82,14 +91,14 @@ emits a CSV row per named phase to stderr: EVM_CACHE_PROFILE,, -Named phases: `buildGasBlocks`, `collectJumpDests`, `buildCFGEdges`, -`splitCriticalEdges`, `computeReachable`, `computeDomInfo`, `findBackEdges`, -`computeReverseTopo`, `computeInCycle`, `buildLoopsUsingDominance`, -`meteringInit`, `lemma614Schedule`, `writeback`. When `OFF` (default), the -macros expand to `((void)0)` and the release build is bytecode-identical to -the un-instrumented variant — used to drive `tools/bench_evm_cache.sh` and -`tools/analyze_evm_cache_bench.py` for paired-ratio cluster-bootstrap BCa -analysis (see `tests/corpus/evm-cache/`). +Named phases: `buildJumpDestMap`, `buildGasBlocks`, `buildCFGEdges`, +`splitCriticalEdges`, `buildCSR`, `computeReachable`, `computeDomInfo`, +`findBackEdges`, `computeReverseTopo`, `buildLoopsUsingDominance`, +`computeInCycle`, `meteringInit`, `lemma614Schedule`, `writeback`. When `OFF` +(default), the macros expand to `((void)0)` and the release build is +bytecode-identical to the un-instrumented variant — used to drive +`tools/bench_evm_cache.sh` and `tools/analyze_evm_cache_bench.py` for +paired-ratio cluster-bootstrap BCa analysis (see `tests/corpus/evm-cache/`). This moves common costs earlier, reducing the number of non-zero charge points. The resulting shifted value `m(s)` is stored in `GasChunkCostSPP[s]` at each @@ -99,8 +108,11 @@ modules that will be JIT-compiled (gated by `EnableSPP` in `buildBytecodeCache`); for interpreter-only modules `GasChunkCostSPP` is left empty and the CFG / metering work is skipped. -If the CFG is not suitable for linear SPP (e.g., dominance-based loop analysis -fails), we still run SPP updates once per node in reverse topological order +`UseLinearSPP=true` means only that the detected dominance-based natural loops +passed their body-dominance and nesting checks; it is not a general CFG +reducibility proof. In this path, `InCycle` is the union of the detected loop +masks. If those checks fail, a two-pass Kosaraju-style SCC traversal computes +`InCycle`, and SPP updates only non-cycle nodes in reverse topological order without loop fast-forward. ## Design Goal @@ -135,12 +147,15 @@ zero bytes on the right, matching the EVM encoding. interpreter's fast path enters a chunk only when `gas_left >= GasChunkCost[s]` and base-cost out-of-gas cannot occur inside a block. The multipass JIT reads the shifted value `m(s)` from `GasChunkCostSPP[s]`. Lemma 6.14 updates move -cost along CFG edges while preserving total base cost on every path. -Over-approximating dynamic jumps to all `JUMPDEST`s keeps the optimization -safe — narrowing those edges with partial call-site resolution would -under-approximate the CFG and let the SPP pass shift gas along edges that -don't exist at runtime, producing unsafe metering. Splitting critical edges -ensures that cost is only moved along edges where the local update is valid. -When loop analysis fails, the reverse-topological updates still preserve -correctness without fast-forward. Dynamic/extra gas is charged inside opcode -handlers as before (memory expansion, cold access, keccak word cost, etc). +cost along represented CFG edges while preserving total base cost on accepted +paths. An unresolved dynamic source has +`HasUnresolvedDynamicSuccessor != 0`, so it cannot receive shifted successor +cost when runtime targets are absent from the explicit CFG. +`effectivePredCount` includes `ImplicitDynamicPredCount`, so a possible dynamic +target cannot receive a shift from only one represented predecessor. `SSTORE` +is also a gas-sensitive boundary because the EIP-2200 sentry depends on the gas +remaining at that instruction. Splitting critical edges and the loop schedule +retain the local update preconditions; the SCC fallback skips cycle nodes and +applies per-node updates without fast-forward. Dynamic and extra gas remain +charged inside opcode handlers as before (memory expansion, cold access, +keccak word cost, and related charges). diff --git a/src/tests/evm_cache_tests.cpp b/src/tests/evm_cache_tests.cpp index 7d9e2fadc..8bff9974c 100644 --- a/src/tests/evm_cache_tests.cpp +++ b/src/tests/evm_cache_tests.cpp @@ -25,8 +25,11 @@ constexpr uint8_t OP_ADD = static_cast(evmc_opcode::OP_ADD); constexpr uint8_t OP_CALLDATALOAD = static_cast(evmc_opcode::OP_CALLDATALOAD); constexpr uint8_t OP_POP = static_cast(evmc_opcode::OP_POP); +constexpr uint8_t OP_SSTORE = static_cast(evmc_opcode::OP_SSTORE); constexpr uint8_t OP_JUMP = static_cast(evmc_opcode::OP_JUMP); +constexpr uint8_t OP_JUMPI = static_cast(evmc_opcode::OP_JUMPI); constexpr uint8_t OP_JUMPDEST = static_cast(evmc_opcode::OP_JUMPDEST); +constexpr uint8_t OP_PUSH0 = static_cast(evmc_opcode::OP_PUSH0); constexpr uint8_t OP_PUSH1 = static_cast(evmc_opcode::OP_PUSH1); EVMBytecodeCache buildSPPCache(const std::vector &Code) { @@ -86,6 +89,35 @@ TEST(EVMCacheImplicitDynPred, InterpreterOnly_LeavesSPPArrayEmpty) { EXPECT_TRUE(Cache.GasChunkCostSPP.empty()); } +TEST(EVMCacheSPP, DoesNotShiftSuccessorCostBeforeSstore) { + const std::vector Code = { + OP_PUSH0, OP_PUSH0, OP_SSTORE, // block [0, 3) + OP_PUSH0, OP_POP, // block [3, 5) + OP_JUMPDEST, OP_STOP, + }; + const EVMBytecodeCache Cache = buildSPPCache(Code); + + ASSERT_EQ(Cache.GasChunkCostSPP.size(), Code.size()); + ASSERT_GT(Cache.GasChunkCost[3], 0u); + EXPECT_EQ(Cache.GasChunkCostSPP[0], Cache.GasChunkCost[0]); + EXPECT_EQ(Cache.GasChunkCostSPP[3], Cache.GasChunkCost[3]); +} + +TEST(EVMCacheSPP, DoesNotShiftFallthroughBeforeUnresolvedJumpi) { + const std::vector Code = { + OP_PUSH1, 0x00, OP_CALLDATALOAD, OP_PUSH1, 0x20, OP_CALLDATALOAD, + OP_JUMPI, // unresolved target + OP_PUSH0, OP_POP, // explicit fallthrough + OP_JUMPDEST, OP_STOP, // implicit dynamic target + }; + const EVMBytecodeCache Cache = buildSPPCache(Code); + + ASSERT_EQ(Cache.GasChunkCostSPP.size(), Code.size()); + ASSERT_EQ(Cache.GasChunkCost[7], 4u); + EXPECT_EQ(Cache.GasChunkCostSPP[0], Cache.GasChunkCost[0]); + EXPECT_EQ(Cache.GasChunkCostSPP[7], Cache.GasChunkCost[7]); +} + // Two dynamic JUMPs => ImplicitDynamicPredCount == 2 on each JUMPDEST. // effectivePredCount must block any lemma614 shift INTO either JUMPDEST. TEST(EVMCacheImplicitDynPred, MultipleDynJumps_BothTargetsCounted) { diff --git a/src/tests/evm_differential_tests.cpp b/src/tests/evm_differential_tests.cpp index 9286de23e..4d7156b6f 100644 --- a/src/tests/evm_differential_tests.cpp +++ b/src/tests/evm_differential_tests.cpp @@ -78,7 +78,8 @@ EVMExecutionResult runEvmBytecode( const std::string &Label, const std::vector &Bytecode, common::RunMode Mode, const std::vector &CallData = {}, uint32_t MessageFlags = 0u, bool EnableGasMetering = false, - uint64_t GasLimit = 0xFFFF'FFFF'FFFF - zen::evm::BASIC_EXECUTION_COST) { + uint64_t GasLimit = 0xFFFF'FFFF'FFFF - zen::evm::BASIC_EXECUTION_COST, + evmc_revision Revision = evmc_revision::EVMC_OSAKA) { EVMExecutionResult Empty; RuntimeConfig Config; @@ -95,7 +96,8 @@ EVMExecutionResult runEvmBytecode( } MockedHost->setRuntime(RT.get()); - auto ModRet = RT->loadEVMModule(Label, Bytecode.data(), Bytecode.size()); + auto ModRet = + RT->loadEVMModule(Label, Bytecode.data(), Bytecode.size(), Revision); if (!ModRet) { ADD_FAILURE() << "module load failed: " << Label; return Empty; @@ -114,7 +116,7 @@ EVMExecutionResult runEvmBytecode( return Empty; } EVMInstance *Inst = *InstRet; - Inst->setRevision(evmc_revision::EVMC_OSAKA); + Inst->setRevision(Revision); evmc_message Msg = { .kind = EVMC_CALL, @@ -665,6 +667,61 @@ TEST(EVMRangeDifferential, "0000000000000000000000000000000000000000000000005555555555555555"); } +TEST(EVMRangeDifferential, UnresolvedJumpiTakenTargetPreservesGas) { + const std::vector Bytecode = { + 0x60, 0x00, 0x35, // PC0 condition = calldata[0] + 0x60, 0x20, 0x35, // PC3 destination = calldata[32] + 0x57, // PC6 dynamic JUMPI + 0x5f, 0x50, // PC7 untaken-only fallthrough cost + 0x5b, 0x5a, // PC9 dynamic target; observe GAS + 0x5f, 0x52, // PC11 MSTORE(0, gas) + 0x60, 0x20, 0x5f, 0xf3, + }; + std::vector TakenCallData(64, 0); + TakenCallData[31] = 1; + TakenCallData[63] = 9; + + const auto TakenInterp = + runEvmBytecode("unresolved_jumpi_taken_target_interp", Bytecode, + common::RunMode::InterpMode, TakenCallData, 0u, true, + 0x2210, evmc_revision::EVMC_CANCUN); + const auto TakenMulti = + runEvmBytecode("unresolved_jumpi_taken_target_multipass", Bytecode, + common::RunMode::MultipassMode, TakenCallData, 0u, true, + 0x2210, evmc_revision::EVMC_CANCUN); +#ifdef ZEN_ENABLE_JIT + EXPECT_TRUE(TakenMulti.JITCompiled); +#endif + constexpr char ExpectedTakenGasOutput[] = + "00000000000000000000000000000000000000000000000000000000000021F7"; + EXPECT_EQ(TakenInterp.Status, EVMC_SUCCESS); + EXPECT_EQ(TakenInterp.OutputHex, ExpectedTakenGasOutput); + EXPECT_EQ(TakenMulti.Status, TakenInterp.Status); + EXPECT_EQ(TakenMulti.GasLeft, TakenInterp.GasLeft); + EXPECT_EQ(TakenMulti.OutputHex, ExpectedTakenGasOutput); + + std::vector FallthroughCallData = TakenCallData; + FallthroughCallData[31] = 0; + const auto FallthroughInterp = + runEvmBytecode("unresolved_jumpi_fallthrough_interp", Bytecode, + common::RunMode::InterpMode, FallthroughCallData, 0u, true, + 0x2210, evmc_revision::EVMC_CANCUN); + const auto FallthroughMulti = + runEvmBytecode("unresolved_jumpi_fallthrough_multipass", Bytecode, + common::RunMode::MultipassMode, FallthroughCallData, 0u, + true, 0x2210, evmc_revision::EVMC_CANCUN); +#ifdef ZEN_ENABLE_JIT + EXPECT_TRUE(FallthroughMulti.JITCompiled); +#endif + constexpr char ExpectedFallthroughGasOutput[] = + "00000000000000000000000000000000000000000000000000000000000021F3"; + EXPECT_EQ(FallthroughInterp.Status, EVMC_SUCCESS); + EXPECT_EQ(FallthroughInterp.OutputHex, ExpectedFallthroughGasOutput); + EXPECT_EQ(FallthroughMulti.Status, FallthroughInterp.Status); + EXPECT_EQ(FallthroughMulti.GasLeft, FallthroughInterp.GasLeft); + EXPECT_EQ(FallthroughMulti.OutputHex, ExpectedFallthroughGasOutput); +} + // Sweep SHL/SHR/SAR with shift amounts that land inside the limb-crossing // region 2..255. The 11-value operand matrix, when fed as a shift amount, only // realizes {0, 1, >=2^64}; it never produces an amount in 2..255, so the