Skip to content

feat: local call/return detection with context-sensitive resolution#222

Draft
DaniPopes wants to merge 29 commits intomainfrom
dani/local-jump-resolution
Draft

feat: local call/return detection with context-sensitive resolution#222
DaniPopes wants to merge 29 commits intomainfrom
dani/local-jump-resolution

Conversation

@DaniPopes
Copy link
Copy Markdown
Contributor

@DaniPopes DaniPopes commented Apr 1, 2026

Adds a private call/return detection pass that resolves dynamic jump targets
using context-sensitive call-string analysis, integrated into the block analysis
fixpoint.

What

The pass detects two patterns in each basic block:

  • Private function call: a static JUMP (adjacent PUSH+JUMP) where the block
    also pushes a valid JUMPDEST label that survives to exit — the label is the
    return address, and the static target is the callee.

  • Private function return: a dynamic JUMP whose operand has entry-stack
    provenance
    (passed by the caller, not computed in-block from
    memory/storage/arithmetic). Detected via a lightweight stack provenance
    simulation that tracks whether each value originated from the block's
    entry stack or was produced locally.

A worklist-based traversal over (block, call-string) pairs then traces which
callers push which return addresses, resolving return jumps to their
continuation targets (single or multi-target).

How it integrates

Instead of committing resolutions as a separate pre-pass, the PCR results are
fed as seed edges into the abstract interpreter's fixpoint. This lets the
fixpoint propagate abstract states along return edges, discovering additional
resolutions it couldn't find on its own. PCR resolutions that the fixpoint
can't independently confirm are used as fallback targets, subject to the
existing invalidate_suspect_jumps soundness check.

Pipeline: static_jump_analysis → mark_dead_code → rebuild_cfg → block_analysis (includes PCR) → mark_dead_code → dedup → rebuild_cfg → sections

Results

Full resolution stats across all benchmarks:

BENCHMARK                  LOCAL NONADJ    PCR  FIXPT  UNRES      TOTAL
---------                  ----- ------    ---  -----  -----      -----
fibonacci                      2      0      0      0      0          2
fibonacci-calldata             2      0      0      0      0          2
factorial                      2      0      0      0      0          2
counter                       11      0      0      0      0         11
snailtracer                  502     17      3      0     62        564
weth                          55      0      1     11      0         66
hash_10k                       6      0      0      2      0          8
erc20_transfer               114      0      7      0     18        132
push0_proxy                    1      0      0      0      0          1
usdc_proxy                    54      0      7     14      0         68
fiat_token                   452      6     29      0     68        520
uniswap_v2_pair              257     30     13      0     37        294
univ2_router                 474     27     22     27      0        501
seaport                     1026     39     11      0    153       1179
airdrop                      217      7      8      0     35        252
bswap64                       14      0      0      0      0         14
bswap64_opt                    8      0      0      2      0         10
eip4788                        4      0      0      0      0          4
eip2935                        4      0      0      0      0          4
curve_stableswap             176      5      4      0     13        189

weth, usdc_proxy, and univ2_router are now fully resolved (0 unresolved). PCR
enables the fixpoint to discover additional resolutions it couldn't find on
its own by seeding return edges into the abstract interpreter.

Soundness

  • Return blocks require entry-stack provenance for the jump operand:
    a lightweight stack simulation tracks whether each value originates from the
    block's entry stack or was produced locally by computation/memory/storage.
  • Return blocks exclude unsafe opcodes (TLOAD/TSTORE, CALL-family, CREATE-family,
    RETURN, REVERT, STOP, INVALID) to prevent false positives on shared utility
    blocks.
  • Private-call detection excludes multi-target and invalid jumps (where
    term.data is not a valid callee).
  • PCR hints are discarded entirely if PCR traversal does not converge.
  • PCR resolutions remain subject to invalidate_suspect_jumps: if unresolved
    Top jumps exist, any resolution in a suspect block is invalidated.
  • Bounded call-strings (depth 8) can under-approximate return targets; the
    invalidation check catches these cases.
  • Also fixes a pre-existing bug where has_dynamic_jumps counted dead-code
    jumps (added recompute_has_dynamic_jumps()).
  • All 920 tests pass including all Ethereum state tests.

Add a pre-fixpoint pass that detects private function call/return patterns
and resolves return jumps via context-sensitive graph traversal.

Local summaries (no fixpoint needed):
- PrivateFunctionCall: block has static JUMP + pushes a surviving JUMPDEST label
- PrivateFunctionReturn: block ends with dynamic JUMP using only stack ops

Context-sensitive resolution:
- Transactional call-string contexts (max depth 8)
- On call: push caller to context, follow to callee
- On return: pop context to find caller's continuation, add edge
- CutToCaller pattern from gigahorse's transactional_shrinking_context.dl

Soundness:
- Only pure return blocks (JUMPDEST/POP/DUP/SWAP/PUSH/JUMP) are classified
  as returns, excluding TLOAD/SLOAD/MLOAD-based patterns
- Resolutions are only committed when ALL dynamic jumps in the contract are
  accounted for (no other unresolved dynamic jumps remain)
- Dead code excluded from has_dynamic_jumps recomputation

Resolves the nested_call_return test case (wrapper return through inner
function) that was previously unresolvable by the flat abstract interpreter.
Instead of committing PCR resolutions as a separate pre-pass, the private
call/return detection now produces hints that are seeded into the abstract
interpreter's fixpoint as discovered edges. This allows the fixpoint to
propagate abstract states along PCR-discovered return edges, resolving
additional jumps that were previously unreachable.

PCR resolutions that the fixpoint cannot independently confirm are used as
fallback targets, but remain subject to invalidate_suspect_jumps for
soundness.

Gains across benchmarks: erc20_transfer +3, fiat_token +3, seaport +1,
airdrop +1, curve_stableswap +2.
@DaniPopes DaniPopes marked this pull request as draft April 1, 2026 13:49
@codspeed-hq
Copy link
Copy Markdown

codspeed-hq bot commented Apr 1, 2026

Merging this PR will degrade performance by 74.2%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 1 improved benchmark
❌ 5 regressed benchmarks
✅ 63 untouched benchmarks

⚠️ Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
airdrop/compile/translate 17.7 ms 21.4 ms -17.12%
fiat_token/compile/translate 57.4 ms 71.4 ms -19.62%
uniswap_v2_pair/compile/translate 26.8 ms 31.9 ms -16.03%
univ2_router/compile/translate 50.8 ms 196.7 ms -74.2%
usdc_proxy/compile/translate 5.5 ms 7.6 ms -28.38%
weth/compile/jit 543.5 ms 379.8 ms +43.11%

Comparing dani/local-jump-resolution (91bc0d8) with main (013cfe1)

Open in CodSpeed

DaniPopes added 12 commits April 1, 2026 16:15
…ution

# Conflicts:
#	crates/revmc/src/bytecode/passes/block_analysis.rs
- Exclude MULTI_JUMP/INVALID_JUMP from private-call detection (term.data
  is not a valid callee for those).
- Discard all PCR hints on non-convergence (partial exploration can miss
  valid continuations).
- Dedup return targets to avoid duplicate continuations from different
  contexts.
- Only use PCR fallback for Top (reachable but unknown), not Bottom
  (unreachable).
- Bump Context SmallVec from 4 to 8 to match MAX_CONTEXT_DEPTH.
- Fix jump-resolution script to skip the initial pre-resolution
  unresolved count.
Replace the opcode whitelist for private function return detection with
a stack provenance simulation. Tracks whether each stack slot originates
from the block's entry stack (Input) or was produced in-block (Local).
A dynamic JUMP qualifies as a private return if its operand has Input
provenance and the block contains no unsafe opcodes (TLOAD/TSTORE,
CALL-family, CREATE-family).

This resolves weth (0 unresolved), usdc_proxy (0 unresolved), and
univ2_router (0 unresolved) by detecting return blocks that contain
arithmetic, memory ops, SLOAD, BALANCE, LOG, etc.
@DaniPopes DaniPopes force-pushed the dani/local-jump-resolution branch from 8d7410c to 14bf82f Compare April 4, 2026 21:07
DaniPopes added 13 commits April 5, 2026 00:54
…ith opaque taint

- Extract shared stack-shuffling ops (POP, DUP, SWAP, DUPN, SWAPN,
  EXCHANGE) into generic apply_stack_shuffle, used by both
  interpret_block and simulate_provenance.

- Remove is_return_safe_block opcode deny-list. Return classification
  now uses provenance alone.

- Add compute_opaque_taint for structural soundness: seeds opaque
  entries from callee blocks with non-private-call preds (seed A) and
  all JUMPDEST blocks when unmodeled dynamic jumps exist (seed B).
  Propagates forward and taints reachable return blocks.

- Fix block_analysis_local to commit Invalid jump resolutions so PCR
  doesn't see them as unmodeled dynamic jumps.
…check

Move apply_stack_shuffle from a free function in block_analysis to a
method on Bytecode, using get_u8_imm instead of raw code slice.

Fix provenance check to use the destination operand position instead
of always checking TOS. For JUMP dest is TOS, but for JUMPI dest is
second-from-top (TOS is the condition).
- PCR: taint return blocks reached with empty/invalid context during
  traversal. Context truncation at MAX_CONTEXT_DEPTH can silently drop
  callers, producing incomplete target sets.
- Dedup: restore transitive redirect chain compression between
  iterations. Without it, chained redirects (A→B, B→C) leave stale
  intermediate targets in rebuild_cfg and translate.rs.
- LLVM: revert AOT mode from thread-local context to owned Box<Context>.
  The TLS approach was unsound with unsafe impl Send — moving the
  backend across threads would create a dangling reference.
- Add regression tests for all three issues plus JUMPI provenance.
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