Skip to content

feat(vm): retain contextual block exit states - #743

Open
Jon-Becker wants to merge 1 commit into
feat/decompiler-canonical-sidecarfrom
feat/contextual-block-exits
Open

Jon-Becker wants to merge 1 commit into
feat/decompiler-canonical-sidecarfrom
feat/contextual-block-exits

Conversation

@Jon-Becker

@Jon-Becker Jon-Becker commented Sep 6, 2026

Copy link
Copy Markdown
Owner

What changed? Why?

Stacked on #742. Retains fixpoint block-exit artifacts for canonical graph-to-IR lowering: post-block abstract state, consumed jump targets, and consumed branch conditions. Exit maps are available on context-free and contextual CFGs, with contextual exits keyed by full ContextualPoint.

Notes to reviewers

Key files are crates/vm/src/core/analysis.rs, crates/vm/src/core/context.rs, and crates/decompile/src/core/canonical.rs. Stale exits are removed if a later joined entry exposes definite stack underflow. This does not switch source lowering yet.

How has it been tested?

193 VM unit tests, canonical decompiler sidecar tests, workspace check, formatting, and tests for retained jump targets, branch conditions, post-jump stack state, and sidecar entry/exit coverage.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

❌ AI Evaluation Suite for 4201990

Completed heimdall-eval. View workflow run.

Test Case CFG Decompilation
NestedLoop 100 20
NestedMappings 100 45
SimpleStorage 100 92
WETH9 100 95
SimpleLoop 100 20
NestedMapping 100 8
Mapping 100 92
WhileLoop 100 20
TransientStorage 100 18
Events 100 62
Average 100 47
⚠️ 7 eval(s) scoring <70%

NestedLoop (CFG: 100, Decompilation: 20)

Decompilation

{
  "score": 20,
  "summary": "The public getter for the storage variable is correctly recovered, but the contract's only real function, loop(uint256), is decompiled as an unconditional revert(). The entire nested loop, the loop bounds check against the argument, and the repeated storage increment of `number` are absent, so the core program behavior is not captured.",
  "differences": [
    "loop(uint256) is rendered as an unconditional revert() — the outer and inner for-loops over `loops` are entirely missing",
    "The storage write `number += 1` (SLOAD/ADD/SSTORE on slot 0x00) inside the loop body is not represented at all",
    "The loop counter increments and the `i < loops` / `j < loops` comparisons against the argument are missing",
    "loop(uint256) is marked `public view`, but the original mutates state; the decompiled version has no state-changing behavior whatsoever",
    "Arithmetic overflow revert behavior of the 0.8.x checked increment is not represented"
  ]
}

NestedMappings (CFG: 100, Decompilation: 45)

Decompilation

{
  "score": 45,
  "summary": "The state-changing function (approve) is decompiled perfectly, including the correct nested mapping storage layout (allowances[msg.sender][spender] = amount) at slot 0. However, both read functions -- the explicit allowance(address,address) getter (0xdd62ed3e) and the auto-generated public mapping getter allowances(address,address) (0x55b6ed5c) -- are emitted as empty bodies containing only calldata-length checks. Their nested mapping storage reads and return values are entirely absent, and they are mislabeled as 'pure' with no return type. Two of the three entry points therefore lose all of their functional behavior.",
  "differences": [
    "func 0xdd62ed3e (allowance): the nested mapping storage read allowances[owner][spender] and the uint256 return value are completely missing; the body only contains calldata size checks and returns nothing",
    "func 0x55b6ed5c (public allowances getter): same loss -- no storage read of the nested mapping, no return value emitted",
    "Both read functions are marked 'pure' with no return type, whereas the originals are 'view' returning uint256",
    "Both read functions only recover one argument (address arg0) instead of the two address parameters actually consumed",
    "approve is marked 'payable' while the original is non-payable (the compiler's callvalue check is not represented as a revert condition)"
  ]
}

SimpleLoop (CFG: 100, Decompilation: 20)

Decompilation

{
  "score": 20,
  "summary": "The public getter for the storage variable is correctly recovered, but the contract's only real logic — the loop() function — is entirely lost. Its body is emitted as an unconditional revert(), so the loop, the counter comparison, the increment, and the storage write to `number` are all missing. Since loop() is the substance of this contract, the decompilation fails to capture the fundamental program behavior.",
  "differences": [
    "loop(uint256) is decompiled as an unconditional revert() — the original never reverts for valid input and instead performs work",
    "The for-loop control flow (i = 0; i < loops; i++) is completely absent, including the loop bound comparison and induction variable increment",
    "The repeated increment and SSTORE of storage slot 0 (number++) inside the loop body is missing; no state change is represented at all",
    "loop() is annotated `view` although the original is state-mutating (writes storage), inverting its mutability behavior",
    "Implicit overflow/underflow checks (Solidity 0.8 checked arithmetic on number++ and i++) that exist in the bytecode are not represented"
  ]
}

NestedMapping (CFG: 100, Decompilation: 8)

Decompilation

{
  "score": 8,
  "summary": "The decompilation recovers the correct number of external entry points (7, matching the 4 explicit functions plus 3 auto-generated public mapping getters) but captures essentially none of their behavior. Every function body is either a bare revert() or a calldata-length check followed by require(true). No SSTORE, no SLOAD, no keccak256-based nested mapping slot derivation, and no return values appear anywhere. The core purpose of the contract -- writing to and reading from one-, two-, and three-level nested mappings -- is entirely absent from the output.",
  "differences": [
    "All storage writes are missing: setAllowance, setGrid, and setDeepNested each perform an SSTORE in the original, but the corresponding decompiled functions contain only revert() or require(true) with no state change.",
    "All storage reads are missing: getAllowance and the three public mapping getters (allowances, grid, deepNested) perform SLOADs and return values; the decompiled functions read nothing.",
    "Nested mapping slot computation (keccak256 of key concatenated with parent slot, applied 2x or 3x) is not represented at all.",
    "No return values are produced. getAllowance/allowances/deepNested return uint256 and grid returns bool in the original; every decompiled function is declared with no return type and returns nothing.",
    "Three functions (0x8019f65b, 0x9d266b8a, 0x1365b4e1, 0x146008e3) are decompiled as unconditional revert(), meaning the output implies these selectors always fail, which is functionally opposite to the original where all of them succeed.",
    "Functions that mutate state are incorrectly marked `pure`; the setters are non-payable state-modifying and the getters are `view`.",
    "Function signatures/arity are wrong: e.g. the three-argument setters are shown as taking one or two arguments, and the bool/uint256 parameter types of setGrid are not recovered.",
    "The remaining non-reverting bodies contain only a vacuous `require(true)` and a calldata-size check, which is dead logic rather than any recovered operation."
  ]
}

WhileLoop (CFG: 100, Decompilation: 20)

Decompilation

{
  "score": 20,
  "summary": "The public getter for the storage variable is correctly recovered, but the contract's only real logic - the while loop that increments the storage counter - is entirely missing. loop(uint256) is rendered as an unconditional revert and is marked view, so the decompilation fails to capture the contract's fundamental behavior.",
  "differences": [
    "loop(uint256) body is replaced with an unconditional revert(); the original executes a loop and returns normally, so any call is misrepresented as failing",
    "The while (i < loops) loop and its counter increment (i = i + 1) are absent - no loop or comparison control flow appears in the output",
    "The storage write number = number + 1 is missing; the decompiled contract performs no SSTORE at all, so the state mutation is lost",
    "loop(uint256) is annotated as view/non-state-changing, whereas the original is state-mutating"
  ]
}

TransientStorage (CFG: 100, Decompilation: 18)

Decompilation

{
  "score": 18,
  "summary": "Only one of six functions (setTempOwner) is recovered as executable logic; the other five are collapsed into bogus 'constant' declarations with fabricated values, so the contract's transient counter increment, lock/unlock state writes, and the two getters' actual reads are entirely absent. Transient storage semantics (TLOAD/TSTORE) are not represented as distinct operations, and setTempOwner itself gains a spurious read-modify-write mask that does not exist in the source.",
  "differences": [
    "incrementCounter() is emitted as `bytes public constant incrementCounter` with no body — the transient read, +1 arithmetic, and transient write are completely lost.",
    "lock() is emitted as a constant with no body — the write of `true` to transient `locked` is missing.",
    "unlock() is emitted as a constant with no body — the write of `false` to transient `locked` is missing.",
    "getCounter() is represented as `uint256 public constant getCounter = 1`, a fabricated constant instead of a load-and-return of the transient counter; it would return the wrong value.",
    "isLocked() is represented as `bool public constant isLocked = true` instead of returning the current transient `locked` value; it would return the wrong value.",
    "setTempOwner writes `arg0 | (0xffff...0000 & tstore_a)`, i.e. an address-mask read-modify-write against an existing slot value, whereas the source performs a plain transient store of the address; the upper-96-bit merge is spurious logic not present in the original.",
    "Transient storage (TSTORE/TLOAD) is not distinguished from regular storage or represented at all in the recovered function; the declared `tstore_a`/`tstore_b` variables are annotated with 'storage slot: unknown' and `tstore_b` is never used.",
    "setTempOwner is annotated `pure` despite performing a state (transient) write, and the `require(true)` is a degenerate no-op condition.",
    "Emitted declarations are syntactically empty (`bytes public constant unlock = ;`) and `0xBool(true)`, indicating the decompiler failed to lift those function bodies rather than expressing them as pseudo-code."
  ]
}

Events (CFG: 100, Decompilation: 62)

Decompilation

{
  "score": 62,
  "summary": "Five of the seven functions are reproduced faithfully, including correct event names, argument ordering, the multi-emit function, and the exact inline string literal \"Multiple events emitted\" (hex + length 0x17). Dynamic-type calldata bounds checks for the string/bytes variants are reconstructed correctly. However, the two three-argument emitters (emitTransfer and emitApproval) lose their event emission entirely: they decompile to bare calldata-length requires with no LOG output, and only one of their three parameters is recovered. The Approval event is absent from the reconstructed event set altogether, so roughly 30% of the contract's observable behavior is missing.",
  "differences": [
    "emitTransfer (0x23de6651) is decompiled as a body containing only calldata length checks — the Transfer event emission (two indexed topics + value) is completely missing.",
    "emitApproval (0x5687f2b8) is decompiled as a body containing only calldata length checks — the Approval event emission is completely missing.",
    "The Approval event does not appear anywhere in the decompiled output (no declaration, no topic hash), so that log signature is unrecoverable from the output.",
    "Both three-argument emitters are given a single `address arg0` parameter instead of (address, address, uint256); the remaining two arguments are dropped despite the 0x60 calldata size check implying three words.",
    "Indexed vs. non-indexed topic structure is not represented: emitted events show all values as flat data arguments, so Transfer/Deposit/Withdrawal indexed address topics are indistinguishable from data.",
    "String/bytes emissions render as `emit Log(var_b + 0x20 - var_b, len)` with an undefined `var_b` and no actual memory payload reference, so the emitted data contents for emitLog/emitLogBytes are only implied by the length, not shown.",
    "All functions are annotated `public pure`, but functions that emit logs are not pure (state-modifying in the EVM sense); the emit-less decompilation of the two Transfer/Approval functions makes this mislabeling consistent with the incorrect body rather than the source."
  ]
}
  • Run AI Evaluation Suite

@Jon-Becker
Jon-Becker force-pushed the feat/contextual-block-exits branch from 58f42e4 to 4201990 Compare September 6, 2026 16:31
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

✅ Coverage Report for 20bb3d5

Metric Value
Base branch 71.77%
PR branch 74.94%
Diff +3.16%

@Jon-Becker
Jon-Becker force-pushed the feat/contextual-block-exits branch from 5562213 to 1d964c3 Compare September 14, 2026 17:43
@github-actions

github-actions Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Benchmark for 20bb3d5

Click to view benchmark
Test Base PR %
heimdall_cfg/complex 13.6±0.70ms 148.1±4.90ms +988.97%
heimdall_cfg/simple 1372.8±29.47µs 578.2±8.89µs -57.88%
heimdall_decoder/seaport 58.8±5.69µs 58.6±5.25µs -0.34%
heimdall_decoder/transfer 4.3±0.42µs 4.2±0.52µs -2.33%
heimdall_decoder/uniswap 16.1±1.34µs 16.0±1.31µs -0.62%
heimdall_decompiler/abi_complex 58.4±1.16ms 194.2±6.53ms +232.53%
heimdall_decompiler/abi_simple 1485.6±18.45µs 1939.5±15.49µs +30.55%
heimdall_decompiler/sol_complex 76.1±1.42ms 208.0±3.12ms +173.32%
heimdall_decompiler/sol_simple 1923.4±25.09µs 2.4±0.02ms +24.78%
heimdall_decompiler/yul_complex 63.9±1.23ms 202.7±6.73ms +217.21%
heimdall_decompiler/yul_simple 1688.2±50.24µs 2.2±0.03ms +30.32%
heimdall_disassembler/complex 1575.5±160.67µs 1522.6±111.01µs -3.36%
heimdall_disassembler/simple 76.3±9.60µs 73.2±6.73µs -4.06%
heimdall_vm/erc20_transfer 268.7±26.79µs 264.0±18.14µs -1.75%
heimdall_vm/fib 878.9±10.09µs 867.9±43.38µs -1.25%
heimdall_vm/ten_thousand_hashes 1375.6±1803.87ms 764.2±169.38ms -44.45%

📊 View the full Criterion report

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant