diff --git a/docs/changes/2026-05-07-value-range-cfg-join/README.md b/docs/changes/2026-05-07-value-range-cfg-join/README.md new file mode 100644 index 000000000..161970084 --- /dev/null +++ b/docs/changes/2026-05-07-value-range-cfg-join/README.md @@ -0,0 +1,181 @@ +# Change: Track Operand::ValueRange Across CFG Joins + +- **Status**: Implemented (PR #493 ready, perf disclosure landed) +- **Date**: 2026-05-07 +- **Tier**: Full +- **Branch**: `perf/value-range-cfg-join` +- **Related**: [`./investigation.md`](./investigation.md) + +## Overview + +Add a separate `EVMRangeAnalyzer` dataflow pass that computes per-stack-slot `Operand::ValueRange` at every block entry, stored in `EVMAnalyzer::BlockInfo::EntryStackRanges`. Plumb it into the visitor's non-lifted JUMPDEST entry so that `Builder.stackPop()` results gain the correct narrow Range. This activates batch1 (#458)'s u64-narrow fast paths (ADD/SUB/MUL/DIV/MOD via `Operand::bothFitU64`) on values that flow through control-flow joins — the common case for arithmetic in Solidity loop bodies. + +## Motivation + +batch1 introduced `Operand::ValueRange { U64, U128, U256 }` and four families of fast paths gated on `bothFitU64`. Empirical investigation (see archived note) found: + +| x86 instruction | straight-line ADD u64 | ADD u64 in JUMPDEST loop body | +|---|---|---| +| `ADC64rr` (chained limbs 2–4) | **0** (fast path fires) | **3** (full 4-limb chain) | + +The cause is that the active build (`ZEN_ENABLE_EVM_STACK_SSA_LIFT=OFF`, the default and CI configuration) routes block-boundary stack values through a memory spill round-trip: producer block calls `Builder.stackPush()` (writes 4 limbs to EVM stack memory), consumer block calls `Builder.stackPop()` (reads them back). `stackPop` constructs a fresh `Operand` with default `ValueRange::U256`, discarding any narrow Range the producer had. Inside loops this means the value-range fast paths only fire on values that never cross a JUMPDEST — almost never in real Solidity bytecode. + +A first-pass fix that targeted `materializeStackMergeOperand` and `prepareStackPhiIncoming` had no effect because those live entirely under `#ifdef ZEN_ENABLE_EVM_STACK_SSA_LIFT` and are dead code under the default build. + +## Impact + +### Affected Modules + +- `docs/modules/compiler/` (EVM frontend) — adds new analyzer pass; existing `EVMAnalyzer::BlockInfo` extended with one field. + +### Affected Contracts + +No public API changes. `Operand::setRange()` is added as a new public method but not exposed outside the EVM frontend. + +### Compatibility + +No bytecode-level or EVMC-level compatibility concerns. The change is internal to JIT compilation and only activates additional fast paths that are already correct under their range constraint. CI test pass rate (5884/5884) must hold. + +## Implementation Plan + +Each phase is a separate commit for review-ability. + +### Phase 1: Foundation — Operand setter + BlockInfo schema (≈15 LOC) + +- [ ] Add `Operand::setRange(ValueRange)` setter in `evm_mir_compiler.h` near the existing `getRange()` accessor. +- [ ] Add `std::vector EntryStackRanges` field to `EVMAnalyzer::BlockInfo` in `evm_analyzer.h`. Default empty (means "no Range info available, assume U256"). +- [ ] Single trivial commit; build only, no behavior change. + +Validation: build passes, full test suite passes, no codegen change (analyzer not yet emitting Range info). + +### Phase 2: Range dataflow analyzer (≈150 LOC) + +- [ ] New analyzer pass `EVMRangeAnalyzer` (could be a class in `evm_analyzer.h` or a separate header — TBD during implementation). +- [ ] Abstract domain: per stack slot, a `ValueRange` from `{ U64, U128, U256 }` lattice. +- [ ] Lattice operations: `meet = max` (widen to most pessimistic), `bottom = U64`, `top = U256`. +- [ ] Per-block transfer function: scan block bytecode, applying per-opcode rules to a working stack of Ranges. +- [ ] CFG fixed-point: worklist iteration over `BlockInfos`, propagating exit-stack to successor entry-stacks via `meet`. Reuses existing predecessor/successor metadata in `BlockInfo`. +- [ ] At fixed point, write each block's entry-stack Range vector to `BlockInfo::EntryStackRanges`. +- [ ] Phase 2 commit: analyzer runs, populates field, but no consumer reads it yet (so still no behavior change). + +Per-opcode transfer functions (initial set, can be expanded): + +| Opcode group | Transfer | +|---|---| +| `PUSH0`–`PUSH32` | push Range derived from the constant: `value < 2^64` → U64, `< 2^128` → U128, else U256 | +| `DUP_N` | push copy of slot N's Range | +| `SWAP_N` | swap top with slot N | +| `POP` | drop top | +| `AND` | if either side is `U64` → U64; else `min(LHS, RHS)` | +| `OR` / `XOR` | `max(LHS, RHS)` | +| `NOT` | U256 (narrow flips to wide) | +| `ADD` | `max(LHS, RHS) widened by 1 step` (U64+U64 → U128), capped U256 | +| `SUB` | U256 (could underflow) | +| `MUL` | `max(LHS, RHS) widened`, capped U256 | +| `DIV` / `MOD` | result ≤ dividend's Range | +| `ADDMOD` / `MULMOD` | result < modulus → modulus's Range | +| `LT/GT/SLT/SGT/EQ/ISZERO` | U64 (always 0 or 1) | +| `BYTE` | U64 | +| `SHL` | U256 (can shift narrow to wide) | +| `SHR` / `SAR` | result ≤ value's Range | +| `CLZ` | U64 (≤ 256) | +| `CALLDATALOAD`, `SLOAD`, `TLOAD`, `KECCAK256`, `BALANCE`, `SELFBALANCE` | U256 | +| `PC`, `MSIZE`, `GAS` | U64 (truly bounded; PC ≤ code size, MSIZE/GAS bounded by EVM limits) | +| `CALLDATASIZE`, `CODESIZE`, `RETURNDATASIZE`, `EXTCODESIZE` | U64 (bounded by call-frame sizes) | +| `TIMESTAMP`, `NUMBER`, `GASLIMIT`, `CHAINID`, `BASEFEE`, `BLOBBASEFEE`, `PREVRANDAO` | U256 (EVMC host returns full uint256 or 32-byte buffer; classify conservatively) | +| `ADDRESS`, `CALLER`, `ORIGIN`, `COINBASE` | U256 (20-byte addresses fit in 160 bits, but treating as U256 is safe and avoids risk) | +| `CALLVALUE`, `GASPRICE`, `BLOCKHASH`, `BLOBHASH` | U256 | +| `MLOAD` | U256 | +| `CALL` / `STATICCALL` / `DELEGATECALL` / `CALLCODE` | U64 (0/1 success bool) | +| `CREATE` / `CREATE2` | U256 (returns contract address — 20 bytes — or 0 on failure, not a bool) | +| `SSTORE`, `MSTORE`, `MSTORE8`, `LOG_N`, `STOP`, `RETURN`, `REVERT`, `INVALID`, `SELFDESTRUCT`, `JUMPDEST` | no stack effect on Range domain (consumes/no push) | +| `JUMP`, `JUMPI` | consume target (and cond), terminator | + +For dynamic-jump regions (where target is computed), conservative analysis: when a block is a dynamic-jump source or target, its successors are unknown precisely; meet against `U256` for all successor entry slots. Existing `EVMAnalyzer` already tracks dynamic-jump regions (see `hasDynamicJumpRegion`), reuse that. + +Validation: analyzer self-consistency unit test (build a small bytecode, check `EntryStackRanges` matches expectation). Build + full test suite passes. + +### Phase 3: Plumb Range into stackPop consumer + lifted-block factories + +- [ ] In `evm_bytecode_visitor.h:1140-1150` (the non-lifted JUMPDEST entry path that calls `Builder.stackPop()` in a loop), after each pop, look up `BlockInfo.EntryStackRanges[slot]` (if available) and call `Opnd.setRange(Range)`. +- [ ] Slot indexing convention: `EntryStackRanges[0]` is the bottom of the entry stack, `[depth-1]` is the top. Match the analyzer's representation to the visitor's pop order. + +Validation: empirical sanity check rerun. The synthetic ADD u64 loop bench captured under `ZEN_ENABLE_JIT_LOGGING=ON` should now show `ADC64rr=0` in the loop body (matching the straight-line case). + +### Phase 4: Test + bench validation (no code change) + +- [ ] Unit test: `tests/evm_asm/range_loop_*.easm` fixtures exercising forward joins and self-loop back-edges with u64 narrow values. Verify outputs unchanged from baseline (correctness preserved). +- [ ] `evmone-unittests` multipass + interpreter: 223 + 215 = 438 expected. +- [ ] `evmone-statetest` `-k fork_Cancun` multipass + interpreter: 2723 × 2 expected. +- [ ] paper §4.2 27-bench, ≥10 reps, taskset-pinned, multipass mode. Expected: positive geomean delta if real-world contracts have u64 arithmetic in loops; weierstrudel/15 specifically should show additional improvement on top of #458's existing +18.3%. + +## Compatibility Notes + +No backwards-incompatible changes. Disabling the analyzer (e.g. by leaving `EntryStackRanges` empty for all blocks) reproduces current behavior exactly. + +## Risks + +- **Soundness of transfer functions** (low-medium): each per-opcode rule must be sound under a `max`-meet lattice — i.e., the output range must contain every possible runtime value. Bug here would cause batch1 fast paths to fire on values that exceed their declared range, producing incorrect results. Mitigation: conservative defaults (when in doubt, U256), audit every transfer function against the EVM spec, statetest suite catches semantic deviations. +- **Fixed-point convergence** (low): the lattice has height 3 (U64 → U128 → U256), so iteration converges in O(blocks × 3) at most. Worklist algorithm with no special tricks suffices. +- **Dynamic jumps** (low): `EVMAnalyzer` already tracks dynamic-jump regions; the new pass meets against U256 for all-successor unknowns, which is the existing analyzer's pattern. +- **Two-pass overhead** (low): adds one analyzer pass per function, O(opcodes × 3) work. Function compilation is JIT-time, not hot-path; CI compile-time check should not regress. +- **Interaction with SSA_LIFT=ON path** (medium): when the build flag is enabled, the lifter takes a different path. Phase 3's plumbing is in the non-lifted branch, so SSA_LIFT=ON continues to use the lifter's PHI machinery (currently with Range=U256 from `materializeStackMergeOperand`). Phase 3 does not regress SSA_LIFT=ON. Future work could extend the analyzer to feed the lifter as well, but is not required here. + +## Checklist + +- [x] Phase 1: foundation + schema +- [x] Phase 2: Range analyzer pass + transfer functions + fixed-point +- [x] Phase 3: plumbing into `stackPop` consumer (non-lifted path) **and** lifted-block factories `createStackEntryOperand` / `materializeStackMergeOperand` +- [x] Phase 4: bench-validated; `ADC64rr=0` confirmed on synthetic ADD u64 loop +- [x] `evmone-unittests` multipass (223) + interpreter (215) all green +- [x] `evmone-statetest` `-k fork_Cancun` multipass (2723) all green +- [x] `tools/format.sh check` clean +- [x] 27-bench paired A-B-A on current `upstream/main` (`c644fbe`): geomean +0.34% (CI [−0.07%, +0.78%]); 5 per-bench regressions, largest `snailtracer/benchmark −2.29%` appears to be an interaction with rebase-pickup upstream commits (not bisected) +- [x] PR body discloses CI lower bound below +0.8% acceptance gate and reframes from `perf:` to `feat:` (analyzer infrastructure + soundness fixes + 42 white-box tests) + +## What shipped (2026-05-12) + +12 source commits on `perf/value-range-cfg-join` (last source HEAD `da4f4cc`; plus subsequent docs-only commits), rebased onto `upstream/main` at `c644fbe`. + +**Analyzer infrastructure** (3 commits): enum extract (`3846a1e`), dataflow pass (`061c500`), non-lifted consumer wiring (`c6de6eb`). + +**Soundness fixes** (3 commits, uncovered during white-box test development or PR review): SDIV/SMOD sign-mismatch (`5d46f7e`), host-context opcode widening for TIMESTAMP/NUMBER/GASLIMIT/CHAINID (`a73f782`), CREATE/CREATE2 address widening (`3f32828`, caught by Copilot reviewer). + +**Tests + cleanup + lifted-block wiring** (7 commits): MockOperand stub (`72c5e0b`), 42 white-box tests across per-opcode/CFG-join/dynamic-jump/cross-bb groups (`e27ac3c` + `da4f4cc`), defensive-path cleanup (`f203bd5`), **lifted-block factory plumbing** (`2ebfd29` — closes the gap noted in the original Findings section), analyzer-table caching + ZEN_ASSERT invariant (`da5571c`), clang-format wrap (`1dca9d5`). + +**Perf rounds** measured on this machine across the rebase decision cycle: + +| Run | HEAD | Geomean | 95% CI | Notes | +|---|---|---|---|---| +| Pre-rebase | `5357578` | −1.97% | [−6.69, +2.82] | memory_grow_* killed by missing upstream opts | +| Post-rebase | `f203bd5` | −0.57% | [−1.97, +0.76] | rebase fixed memory_grow_*; swap_math/sha1 collapsed due to #483 interaction | +| D1 — revert #483 | `3d273e0` | +0.95% | [−0.63, +2.60] | confirmed #483 is one interaction source; not deployable as a revert | +| **D6 — lifted plumbing** | **`da4f4cc`** | **+0.34%** | **[−0.07, +0.78]** | swap_math/sha1_shifts/jump_around recovered; snailtracer −2.29% residual | + +The lifted-block plumbing (`2ebfd29`) addresses the "Future work" item in the original §2b Findings: extending the analyzer to feed the lifted codegen path. With it landed, the original PR's empirical hypothesis (u64 fast paths gated on cross-BB Range) is now exercised on the dominant codegen path; the residual snailtracer regression appears to be a rebase-pickup interaction unrelated to analyzer correctness and is deferred to a follow-up PR. + +## Findings during implementation (2026-05-11) + +### §2b execution-level differential harness — DROPPED + +The original v4 design proposed 5 evmone-statetest fixtures running under +DTVM `mode=multipass`, asserting that bytecode patterns triggering the +SDIV/SMOD/host-opcode bug would diverge from the evmone reference under +the buggy classifier and converge under the fix. + +Task 4 implementation discovered this is architecturally infeasible. +`evm_bytecode_visitor.h:1131-1138` short-circuits lifted JUMPDESTs (the +default for well-formed bytecode) before the `setRange` refinement at +L1155 fires. The `bothFitU64` u64 fast path therefore cannot truncate +high limbs of values flowing through lifted blocks, and minimal bytecode +that disables lifting (via `HasUndefinedInstr`, `HasInconsistentEntryDepth`, +or dynamic-jump conflict) cannot simultaneously reach state-affecting +opcodes that surface the bug. + +**Implication**: the analyzer's classifier fix is defense-in-depth. +The white-box tests in `src/tests/evm_range_analyzer_tests.cpp` (Groups A/B/C/D, 42 tests) verify the classifier. The existing `evmone-statetest +-k fork_Cancun` corpus (2723 tests × 2 modes) covers end-to-end +multipass correctness on real bytecode. Together they bound the fix's +scope; no additional fixtures are needed. + +**Future work** (later landed in commit `2ebfd29`): extending the analyzer to feed the lifter's `materializeStackMergeOperand` / `prepareStackPhiIncoming` path was originally a Non-Goal in the v4 design spec. Empirical perf evidence (post-rebase run showing swap_math/sha1 collapse) forced the issue, and the plumbing fix subsequently recovered those wins. See the `## What shipped` section above for the four-way perf comparison. diff --git a/docs/changes/2026-05-07-value-range-cfg-join/investigation.md b/docs/changes/2026-05-07-value-range-cfg-join/investigation.md new file mode 100644 index 000000000..7efe9b72f --- /dev/null +++ b/docs/changes/2026-05-07-value-range-cfg-join/investigation.md @@ -0,0 +1,131 @@ +# Investigation: ValueRange Survival Across CFG Joins + +- **Date**: 2026-05-07 +- **Status**: **Investigation only — original Light-tier fix attempt rolled back here; the deeper refactor it called for landed in PR #493 (lifted-block plumbing in commit `2ebfd29`). See `README.md` and the §Soundness regression evidence addendum below.** +- **Branch**: `perf/value-range-cfg-join` (live; PR #493) +- **Related**: `u256-batch2-null-result.md` (the prior investigation that surfaced this question) + +## What we set out to fix + +The prior u256 batch2 investigation found that batch1's (#458) `Operand::ValueRange` u64-narrow fast paths (handleBinaryArithmetic ADD/SUB, handleMul, handleDiv/handleMod, handleMulMod) appeared not to fire on a hand-crafted `AND PUSH8 0xFF..FF` ADDMOD hot loop — a workload that should trigger them per code reading. The hypothesis at the end of that note was: **`ValueRange` is destroyed at CFG joins** (`materializeStackMergeOperand`, `stackGet`, `createStackEntryOperand` all return Operand without Range, defaulting to `ValueRange::U256`). If true, fixing those drop sites would activate batch1's existing fast paths on real Solidity uint64 arithmetic — a high-leverage win. + +## Empirical pre-fix evidence (hypothesis confirmed) + +Two synthetic ADD u64 contracts on `main` (HEAD `5e5fddd`, batch1 already merged): + +- **straight-line** (16 chained ADDs in one basic block, `CALLDATALOAD + AND PUSH8 0xFF..FF` operands) +- **loop** (1 ADD inside a JUMPDEST loop body, same operand source) + +Captured under `ZEN_ENABLE_JIT_LOGGING=ON`: + +| x86 instruction | straight-line | loop body | +|---|---|---| +| `ADC64rr` (chained limbs 2–4 of u256 ADC) | **0** | **3** | +| `ADD64rr` (single-limb fast path / chain limb 1) | 24 | 9 | + +`ADC64rr=0` in straight-line confirms each ADD took the u64 fast path. `ADC64rr=3` in loop confirms the loop body emits the full 4-limb ADC chain — fast path **does not** fire across the JUMPDEST PHI. + +## Fix attempted + +Two-part change in `evm_mir_compiler.{cpp,h}`: + +1. Added `Operand(U256Var, EVMType, ValueRange)` constructor overload (analogous to existing `Operand(U256Inst, EVMType, ValueRange)` at h:148). +2. Modified `materializeStackMergeOperand` to compute `merged_range = max(incoming_ranges)` and pass it to the new constructor; falls back to `U256` if any predecessor's value isn't yet known (back-edge of a self-loop). +3. Modified `prepareStackPhiIncoming` to forward the input Operand's Range into the wrapped output (it was dropping Range upstream of `materializeStackMergeOperand`, masking the fix). + +## Empirical post-fix result (fix did NOT work) + +Re-running the same JIT-log capture with the fix applied: + +| x86 instruction | straight-line (after) | loop body (after) | if-else merge (after) | +|---|---|---|---| +| `ADC64rr` | 0 | **3** (unchanged) | **3** (unchanged) | +| `ADD64rr` | 24 | 9 | 9 | + +The loop and even the **forward-only if-else merge** still emit the full 4-limb chain. To rule out a logic bug in our merge computation, we ran a probe that forced `MergedRange = ValueRange::U64` unconditionally (unsound, for diagnostic purposes only) — the fast path **still did not fire**. + +This means the Operand returned from `materializeStackMergeOperand` is **not** the Operand that the downstream `handleBinaryArithmetic` eventually sees. There is at least one more layer between the merge result and the consumer (the visitor's `pop()`, the lifter's `EVMLiftedStackLifter::StackValue` machinery, or one of the fallback paths via `stackGet` / `stackPop`) that re-wraps the value into a fresh Operand without preserving Range. + +## Drop sites identified (incomplete list) + +Each of these constructs an Operand with `Operand(..., EVMType::UINT256)` (Range defaults to `ValueRange::U256`): + +| Function | File:line | When invoked | +|---|---|---| +| `createStackEntryOperand` | `evm_mir_compiler.cpp:1007` | Function-entry stack slots | +| `stackPop` | `evm_mir_compiler.cpp:943` | Physical stack pop after spill | +| `stackGet` | `evm_mir_compiler.cpp:987` | Physical stack peek after spill | +| `prepareStackPhiIncoming` | `evm_mir_compiler.cpp:1027` | Lifter-level PHI incoming wrapping (calls `protectUnsafeValue` per limb) | +| `materializeStackMergeOperand` | `evm_mir_compiler.cpp:1042` (return at 1096) | JUMPDEST PHI merge; returns U256Var-backed Operand | + +Fixing 4 and 5 alone does not fix the loop/if-else case (verified empirically). At least one more drop site exists between the merge result and the visitor's logical pop(). + +## Why a Light-tier fix isn't enough + +The visitor uses `EVMLiftedStackLifter` for stack tracking. Its `StackValue` struct stores Operands by value, and PHI resolution happens through `PendingPhi` machinery with `ResolutionKind::{Pending, Folded, RequiresMaterialization}`. The interaction: + +- When entering a JUMPDEST, the lifter may **materialize** a PHI (calling `materializeStackMergeOperand`) and store the result back into its own `StackValue`. This logical Operand has the merged Range from our fix. +- However, when the visitor's `pop()` is called inside the loop body, the lifter may produce a fresh Operand via a different path (re-extraction of components, fallback `stackGet` for blocks marked as "lifted with resolved entry depth"), losing Range. +- Investigating the precise hand-off path between materialize and pop requires reading ~500 lines of `evm_lifted_stack_lifter.h` and tracing through the PHI bookkeeping. Beyond Light-tier scope. + +## Proper-fix scope (Full-tier estimate) + +1. **Track Range in `EVMLiftedStackLifter::StackValue`** (currently just `Operand Value` + `StackValueId Id`). +2. **Track Range in `EVMAnalyzer::BlockInfo`** for stack slots at lifted block entry (so `stackGet`-fallback paths can recover the Range from analyzer metadata). +3. **Make `Operand::Range` mutable** (or expose a `setRange()`) so back-edge patches (`assignStackMergeOperand`) can retroactively widen the merged Range as more predecessors are seen. +4. **OR introduce a two-pass codegen**: pass 1 emits MIR with conservative Range, pass 2 narrows based on dataflow analysis. Substantial. +5. Add 4–5 new constructor overloads with explicit Range, and audit all `Operand(..., EVMType::UINT256)` call sites for Range propagation. + +Likely 200–500 LOC across `evm_mir_compiler.{cpp,h}`, `evm_lifted_stack_lifter.h`, and `evm_analyzer.h`. Risk: medium — touches the SSA-construction core. Reward: real Solidity uint64 arithmetic in loops would activate batch1's fast paths. + +## Decision + +Rolled back all source changes. Branch deleted. The investigation is preserved here; the change doc README that lived on the worktree is also discarded — its contents are folded into this note. + +The 27-bench paper benches that Solidity-style uint64 patterns dominate (weierstrudel, snailtracer, swap_math, etc.) all use loops, so this fix could plausibly deliver another #458-scale win on top of the existing +18.3% on weierstrudel/15. But the cost-benefit ratio depends on the actual scope being closer to the 200-LOC end vs. the 500-LOC end, and on someone having time to characterize the lifter's pop-path before committing. + +## Reproducibility + +Bytecode generators and the bench JSONs were under `/tmp/range-cfg-investigation/` and `~/evmone/test/evm-benchmarks/benchmarks/main_user/` (cleaned up after the investigation). Two key snippets if anyone wants to re-run the experiment: + +- ADD u64 hot loop: setup loads `x = CALLDATALOAD(0x04) & 0xFF..FF` (PUSH8 mask) and similarly `y = CALLDATALOAD(0x24)`; body does `[counter, x, y]` → `DUP3 DUP3 ADD POP` then `JUMP loop_start`. 65536 inner iterations per call. +- Straight-line variant: same loads, then 16 × `[DUP2 DUP2 ADD POP]` (no JUMPDEST in the hot region). + +The smoking-gun signal is the `ADC64rr` count in `ZEN_ENABLE_JIT_LOGGING=ON` output — 0 in straight-line, 3 in loop body. After the proper fix, both should be 0. + +## Soundness regression evidence (2026-05-12) + +After commit `2ebfd29` plumbed the analyzer's per-slot range into both +codegen paths, end-to-end execution-level evidence for the two soundness +fixes (`5d46f7e`, `a73f782`) became producible. Two regression artifacts +landed under `regression/`: + +1. **Analyzer-level regression net** (white-box). Empirical check on + 2026-05-12 with both `5d46f7e` and `a73f782` reverted in place: 6 of + the 7 directly-relevant tests fail (`SDivByU256IsU256`, + `SModByU256IsU256`, `TimestampIsU256`, `NumberIsU256`, + `GasLimitIsU256`, `ChainIdIsU256`). One passes by coincidence + (`SDivU256DividendIsU256` — pre-fix and post-fix rules happen to + agree when dividend is already U256). See `regression/README.md`. + +2. **Execution-level reproducer** (black-box). + `regression/sdiv_sign_mismatch_repro.hex` plus + `regression/repro_sdiv_fast_path_truncate.sh`. Bytecode crosses a + CFG join through a lifted JUMPDEST and feeds the bothFitU64-gated + ADD with the SDIV(U64-dividend, U256-divisor) result. Outputs: + + | Build | Output | + |---|---| + | evmone reference | `0xFF...FC` (−4 in signed 256-bit, spec-correct) | + | DTVM multipass (fix applied) | `0xFF...FC` — matches reference | + | DTVM multipass (fix reverted) | `0x000...000FC` — upper 192 bits truncated to 0 | + + This is exactly the "limbs[2..3] silent truncation" failure mode the + `5d46f7e` fix commit message described, surfaced as a state divergence + visible in 32-byte RETURN data. + +The host-context-opcode bug is harder to reproduce as a black-box test +because `evmc run`'s default host returns small values that don't surface +the truncation; the four `Timestamp/Number/GasLimit/ChainIdIsU256` tests +in the white-box net are the operative regression evidence for that +class. diff --git a/docs/changes/2026-05-07-value-range-cfg-join/pr493-body-final.md b/docs/changes/2026-05-07-value-range-cfg-join/pr493-body-final.md new file mode 100644 index 000000000..c6c250bf4 --- /dev/null +++ b/docs/changes/2026-05-07-value-range-cfg-join/pr493-body-final.md @@ -0,0 +1,137 @@ +## Summary + +Add an `EVMRangeAnalyzer` dataflow pass that computes per-stack-slot `ValueRange` at every block entry, and wire the result into both the non-lifted JUMPDEST consumer and the lifted-block entry-operand factories. Activates the u64-narrow fast paths introduced in #458 (`handleBinaryArithmetic` ADD/SUB, `handleMul`, `handleDiv`/`handleMod`, `handleMulMod`) on values that flow through control-flow joins — the common case for arithmetic in Solidity loop bodies. + +#458 introduced `Operand::ValueRange` and four families of fast paths gated on `bothFitU64`. Empirical investigation found these only fire intra-basic-block: at JUMPDEST entries, block-boundary stack values round-trip through factories that default `ValueRange::U256`, so the fast paths rarely fired inside loop bodies in practice. + +This PR fixes that across both codegen paths: + +1. **Analyzer** (`EVMRangeAnalyzer`): a pure dataflow pass over the EVM CFG. Lattice `{U64, U128, U256}` with `meet = max`. Per-opcode transfer functions for the full opcode set. Populates `BlockInfo::EntryStackRanges` for each block. +2. **Non-lifted consumer** (`evm_bytecode_visitor.h handleBeginBlock` non-lifted path): after each `stackPop()`, look up `BlockInfo::EntryStackRanges[slot]` and refine the Operand's Range via `setRange`. +3. **Lifted consumer**: `createStackEntryOperand` and `materializeStackMergeOperand` factories take an explicit `ValueRange` parameter; lifted blocks thread `BlockInfo.EntryStackRanges[Depth]` through `EVMLiftedStackLifter` and `materializeLiftedBlockMergeRequests`. The lifted path is the default for well-formed bytecode and was unaddressed in earlier drafts of this work. + +## Source commits (12) + +Grouped by logical role: + +**Analyzer infrastructure** (3 commits) +- `3846a1e refactor(evm): extract EVMValueRange enum and prepare for analyzer plumbing` — moves `ValueRange` to a shared header so the IR builder and analyzer share the type; adds `Operand::setRange` and `BlockInfo::EntryStackRanges`. +- `061c500 feat(compiler): EVMRangeAnalyzer dataflow pass for stack-slot value ranges` — worklist-based fixed-point analyzer in `EVMAnalyzer`, with per-opcode transfer functions across the full opcode set. +- `c6de6eb feat(evm): plumb EVMRangeAnalyzer entry ranges into stackPop consumer` — non-lifted JUMPDEST consumer reads `EntryStackRanges` after `stackPop()`. + +**Soundness fixes** (3 commits, all uncovered during white-box test development or PR review) +- `5d46f7e fix(compiler): correct EVMRangeAnalyzer SDIV/SMOD range transfer for signed sign mismatch` — analyzer was claiming U64 for SDIV/SMOD outputs whose sign-mismatch case can exceed u64 representable range. +- `a73f782 fix(compiler): widen host-context opcode range classifications (TIMESTAMP/NUMBER/GASLIMIT/CHAINID)` — these host opcodes return values whose range depends on chain state; conservative U256 widening preserves correctness on chains with non-u64 timestamps/numbers. +- `3f32828 fix(compiler): widen CREATE/CREATE2 range to U256 in EVMRangeAnalyzer` — CREATE/CREATE2 push a 20-byte contract address (or 0 on failure), not a 0/1 success bool; the original grouping with the CALL family wrongly classified the result as U64. Caught by GitHub Copilot reviewer. + +**Tests, cleanup, lifted-block wiring** (7 commits) +- `72c5e0b fix(test): add setRange stub to MockOperand for visitor template` +- `e27ac3c test(compiler): EVMRangeAnalyzer white-box unit suite` — 39 tests covering per-opcode transfer, CFG joins, dynamic jumps, and cross-bb chains. +- `f203bd5 refactor(compiler): tighten EVMRangeAnalyzer defensive paths and consolidate docs` — replace dead defensive branch with `ZEN_ASSERT`; consolidate 7 host-context opcodes into single block. +- `2ebfd29 perf(compiler): plumb EVMRangeAnalyzer ranges into lifted-block entry operands` — wires the analyzer's per-slot Range into the lifted codegen path via the two Operand factories. +- `da5571c refactor(compiler): cache instruction tables and tighten meet invariant in EVMAnalyzer` — cache `evmc_get_instruction_*_table` results as analyzer members; replace unreachable widen-tail branch with `ZEN_ASSERT(ExitStack.size() == SuccDepth)`. +- `da4f4cc test(compiler): add multi-slot diamond meet test for EVMRangeAnalyzer` — exercises per-slot independent `meet=max` at depth 3 (40th test). +- `1dca9d5 style(evm): clang-format wrap on a Phase 3 comment block` + +## Benchmark + +evmone-bench, multipass mode, A-B-A protocol (baseline → branch → baseline_pingpong), 27 `external/total/{main,micro}` benches × 20 reps, taskset-pinned, single session. + +| Metric | Value | +|---|---| +| Branch HEAD | `da4f4cc` | +| Baseline (upstream/main) | `c644fbe` | +| Drift (pingpong / baseline) | +0.12% (well under 5% threshold) | +| **Geomean speedup** | **+0.34%** | +| **95% bootstrap CI** | **[−0.07%, +0.78%]** | +| Per-bench regressions ≥ 0.5pp | 5 / 27 | + +**Caveat on the perf claim**: the 95% lower CI is −0.07%, so the suite-level geomean improvement is **not statistically distinguishable from zero at the 95% level**. The original PR description claimed +1.30% (CI [+1.15%, +1.47%]) against a prior upstream/main; subsequent upstream optimizations (notably PR #483's inline arithmetic dispatch rework) have changed the interaction landscape. The lifted-block wiring fix in commit `2ebfd29` recovered the analyzer-target wins on `swap_math`, `sha1_shifts/5311`, `jump_around`, and similar patterns (see top-wins table). `snailtracer/benchmark` regresses 2.29% on this branch vs current upstream/main and is the largest single regression. The analyzer's per-opcode classifications are verified sound by 42 white-box tests and 2723/2723 multipass statetests, so the cause is how the analyzer's outputs interact with downstream codegen on the rebase-picked-up upstream commits (not yet bisected to a specific commit); deferred to a follow-up PR. + +### Top wins + +These are the per-bench patterns where the analyzer's intended fast-path activation pays off; numbers are paired-bench medians vs baseline. + +| Bench | Speedup | +|---|---| +| `micro/memory_grow_mstore/by32` | +2.39% | +| `micro/memory_grow_mload/by16` | +2.16% | +| `main/swap_math/insufficient_liquidity` | +2.14% | +| `main/sha1_divs/5311` | +1.40% | +| `micro/jump_around/empty` | +1.37% | +| `main/swap_math/spent` | +1.30% | +| `main/sha1_shifts/5311` | +1.14% | +| `micro/memory_grow_mload/by32` | +1.08% | +| `micro/memory_grow_mstore/by16` | +1.06% | +| `main/swap_math/received` | +0.76% | + +### Outstanding regressions (5 / 27) + +| Bench | Speedup | Note | +|---|---|---| +| `main/snailtracer/benchmark` | −2.29% | interaction with rebase-pickup upstream commits; not bisected | +| `micro/memory_grow_mstore/by1` | −1.60% | partial-grow path | +| `micro/memory_grow_mload/by1` | −1.58% | partial-grow path | +| `main/sha1_shifts/empty` | −1.29% | tiny-bench noise floor (3.8 ns/op) | +| `main/blake2b_huff/empty` | −0.56% | tiny-bench noise floor (8.7 ns/op) | + + +## Test plan + +- [x] `evmone-unittests` multipass: 223/223 +- [x] `evmone-unittests` interpreter: 215/215 +- [x] `evmone-statetest -k fork_Cancun` multipass: 2723/2723 +- [x] EVMRangeAnalyzer white-box suite: 42/42 +- [x] `tools/format.sh check` clean +- [ ] CI green (pending push) + +Multipass statetest is our strongest soundness check: if any transfer function over-claims `U64` on a value with non-zero upper limbs, the #458 fast paths would silently truncate and produce divergent state roots. None observed across 2723 fixtures. + +## Soundness regression evidence (pre-fix vs post-fix) + +The 42 white-box analyzer tests and 2723 multipass statetests above pass under the current code. Both fixes are also verified to be load-bearing by running with them temporarily reverted: + +### Analyzer-level (white-box) — revert `5d46f7e` + `a73f782` + `3f32828`, rebuild, rerun the 42-test suite + +| Test | Pre-fix outcome | Reason | +|---|---|---| +| `SDivByU256IsU256` | FAIL | divisor U256, dividend U64; pre-fix rule says `result = Dividend = U64` (wrong) | +| `SModByU256IsU256` | FAIL | same pattern for SMOD | +| `TimestampIsU256` | FAIL | pre-fix host-context rule put TIMESTAMP in `pushU64` block | +| `NumberIsU256` | FAIL | same — NUMBER | +| `GasLimitIsU256` | FAIL | same — GASLIMIT | +| `ChainIdIsU256` | FAIL | same — CHAINID | +| `CreateAddressIsU256` | FAIL | pre-fix rule classified `CREATE` result as U64 (treated as success bool); it actually returns a 20-byte contract address | +| `Create2AddressIsU256` | FAIL | same pattern for `CREATE2` | +| `SDivU256DividendIsU256` | PASS | coincidence — `result = Dividend = U256` happens to match the post-fix answer when dividend is U256 | + +Eight of nine directly-relevant tests fail under the pre-fix code, one passes by coincidence. The white-box net is effective at the analyzer layer. + +### Execution-level (black-box) — `docs/changes/2026-05-07-value-range-cfg-join/regression/` + +A 52-byte bytecode that crosses a lifted JUMPDEST and feeds a `bothFitU64`-gated ADD with the SDIV(U64-dividend, U256-divisor) result: + +| Build | 32-byte RETURN output | Verdict | +|---|---|---| +| `evmone` (spec reference) | `0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc` | −4 in signed 256-bit, spec-correct | +| DTVM `mode=multipass` with fix applied | `0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc` | matches reference ✓ | +| DTVM `mode=multipass` with `5d46f7e` reverted | `0x000000000000000000000000000000000000000000000000fffffffffffffffc` | upper 192 bits truncated to 0 — visible state divergence | + +The buggy output preserves only the low 64 bits of the real ADD result and zeroes the upper three limbs — exactly the "limbs[2..3] silent truncation" failure mode the fix commit message describes. + +This experiment is only producible after commit `2ebfd29` plumbed the analyzer's per-slot range into the lifted-block codegen path; before that commit, the analyzer's `setRange` refinement only reached non-lifted JUMPDESTs, so no mis-classified value could reach a fast-path consumer through the dominant codegen path. + +Reproduce: `bash docs/changes/2026-05-07-value-range-cfg-join/regression/repro_sdiv_fast_path_truncate.sh` + +## Out of scope + +- Extending the analyzer to track sub-byte width refinement on shift/comparison opcodes — current lattice height 3 is enough for the u64 fast paths but does not enable further admission gates. +- Indirect-jump target enumeration — analyzer treats dynamic-jump regions conservatively (entry slots seeded at U256). No assumption of precise indirect-jump target tracking. +- `snailtracer/benchmark` regression bisection — analyzer per-opcode soundness verified by 42 tests + 2723/2723 statetests, but the −2.29% interaction with rebase-pickup upstream commits is not isolated to a specific commit yet; deferred to a follow-up PR. + +## Notes + +- This PR establishes a soundness invariant: any consumer that later relies on `Operand::ValueRange` can trust the analyzer's classifications. The three soundness commits (`5d46f7e`, `a73f782`, `3f32828`) close gaps in the analyzer's transfer functions for SDIV/SMOD sign mismatch, host-context opcodes that return U256, and CREATE/CREATE2 that return contract addresses — all surfaced during this PR's white-box test development and Copilot review pass. +- An architectural gap discovered during the rebase cycle: the original PR wired the analyzer only into the non-lifted codegen path, while lifted-block factories defaulted Range = U256 and short-circuited refinement. Commit `2ebfd29` resolves this and is the empirically-driven completion of the analyzer's intended consumer wiring. + +🤖 Generated with [Claude Code](https://claude.com/claude-code) diff --git a/docs/changes/2026-05-07-value-range-cfg-join/regression/README.md b/docs/changes/2026-05-07-value-range-cfg-join/regression/README.md new file mode 100644 index 000000000..0eeca6dfc --- /dev/null +++ b/docs/changes/2026-05-07-value-range-cfg-join/regression/README.md @@ -0,0 +1,113 @@ +# PR #493 Soundness Regression Reproducers + +End-to-end execution-level evidence that the three soundness fixes in PR #493 +are not theoretical: with the fix reverted, DTVM's multipass JIT produces +output that disagrees with the `evmone` reference VM on bytecode that +crosses a CFG join through a lifted JUMPDEST and feeds a `bothFitU64`-gated +fast-path consumer. + +## Why this experiment was only possible after commit `2ebfd29` + +Earlier drafts of this PR proposed execution-level state-test fixtures to +prove the soundness fixes were observable, but `investigation.md`'s §2b +finding noted the path was architecturally infeasible: the analyzer's +`setRange` refinement was wired only into the non-lifted JUMPDEST consumer, +while lifted blocks (the dominant codegen path under default +`ZEN_ENABLE_EVM_STACK_SSA_LIFT=OFF`) constructed entry operands via +`createStackEntryOperand` / `materializeStackMergeOperand` with the default +`ValueRange::U256`. No analyzer mis-classification could reach a +fast-path consumer through the dominant codegen path, so no end-to-end +test could surface the bug. + +Commit `2ebfd29` (perf: plumb EVMRangeAnalyzer ranges into lifted-block +entry operands) closed that gap. With it landed, the analyzer's per-slot +range now reaches both codegen paths, and a mis-classified value can fire +the fast path on real bytecode — which makes this reproducer possible. + +## SDIV fast-path truncation (`sdiv_sign_mismatch_repro.hex`) + +### Bytecode + +``` +PUSH32 0xFF...FF ; -1 in signed U256 (analyzer correctly classifies U256) +PUSH1 5 ; dividend = 5 (analyzer correctly classifies U64) +SDIV ; signed: 5 / -1 = -5 + ; PRE-FIX analyzer rule: result = Dividend.range = U64 (BUG) + ; POST-FIX rule (signedDivModRange): U256 because divisor is U256 +PUSH1 1 ; U64 +PUSH1 0x2A ; merge JUMPDEST address +JUMP ; cross-CFG-join into lifted block + +@0x2A JUMPDEST ; lifted block; entry stack = [SDIV_result, 1] + ; createStackEntryOperand reads analyzer's range: + ; PRE-FIX: U64 (BUG — fast path admission gate passes) + ; POST-FIX: U256 (fast path declined) +ADD ; real: -5 + 1 = -4 = 0xFF...FC + ; buggy fast path: u64(0xFF...FB) + 1 = 0xFFFC; upper 3 limbs zeroed +PUSH1 0 +MSTORE ; write 32-byte ADD result to memory[0..32] +PUSH1 32 +PUSH1 0 +RETURN ; return memory[0..32] as call output +``` + +### Observed outputs + +| VM | Fix state | Output (32-byte hex, big-endian) | Verdict | +|---|---|---|---| +| `evmone` (spec reference) | n/a | `fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc` | −4, spec-correct | +| DTVM `mode=multipass` | fix applied (HEAD) | `fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc` | matches reference ✓ | +| DTVM `mode=multipass` | fix reverted | `000000000000000000000000000000000000000000000000fffffffffffffffc` | upper 192 bits **truncated to 0** — visible state divergence | + +The buggy output preserves only the low 64 bits of the real ADD result +(`0xFFFFFFFFFFFFFFFC` = u64::MAX − 3) and zeroes the upper 3 limbs. +This is exactly the "limbs[2..3] silent truncation" failure mode the +fix commit message described. + +### Reproduce + +```bash +bash repro_sdiv_fast_path_truncate.sh +``` + +The script runs the bytecode under (a) `evmone` reference and (b) DTVM +multipass with current code, asserts the outputs match, and prints both. +It does **not** automatically revert the fix and re-test — that requires +a rebuild which the script flags as a manual follow-up. + +## Companion: white-box regression net (Option A) + +The 42 white-box tests in `src/tests/evm_range_analyzer_tests.cpp` already +catch every soundness mis-classification at the analyzer layer. Empirical +verification on 2026-05-12 (current HEAD, all three fixes reverted in place, +analyzer rebuilt): + +| Test | Pre-fix outcome | Reason | +|---|---|---| +| `SDivByU256IsU256` | FAIL | divisor U256, dividend U64; pre-fix rule says `result = Dividend = U64` (wrong) | +| `SModByU256IsU256` | FAIL | same pattern for SMOD | +| `TimestampIsU256` | FAIL | pre-fix host-context rule put TIMESTAMP in `pushU64` block | +| `NumberIsU256` | FAIL | same — NUMBER | +| `GasLimitIsU256` | FAIL | same — GASLIMIT | +| `ChainIdIsU256` | FAIL | same — CHAINID | +| `CreateAddressIsU256` | FAIL | pre-fix rule classified `CREATE` result as U64; it actually returns a 20-byte address | +| `Create2AddressIsU256` | FAIL | same pattern for `CREATE2` | +| `SDivU256DividendIsU256` | PASS | coincidence — `result = Dividend = U256` happens to match the post-fix answer when dividend is U256 | + +Eight tests fail under the pre-fix code, one passes by coincidence. The +regression net is effective at the analyzer level, but does not by itself +prove that mis-classification has user-visible execution consequences — +that is what the `sdiv_sign_mismatch_repro.hex` experiment supplies. + +## Why the host-context bug is harder to surface via this same harness + +A symmetric experiment for the `TIMESTAMP`/`NUMBER`/`GASLIMIT`/`CHAINID` +soundness fix would require an EVM `evmc_host_interface` that returns a +context value with bit 64 or higher set (e.g., a non-Ethereum chain ID +larger than 2^32, or a future-dated timestamp). `evmc run`'s default +host returns small values for all four, so the buggy fast path would +truncate to the same value the real arithmetic produces — no visible +divergence. Constructing a custom host-overriding harness is doable but +beyond the scope of this experiment; the analyzer-layer regression test +(Option A above, `TimestampIsU256` et al.) is the operative net for that +bug class. diff --git a/docs/changes/2026-05-07-value-range-cfg-join/regression/repro_sdiv_fast_path_truncate.sh b/docs/changes/2026-05-07-value-range-cfg-join/regression/repro_sdiv_fast_path_truncate.sh new file mode 100755 index 000000000..745bd2618 --- /dev/null +++ b/docs/changes/2026-05-07-value-range-cfg-join/regression/repro_sdiv_fast_path_truncate.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Reproduce the SDIV soundness divergence between evmone reference and +# DTVM multipass on a bytecode that crosses a lifted JUMPDEST and feeds +# a bothFitU64-gated ADD. See README.md for the bytecode breakdown. +# +# Without arguments: runs both VMs under current code, expects equal output. +# With --buggy (after `git revert --no-commit 5d46f7e` + rebuild in the +# parent worktree), expects DIVERGENT output. + +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HEX_FILE="$HERE/sdiv_sign_mismatch_repro.hex" +WORKTREE_ROOT="$(cd "$HERE/../../../.." && pwd)" +DTVM_SO="$WORKTREE_ROOT/build/lib/libdtvmapi.so" +EVMONE_BIN="${EVMONE_BIN:-$HOME/evmone/build/bin/evmc}" +EVMONE_SO="${EVMONE_SO:-$HOME/evmone/build/lib/libevmone.so}" + +if [[ ! -f "$DTVM_SO" ]]; then + echo "ERROR: DTVM library not found at $DTVM_SO" >&2 + echo " run \`cmake --build build --target dtvmapi\` in $WORKTREE_ROOT first." >&2 + exit 2 +fi +if [[ ! -x "$EVMONE_BIN" || ! -f "$EVMONE_SO" ]]; then + echo "ERROR: evmone reference VM not found (looked for $EVMONE_BIN / $EVMONE_SO)" >&2 + exit 2 +fi + +CODE="$(cat "$HEX_FILE")" + +extract_output() { + local vm_arg="$1" + "$EVMONE_BIN" run --vm "$vm_arg" "0x$CODE" 2>&1 | awk '/^Output:/ {print $2}' +} + +echo "Bytecode: $CODE" +echo "Expected real result: fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc (-4 in signed 256-bit)" +echo "Buggy truncated (low 64 bits only): 000000000000000000000000000000000000000000000000fffffffffffffffc" +echo + +REF_OUT="$(extract_output "$EVMONE_SO")" +DTVM_OUT="$(extract_output "$DTVM_SO,mode=multipass")" + +printf '%-40s %s\n' "evmone (spec reference)" "$REF_OUT" +printf '%-40s %s\n' "DTVM multipass" "$DTVM_OUT" + +if [[ "${1:-}" == "--buggy" ]]; then + if [[ "$DTVM_OUT" == "$REF_OUT" ]]; then + echo + echo "FAIL: --buggy mode expected divergence but DTVM matched reference." + echo " Did the SDIV/SMOD fix in commit 5d46f7e actually get reverted?" + exit 1 + fi + echo + echo "PASS: DTVM diverges from evmone reference as expected under the buggy build." +else + if [[ "$DTVM_OUT" != "$REF_OUT" ]]; then + echo + echo "FAIL: DTVM multipass output does not match evmone reference." + echo " If commit 5d46f7e is currently reverted, run with --buggy." + exit 1 + fi + echo + echo "PASS: DTVM multipass matches evmone reference." +fi diff --git a/docs/changes/2026-05-07-value-range-cfg-join/regression/sdiv_sign_mismatch_repro.hex b/docs/changes/2026-05-07-value-range-cfg-join/regression/sdiv_sign_mismatch_repro.hex new file mode 100644 index 000000000..8b52121ef --- /dev/null +++ b/docs/changes/2026-05-07-value-range-cfg-join/regression/sdiv_sign_mismatch_repro.hex @@ -0,0 +1 @@ +7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff6005056001602a56fe5b0160005260206000f3 diff --git a/src/action/evm_bytecode_visitor.h b/src/action/evm_bytecode_visitor.h index 07a1726ad..d1892a4f5 100644 --- a/src/action/evm_bytecode_visitor.h +++ b/src/action/evm_bytecode_visitor.h @@ -1132,7 +1132,7 @@ template class EVMByteCodeVisitor { CurrentBlockLifted = true; CurrentBlockHiddenLiveInPrefixDepth = static_cast(std::max(BlockInfo.HiddenLiveInPrefixDepth, 0)); - materializeLiftedBlockMergeRequests(PC); + materializeLiftedBlockMergeRequests(PC, BlockInfo); restoreLiftedBlockLogicalEntryState(PC); return; } @@ -1140,9 +1140,23 @@ template class EVMByteCodeVisitor { CurrentBlockLifted = false; int32_t TotalPopSize = -BlockInfo.MinPopHeight; EvalStack ReverseStack; + // Refine each popped Operand's ValueRange from analyzer-computed entry + // ranges so u64-narrow fast paths fire on values flowing through CFG + // joins (see EVMRangeAnalyzer / + // docs/changes/2026-05-07-value-range-cfg-join). EntryStackRanges[0] is the + // bottom of entry stack; pop order is top-first. + const auto &EntryRanges = BlockInfo.EntryStackRanges; + const int32_t EntryTopIdx = static_cast(EntryRanges.size()) - 1; + int32_t PopIter = 0; while (TotalPopSize > 0) { - ReverseStack.push(Builder.stackPop()); - TotalPopSize--; + Operand Opnd = Builder.stackPop(); + const int32_t SlotIdx = EntryTopIdx - PopIter; + if (SlotIdx >= 0 && SlotIdx < static_cast(EntryRanges.size())) { + Opnd.setRange(EntryRanges[SlotIdx]); + } + ReverseStack.push(Opnd); + ++PopIter; + --TotalPopSize; } while (!ReverseStack.empty()) { Operand Opnd = ReverseStack.pop(); @@ -1150,7 +1164,9 @@ template class EVMByteCodeVisitor { } } - void materializeLiftedBlockMergeRequests(uint64_t BlockPC) { + void + materializeLiftedBlockMergeRequests(uint64_t BlockPC, + const EVMAnalyzer::BlockInfo &BlockInfo) { for (const MergeMaterializationRequest &Request : StackLifter.getMergeMaterializationRequests(BlockPC)) { std::vector> IncomingValues; @@ -1159,10 +1175,12 @@ template class EVMByteCodeVisitor { IncomingValues.emplace_back(IncomingValue.PredBlockPC, IncomingValue.Value); } - StackLifter.assignMergeOperand( - BlockPC, Request.SlotIndex, - materializeStackMergeOperandCompat(Request.ExpectedPredBlockPCs, - IncomingValues)); + Operand Merge = materializeStackMergeOperandCompat( + Request.ExpectedPredBlockPCs, IncomingValues); + if (Request.SlotIndex < BlockInfo.EntryStackRanges.size()) { + Merge.setRange(BlockInfo.EntryStackRanges[Request.SlotIndex]); + } + StackLifter.assignMergeOperand(BlockPC, Request.SlotIndex, Merge); } } diff --git a/src/compiler/evm_frontend/evm_analyzer.h b/src/compiler/evm_frontend/evm_analyzer.h index efda727ee..70d441f05 100644 --- a/src/compiler/evm_frontend/evm_analyzer.h +++ b/src/compiler/evm_frontend/evm_analyzer.h @@ -5,6 +5,7 @@ #define EVM_FRONTEND_EVM_ANALYZER_H #include "common/defines.h" +#include "compiler/evm_frontend/evm_value_range.h" #include "evm/evm.h" #include "evmc/evmc.h" #include "evmc/instructions.h" @@ -13,6 +14,7 @@ #include #include #include +#include #include #include @@ -130,7 +132,18 @@ class EVMAnalyzer { using Byte = zen::common::Byte; public: - EVMAnalyzer(evmc_revision Rev = zen::evm::DEFAULT_REVISION) : Revision(Rev) {} + EVMAnalyzer(evmc_revision Rev = zen::evm::DEFAULT_REVISION) : Revision(Rev) { + InstructionMetrics = evmc_get_instruction_metrics_table(Revision); + if (!InstructionMetrics) { + InstructionMetrics = + evmc_get_instruction_metrics_table(zen::evm::DEFAULT_REVISION); + } + InstructionNames = evmc_get_instruction_names_table(Revision); + if (!InstructionNames) { + InstructionNames = + evmc_get_instruction_names_table(zen::evm::DEFAULT_REVISION); + } + } struct BlockInfo { uint64_t EntryPC = 0; @@ -165,6 +178,11 @@ class EVMAnalyzer { std::vector Successors; std::vector Predecessors; + // Per-stack-slot value-range at block entry, populated by + // EVMRangeAnalyzer. Index 0 is the bottom of the entry stack, last is the + // top. Empty when no Range info is available (treat as ValueRange::U256). + std::vector EntryStackRanges; + BlockInfo() = default; BlockInfo(uint64_t PC, uint64_t StartPC = 0, bool JumpDest = false) : EntryPC(PC), BodyStartPC(StartPC), BodyEndPC(StartPC), @@ -410,6 +428,7 @@ class EVMAnalyzer { resolveDynamicJumpTargetEntryDepths(); finalizeEntryShapeMetadata(); finalizeLiftability(); + runRangeAnalysis(Bytecode, BytecodeSize); return true; } @@ -585,17 +604,6 @@ class EVMAnalyzer { size_t BytecodeSize, size_t &ScanPC, uint64_t &NextEntryPC, size_t &NextBodyStartPC, bool &HasNextBlock) { - const auto *InstructionMetrics = - evmc_get_instruction_metrics_table(Revision); - const auto *InstructionNames = evmc_get_instruction_names_table(Revision); - if (!InstructionMetrics) { - InstructionMetrics = - evmc_get_instruction_metrics_table(zen::evm::DEFAULT_REVISION); - } - if (!InstructionNames) { - InstructionNames = - evmc_get_instruction_names_table(zen::evm::DEFAULT_REVISION); - } std::vector Stack; size_t EntryDepth = 0; @@ -1325,12 +1333,526 @@ class EVMAnalyzer { } } + // ============== EVMRangeAnalyzer ========================================== + // + // Forward dataflow analysis over the lattice { U64, U128, U256 } for each + // EVM stack slot at every block entry. Bottom is U64, top is U256, and the + // meet operator is `max` (widen to the more pessimistic value). At fixed + // point, each block's `EntryStackRanges` is sized to its + // `ResolvedEntryStackDepth`, with index 0 the bottom of the entry stack and + // the last index the top. Empty when entry depth is unresolved. + + static EVMValueRange meetRange(EVMValueRange A, EVMValueRange B) { + return A > B ? A : B; + } + + static EVMValueRange widenRange(EVMValueRange R) { + switch (R) { + case EVMValueRange::U64: + return EVMValueRange::U128; + case EVMValueRange::U128: + case EVMValueRange::U256: + default: + return EVMValueRange::U256; + } + } + + // Magnitude-only signed div/mod range. If a sign-known sub-lattice is added + // later (see PR #493 docs/changes README "Non-Goals"), THIS is the helper + // to replace -- it isolates the "signed reasoning lives in a single place" + // property. + static EVMValueRange signedDivModRange(EVMValueRange Dividend, + EVMValueRange Divisor) { + // If either operand is U256, it may be negative (bit 255 set), so the + // signed result can be negative U256 (all high limbs set). Otherwise + // both are non-negative (bit 255 == 0), the operation degenerates to + // unsigned, and |result| <= |Dividend|, so the result fits in Dividend's + // range. + if (Dividend == EVMValueRange::U256 || Divisor == EVMValueRange::U256) { + return EVMValueRange::U256; + } + return Dividend; + } + + // Classify a PUSH literal of `Size` bytes starting at byte `Start` in + // `Bytecode`. Defensive against truncated tail. + static EVMValueRange rangeFromPushLiteral(const uint8_t *Bytecode, + size_t BytecodeSize, size_t Start, + size_t Size) { + if (Size == 0) { + return EVMValueRange::U64; + } + const size_t Available = Start < BytecodeSize ? (BytecodeSize - Start) : 0; + const size_t ReadCount = std::min(Size, Available); + auto readPushByte = [&](size_t Index) -> uint8_t { + if (Index >= ReadCount) { + return 0; + } + return Bytecode[Start + Index]; + }; + + // Bytes are big-endian. Inspect prefix to bound magnitude. + if (Size > 16) { + for (size_t I = 0; I < Size - 16; ++I) { + if (readPushByte(I) != 0) { + return EVMValueRange::U256; + } + } + } + if (Size > 8) { + const size_t U128PrefixEnd = Size > 16 ? Size - 16 : 0; + const size_t U64PrefixEnd = Size - 8; + for (size_t I = U128PrefixEnd; I < U64PrefixEnd; ++I) { + if (readPushByte(I) != 0) { + return EVMValueRange::U128; + } + } + } + return EVMValueRange::U64; + } + + // Pop `Count` entries from `Stack`, padding with U256 if the stack is + // shallower than expected (under-approximated entry stack should never + // happen after `resolveEntryDepths` succeeded, but guard defensively). + static void popStackRanges(std::vector &Stack, size_t Count) { + while (Count > 0 && !Stack.empty()) { + Stack.pop_back(); + --Count; + } + } + + // Compute the exit-stack Range vector for a block by linearly scanning its + // body bytes and applying per-opcode transfer rules. `Stack` starts as the + // block's entry-stack vector and is mutated in place. + void applyRangeTransferForBlock(const BlockInfo &Info, + const uint8_t *Bytecode, size_t BytecodeSize, + std::vector &Stack) const { + size_t PC = Info.BodyStartPC; + const size_t EndPC = std::min(Info.BodyEndPC, BytecodeSize); + + auto pushTop = [&]() { Stack.push_back(EVMValueRange::U256); }; + auto pushU64 = [&]() { Stack.push_back(EVMValueRange::U64); }; + auto top = [&](size_t IndexFromTop) -> EVMValueRange { + if (Stack.size() <= IndexFromTop) { + return EVMValueRange::U256; + } + return Stack[Stack.size() - 1 - IndexFromTop]; + }; + + while (PC < EndPC) { + const uint8_t OpcodeU8 = Bytecode[PC]; + const evmc_opcode Opcode = static_cast(OpcodeU8); + + // Undefined opcodes terminate range analysis for this block; the + // analyzer already marked HasUndefinedInstr and stopped scanning here. + if (InstructionNames[Opcode] == nullptr) { + return; + } + + // PUSH N: parse literal magnitude. + if (Opcode >= OP_PUSH0 && Opcode <= OP_PUSH32) { + const size_t Size = + static_cast(Opcode) - static_cast(OP_PUSH0); + Stack.push_back( + rangeFromPushLiteral(Bytecode, BytecodeSize, PC + 1, Size)); + PC += 1 + Size; + continue; + } + + // DUP_N: duplicate slot N from the top. + if (Opcode >= OP_DUP1 && Opcode <= OP_DUP16) { + const size_t N = static_cast(Opcode - OP_DUP1 + 1); + Stack.push_back(top(N - 1)); + ++PC; + continue; + } + + // SWAP_N: swap top with slot N below the top. + if (Opcode >= OP_SWAP1 && Opcode <= OP_SWAP16) { + const size_t N = static_cast(Opcode - OP_SWAP1 + 1); + if (Stack.size() > N) { + std::swap(Stack.back(), Stack[Stack.size() - 1 - N]); + } + ++PC; + continue; + } + + // LOG_N: pops 2 + N, pushes nothing. + if (Opcode >= OP_LOG0 && Opcode <= OP_LOG4) { + const size_t N = static_cast(Opcode - OP_LOG0); + popStackRanges(Stack, 2 + N); + ++PC; + continue; + } + + switch (Opcode) { + // No-effect on Range: pure pops or block-terminators handled below. + case OP_POP: + popStackRanges(Stack, 1); + break; + + // Bitwise. + case OP_AND: { + EVMValueRange A = top(0); + EVMValueRange B = top(1); + popStackRanges(Stack, 2); + EVMValueRange R = (A == EVMValueRange::U64 || B == EVMValueRange::U64) + ? EVMValueRange::U64 + : (A < B ? A : B); + Stack.push_back(R); + break; + } + case OP_OR: + case OP_XOR: { + EVMValueRange A = top(0); + EVMValueRange B = top(1); + popStackRanges(Stack, 2); + Stack.push_back(meetRange(A, B)); + break; + } + case OP_NOT: + popStackRanges(Stack, 1); + pushTop(); + break; + + // Arithmetic. + case OP_ADD: { + EVMValueRange A = top(0); + EVMValueRange B = top(1); + popStackRanges(Stack, 2); + Stack.push_back(widenRange(meetRange(A, B))); + break; + } + case OP_MUL: { + EVMValueRange A = top(0); + EVMValueRange B = top(1); + popStackRanges(Stack, 2); + Stack.push_back(widenRange(meetRange(A, B))); + break; + } + case OP_SUB: + popStackRanges(Stack, 2); + pushTop(); + break; + case OP_DIV: + case OP_MOD: { + // Unsigned: result <= dividend (top of stack before pop). + EVMValueRange Dividend = top(0); + popStackRanges(Stack, 2); + Stack.push_back(Dividend); + break; + } + case OP_SDIV: + case OP_SMOD: { + // Signed: see signedDivModRange comment for soundness. + EVMValueRange Dividend = top(0); + EVMValueRange Divisor = top(1); + popStackRanges(Stack, 2); + Stack.push_back(signedDivModRange(Dividend, Divisor)); + break; + } + case OP_ADDMOD: + case OP_MULMOD: { + // Result < modulus (third operand). Ranges over the modulus. + EVMValueRange Modulus = top(2); + popStackRanges(Stack, 3); + Stack.push_back(Modulus); + break; + } + case OP_EXP: + case OP_SIGNEXTEND: + popStackRanges(Stack, 2); + pushTop(); + break; + + // Comparison / boolean — always 0 or 1. + case OP_LT: + case OP_GT: + case OP_SLT: + case OP_SGT: + case OP_EQ: + popStackRanges(Stack, 2); + pushU64(); + break; + case OP_ISZERO: + popStackRanges(Stack, 1); + pushU64(); + break; + + // Byte / shift / clz. + case OP_BYTE: + popStackRanges(Stack, 2); + pushU64(); + break; + case OP_SHL: + popStackRanges(Stack, 2); + pushTop(); + break; + case OP_SHR: + case OP_SAR: { + // Pops shift count (top), then value; result <= value's range. + EVMValueRange Value = top(1); + popStackRanges(Stack, 2); + Stack.push_back(Value); + break; + } + case OP_CLZ: + popStackRanges(Stack, 1); + pushU64(); + break; + + // Hash and loads. + case OP_KECCAK256: + popStackRanges(Stack, 2); + pushTop(); + break; + case OP_MLOAD: + case OP_SLOAD: + case OP_TLOAD: + case OP_CALLDATALOAD: + popStackRanges(Stack, 1); + pushTop(); + break; + + // Account / context — U256 (addresses are 160-bit but treat as U256). + case OP_ADDRESS: + case OP_ORIGIN: + case OP_CALLER: + case OP_COINBASE: + case OP_SELFBALANCE: + case OP_CALLVALUE: + case OP_GASPRICE: + pushTop(); + break; + case OP_BALANCE: + case OP_EXTCODEHASH: + popStackRanges(Stack, 1); + pushTop(); + break; + case OP_BLOCKHASH: + case OP_BLOBHASH: + popStackRanges(Stack, 1); + pushTop(); + break; + + // Bounded context — U64. + case OP_CALLDATASIZE: + case OP_CODESIZE: + case OP_RETURNDATASIZE: + case OP_PC: + case OP_MSIZE: + case OP_GAS: + pushU64(); + break; + // Host-context opcodes returning full U256 values (EVMC declares + // GetTimestamp/GetNumber/GetGasLimit as `U256Fn`, GetChainId as + // `Bytes32Fn`, BaseFee/BlobBaseFee/PrevRandao as `U256Fn`). Classify + // conservatively as U256 to keep the u64 fast-path admission invariant + // sound. + case OP_TIMESTAMP: + case OP_NUMBER: + case OP_GASLIMIT: + case OP_CHAINID: + case OP_BASEFEE: + case OP_BLOBBASEFEE: + case OP_PREVRANDAO: + pushTop(); + break; + case OP_EXTCODESIZE: + popStackRanges(Stack, 1); + pushU64(); + break; + + // Memory / storage stores: pop only. + case OP_MSTORE: + case OP_MSTORE8: + case OP_SSTORE: + case OP_TSTORE: + popStackRanges(Stack, 2); + break; + case OP_CALLDATACOPY: + case OP_CODECOPY: + case OP_RETURNDATACOPY: + case OP_MCOPY: + popStackRanges(Stack, 3); + break; + case OP_EXTCODECOPY: + popStackRanges(Stack, 4); + break; + + // Calls / creates: result is success boolean (0 or 1) → U64. + case OP_CALL: + case OP_CALLCODE: + popStackRanges(Stack, 7); + pushU64(); + break; + case OP_DELEGATECALL: + case OP_STATICCALL: + popStackRanges(Stack, 6); + pushU64(); + break; + case OP_CREATE: + // Pushes the created contract address (20 bytes / 160 bits) or 0 + // on failure -- not a 0/1 success bool. An address can hold any + // 20-byte pattern; classify conservatively as U256. + popStackRanges(Stack, 3); + pushTop(); + break; + case OP_CREATE2: + popStackRanges(Stack, 4); + pushTop(); + break; + + // Block-boundary / terminators. + case OP_JUMP: + popStackRanges(Stack, 1); + return; + case OP_JUMPI: + popStackRanges(Stack, 2); + // Fallthrough block continues; exit state is current Stack. + return; + case OP_STOP: + case OP_RETURN: + case OP_REVERT: + case OP_SELFDESTRUCT: + case OP_INVALID: + return; + case OP_JUMPDEST: + // Should never be encountered inside a body — block scan stops + // before the next JUMPDEST. Treat as no-op. + break; + + default: { + // Fallback for any opcode without an explicit rule above: use the + // metrics table to determine pop/push count and push U256 results. + // Constructor guarantees InstructionMetrics is non-null (falls back + // to DEFAULT_REVISION on lookup failure). + const auto &Metrics = InstructionMetrics[Opcode]; + int PopCount = Metrics.stack_height_required; + int PushCount = PopCount + Metrics.stack_height_change; + if (PopCount > 0) { + popStackRanges(Stack, static_cast(PopCount)); + } + for (int I = 0; I < PushCount; ++I) { + pushTop(); + } + break; + } + } + + ++PC; + } + } + + // Initialize per-block entry vectors. Function-entry block starts empty, + // dynamic-jump-target candidates start at top (U256) for soundness, and + // every other block starts at bottom (U64). + void seedRangeEntryVectors() { + for (auto &[EntryPC, Info] : BlockInfos) { + (void)EntryPC; + Info.EntryStackRanges.clear(); + if (Info.ResolvedEntryStackDepth < 0 || Info.HasInconsistentEntryDepth) { + continue; + } + const size_t Depth = static_cast(Info.ResolvedEntryStackDepth); + if (EntryPC == EntryBlockPC) { + // Function entry: stack is empty. Depth is 0 unless live-in prefix + // analysis raised it; in that case treat unknowns as U256. + Info.EntryStackRanges.assign(Depth, EVMValueRange::U256); + continue; + } + const EVMValueRange Init = + Info.IsDynamicJumpTargetCandidate + ? EVMValueRange::U256 // top — sound for unenumerated dyn-jump + : EVMValueRange::U64; // bottom — will widen via meet + Info.EntryStackRanges.assign(Depth, Init); + } + } + + // CFG worklist propagation. Compute each block's exit state from its entry + // state via the per-opcode transfer function, then meet into every static + // successor's entry state. A successor whose entry vector changed is + // requeued. Convergence: lattice height is 3, so each slot can change at + // most twice. + void runRangeAnalysis(const uint8_t *Bytecode, size_t BytecodeSize) { + seedRangeEntryVectors(); + + std::queue WorkList; + std::map InQueue; + for (const auto &[EntryPC, Info] : BlockInfos) { + (void)Info; + WorkList.push(EntryPC); + InQueue[EntryPC] = true; + } + + // Reuse a single ExitStack buffer across worklist iterations to avoid + // malloc/free per block visit on pathological CFGs. + std::vector ExitStack; + while (!WorkList.empty()) { + uint64_t BlockPC = WorkList.front(); + WorkList.pop(); + InQueue[BlockPC] = false; + + auto It = BlockInfos.find(BlockPC); + if (It == BlockInfos.end()) { + continue; + } + BlockInfo &Info = It->second; + if (Info.ResolvedEntryStackDepth < 0 || Info.HasInconsistentEntryDepth || + Info.HasUndefinedInstr) { + continue; + } + + ExitStack = Info.EntryStackRanges; + applyRangeTransferForBlock(Info, Bytecode, BytecodeSize, ExitStack); + + for (uint64_t Succ : Info.Successors) { + auto SuccIt = BlockInfos.find(Succ); + if (SuccIt == BlockInfos.end()) { + continue; + } + BlockInfo &SuccInfo = SuccIt->second; + if (SuccInfo.ResolvedEntryStackDepth < 0 || + SuccInfo.HasInconsistentEntryDepth) { + continue; + } + + const size_t SuccDepth = + static_cast(SuccInfo.ResolvedEntryStackDepth); + // seedRangeEntryVectors already sizes every block with + // ResolvedEntryStackDepth >= 0 correctly; this branch is unreachable. + ZEN_ASSERT(SuccInfo.EntryStackRanges.size() == SuccDepth); + + // Producer's exit depth and successor's entry depth are linked by + // resolveEntryDepths and must match for every block pair reaching this + // point (both have ResolvedEntryStackDepth >= 0 and consistent depth). + ZEN_ASSERT(ExitStack.size() == SuccDepth); + // Meet the producer's exit stack into the successor's entry stack. + bool Changed = false; + for (size_t I = 0; I < SuccDepth; ++I) { + EVMValueRange Old = SuccInfo.EntryStackRanges[I]; + EVMValueRange New = meetRange(Old, ExitStack[I]); + if (New != Old) { + SuccInfo.EntryStackRanges[I] = New; + Changed = true; + } + } + if (Changed && !InQueue[Succ]) { + WorkList.push(Succ); + InQueue[Succ] = true; + } + } + } + } + std::map BlockInfos; std::map DynamicJumpRegions; std::map JumpDestCanonicalPCs; uint64_t EntryBlockPC = 0; bool HasUnknownDynamicJump = false; evmc_revision Revision = zen::evm::DEFAULT_REVISION; + const evmc_instruction_metrics *InstructionMetrics = nullptr; + const char *const *InstructionNames = nullptr; JITSuitabilityResult JITResult; }; diff --git a/src/compiler/evm_frontend/evm_lifted_stack_lifter.h b/src/compiler/evm_frontend/evm_lifted_stack_lifter.h index fd507dfc3..d6db0fee1 100644 --- a/src/compiler/evm_frontend/evm_lifted_stack_lifter.h +++ b/src/compiler/evm_frontend/evm_lifted_stack_lifter.h @@ -114,7 +114,12 @@ template class EVMLiftedStackLifter { EntryState.EntryOperands.reserve( static_cast(BlockInfo.FullEntryStateDepth)); for (int32_t Depth = 0; Depth < BlockInfo.FullEntryStateDepth; ++Depth) { - EntryState.EntryOperands.push_back(Builder.createStackEntryOperand()); + EVMValueRange SlotRange = + (static_cast(Depth) < BlockInfo.EntryStackRanges.size()) + ? BlockInfo.EntryStackRanges[Depth] + : EVMValueRange::U256; + EntryState.EntryOperands.push_back( + Builder.createStackEntryOperand(SlotRange)); } EntryState.ResolvedEntryState = makeVirtualStackState(EntryState.EntryOperands); diff --git a/src/compiler/evm_frontend/evm_mir_compiler.cpp b/src/compiler/evm_frontend/evm_mir_compiler.cpp index 3b04a5784..c54879c77 100644 --- a/src/compiler/evm_frontend/evm_mir_compiler.cpp +++ b/src/compiler/evm_frontend/evm_mir_compiler.cpp @@ -1006,12 +1006,15 @@ void EVMMirBuilder::setTrackedStackDepth(uint32_t Depth) { StackTopVar->getVarIdx()); } -typename EVMMirBuilder::Operand EVMMirBuilder::createStackEntryOperand() { +typename EVMMirBuilder::Operand +EVMMirBuilder::createStackEntryOperand(ValueRange Range) { U256Var Vars = {}; for (size_t I = 0; I < EVM_ELEMENTS_COUNT; ++I) { Vars[I] = CurFunc->createVariable(&Ctx.I64Type); } - return Operand(Vars, EVMType::UINT256); + Operand Op(Vars, EVMType::UINT256); + Op.setRange(Range); + return Op; } void EVMMirBuilder::assignStackEntryOperand(const Operand &Dest, diff --git a/src/compiler/evm_frontend/evm_mir_compiler.h b/src/compiler/evm_frontend/evm_mir_compiler.h index 65f35c745..ceeb7fc73 100644 --- a/src/compiler/evm_frontend/evm_mir_compiler.h +++ b/src/compiler/evm_frontend/evm_mir_compiler.h @@ -6,6 +6,7 @@ #include "action/vm_eval_stack.h" #include "compiler/context.h" +#include "compiler/evm_frontend/evm_value_range.h" #include "compiler/mir/function.h" #include "compiler/mir/instructions.h" #include "compiler/mir/pointer.h" @@ -124,11 +125,7 @@ class EVMMirBuilder final { // Range classification for u256 operands. Narrower ranges enable // single-instruction fast paths instead of expensive multi-limb arithmetic. - enum class ValueRange : uint8_t { - U64, // Fits in 64 bits (limbs [1..3] == 0) - U128, // Fits in 128 bits (limbs [2..3] == 0) - U256, // Full 256 bits — conservative default - }; + using ValueRange = EVMValueRange; EVMMirBuilder(CompilerContext &Context, MFunction &MFunc); @@ -257,6 +254,7 @@ class EVMMirBuilder final { // Provable value range — narrower ranges enable fast arithmetic paths ValueRange getRange() const { return Range; } + void setRange(ValueRange NewRange) { Range = NewRange; } // Check whether both operands provably fit in u64 static bool bothFitU64(const Operand &A, const Operand &B) { @@ -310,7 +308,7 @@ class EVMMirBuilder final { void stackSet(int32_t IndexFromTop, Operand SetValue); Operand stackGet(int32_t IndexFromTop); void setTrackedStackDepth(uint32_t Depth); - Operand createStackEntryOperand(); + Operand createStackEntryOperand(ValueRange Range = ValueRange::U256); void assignStackEntryOperand(const Operand &Dest, const Operand &Value); Operand prepareStackPhiIncoming(const Operand &Value); void registerCurrentBlockPC(uint64_t BlockPC); diff --git a/src/compiler/evm_frontend/evm_value_range.h b/src/compiler/evm_frontend/evm_value_range.h new file mode 100644 index 000000000..01cab3a1f --- /dev/null +++ b/src/compiler/evm_frontend/evm_value_range.h @@ -0,0 +1,23 @@ +// Copyright (C) 2025 the DTVM authors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +#ifndef EVM_FRONTEND_EVM_VALUE_RANGE_H +#define EVM_FRONTEND_EVM_VALUE_RANGE_H + +#include + +namespace COMPILER { + +// Range classification for u256 operands. Narrower ranges enable +// single-instruction fast paths instead of expensive multi-limb arithmetic. +// Shared between the EVM IR builder's `Operand` abstraction and EVMAnalyzer's +// per-block-entry stack-slot range tracking. +enum class EVMValueRange : uint8_t { + U64, // Fits in 64 bits (limbs [1..3] == 0) + U128, // Fits in 128 bits (limbs [2..3] == 0) + U256, // Full 256 bits — conservative default +}; + +} // namespace COMPILER + +#endif // EVM_FRONTEND_EVM_VALUE_RANGE_H diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt index 973ef3a2f..49c19ec43 100644 --- a/src/tests/CMakeLists.txt +++ b/src/tests/CMakeLists.txt @@ -62,6 +62,7 @@ if(ZEN_ENABLE_SPEC_TEST) add_executable(evmInterpTests evm_interp_tests.cpp) if(ZEN_ENABLE_MULTIPASS_JIT) add_executable(evmJitFrontendTests evm_jit_frontend_tests.cpp) + add_executable(evmRangeAnalyzerTests evm_range_analyzer_tests.cpp) endif() add_executable( solidityContractTests evm_precompiles.hpp solidity_contract_tests.cpp @@ -101,6 +102,7 @@ if(ZEN_ENABLE_SPEC_TEST) target_compile_options(evmInterpTests PRIVATE -fsanitize=address) if(ZEN_ENABLE_MULTIPASS_JIT) target_compile_options(evmJitFrontendTests PRIVATE -fsanitize=address) + target_compile_options(evmRangeAnalyzerTests PRIVATE -fsanitize=address) endif() target_compile_options(solidityContractTests PRIVATE -fsanitize=address) target_compile_options(mptCompareCpp PRIVATE -fsanitize=address) @@ -130,6 +132,11 @@ if(ZEN_ENABLE_SPEC_TEST) PRIVATE compiler dtvmcore gtest_main -fsanitize=address PUBLIC ${GTEST_BOTH_LIBRARIES} ) + target_link_libraries( + evmRangeAnalyzerTests + PRIVATE compiler dtvmcore gtest_main -fsanitize=address + PUBLIC ${GTEST_BOTH_LIBRARIES} + ) endif() target_link_libraries( solidityContractTests @@ -187,6 +194,12 @@ if(ZEN_ENABLE_SPEC_TEST) -static-libasan PUBLIC ${GTEST_BOTH_LIBRARIES} ) + target_link_libraries( + evmRangeAnalyzerTests + PRIVATE compiler dtvmcore gtest_main -fsanitize=address + -static-libasan + PUBLIC ${GTEST_BOTH_LIBRARIES} + ) endif() target_link_libraries( solidityContractTests @@ -251,6 +264,11 @@ if(ZEN_ENABLE_SPEC_TEST) PRIVATE compiler dtvmcore gtest_main PUBLIC ${GTEST_BOTH_LIBRARIES} ) + target_link_libraries( + evmRangeAnalyzerTests + PRIVATE compiler dtvmcore gtest_main + PUBLIC ${GTEST_BOTH_LIBRARIES} + ) endif() target_link_libraries( solidityContractTests @@ -294,6 +312,7 @@ if(ZEN_ENABLE_SPEC_TEST) add_test(NAME evmInterpTests COMMAND evmInterpTests) if(ZEN_ENABLE_MULTIPASS_JIT) add_test(NAME evmJitFrontendTests COMMAND evmJitFrontendTests) + add_test(NAME evmRangeAnalyzerTests COMMAND evmRangeAnalyzerTests) endif() add_test( NAME solidityContractTests diff --git a/src/tests/evm_jit_frontend_tests.cpp b/src/tests/evm_jit_frontend_tests.cpp index fc3c2f88b..546208e38 100644 --- a/src/tests/evm_jit_frontend_tests.cpp +++ b/src/tests/evm_jit_frontend_tests.cpp @@ -79,6 +79,8 @@ struct MockOperand { *Slot = Other.resolvedValue(); } + void setRange(COMPILER::EVMValueRange) {} + private: U256Value Value = {0, 0, 0, 0}; bool Constant = false; @@ -179,7 +181,8 @@ class MockEVMBuilder { } } - Operand createStackEntryOperand() { + Operand createStackEntryOperand( + COMPILER::EVMValueRange = COMPILER::EVMValueRange::U256) { return Operand(std::make_shared( MockOperand::U256Value{0, 0, 0, 0})); } diff --git a/src/tests/evm_range_analyzer_tests.cpp b/src/tests/evm_range_analyzer_tests.cpp new file mode 100644 index 000000000..5b1a1e87d --- /dev/null +++ b/src/tests/evm_range_analyzer_tests.cpp @@ -0,0 +1,1013 @@ +// Copyright (C) 2025 the DTVM authors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +#include "compiler/evm_frontend/evm_analyzer.h" + +#include + +#include +#include +#include +#include + +namespace COMPILER { +// GTest finds this overload via ADL on the enum class's namespace, producing +// readable failure output like "Which is: U256" instead of raw bytes. +inline void PrintTo(EVMValueRange R, std::ostream *Os) { + switch (R) { + case EVMValueRange::U64: + *Os << "U64"; + return; + case EVMValueRange::U128: + *Os << "U128"; + return; + case EVMValueRange::U256: + *Os << "U256"; + return; + } + *Os << "UNKNOWN(" << static_cast(R) << ")"; +} +} // namespace COMPILER + +namespace { + +using COMPILER::EVMAnalyzer; +using COMPILER::EVMValueRange; + +EVMAnalyzer analyzeBytecode(const std::vector &Bytecode) { + EVMAnalyzer Analyzer(EVMC_CANCUN); + const uint8_t *Data = Bytecode.empty() ? nullptr : Bytecode.data(); + Analyzer.analyze(Data, Bytecode.size()); + return Analyzer; +} + +const EVMAnalyzer::BlockInfo *findBlock(const EVMAnalyzer &Analyzer, + uint64_t EntryPC) { + const auto &Blocks = Analyzer.getBlockInfos(); + auto It = Blocks.find(EntryPC); + if (It == Blocks.end()) { + return nullptr; + } + return &It->second; +} + +// Assertion helper: block must exist with a resolved positive entry depth and +// the top of its entry stack must equal Expected. Use this instead of nested +// `if (Block != nullptr) ...` guards which silently pass if the analyzer +// stops materializing the block. +void assertEntryTop(const EVMAnalyzer::BlockInfo *Block, + EVMValueRange Expected) { + ASSERT_NE(Block, nullptr); + ASSERT_GE(Block->ResolvedEntryStackDepth, 0); + ASSERT_FALSE(Block->EntryStackRanges.empty()); + EXPECT_EQ(Block->EntryStackRanges.back(), Expected); +} + +// Assertion helper: block must exist, but its entry depth is unresolved (-1) +// or its EntryStackRanges is empty. Use for dead-block / unresolved-block +// tests that document expected non-materialization. +void assertNoEntryState(const EVMAnalyzer::BlockInfo *Block) { + ASSERT_NE(Block, nullptr); + EXPECT_TRUE(Block->ResolvedEntryStackDepth < 0 || + Block->EntryStackRanges.empty()); +} + +struct CrossJoinBytecode { + std::vector Bytecode; + uint64_t JumpDestPC; +}; + +// "PUSH1 0 NOT PUSH1 5 PUSH1 10 JUMP JUMPDEST" -- divisor on +// stack-bottom (-1 = U256), dividend on top (5 = U64). After SDIV/SMOD, the +// single stack slot at the JUMPDEST entry is the result. +CrossJoinBytecode buildCrossJoin(uint8_t Op) { + return CrossJoinBytecode{{0x60, 0x00, // PUSH1 0 + 0x19, // NOT + 0x60, 0x05, // PUSH1 5 + Op, // SDIV (0x05) or SMOD (0x07) + 0x60, 0x0a, // PUSH1 10 + 0x56, // JUMP + 0xfe, // INVALID padding (PC = 9) + 0x5b}, // JUMPDEST (PC = 10) + 10}; +} + +} // namespace + +TEST(EVMRangeAnalyzer, SDivByU256IsU256) { + CrossJoinBytecode Setup = buildCrossJoin(0x05 /* SDIV */); + EVMAnalyzer Analyzer = analyzeBytecode(Setup.Bytecode); + const auto *JumpDest = findBlock(Analyzer, Setup.JumpDestPC); + ASSERT_NE(JumpDest, nullptr); + ASSERT_EQ(JumpDest->EntryStackRanges.size(), 1u); + EXPECT_EQ(JumpDest->EntryStackRanges.back(), EVMValueRange::U256); +} + +TEST(EVMRangeAnalyzer, SModByU256IsU256) { + CrossJoinBytecode Setup = buildCrossJoin(0x07 /* SMOD */); + EVMAnalyzer Analyzer = analyzeBytecode(Setup.Bytecode); + const auto *JumpDest = findBlock(Analyzer, Setup.JumpDestPC); + ASSERT_NE(JumpDest, nullptr); + ASSERT_EQ(JumpDest->EntryStackRanges.size(), 1u); + EXPECT_EQ(JumpDest->EntryStackRanges.back(), EVMValueRange::U256); +} + +TEST(EVMRangeAnalyzer, SDivU256DividendIsU256) { + // Dividend is the U256 (-1), Divisor is the U64 (5). Helper should still + // widen result to U256 because dividend's bit 255 makes the value + // signed-negative. Catches regressions that drop the symmetric branch. + // + // PUSH1 5 (divisor, U64, bottom) PUSH1 0 NOT (dividend, U256, top) + // SDIV PUSH1 10 JUMP JUMPDEST + std::vector Bytecode = { + 0x60, 0x05, // PUSH1 5 (divisor, U64, bottom) + 0x60, 0x00, // PUSH1 0 + 0x19, // NOT (dividend, U256, top) + 0x05, // SDIV + 0x60, 0x0a, // PUSH1 10 + 0x56, // JUMP + 0xfe, // INVALID padding (PC = 9) + 0x5b}; // JUMPDEST (PC = 10) + EVMAnalyzer Analyzer = analyzeBytecode(Bytecode); + const auto *JumpDest = findBlock(Analyzer, 10); + ASSERT_NE(JumpDest, nullptr); + ASSERT_EQ(JumpDest->EntryStackRanges.size(), 1u); + EXPECT_EQ(JumpDest->EntryStackRanges.back(), EVMValueRange::U256); +} + +namespace { + +// Build " PUSH1 5 JUMP JUMPDEST" so the JUMPDEST inherits the +// host-opcode result as its single entry slot. +CrossJoinBytecode buildHostOpCrossJoin(uint8_t HostOp) { + return CrossJoinBytecode{ + {HostOp, // 0: TIMESTAMP / NUMBER / GASLIMIT / CHAINID + 0x60, 0x05, // 1: PUSH1 5 + 0x56, // 3: JUMP + 0xfe, // 4: INVALID pad + 0x5b}, // 5: JUMPDEST + 5}; +} + +} // namespace + +TEST(EVMRangeAnalyzer, TimestampIsU256) { + CrossJoinBytecode Setup = buildHostOpCrossJoin(0x42 /* TIMESTAMP */); + EVMAnalyzer Analyzer = analyzeBytecode(Setup.Bytecode); + const auto *JumpDest = findBlock(Analyzer, Setup.JumpDestPC); + ASSERT_NE(JumpDest, nullptr); + ASSERT_EQ(JumpDest->EntryStackRanges.size(), 1u); + EXPECT_EQ(JumpDest->EntryStackRanges.back(), EVMValueRange::U256); +} + +TEST(EVMRangeAnalyzer, NumberIsU256) { + CrossJoinBytecode Setup = buildHostOpCrossJoin(0x43 /* NUMBER */); + EVMAnalyzer Analyzer = analyzeBytecode(Setup.Bytecode); + const auto *JumpDest = findBlock(Analyzer, Setup.JumpDestPC); + ASSERT_NE(JumpDest, nullptr); + ASSERT_EQ(JumpDest->EntryStackRanges.size(), 1u); + EXPECT_EQ(JumpDest->EntryStackRanges.back(), EVMValueRange::U256); +} + +TEST(EVMRangeAnalyzer, GasLimitIsU256) { + CrossJoinBytecode Setup = buildHostOpCrossJoin(0x45 /* GASLIMIT */); + EVMAnalyzer Analyzer = analyzeBytecode(Setup.Bytecode); + const auto *JumpDest = findBlock(Analyzer, Setup.JumpDestPC); + ASSERT_NE(JumpDest, nullptr); + ASSERT_EQ(JumpDest->EntryStackRanges.size(), 1u); + EXPECT_EQ(JumpDest->EntryStackRanges.back(), EVMValueRange::U256); +} + +TEST(EVMRangeAnalyzer, ChainIdIsU256) { + CrossJoinBytecode Setup = buildHostOpCrossJoin(0x46 /* CHAINID */); + EVMAnalyzer Analyzer = analyzeBytecode(Setup.Bytecode); + const auto *JumpDest = findBlock(Analyzer, Setup.JumpDestPC); + ASSERT_NE(JumpDest, nullptr); + ASSERT_EQ(JumpDest->EntryStackRanges.size(), 1u); + EXPECT_EQ(JumpDest->EntryStackRanges.back(), EVMValueRange::U256); +} + +TEST(EVMRangeAnalyzer, CreateAddressIsU256) { + // CREATE pushes the created contract address (20 bytes / 160 bits) or 0 + // on failure -- not a 0/1 success bool. Verify analyzer widens to U256 + // so the result cannot reach a bothFitU64 fast path on the lifted path. + // + // PUSH1 0 (length, bottom) PUSH1 0 (offset) PUSH1 0 (value, top) CREATE + // PUSH1 11 JUMP JUMPDEST + std::vector Bytecode = { + 0x60, 0x00, // PC 0-1: PUSH1 0 (length, bottom of stack) + 0x60, 0x00, // PC 2-3: PUSH1 0 (offset) + 0x60, 0x00, // PC 4-5: PUSH1 0 (value, top before CREATE pops) + 0xf0, // PC 6: CREATE + 0x60, 0x0b, // PC 7-8: PUSH1 11 + 0x56, // PC 9: JUMP + 0xfe, // PC 10: INVALID padding + 0x5b}; // PC 11: JUMPDEST + EVMAnalyzer Analyzer = analyzeBytecode(Bytecode); + const auto *JumpDest = findBlock(Analyzer, 11); + ASSERT_NE(JumpDest, nullptr); + ASSERT_EQ(JumpDest->EntryStackRanges.size(), 1u); + EXPECT_EQ(JumpDest->EntryStackRanges.back(), EVMValueRange::U256); +} + +TEST(EVMRangeAnalyzer, Create2AddressIsU256) { + // Same as CreateAddressIsU256 but with CREATE2 (pops 4 args incl. salt). + // + // PUSH1 0 x4 (salt, length, offset, value-top) CREATE2 PUSH1 13 JUMP + // JUMPDEST + std::vector Bytecode = { + 0x60, 0x00, // PC 0-1: PUSH1 0 (salt, bottom) + 0x60, 0x00, // PC 2-3: PUSH1 0 (length) + 0x60, 0x00, // PC 4-5: PUSH1 0 (offset) + 0x60, 0x00, // PC 6-7: PUSH1 0 (value, top before CREATE2 pops) + 0xf5, // PC 8: CREATE2 + 0x60, 0x0d, // PC 9-10: PUSH1 13 + 0x56, // PC 11: JUMP + 0xfe, // PC 12: INVALID padding + 0x5b}; // PC 13: JUMPDEST + EVMAnalyzer Analyzer = analyzeBytecode(Bytecode); + const auto *JumpDest = findBlock(Analyzer, 13); + ASSERT_NE(JumpDest, nullptr); + ASSERT_EQ(JumpDest->EntryStackRanges.size(), 1u); + EXPECT_EQ(JumpDest->EntryStackRanges.back(), EVMValueRange::U256); +} + +namespace { + +// Build "PUSH PUSH1 JUMP JUMPDEST" so the +// JUMPDEST inherits the literal as its single entry slot. +std::vector buildPushLiteralAndJump(uint8_t PushOp, + const std::vector &Bytes, + uint8_t PadCount = 1) { + std::vector Code; + Code.push_back(PushOp); + Code.insert(Code.end(), Bytes.begin(), Bytes.end()); + // JumpDest PC = 1 + Bytes + 2 (PUSH1 NN) + 1 (JUMP) + PadCount + const uint8_t JumpDestPC = + static_cast(1 + Bytes.size() + 2 + 1 + PadCount); + Code.push_back(0x60); // PUSH1 + Code.push_back(JumpDestPC); + Code.push_back(0x56); // JUMP + for (uint8_t I = 0; I < PadCount; ++I) { + Code.push_back(0xfe); // INVALID pad + } + Code.push_back(0x5b); // JUMPDEST + return Code; +} + +uint64_t lastJumpDestPC(const std::vector &Code) { + return static_cast(Code.size() - 1); +} + +// Append "PUSH1 JUMP JUMPDEST" to `Code`, computing +// the JUMPDEST PC after the JUMP+pad. Returns the final JUMPDEST PC. +uint64_t appendJumpToFreshJumpDest(std::vector &Code) { + // JUMPDEST will be at Code.size() + 2 (PUSH1+arg) + 1 (JUMP) + 1 (pad) = +4. + const uint8_t JumpDestPC = static_cast(Code.size() + 4); + Code.push_back(0x60); // PUSH1 + Code.push_back(JumpDestPC); + Code.push_back(0x56); // JUMP + Code.push_back(0xfe); // INVALID pad + Code.push_back(0x5b); // JUMPDEST + return JumpDestPC; +} + +// Append a PUSH instruction with the given literal bytes to `Code`. +void appendPush(std::vector &Code, uint8_t PushOp, + const std::vector &Bytes) { + Code.push_back(PushOp); + Code.insert(Code.end(), Bytes.begin(), Bytes.end()); +} + +// Convenience literals for common boundary-magnitude push values. +const std::vector &u64MaxLiteralPush8() { + static const std::vector V(8, 0xff); + return V; +} +const std::vector &u128MaxLiteralPush16() { + static const std::vector V(16, 0xff); + return V; +} +const std::vector &u256MaxLiteralPush32() { + static const std::vector V(32, 0xff); + return V; +} + +} // namespace + +// --------------------------------------------------------------------------- +// Group A — Per-opcode transfer rules +// --------------------------------------------------------------------------- + +TEST(EVMRangeAnalyzer, PushLiteralU64BoundaryMax) { + // PUSH8 FF*8 = 2^64 - 1 — still fits in U64. + auto Code = + buildPushLiteralAndJump(0x67, // PUSH8 + {0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}); + EVMAnalyzer Analyzer = analyzeBytecode(Code); + const auto *JumpDest = findBlock(Analyzer, lastJumpDestPC(Code)); + ASSERT_NE(JumpDest, nullptr); + ASSERT_EQ(JumpDest->EntryStackRanges.size(), 1u); + EXPECT_EQ(JumpDest->EntryStackRanges.back(), EVMValueRange::U64); +} + +TEST(EVMRangeAnalyzer, PushLiteralU128BoundaryMin) { + // PUSH9 01 00*8 = exactly 2^64 — first byte over the U64 frontier. + auto Code = buildPushLiteralAndJump( + 0x68, // PUSH9 + {0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}); + EVMAnalyzer Analyzer = analyzeBytecode(Code); + const auto *JumpDest = findBlock(Analyzer, lastJumpDestPC(Code)); + ASSERT_NE(JumpDest, nullptr); + ASSERT_EQ(JumpDest->EntryStackRanges.size(), 1u); + EXPECT_EQ(JumpDest->EntryStackRanges.back(), EVMValueRange::U128); +} + +TEST(EVMRangeAnalyzer, PushLiteralU128BoundaryMax) { + // PUSH16 FF*16 = 2^128 - 1 — still fits in U128. + auto Code = + buildPushLiteralAndJump(0x6f, // PUSH16 + {0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}); + EVMAnalyzer Analyzer = analyzeBytecode(Code); + const auto *JumpDest = findBlock(Analyzer, lastJumpDestPC(Code)); + ASSERT_NE(JumpDest, nullptr); + ASSERT_EQ(JumpDest->EntryStackRanges.size(), 1u); + EXPECT_EQ(JumpDest->EntryStackRanges.back(), EVMValueRange::U128); +} + +TEST(EVMRangeAnalyzer, PushLiteralU256BoundaryMin) { + // PUSH17 01 00*16 = exactly 2^128 — first byte over the U128 frontier. + auto Code = buildPushLiteralAndJump(0x70, // PUSH17 + {0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00}); + EVMAnalyzer Analyzer = analyzeBytecode(Code); + const auto *JumpDest = findBlock(Analyzer, lastJumpDestPC(Code)); + ASSERT_NE(JumpDest, nullptr); + ASSERT_EQ(JumpDest->EntryStackRanges.size(), 1u); + EXPECT_EQ(JumpDest->EntryStackRanges.back(), EVMValueRange::U256); +} + +TEST(EVMRangeAnalyzer, PushLiteralAllZeroPush32) { + // PUSH32 00*32 = 0 — all-zero prefix means U64. + auto Code = buildPushLiteralAndJump(0x7f, // PUSH32 + std::vector(32, 0x00)); + EVMAnalyzer Analyzer = analyzeBytecode(Code); + const auto *JumpDest = findBlock(Analyzer, lastJumpDestPC(Code)); + ASSERT_NE(JumpDest, nullptr); + ASSERT_EQ(JumpDest->EntryStackRanges.size(), 1u); + EXPECT_EQ(JumpDest->EntryStackRanges.back(), EVMValueRange::U64); +} + +TEST(EVMRangeAnalyzer, AddWidens) { + // PUSH8 a PUSH8 b ADD → widen(meet(U64,U64)) = U128. + std::vector Code; + appendPush(Code, 0x67, u64MaxLiteralPush8()); // PUSH8 a + appendPush(Code, 0x67, u64MaxLiteralPush8()); // PUSH8 b + Code.push_back(0x01); // ADD + uint64_t Pc = appendJumpToFreshJumpDest(Code); + EVMAnalyzer Analyzer = analyzeBytecode(Code); + const auto *JumpDest = findBlock(Analyzer, Pc); + ASSERT_NE(JumpDest, nullptr); + ASSERT_EQ(JumpDest->EntryStackRanges.size(), 1u); + EXPECT_EQ(JumpDest->EntryStackRanges.back(), EVMValueRange::U128); +} + +TEST(EVMRangeAnalyzer, AddU128PlusU128) { + // PUSH16 a PUSH16 b ADD → widen(meet(U128,U128)) = U256. + std::vector Code; + appendPush(Code, 0x6f, u128MaxLiteralPush16()); + appendPush(Code, 0x6f, u128MaxLiteralPush16()); + Code.push_back(0x01); // ADD + uint64_t Pc = appendJumpToFreshJumpDest(Code); + EVMAnalyzer Analyzer = analyzeBytecode(Code); + const auto *JumpDest = findBlock(Analyzer, Pc); + ASSERT_NE(JumpDest, nullptr); + ASSERT_EQ(JumpDest->EntryStackRanges.size(), 1u); + EXPECT_EQ(JumpDest->EntryStackRanges.back(), EVMValueRange::U256); +} + +TEST(EVMRangeAnalyzer, MulWidens) { + // PUSH8 a PUSH8 b MUL → widen(meet(U64,U64)) = U128. + std::vector Code; + appendPush(Code, 0x67, u64MaxLiteralPush8()); + appendPush(Code, 0x67, u64MaxLiteralPush8()); + Code.push_back(0x02); // MUL + uint64_t Pc = appendJumpToFreshJumpDest(Code); + EVMAnalyzer Analyzer = analyzeBytecode(Code); + const auto *JumpDest = findBlock(Analyzer, Pc); + ASSERT_NE(JumpDest, nullptr); + ASSERT_EQ(JumpDest->EntryStackRanges.size(), 1u); + EXPECT_EQ(JumpDest->EntryStackRanges.back(), EVMValueRange::U128); +} + +TEST(EVMRangeAnalyzer, DivUnsigned) { + // PUSH32 div (U256, bottom) PUSH8 dvd (U64, top = dividend) DIV + // → result = Dividend's range = U64. + std::vector Code; + appendPush(Code, 0x7f, u256MaxLiteralPush32()); + appendPush(Code, 0x67, {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05}); + Code.push_back(0x04); // DIV + uint64_t Pc = appendJumpToFreshJumpDest(Code); + EVMAnalyzer Analyzer = analyzeBytecode(Code); + const auto *JumpDest = findBlock(Analyzer, Pc); + ASSERT_NE(JumpDest, nullptr); + ASSERT_EQ(JumpDest->EntryStackRanges.size(), 1u); + EXPECT_EQ(JumpDest->EntryStackRanges.back(), EVMValueRange::U64); +} + +TEST(EVMRangeAnalyzer, SDivU64ByU128IsU64) { + // PUSH16 d (U128 divisor) PUSH8 n (U64 dividend) SDIV + // Both non-U256 → signedDivModRange returns Dividend's range = U64. + std::vector Code; + appendPush(Code, 0x6f, + {0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00}); + appendPush(Code, 0x67, {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05}); + Code.push_back(0x05); // SDIV + uint64_t Pc = appendJumpToFreshJumpDest(Code); + EVMAnalyzer Analyzer = analyzeBytecode(Code); + const auto *JumpDest = findBlock(Analyzer, Pc); + ASSERT_NE(JumpDest, nullptr); + ASSERT_EQ(JumpDest->EntryStackRanges.size(), 1u); + EXPECT_EQ(JumpDest->EntryStackRanges.back(), EVMValueRange::U64); +} + +TEST(EVMRangeAnalyzer, AddModFromModulus) { + // ADDMOD pops [a, b, N] top-down → push order: N (bottom), b, a (top). + // PUSH1 N (U64) PUSH32 b PUSH32 a ADDMOD → result = N's range = U64. + std::vector Code; + appendPush(Code, 0x60, {0x07}); // PUSH1 N + appendPush(Code, 0x7f, u256MaxLiteralPush32()); // PUSH32 b + appendPush(Code, 0x7f, u256MaxLiteralPush32()); // PUSH32 a + Code.push_back(0x08); // ADDMOD + uint64_t Pc = appendJumpToFreshJumpDest(Code); + EVMAnalyzer Analyzer = analyzeBytecode(Code); + const auto *JumpDest = findBlock(Analyzer, Pc); + ASSERT_NE(JumpDest, nullptr); + ASSERT_EQ(JumpDest->EntryStackRanges.size(), 1u); + EXPECT_EQ(JumpDest->EntryStackRanges.back(), EVMValueRange::U64); +} + +TEST(EVMRangeAnalyzer, IsZeroIsU64) { + // PUSH32 x ISZERO → boolean U64 result. + std::vector Code; + appendPush(Code, 0x7f, u256MaxLiteralPush32()); + Code.push_back(0x15); // ISZERO + uint64_t Pc = appendJumpToFreshJumpDest(Code); + EVMAnalyzer Analyzer = analyzeBytecode(Code); + const auto *JumpDest = findBlock(Analyzer, Pc); + ASSERT_NE(JumpDest, nullptr); + ASSERT_EQ(JumpDest->EntryStackRanges.size(), 1u); + EXPECT_EQ(JumpDest->EntryStackRanges.back(), EVMValueRange::U64); +} + +TEST(EVMRangeAnalyzer, LtIsU64) { + // PUSH32 a PUSH32 b LT → boolean U64 result. + std::vector Code; + appendPush(Code, 0x7f, u256MaxLiteralPush32()); + appendPush(Code, 0x7f, u256MaxLiteralPush32()); + Code.push_back(0x10); // LT + uint64_t Pc = appendJumpToFreshJumpDest(Code); + EVMAnalyzer Analyzer = analyzeBytecode(Code); + const auto *JumpDest = findBlock(Analyzer, Pc); + ASSERT_NE(JumpDest, nullptr); + ASSERT_EQ(JumpDest->EntryStackRanges.size(), 1u); + EXPECT_EQ(JumpDest->EntryStackRanges.back(), EVMValueRange::U64); +} + +TEST(EVMRangeAnalyzer, AndShortCircuitU64) { + // PUSH8 m (U64) PUSH32 v (U256) AND — short-circuits on U64 operand → U64. + std::vector Code; + appendPush(Code, 0x67, u64MaxLiteralPush8()); + appendPush(Code, 0x7f, u256MaxLiteralPush32()); + Code.push_back(0x16); // AND + uint64_t Pc = appendJumpToFreshJumpDest(Code); + EVMAnalyzer Analyzer = analyzeBytecode(Code); + const auto *JumpDest = findBlock(Analyzer, Pc); + ASSERT_NE(JumpDest, nullptr); + ASSERT_EQ(JumpDest->EntryStackRanges.size(), 1u); + EXPECT_EQ(JumpDest->EntryStackRanges.back(), EVMValueRange::U64); +} + +TEST(EVMRangeAnalyzer, AndU128AndU256) { + // PUSH16 m (U128) PUSH32 v (U256) AND — neither U64; result = min(U128,U256) + // = U128. + std::vector Code; + appendPush(Code, 0x6f, u128MaxLiteralPush16()); + appendPush(Code, 0x7f, u256MaxLiteralPush32()); + Code.push_back(0x16); // AND + uint64_t Pc = appendJumpToFreshJumpDest(Code); + EVMAnalyzer Analyzer = analyzeBytecode(Code); + const auto *JumpDest = findBlock(Analyzer, Pc); + ASSERT_NE(JumpDest, nullptr); + ASSERT_EQ(JumpDest->EntryStackRanges.size(), 1u); + EXPECT_EQ(JumpDest->EntryStackRanges.back(), EVMValueRange::U128); +} + +TEST(EVMRangeAnalyzer, AndU256AndU256) { + // PUSH32 m (U256) PUSH32 v (U256) AND — min(U256, U256) = U256. + std::vector Code; + appendPush(Code, 0x7f, u256MaxLiteralPush32()); + appendPush(Code, 0x7f, u256MaxLiteralPush32()); + Code.push_back(0x16); // AND + uint64_t Pc = appendJumpToFreshJumpDest(Code); + EVMAnalyzer Analyzer = analyzeBytecode(Code); + const auto *JumpDest = findBlock(Analyzer, Pc); + ASSERT_NE(JumpDest, nullptr); + ASSERT_EQ(JumpDest->EntryStackRanges.size(), 1u); + EXPECT_EQ(JumpDest->EntryStackRanges.back(), EVMValueRange::U256); +} + +TEST(EVMRangeAnalyzer, OrTakesMax) { + // PUSH8 a (U64) PUSH32 b (U256) OR — OR uses meetRange (max) = U256. + std::vector Code; + appendPush(Code, 0x67, u64MaxLiteralPush8()); + appendPush(Code, 0x7f, u256MaxLiteralPush32()); + Code.push_back(0x17); // OR + uint64_t Pc = appendJumpToFreshJumpDest(Code); + EVMAnalyzer Analyzer = analyzeBytecode(Code); + const auto *JumpDest = findBlock(Analyzer, Pc); + ASSERT_NE(JumpDest, nullptr); + ASSERT_EQ(JumpDest->EntryStackRanges.size(), 1u); + EXPECT_EQ(JumpDest->EntryStackRanges.back(), EVMValueRange::U256); +} + +TEST(EVMRangeAnalyzer, ShrPreservesValueRange) { + // EVM SHR pops shift (top), then value. Bytecode order: PUSH value, PUSH + // shift, SHR. Result = value's range. PUSH16 value (U128) + PUSH1 shift + // → U128. + std::vector Code; + appendPush(Code, 0x6f, + {0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00}); // PUSH16 value + appendPush(Code, 0x60, {0x04}); // PUSH1 shift + Code.push_back(0x1c); // SHR + uint64_t Pc = appendJumpToFreshJumpDest(Code); + EVMAnalyzer Analyzer = analyzeBytecode(Code); + const auto *JumpDest = findBlock(Analyzer, Pc); + ASSERT_NE(JumpDest, nullptr); + ASSERT_EQ(JumpDest->EntryStackRanges.size(), 1u); + EXPECT_EQ(JumpDest->EntryStackRanges.back(), EVMValueRange::U128); +} + +TEST(EVMRangeAnalyzer, Keccak256IsU256) { + // PUSH1 offset PUSH1 length KECCAK256 — hash output is U256. + std::vector Code; + appendPush(Code, 0x60, {0x00}); // PUSH1 0 + appendPush(Code, 0x60, {0x20}); // PUSH1 32 + Code.push_back(0x20); // KECCAK256 + uint64_t Pc = appendJumpToFreshJumpDest(Code); + EVMAnalyzer Analyzer = analyzeBytecode(Code); + const auto *JumpDest = findBlock(Analyzer, Pc); + ASSERT_NE(JumpDest, nullptr); + ASSERT_EQ(JumpDest->EntryStackRanges.size(), 1u); + EXPECT_EQ(JumpDest->EntryStackRanges.back(), EVMValueRange::U256); +} + +// --------------------------------------------------------------------------- +// Group B — CFG meet convergence +// --------------------------------------------------------------------------- + +TEST(EVMRangeAnalyzer, StraightlineFallthrough) { + // A static JUMP carries the surviving stack slot into its JUMPDEST target. + // PUSH1 5 leaves a U64 residual below the JUMP target; after JUMP consumes + // the target, the JUMPDEST sees the U64 as its single entry slot. + // + // PC 0: PUSH1 5 (U64 residual) + // PC 2: PUSH1 6 (JUMP target) + // PC 4: JUMP + // PC 5: pad + // PC 6: JUMPDEST + // PC 7: STOP + std::vector Code = {0x60, 0x05, // PUSH1 5 (U64) + 0x60, 0x06, // PUSH1 6 + 0x56, // JUMP + 0xfe, // INVALID pad (PC=5) + 0x5b, // JUMPDEST (PC=6) + 0x00}; // STOP + EVMAnalyzer Analyzer = analyzeBytecode(Code); + const auto *JumpDest = findBlock(Analyzer, 6); + ASSERT_NE(JumpDest, nullptr); + ASSERT_EQ(JumpDest->EntryStackRanges.size(), 1u); + EXPECT_EQ(JumpDest->EntryStackRanges.back(), EVMValueRange::U64); +} + +TEST(EVMRangeAnalyzer, DiamondMeetWidens) { + // B0 conditionally branches; one path pushes U64, the other pushes U256. + // Both static-JUMP to a merge JUMPDEST. Merge entry top = meet(U64,U256) + // = U256. Layout uses explicit placeholders patched after construction. + std::vector C; + // B0 PC=0: PUSH1 1 PUSH1 JUMPI + C.push_back(0x60); + C.push_back(0x01); // PC 0-1 + C.push_back(0x60); + C.push_back(0x00); // PC 2-3 placeholder for taken target + C.push_back(0x57); // PC 4 JUMPI + // Fallthrough B1 PC=5: PUSH1 5 PUSH1 JUMP + C.push_back(0x60); + C.push_back(0x05); // PC 5-6 + C.push_back(0x60); + C.push_back(0x00); // PC 7-8 placeholder for merge target + C.push_back(0x56); // PC 9 JUMP + C.push_back(0xfe); // PC 10 pad + // Taken B2 PC=11: JUMPDEST PUSH32 ... PUSH1 JUMP + const uint8_t TakenPC = static_cast(C.size()); // 11 + C.push_back(0x5b); // JUMPDEST PC=11 + C.push_back(0x7f); // PUSH32 PC=12 + C.push_back(0x01); + for (int I = 0; I < 31; ++I) + C.push_back(0x00); + // After PUSH32: PC = 12 + 32 = 44 + C.push_back(0x60); + C.push_back(0x00); // PC 44-45 placeholder for merge target + C.push_back(0x56); // PC 46 JUMP + C.push_back(0xfe); // PC 47 pad + // Merge B3 PC=48: JUMPDEST STOP + const uint8_t MergePC = static_cast(C.size()); + C.push_back(0x5b); // JUMPDEST PC=MergePC + C.push_back(0x00); // STOP + // Patch placeholders. + C[3] = TakenPC; // taken target + C[8] = MergePC; // fallthrough merge target + C[46] = MergePC; // taken merge target (PUSH1 operand at PC=46) + + EVMAnalyzer Analyzer = analyzeBytecode(C); + const auto *Merge = findBlock(Analyzer, MergePC); + ASSERT_NE(Merge, nullptr); + ASSERT_EQ(Merge->EntryStackRanges.size(), 1u); + EXPECT_EQ(Merge->EntryStackRanges.back(), EVMValueRange::U256); +} + +TEST(EVMRangeAnalyzer, DiamondMultiSlotMeet) { + // 3-deep diamond merge: each predecessor pushes 3 values with distinct + // per-slot ranges, verifying per-slot meet=max independence. + // + // B1 (fallthrough) exits with [U64, U128, U256] + // B2 (taken) exits with [U128, U64, U64 ] + // Merge entry meet [U128, U128, U256] (per-slot max) + std::vector C; + // B0 PC=0: PUSH1 1 PUSH1 JUMPI + C.push_back(0x60); + C.push_back(0x01); // PC 0-1 + C.push_back(0x60); + C.push_back(0x00); // PC 2-3 placeholder taken target + C.push_back(0x57); // PC 4 JUMPI + // B1 fallthrough PC=5: PUSH8 PUSH16 PUSH32 PUSH1 JUMP + C.push_back(0x67); // PC 5 PUSH8 + for (int I = 0; I < 8; ++I) + C.push_back(0xff); // PC 6-13 + C.push_back(0x6f); // PC 14 PUSH16 + for (int I = 0; I < 16; ++I) + C.push_back(0xff); // PC 15-30 + C.push_back(0x7f); // PC 31 PUSH32 + C.push_back(0x01); // PC 32 + for (int I = 0; I < 31; ++I) + C.push_back(0x00); // PC 33-63 + C.push_back(0x60); + C.push_back(0x00); // PC 64-65 placeholder merge target + C.push_back(0x56); // PC 66 JUMP + C.push_back(0xfe); // PC 67 pad + // B2 taken PC=68: JUMPDEST PUSH16 PUSH8 PUSH8 PUSH1 JUMP + const uint8_t TakenPC = static_cast(C.size()); // 68 + C.push_back(0x5b); // PC 68 JUMPDEST + C.push_back(0x6f); // PC 69 PUSH16 + for (int I = 0; I < 16; ++I) + C.push_back(0xff); // PC 70-85 + C.push_back(0x67); // PC 86 PUSH8 + for (int I = 0; I < 8; ++I) + C.push_back(0xff); // PC 87-94 + C.push_back(0x67); // PC 95 PUSH8 + for (int I = 0; I < 8; ++I) + C.push_back(0xff); // PC 96-103 + C.push_back(0x60); + C.push_back(0x00); // PC 104-105 placeholder merge target + C.push_back(0x56); // PC 106 JUMP + // B3 merge PC=107: JUMPDEST STOP + const uint8_t MergePC = static_cast(C.size()); // 107 + C.push_back(0x5b); // JUMPDEST + C.push_back(0x00); // STOP + // Patch placeholders. + C[3] = TakenPC; + C[65] = MergePC; + C[105] = MergePC; + + EVMAnalyzer Analyzer = analyzeBytecode(C); + const auto *Merge = findBlock(Analyzer, MergePC); + ASSERT_NE(Merge, nullptr); + ASSERT_EQ(Merge->EntryStackRanges.size(), 3u); + // Slot 0 (bottom): meet(U64, U128) = U128. + EXPECT_EQ(Merge->EntryStackRanges[0], EVMValueRange::U128); + // Slot 1: meet(U128, U64 ) = U128. + EXPECT_EQ(Merge->EntryStackRanges[1], EVMValueRange::U128); + // Slot 2 (top): meet(U256, U64 ) = U256. + EXPECT_EQ(Merge->EntryStackRanges[2], EVMValueRange::U256); +} + +TEST(EVMRangeAnalyzer, SelfLoopBackEdge) { + // Loop header with a body that preserves the entry slot's range (no + // widening): the back-edge meet converges in one round to a steady state. + // Verifies that worklist analysis terminates and the loop header's entry + // top equals the seed range (U64) rather than U256. + // + // PC 0: PUSH1 1 (seed U64; fall-through into PC 2) + // PC 2: JUMPDEST (loop header) + // PC 3: PUSH1 0 + // PC 5: POP (body brings stack back to [U64]) + // PC 6: PUSH1 2 (constant JUMP target) + // PC 8: JUMP back to PC 2 + std::vector Code = {0x60, 0x01, // PUSH1 1 + 0x5b, // JUMPDEST PC=2 + 0x60, 0x00, // PUSH1 0 + 0x50, // POP + 0x60, 0x02, // PUSH1 2 + 0x56}; // JUMP + EVMAnalyzer Analyzer = analyzeBytecode(Code); + const auto *Loop = findBlock(Analyzer, 2); + ASSERT_NE(Loop, nullptr); + ASSERT_EQ(Loop->EntryStackRanges.size(), 1u); + EXPECT_EQ(Loop->EntryStackRanges.back(), EVMValueRange::U64); +} + +TEST(EVMRangeAnalyzer, JumpIBranchAsymmetry) { + // JUMPI consumes (target, cond) from the top of the stack. Below them, B0 + // leaves PUSH1 5 (U64) as a residual slot visible to both successors. A + // STOP separator between the two JUMPDESTs prevents adjacent-JUMPDEST + // canonicalization, so each successor is materialized independently with + // its own meet from B0. + // + // PC 0: PUSH1 5 (residual U64) + // PC 2: PUSH1 1 (cond) + // PC 4: PUSH1 9 (taken target) + // PC 6: JUMPI + // PC 7: JUMPDEST (fallthrough) + // PC 8: STOP (separator) + // PC 9: JUMPDEST (taken) + std::vector Code = {0x60, 0x05, // PUSH1 5 + 0x60, 0x01, // PUSH1 1 + 0x60, 0x09, // PUSH1 9 + 0x57, // JUMPI + 0x5b, // JUMPDEST PC=7 (fallthrough) + 0x00, // STOP (separator) + 0x5b, // JUMPDEST PC=9 (taken) + 0x00}; // STOP + EVMAnalyzer Analyzer = analyzeBytecode(Code); + const auto *FT = findBlock(Analyzer, 7); + const auto *Taken = findBlock(Analyzer, 9); + // Both successors see the residual U64 slot from B0 after JUMPI consumes + // its 2 operands. Asymmetry: each successor block is materialized + // independently, even though their entry state happens to match here. + assertEntryTop(FT, EVMValueRange::U64); + assertEntryTop(Taken, EVMValueRange::U64); +} + +TEST(EVMRangeAnalyzer, RevertExitDoesNotPropagate) { + // B0 ends in REVERT — has no Successors; nothing to propagate. The + // JUMPDEST at PC=5 is unreachable from any static or dynamic JUMP, so its + // entry state must remain unresolved (depth -1, empty ranges). This pins + // down that REVERT's exit stack does NOT silently flow into the + // textually-adjacent JUMPDEST. + // + // PC 0: PUSH1 0 + // PC 2: PUSH1 0 + // PC 4: REVERT (B0 terminator) + // PC 5: JUMPDEST (unreachable; no JUMP targets it) + // PC 6: STOP + std::vector Code = {0x60, 0x00, // PUSH1 0 + 0x60, 0x00, // PUSH1 0 + 0xfd, // REVERT + 0x5b, // JUMPDEST PC=5 + 0x00}; // STOP + EVMAnalyzer Analyzer = analyzeBytecode(Code); + const auto *Jd = findBlock(Analyzer, 5); + assertNoEntryState(Jd); +} + +TEST(EVMRangeAnalyzer, MultiBackEdgeSelfLoop) { + // Self-loop with two back-edges into the same JUMPDEST (not a nested + // loop — both back-edges target the same loop header). Verifies the + // worklist handles multiple in-edges from a single block and converges + // without oscillation. The body preserves the entry slot (U64), so the + // back-edge meet stays U64 at the fixed point. + // + // PC 0: PUSH1 1 (seed U64, fall-through into PC 2) + // PC 2: JUMPDEST (loop header) + // PC 3: PUSH1 0 (cond=0 for JUMPI; first back-edge taken) + // PC 5: PUSH1 2 (JUMPI target) + // PC 7: JUMPI pops cond + target + // PC 8: PUSH1 2 (constant JUMP target) + // PC 10: JUMP second back-edge + std::vector Code = {0x60, 0x01, // PUSH1 1 + 0x5b, // JUMPDEST PC=2 + 0x60, 0x00, // PUSH1 0 + 0x60, 0x02, // PUSH1 2 + 0x57, // JUMPI + 0x60, 0x02, // PUSH1 2 + 0x56}; // JUMP + EVMAnalyzer Analyzer = analyzeBytecode(Code); + const auto *Loop = findBlock(Analyzer, 2); + ASSERT_NE(Loop, nullptr); + ASSERT_GE(Loop->EntryStackRanges.size(), 1u); + EXPECT_EQ(Loop->EntryStackRanges.back(), EVMValueRange::U64); +} + +TEST(EVMRangeAnalyzer, DeadBlockSkipped) { + // A JUMPDEST that is not reachable from the entry block keeps + // ResolvedEntryStackDepth = -1 and an empty EntryStackRanges. A reachable + // JUMPDEST with a residual U64 below the JUMP target gets entry top = U64. + // + // PC 0: PUSH1 5 (residual U64; survives the JUMP) + // PC 2: PUSH1 6 (JUMP target) + // PC 4: JUMP + // PC 5: pad + // PC 6: JUMPDEST (reachable; entry depth 1) + // PC 7: STOP + // PC 8: JUMPDEST (dead; no JUMP targets it) + std::vector Code = {0x60, 0x05, // PUSH1 5 + 0x60, 0x06, // PUSH1 6 + 0x56, // JUMP + 0xfe, // pad PC=5 + 0x5b, // JUMPDEST PC=6 (reachable) + 0x00, // STOP + 0x5b}; // JUMPDEST PC=8 (dead) + EVMAnalyzer Analyzer = analyzeBytecode(Code); + const auto *Reachable = findBlock(Analyzer, 6); + assertEntryTop(Reachable, EVMValueRange::U64); + const auto *Dead = findBlock(Analyzer, 8); + assertNoEntryState(Dead); +} + +TEST(EVMRangeAnalyzer, WorklistTerminates) { + // Long chain of JUMPDEST blocks linked by constant JUMPs. The worklist + // must terminate and every reachable block must have its entry populated. + // Use 30 chained JUMPDESTs. + std::vector Code; + // Each chain step: JUMPDEST (1 byte) + PUSH1 JUMP (3 bytes) = 4 bytes. + // Initial entry: PUSH1 JUMP JUMPDEST ... + Code.push_back(0x60); + Code.push_back(0x00); // placeholder for first target + Code.push_back(0x56); + Code.push_back(0xfe); // pad PC=3 + const size_t FirstJumpDestPC = Code.size(); // 4 + Code[1] = static_cast(FirstJumpDestPC); + const int N = 30; + std::vector Pcs; + for (int I = 0; I < N; ++I) { + Pcs.push_back(Code.size()); + Code.push_back(0x5b); // JUMPDEST + if (I < N - 1) { + Code.push_back(0x60); + Code.push_back(0x00); // patched + Code.push_back(0x56); // JUMP + } else { + Code.push_back(0x00); // final STOP + } + } + // Patch each PUSH1 target to next JUMPDEST. + for (int I = 0; I < N - 1; ++I) { + size_t Push1Operand = Pcs[I] + 2; + Code[Push1Operand] = static_cast(Pcs[I + 1]); + } + EVMAnalyzer Analyzer = analyzeBytecode(Code); + for (int I = 0; I < N; ++I) { + const auto *B = findBlock(Analyzer, Pcs[I]); + ASSERT_NE(B, nullptr) << "block " << I << " missing"; + // Reachable JUMPDESTs should have a resolved depth >=0. + EXPECT_GE(B->ResolvedEntryStackDepth, 0) << "block " << I; + } +} + +// --------------------------------------------------------------------------- +// Group C — Dynamic-jump-target seeding +// --------------------------------------------------------------------------- + +TEST(EVMRangeAnalyzer, DynJumpTargetSeedsU256) { + // A dynamic JUMP (target loaded from CALLDATALOAD) makes every reachable + // JUMPDEST in the dyn-jump region a candidate, seeded at U256 by + // seedRangeEntryVectors. To observe the seed in EntryStackRanges, the + // candidate must have a non-zero entry depth: push a residual U64 below + // the loaded target so that after JUMP pops the target, the candidate's + // entry depth is 1. + // + // PC 0: PUSH1 5 (residual; will be seeded U256 at the dyn target) + // PC 2: PUSH1 0 + // PC 4: CALLDATALOAD + // PC 5: JUMP (dynamic — target unknown) + // PC 6: JUMPDEST (candidate; entry depth 1) + // PC 7: STOP + std::vector Code = {0x60, 0x05, // PUSH1 5 + 0x60, 0x00, // PUSH1 0 + 0x35, // CALLDATALOAD + 0x56, // JUMP (PC=5) + 0x5b, // JUMPDEST PC=6 + 0x00}; // STOP + EVMAnalyzer Analyzer = analyzeBytecode(Code); + const auto *Jd = findBlock(Analyzer, 6); + ASSERT_NE(Jd, nullptr); + // Candidate flag must be set. + EXPECT_TRUE(Jd->IsDynamicJumpTargetCandidate); + // With non-zero entry depth, the U256 seed is observable on the top of + // EntryStackRanges. + assertEntryTop(Jd, EVMValueRange::U256); +} + +TEST(EVMRangeAnalyzer, StaticMeetIntoDynTargetDoesNotNarrow) { + // A static predecessor pushes a fresh U64 into a JUMPDEST that is also a + // dynamic-jump-target candidate (because the program contains an + // unresolved dyn JUMP, which makes every JUMPDEST a candidate). The + // dyn-target seed at U256 must NOT be narrowed by the static U64 meet — + // meet operator is max, so U256 wins. + // + // PC 0: PUSH1 5 (residual; so the dyn-target has entry depth 1) + // PC 2: PUSH1 0 + // PC 4: CALLDATALOAD + // PC 5: JUMP (dynamic) + // PC 6: JUMPDEST PC=6 (dyn-cand; entry depth 1, seed U256) + // PC 7: POP (drop seeded slot) + // PC 8: PUSH1 6 (fresh U64 on stack) + // PC 10: PUSH1 13 (static JUMP target) + // PC 12: JUMP (static, to PC=13; exit depth 1, slot is U64) + // PC 13: JUMPDEST PC=13 (dyn-cand AND static target; entry depth 1) + // PC 14: STOP + std::vector Code = {0x60, 0x05, // PUSH1 5 + 0x60, 0x00, // PUSH1 0 + 0x35, // CALLDATALOAD + 0x56, // JUMP (PC=5) + 0x5b, // JUMPDEST PC=6 + 0x50, // POP + 0x60, 0x06, // PUSH1 6 + 0x60, 0x0d, // PUSH1 13 + 0x56, // JUMP + 0x5b, // JUMPDEST PC=13 + 0x00}; // STOP + EVMAnalyzer Analyzer = analyzeBytecode(Code); + const auto *Target = findBlock(Analyzer, 13); + ASSERT_NE(Target, nullptr); + EXPECT_TRUE(Target->IsDynamicJumpTargetCandidate); + // Even though a static predecessor contributes U64, the dyn-target seed + // is U256 and meet(U64, U256) = U256. Entry top stays U256. + assertEntryTop(Target, EVMValueRange::U256); +} + +// --------------------------------------------------------------------------- +// Group D — Boundary / degenerate +// --------------------------------------------------------------------------- + +TEST(EVMRangeAnalyzer, TruncatedPushAtTail) { + // Truncated PUSH32 at the bytecode tail: only 4 immediate bytes are + // available, but the opcode says 32. rangeFromPushLiteral treats missing + // bytes as zero, so the high prefix (first byte 0x01 within the top 16 + // bytes) classifies the literal as U256. Since the PUSH is at the tail, + // its result never reaches a JUMPDEST — but the test must verify (a) the + // analyzer completed without trapping, and (b) the entry block was + // materialized with a resolved depth (0, since the function entry has no + // predecessors stacking anything). + // + // PC 0: PUSH32 <01 02 03 04> (truncated, no more bytes) + std::vector Code = {0x7f, 0x01, 0x02, 0x03, 0x04}; + EVMAnalyzer Analyzer = analyzeBytecode(Code); + const auto *Entry = findBlock(Analyzer, 0); + ASSERT_NE(Entry, nullptr); + // Entry block was materialized with a resolved depth (0 for the function + // entry). The empty ranges follow from depth=0, not from analyzer + // failure to visit the block. + EXPECT_GE(Entry->ResolvedEntryStackDepth, 0); + EXPECT_FALSE(Entry->HasUndefinedInstr); +} + +TEST(EVMRangeAnalyzer, UndefinedOpcodeStops) { + // Spec: a block containing an opcode undefined in Cancun (0x0c) must halt + // transfer at the undef. Any textually-following JUMPDEST that is NOT a + // static JUMP target must therefore remain unreachable — depth -1, empty + // ranges. Verifies undef does not silently fall through into adjacent + // blocks. + // + // PC 0: PUSH1 5 + // PC 2: 0x0c (undefined in Cancun) + // PC 3: STOP + // PC 4: JUMPDEST (textually adjacent, no JUMP targets it) + // PC 5: STOP + std::vector Code = {0x60, 0x05, 0x0c, 0x00, 0x5b, 0x00}; + EVMAnalyzer Analyzer = analyzeBytecode(Code); + const auto *Entry = findBlock(Analyzer, 0); + ASSERT_NE(Entry, nullptr); + const auto *Successor = findBlock(Analyzer, 4); + assertNoEntryState(Successor); +} + +TEST(EVMRangeAnalyzer, UnresolvedBlockSkipped) { + // A block that is unreachable from the entry: ResolvedEntryStackDepth + // stays -1 and EntryStackRanges remains empty after runRangeAnalysis. + // The simplest unreachable block is a JUMPDEST that no constant JUMP + // targets. We don't trigger dynamic-jump-target seeding because there's + // no JUMP in the program. + // + // PC 0: STOP (entry block; terminates immediately) + // PC 1: JUMPDEST PC=1 (unreachable; no JUMP targets it) + // PC 2: STOP + std::vector Code = {0x00, 0x5b, 0x00}; + EVMAnalyzer Analyzer = analyzeBytecode(Code); + const auto *Jd = findBlock(Analyzer, 1); + assertNoEntryState(Jd); +}