The new Yul optimizer toolkit (#519)

This PR introduces powerful code size optimizations. This is achieved
with an additional IR layer in between Yul and LLVM IR. It works quite
well (see below for a comparison) because it captures optimization gains
that neither solc nor LLVM can realize.

The feature is gated behind `--newyork` or a corresponding standard JSON
input setting and **off by default**.

### How it works

We implemented an SSA IR modelled after or inspired by the [MLIR SCF
dialect](https://mlir.llvm.org/docs/Dialects/SCFDialect/). Yul is
translated into this IR and the IR implements mainly the following
optimizations:

- **Type narrowing**: narrows 256-bit values to I1..I160 where provable
(bidirectional + interprocedural), so LLVM emits native 32/64-bit ops
instead
    of software 256-bit arithmetic.
- **FMP range proof**: exposes the `mload(0x40) < heap_size` bound to
LLVM
IPSCCP, eliminating trailing overflow checks on FMP-derived offsets.
Single
    largest code-size contributor.
- **Heap analysis**: skips big-endian byte-swaps where native
little-endian
    layout is safe.
  - **mem_opt / keccak fusion+folding**, **compound outlining** (mapping
sload/sstore), **guard narrowing**, **inlining** tuned for PolkaVM, and
    **exact + fuzzy function dedup**.

### Results
The optimizations implemented with the newyork IR bring substantial code
size improvements.
  - Integration contracts: approximately **-20% to -50%** code size.
  - OpenZeppelin Wizard corpus: around **-47%** code size.

This table shows the code size improvements on various OpenZeppelin ERC
implementations, taken from their [contracts
wizard](https://wizard.openzeppelin.com/), with almost all features
enabled.

| Contract | Default: (bytes) | With `--newyork`: (bytes) | Reduction |
|---|---:|---:|---:|
| erc1155 | 59,206 | 30,649 | -48.2% |
| erc20 | 81,697 | 42,893 | -47.5% |
| erc721 | 92,052 | 49,423 | -46.3% |
| oz_gov | 141,861 | 81,159 | -42.8% |
| oz_rwa | 77,274 | 37,975 | -50.9% |
| oz_simple_erc20 | 29,950 | 16,554 | -44.7% |
| oz_stable | 82,117 | 39,032 | -52.5% |
| **Total** | **564,157** | **297,685** | **-47.2%** |

We believe this to be a reasonably representative dataset and conclude
that the newyork IR achieves drastic code size reductions on real world
dApp implementations.

### Methodology, security and runtime performance

Since the main contributors to these improvements are the FMP range
proofs and the type narrowing, we don't expect big runtime performance
regressions (it may even benefit runtime performance in some cases).

The PR also includes docs
(`book/src/developer_guide/newyork_{optimizer,ir}.md`) and many new
integration tests, including regression tests for known FMP soundness
traps.

The PR was authored collaboratively and reviewed extensively by the
`revive` maintainers, as well as Claude Opus, Claude Fable, Qwen,
Minimax and Deepseek LLMs, over a span of many months.

The `newyork` compiler pipeline introduces no new regressions over our
integration and differential test suites (containing over 30000 test
cases). This was achieved by careful manual reviews and many LLM bughunt
loops. Additionally, a final security review by Anthropic's Fable 5 LLM
found no remaining soundness issues. Nonetheless, like any brand new
software, please consider this an experimental feature upon release.

---------

Signed-off-by: xermicus <bigcyrill@hotmail.com>
Signed-off-by: xermicus <cyrill@parity.io>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: neo <me@90f9.org>
Co-authored-by: LJ <81748770+elle-j@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-06-17 12:35:16 +02:00
1 parent d178110a81
commit 98d818f958
183 files changed
+49503 -561

No files matched your search

+1
View File
@@ -23,3 +23,4 @@ playwright-report
.cache
emsdk
docs-tmp
workdir
+4
View File
@@ -8,6 +8,10 @@ Supported `polkadot-sdk` rev: `2604.2.0`
- Updated `polkadot-sdk` to `2604.2.0`
### Added
- Experimental newyork IR optimization pipeline, selectable via the `--newyork` CLI flag or the `settings.polkavm.newyork` standard JSON input field. Off by default. [#519](https://github.com/paritytech/revive/pull/519)
## v1.2.0
Supported `polkadot-sdk` rev: `2603`
+14 -3
View File
@@ -33,11 +33,22 @@ make test-yul # Yul parser tests
make format # Check formatting (cargo fmt --all --check)
make clippy # Run clippy with --deny warnings
# Run single test
cargo test --package <crate-name> <test_name>
cargo test --package revive-integration <test_name>
```
**For verifying work, claiming a task done, or pre-commit checks: only ever run
`make test*` targets — never raw `cargo test`.** Integration and resolc tests
invoke the installed `resolc` binary as a subprocess. Each `make test*` target
already declares `install-bin` (or `install`) as a dependency, so it always
rebuilds and installs a fresh binary before running tests. Raw `cargo test`
does not — it builds the test binary but leaves system `resolc` stale, so it
passes against code the test isn't actually exercising. That mismatch is
exactly why raw `cargo test` is an anti-pattern for validation.
The one legitimate exception is **actively debugging a single test** for fast
iteration: run `make install-bin` first to refresh the binary, then iterate with
`cargo test --package <crate> <test_name>`, re-running `make install-bin`
between code changes. Never use this exception for validation.
## Crate Architecture
All crates live in `crates/`. Key crates:
Generated
+15
View File
@@ -9443,6 +9443,7 @@ dependencies = [
"rayon",
"revive-common",
"revive-llvm-context",
"revive-newyork",
"revive-solc-json-interface",
"revive-yul",
"semver 1.0.28",
@@ -9572,6 +9573,20 @@ dependencies = [
"serde",
]
[[package]]
name = "revive-newyork"
version = "1.0.0"
dependencies = [
"anyhow",
"inkwell",
"log",
"num",
"revive-common",
"revive-llvm-context",
"revive-yul",
"thiserror 2.0.18",
]
[[package]]
name = "revive-runner"
version = "1.2.0"
+1
View File
@@ -24,6 +24,7 @@ revive-differential = { version = "1.0.0", path = "crates/differential" }
revive-integration = { version = "1.2.0", path = "crates/integration" }
revive-linker = { version = "1.0.0", path = "crates/linker" }
revive-llvm-context = { version = "1.2.0", path = "crates/llvm-context" }
revive-newyork = { version = "1.0.0", path = "crates/newyork" }
revive-runner = { version = "1.1.0", path = "crates/runner" }
revive-runtime-api = { version = "1.2.0", path = "crates/runtime-api" }
revive-solc-json-interface = { version = "1.0.0", path = "crates/solc-json-interface", default-features = false }
+2
View File
@@ -68,6 +68,7 @@ test: format clippy machete test-workspace install-revive-runner doc test-book
test-integration: install-bin
cargo test --package revive-integration
cargo test --package revive-integration --features newyork
test-resolc: install
cargo test --package resolc --all-targets
@@ -77,6 +78,7 @@ test-yul:
test-workspace: install
cargo test --workspace --all-targets --exclude revive-llvm-builder
cargo test --package revive-integration --features newyork
test-wasm: install-wasm
npm run test:wasm
+2
View File
@@ -13,6 +13,8 @@
- [Developer Guide](./developer_guide.md)
- [Contributor guide](./developer_guide/contributing.md)
- [Compiler architecture](./developer_guide/architecture.md)
- [The newyork optimizer](./developer_guide/newyork_optimizer.md)
- [IR reference](./developer_guide/newyork_ir.md)
- [PVM and the pallet-revive runtime target](./developer_guide/target.md)
- [Testing strategy](./developer_guide/testing.md)
- [Cross compilation](./developer_guide/cross_compilation.md)
+1 -1
View File
@@ -52,4 +52,4 @@ We also maintain the [lld-sys crate](https://crates.io/crates/lld-sys) for inter
## Custom optimizations
At the moment, no significant custom optimizations are implemented. Thus, we are missing some optimization opportunities that neither `solc` nor LLVM can realize (due to their lack of domain specific knowledge about the semantics of our target environment). Furthermore, `solc` optimizes for EVM gas and a target machine orthogonal to our target (BE 256-bit stack machine EVM vs. 64-bit LE RISC architecture PVM). We have started working on an additional IR layer between Yul and LLVM to capture missed optimization opportunities, though.
An experimental [`newyork` optimizer](./newyork_optimizer.md) introduces a custom IR layer between Yul and LLVM IR to capture optimization opportunities that neither `solc` nor LLVM can realize on their own. `solc` optimizes for EVM gas on a 256-bit big-endian stack machine, while LLVM lacks the domain knowledge to understand EVM memory semantics or Solidity patterns. The newyork IR bridges this gap with passes for type narrowing, memory optimization, function deduplication, and more.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,323 @@
# The newyork optimizer
The `newyork` crate (`crates/newyork/`) introduces an additional intermediate representation (IR) layer between Yul and LLVM IR. It enables domain-specific optimizations that neither `solc` nor LLVM can perform on their own, because they lack semantic knowledge about the cross-domain compilation from EVM to PolkaVM.
> [!NOTE]
> The newyork optimizer is experimental. It is gated behind the `RESOLC_USE_NEWYORK=1` environment variable (for standard JSON mode) or the `--newyork` CLI flag, and not yet enabled by default.
## Motivation
The EVM and PolkaVM are fundamentally different machines:
| Property | EVM | PolkaVM (RISC-V) |
|---|---|---|
| Word size | 256-bit | 64-bit |
| Endianness | Big-endian | Little-endian |
| Architecture | Stack machine | Register-based |
| Memory model | Linear with free pointer convention | Flat address space |
`solc` optimizes Yul IR for EVM gas costs on a 256-bit big-endian stack machine. LLVM, on the other hand, operates at too low a level to understand EVM memory semantics or Solidity patterns. By the time Yul reaches LLVM IR, the high-level intent is lost.
The newyork IR sits between these two worlds and recovers enough semantic information to make optimization decisions that neither compiler can make alone.
## Pipeline overview
```text
┌──────────────────────────────────────────────────┐
Yul AST ──────────► │ newyork IR │ ──► LLVM IR ──► RISC-V
from_yul│ │ to_llvm
│ 1. inline │
│ 2. simplify (pass 1) │
│ 3. dedup (exact + fuzzy) │
│ 4. mem_opt + fmp_prop + keccak_fold │
│ 5. simplify (pass 2) │
│ 6. compound_outlining + guard_narrow │
│ 7. simplify (pass 3) │
│ 8. dedup (exact + fuzzy, pass 2) │
│ ── recursive on subobjects ── │
│ 9. heap_opt (analysis) │
│ 10. type_inference (4 iterative rounds) │
│ 11. validate │
└──────────────────────────────────────────────────┘
```
The optimizer runs the following passes in order:
1. **Inlining** -- custom heuristics tuned for PolkaVM call overhead, with Tarjan SCC-based recursion detection and quadratic leave-overhead modeling.
2. **Simplify** (pass 1) -- constant folding, algebraic identities, strength reduction (`mul` by power-of-2 to `shl`), copy propagation, dead code elimination, environment read CSE (`callvalue`, `caller`, `origin`, etc.), and revert pattern outlining (panic selectors, custom error selectors).
3. **Function deduplication** -- exact structural match, then fuzzy dedup (functions differing only in literal constants are parameterized and merged, up to 4 differing positions).
4. **Memory optimization** -- load-after-store elimination, keccak256 fusion (`mstore` + `keccak256` sequences into `Keccak256Single`/`Keccak256Pair` nodes), free memory pointer propagation (replaces `mload(0x40)` with a known constant), and constant keccak256 folding (precomputes hashes of compile-time-constant inputs).
5. **Simplify** (pass 2) -- cleans up dead code and new constant expressions exposed by memory optimization and keccak folding.
6. **Compound outlining** -- detects `keccak256_pair` + `sload`/`sstore` sequences and fuses them into `MappingSLoad`/`MappingSStore` IR nodes, eliminating intermediate hash values. **Guard narrowing** -- detects `if gt(val, MASK) { revert }` and `iszero(eq(val, and(val, MASK)))` patterns and inserts AND-mask narrowing, giving type inference proof that values fit in fewer bits.
7. **Simplify** (pass 3) -- propagates opportunities created by compound outlining and guard narrowing.
8. **Function deduplication** (pass 2) -- catches new duplicates exposed by guard narrowing and compound outlining canonicalization.
9. **Heap analysis** -- analyzes memory access patterns (alignment, static offsets, taintedness, escaping regions) to determine which accesses can use native little-endian layout, skipping byte-swap operations. Uses GCD-based alignment propagation and per-region taint tracking.
10. **Type inference** -- narrows 256-bit values to smaller widths (`I1`, `I8`, `I32`, `I64`, `I128`, `I160`) where provable. Runs iteratively for up to 4 cascading refinement rounds, combining forward min-width propagation, backward use-context demands, transparent-operation demand propagation, and interprocedural parameter/return narrowing.
11. **Validation** -- checks SSA well-formedness (use-before-def, multiple definitions), yield count consistency, and function reference correctness.
Steps 1-8 run recursively on subobjects (deployed contract code), where optimization impact is greatest. Steps 9-11 run on the full object tree.
## IR design
The newyork IR is an SSA form with structured control flow, inspired by MLIR's SCF dialect. Key design choices:
- **Explicit types with address spaces**: Every value carries a bit-width (`I1`, `I8`, `I32`, `I64`, `I128`, `I160`, `I256`) and pointers carry address space information (`Heap`, `Stack`, `Storage`, `Code`). All values start as `I256` and are narrowed by type inference.
- **Pure expressions vs. effectful statements**: Expressions compute values without side effects; statements perform memory, storage, or control flow effects. This separation simplifies analysis and rewriting.
- **Semantic annotations**: Memory operations are tagged with region information (`Scratch`, `FreePointerSlot`, `Dynamic`). Storage operations carry static slot values when known at compile time.
- **Structured control flow**: `If`, `Switch`, and `For` nodes preserve the high-level structure from Yul, with explicit region arguments and yields for value flow across control edges.
For per-operation detail — printed syntax, operand and result types, and more — see the [newyork IR reference](./newyork_ir.md).
## Key optimizations explained
### Type narrowing
EVM operates on 256-bit words, but most values in practice fit in 32 or 64 bits. The type inference pass performs bidirectional analysis:
- **Forward**: computes minimum width from literal values and operation semantics (e.g., `add(I64, I8)` produces `I65`, rounded up to `I128`).
- **Backward use tracking**: classifies each value's uses into 9 context categories (`MemoryOffset`, `MemoryValue`, `StorageAccess`, `Comparison`, `Arithmetic`, `FunctionArg`, `FunctionReturn`, `ExternalCall`, `General`). All categories conservatively demand the full `I256` width by default; the categorisation is what enables the interprocedural phase to selectively relax the demand for narrowed function arguments. Earlier versions narrowed directly from the use category, but that was unsound for memory offsets — `mload(2^128)` aliased to `mload(0)` because the bounds check ran on an already-truncated value (commit `ccca38df`).
- **Transparent demand propagation**: for modular-arithmetic operations (`Add`, `Sub`, `Mul`, `And`, `Or`, `Xor`), propagates narrow demands backward through operands, exploiting the property that `trunc(op(a,b), N) == op(trunc(a,N), trunc(b,N))`.
- **Interprocedural**: iteratively narrows function parameter and return types in up to four rounds, combining four narrowing strategies — body-driven parameter narrowing, caller-driven parameter narrowing, forward-based return narrowing, and demand-based return narrowing — and re-running full inference between rounds. Parameters are clamped to at least `I32` (XLEN on PolkaVM).
This allows LLVM to emit native 32/64-bit instructions instead of software-emulated 256-bit arithmetic, and eliminates expensive multi-instruction comparison sequences (16-20 RISC-V instructions for i256 comparisons reduced to 1-2 for i64).
### Guard narrowing
Solidity emits runtime guards that prove values fit in narrow ranges (e.g., address validation via `if gt(val, 2^160-1) { revert }`). The guard narrowing pass detects these patterns and inserts explicit AND-mask narrowing after the guard. This gives downstream type inference proof that the value fits in fewer bits, enabling cascading narrowing of comparisons, arithmetic, and memory operations that use the guarded value.
Two pattern families are recognized:
- **GT-based guards**: `if gt(val, MASK) { <terminates> }` where MASK is a boundary value like `2^N - 1`
- **EQ-based guards**: `iszero(eq(val, and(val, MASK)))` patterns common in Solidity's address validation
### Heap optimization
PVM doesn't provide EVM-compatible linear memory, so the compiler emulates it using a byte buffer with byte-swap operations for big-endian compatibility. The heap analysis pass determines which memory accesses can use native little-endian layout by analyzing access patterns:
- Tracks alignment and static offset information for all memory accesses using GCD-based propagation
- Propagates taintedness when addresses escape to external calls, are written by external sources (`codecopy`, `calldatacopy`), or use unaligned access patterns
- Tracks variable-accessed offsets to prevent mode mismatches between native and byte-swap accesses to the same location
- Handles loop-carried variables conservatively (marked as non-literal to prevent false constant propagation)
The codegen backend supports four memory access modes: `AllNative` (all accesses skip byte-swap), `InlineNative` (constant-offset accesses use native layout), `InlineByteSwap` (constant-offset accesses use inline byte-swap), and `ByteSwap` (standard byte-swap through helper functions).
### Free memory pointer range proof
The Solidity free memory pointer (`mload(0x40)`) always fits in 32 bits — sbrk enforces `FMP < heap_size` on every store, regardless of which memory mode the contract uses. After every literal `mload(0x40)`, codegen emits a `trunc N → zext 256` chain (where N is `bits(heap_size - 1)`, e.g. 17 for the 131,072-byte default heap). The trunc-extend round-trip is a no-op semantically, but exposes the bound to LLVM's IPSCCP range analysis, which then propagates it through every `add(fmp, K)` and eliminates the trailing `safe_truncate_int_to_xlen` overflow checks at every FMP-derived offset use. Despite only affecting a single codegen site, this is the single largest contributor to the optimizer's code-size reduction.
A subtle gating issue: the byte-order mode (`InlineNative` / `ByteSwap`) and the value bound on FMP are *independent* invariants. `fmp_native_safe()` and `can_use_native(0x40)` protect against mixing little-endian writers with big-endian readers on the FMP slot, which would corrupt the stored offset; the value bound is unrelated and holds in every mode. Earlier versions of the codegen gated the load-side range proof on the byte-order checks, which suppressed the optimization for any contract with dynamic memory accesses. Decoupling the two reasonings — keeping the byte-order gate on the *store* side, dropping it from the load-side range proof — is what makes the multiplicative IPSCCP effect available to OZ-class contracts.
### Soundness traps for FMP optimizations
The FMP slot is small but easy to mis-optimize. The codebase carries several
regression tests for previously-found soundness bugs; new FMP-related changes
should be verified against them:
- **`mload_at_fmp_slot`** (`crates/integration/src/tests.rs`, fixed in
`1fd6063c`): tests `mload(0x40)` and offsets near it (`0x21`, `0x3f`, `0x42`)
on a contract that also performs dynamic mloads. Catches byte-order mismatches
when one access goes native (LE) and another goes byte-swap (BE). The fix
blocks native mode for FMP whenever `has_dynamic_accesses` is true.
- **`mload_huge_offset_traps`** (fixed in `ccca38df`): tests that
`mload(2^128)` and `mload(2^255)` correctly trap via the gas-exhaustion
path. Catches `UseContext::MemoryOffset` narrowing bypassing the
`safe_truncate_int_to_xlen` overflow check at the use site —
`mload(2^128)` aliasing to `mload(0)` and returning the zero-initialized
scratch slot. The fix classifies `MemoryOffset` as `I256` so it doesn't
drive narrowing; the bounds check at the use site catches out-of-range.
- **FMP i32 shortcut removal** (`dbcfc921`): an earlier optimization stored
only 4 bytes at offset 0x40 instead of the full 32-byte EVM word, breaking
any inline assembly using `mstore(0x40, ...)` for non-FMP purposes.
Caused a cascade of 249/251 retester failures via allocator corruption.
No dedicated regression test was added — the retester corpus was sufficient
coverage — but the lesson generalizes: writes to 0x40 must store the full
word, even when the high bits are provably zero, because the slot is part
of the same 32-byte memory region read by other code.
When adding an optimization that touches FMP, distinguish carefully between:
the **byte-order encoding** at the slot (must be consistent between writers
and readers), the **value bound** (FMP < heap_size, always true), and the
**stored width** (must be 32 bytes for `mstore(0x40, ...)`, even though only
the low N bits are non-zero).
#### Known limitation: dynamic full-word stores to the FMP word
The `fmp_could_be_unbounded` analysis flags a static `mstore(0x40, untrusted)` and any
dynamic-offset `mstore8`, but **not** a dynamic-offset full-word `mstore`. Such a store whose
i256 offset wraps (mod 2²⁵⁶) to the FMP word `[0x40, 0x5f]` overwrites the free pointer with an
arbitrary value, which the load-side range proof would then truncate — a miscompile.
This is a deliberate, documented gap rather than a bug fix because there is no cheap sound
discriminator. A store hits the FMP word iff its offset lands in `[0x40, 0x5f]`, which is
in-bounds — `safe_truncate_int_to_xlen` only traps offsets `≥ heap_size` — and 256-bit wrap lets
any computed `add(base, k)` reach `0x40` with an adversarial operand, so the offset cannot be
proven to miss the slot from width/range information. The only sound recognizer (treat
`add(fmp, small_const)` as `≥ 0x80` by induction on FMP-boundedness) needs new FMP-derivation
dataflow, is fragile, and still misses dynamic-index array stores. Conservatively flagging *every*
dynamic full-word store (as the rare dynamic `mstore8` does, where it is free) disables the FMP
range proof for essentially every contract — measured at roughly **+9% / +30 KB** on the
OpenZeppelin corpus.
The gap is unreachable from solc output: solc's dynamic memory stores are all free-pointer-relative
(`≥ 0x80`) and never target `0x40`. Only hand-written Yul (`resolc --yul`) with an offset
engineered to equal `0x40` reaches it.
### Keccak256 fusion and folding
Two complementary optimizations target the common Solidity pattern of hashing values for storage slot computation:
- **Fusion**: Recognizes `mstore` + `keccak256` sequences and fuses them into dedicated IR nodes (`Keccak256Single`, `Keccak256Pair`), eliminating intermediate memory traffic.
- **Constant folding**: When all keccak256 inputs are compile-time constants, the hash is computed at compile time and replaced with a literal.
#### Known limitation: constant-folding drops the fused-keccak scratch write-back
The fused `Keccak256Pair`/`Keccak256Single` helpers write their inputs back to scratch memory
(`[0, 0x40)` / `[0, 0x20)`), and fusion *dead-eliminates the original `mstore`s* because that
write-back reproduces them. Constant-folding the fused node to a literal removes the helper, so the
scratch is left unwritten — a later `mload` from `[0, 0x40)` that the optimizer cannot forward
(across a region/call boundary) would read stale memory.
This gap is deliberately left open: it is solc-unreachable (solc treats scratch as volatile and never
re-reads it as data after a keccak), and every sound fix is a code-size regression because the dropped
write-back means the current output is already short the stores (disabling the fold falls back to the
runtime keccak helper, +0.78% on the OZ corpus, with no later mem_opt pass to clean re-emitted
writes). Only hand-written Yul that reads scratch after a constant-operand keccak across a boundary
can observe it.
### Compound outlining (mapping access)
Solidity mapping accesses follow a predictable pattern: hash a key with a storage slot, then load/store the result. The compound outlining pass detects `keccak256_pair(key, slot)` followed by `sload`/`sstore` and fuses them into `MappingSLoad`/`MappingSStore` IR nodes. These are lowered to outlined helper functions (`__revive_mapping_sload`, `__revive_mapping_sstore`) that combine the hash computation with the storage operation, eliminating intermediate values and redundant byte-swaps.
### Fuzzy function deduplication
Solidity generates many near-identical functions that differ only in literal constants (e.g., error selectors, storage slot offsets). Fuzzy deduplication identifies such groups, parameterizes the differing literals (up to 4 positions), and replaces all copies with calls to a single shared implementation.
### Revert pattern outlining
The simplify pass detects common revert patterns and replaces them with compact IR nodes:
- **Panic reverts**: Solidity `Panic(uint256)` sequences (selector `0x4e487b71` + encoded panic code) are collapsed into `PanicRevert { code }` nodes, which are lowered to shared helper functions.
- **Custom error reverts**: ABI-encoded custom error reverts with known selectors are collapsed into `CustomErrorRevert { selector, args }` nodes.
These patterns appear dozens of times in typical contracts, and outlining them into shared blocks eliminates significant code duplication.
### Outlined helper functions
The LLVM codegen backend generates approximately 15 types of outlined helper functions for common operations:
- **Storage**: `__revive_sload_word`, `__revive_sstore_word` (handle byte-swap internally)
- **Mapping**: `__revive_mapping_sload`, `__revive_mapping_sstore` (keccak256 + storage in one call)
- **Callvalue**: `__revive_callvalue`, `__revive_callvalue_nonzero` (boolean optimization for non-payable checks)
- **Calldataload**: `__revive_calldataload` (outlined when >= 20 call sites)
- **Memory**: `__revive_store_bswap`, `__revive_exit_checked`, `__revive_return_word`
- **Errors**: `__revive_error_string_revert_N`, `__revive_custom_error_N` (per data-word count)
- **Keccak wrappers**: `__keccak256_slot_N` (one `noinline` wrapper per constant slot, internally dispatching to `__revive_keccak256_two_words`)
Additionally, common exit patterns (revert with constant length, zero-value returns) are deduplicated into shared LLVM basic blocks, saving hundreds of instruction copies in large contracts.
## Codesize results
### Integration test contracts
Reproducible with `cargo test --package revive-integration -- codesize`. The `main` column is the value committed to `crates/integration/codesize.json` on `main`; the `newyork` column is the value produced by the same test with `RESOLC_USE_NEWYORK=1` set, currently committed on this branch.
| Contract | main (bytes) | newyork (bytes) | Reduction |
|---|---|---|---|
| Baseline | 870 | 479 | −44.9% |
| Computation | 2,418 | 1,376 | −43.1% |
| DivisionArithmetics | 9,327 | 7,192 | −22.9% |
| ERC20 | 17,160 | 10,138 | −40.9% |
| Events | 1,662 | 1,279 | −23.0% |
| FibonacciIterative | 1,427 | 949 | −33.5% |
| Flipper | 2,240 | 1,123 | −49.9% |
| SHA1 | 8,009 | 6,286 | −21.5% |
### OpenZeppelin contracts
Measured by running `oz-tests/oz.sh` against real-world contracts generated with the OpenZeppelin Wizard. The numbers below are a development snapshot — there is no committed measurement file in the repo, so these may drift as the optimizer evolves; rerun the script for fresh figures.
| Contract | newyork (bytes) |
|---|---|
| oz_gov | 81,840 |
| erc721 | 52,634 |
| erc20 | 45,703 |
| oz_stable | 45,052 |
| oz_rwa | 41,581 |
| erc1155 | 33,087 |
| oz_simple_erc20 | 17,024 |
| proxy | 3,748 |
| **Total** | **320,669** |
For comparison, building the same contracts without the newyork optimizer at the equivalent snapshot produced **563,526** bytes total — a reduction of about **−43%** across the corpus.
Per-contract reductions in the integration suite range from roughly **−21%** (SHA1, where the bulk of the work is the SHA-1 inner loop and offers little to optimise) to nearly **−50%** (Flipper, where the optimiser strips away most of Solidity's dispatch and storage-access scaffolding).
## Development history and challenges
The newyork optimizer was developed over roughly three months — from early February 2026 through early May 2026 — largely through AI-assisted pair programming with Claude. The development progressed through several distinct phases:
**Phase 1 -- Initial scaffolding**: The first draft established the core IR data structures, Yul-to-IR translation, and LLVM codegen. Early commits focused on getting a correct round-trip through the new pipeline.
**Phase 2 -- Optimization passes**: Once the baseline was stable, optimization passes were added iteratively: inlining, simplification, memory optimization, function deduplication, keccak256 fusion, and type inference. Each pass was validated against differential tests comparing EVM and PVM execution.
**Phase 3 -- Soundness hardening**: Several type inference and narrowing approaches turned out to be unsound and had to be reworked:
- An early type inference approach caused namespace collisions across subobjects and was scoped per-object.
- Caller-based parameter narrowing was polluted by overly aggressive inference and replaced with body-based structural analysis.
- Backward demand-driven narrowing required multiple iterations to become provably safe.
**Phase 4 -- Measuring and tuning**: Systematic measurement of OpenZeppelin contracts revealed which optimizations had the most impact and which approaches regressed performance.
### Approaches that did not work
| Approach | Outcome |
|---|---|
| Storage bswap decomposition (4x bswap.i64) | Regressed: LLVM handles bswap.i256 better natively |
| NoInline on `__revive_int_truncate` | +62% regression: PolkaVM call overhead exceeds inline cost |
| Native FMP memory (inline sbrk) | Mixed: small contracts improved, large ones regressed from sbrk bloat |
| Shared overflow trap block | Mixed: prevented LLVM from eliminating individual dead overflow checks |
These results highlight a recurring theme: interacting well with LLVM's own optimization passes is critical. Optimizations at the IR level can inadvertently inhibit LLVM's downstream passes, sometimes causing surprising regressions.
## Known limitations and future work
The following opportunities have been identified but are not yet implemented:
- **Bitwise algebraic simplifications**: `BitAnd`, `BitOr`, `BitXor` identity patterns fall through without simplification.
- **Cross-control-flow memory optimization**: Memory state is conservatively cleared at `if`/`switch`/`for` boundaries. Preserving state across simple branches would enable more load-after-store eliminations.
- **Adaptive inlining thresholds**: Current thresholds are static constants. Profile-guided or contract-size-aware heuristics could improve decisions for diverse contract sizes.
- **Extended fuzzy deduplication**: The current pass only compares functions by structure of `Let` bindings. Extending to consider literals inside `MStore`, `Return`, `Revert`, and `Log` statements would find more deduplication opportunities.
- **Type checking in validation**: The validator checks SSA well-formedness and structural correctness, but does not yet verify type consistency of operations (the `TypeMismatch` error variant exists but is not yet wired).
- **Loop variable narrowing**: Loop-carried variables are conservatively widened to `I256`. Reaching a fixed-point across loop iterations could allow narrower types for simple counters.
## Environment variables
A small set of environment variables controls or inspects the newyork pipeline. Only `RESOLC_USE_NEWYORK` affects generated bytecode; the others are read-only inspection knobs used while debugging the optimizer.
| Variable | Effect |
|---|---|
| `RESOLC_USE_NEWYORK=1` | Routes Yul lowering through the newyork pipeline. Equivalent to passing `--newyork` on the command line; the CLI flag and this variable are OR-ed by `resolve_use_newyork` (`crates/resolc/src/lib.rs`). |
| `RESOLC_DEBUG_IR` | When set, prints the translated newyork IR for every object to stderr. Additionally writes `<output_directory>/<object>.newyork.txt` whenever the debug config carries an output directory. |
| `RESOLC_DEBUG_HEAP` | When set, appends per-object heap-analysis details — native regions/offsets, taintedness, dynamic escapes, escaping ranges — to `<output_directory>/resolc_heap_debug.log`. Requires the debug config to carry an output directory. |
| `NEWYORK_DUMP_IR` | When set, writes the IR for every translated object to `/tmp/newyork_ir_<object>.txt` from inside `translate_yul_object` (`crates/newyork/src/lib.rs`). Independent of `RESOLC_DEBUG_IR` — fires before codegen and needs no output directory. |
| `RESOLC_DEBUG_BLOB` | Test harness only. Dumps the compiled PVM blob to `/tmp/debug_blob_<contract>.pvm` and the LLVM IR debug directory to `/tmp/debug_llvm_newyork` or `/tmp/debug_llvm_yul`. Used by `crates/resolc/src/test_utils.rs` when comparing newyork against the Yul path. |
All of these gate on presence/value at the start of compilation; flipping them mid-run has no effect.
## Module reference
| Module | Purpose |
|---|---|
| `lib.rs` | Pipeline orchestration and pass sequencing |
| `ir.rs` | Core IR data structures (types, expressions, statements, functions, objects) |
| `from_yul.rs` | Yul AST to newyork IR translation (two-pass with forward reference support) |
| `to_llvm.rs` | newyork IR to LLVM IR codegen with outlined helpers and narrowing |
| `simplify.rs` | Constant folding, algebraic identities, strength reduction, copy propagation, DCE, environment read CSE, revert outlining, callvalue hoisting, function deduplication (exact and fuzzy), constant keccak folding |
| `inline.rs` | Function inlining with PolkaVM-tuned heuristics (Tarjan SCC, leave elimination) |
| `type_inference.rs` | Bidirectional integer width narrowing with transparent demand propagation |
| `mem_opt.rs` | Load-after-store elimination, keccak256 fusion, FMP propagation |
| `heap_opt.rs` | Heap access pattern analysis, alignment tracking, byte-swap elimination |
| `compound_outlining.rs` | Mapping access pattern detection and fusion (`keccak256_pair` + `sload`/`sstore`) |
| `guard_narrow.rs` | Guard pattern detection and AND-mask narrowing insertion |
| `validate.rs` | IR well-formedness checks (SSA, yields, function references) |
| `printer.rs` | Human-readable IR pretty printer with configurable output |
| `ssa.rs` | SSA construction helpers (scope management, phi-node merging) |
+7 -1
View File
@@ -43,7 +43,13 @@ where
// not running the optimization passes.
let optimizer_settings = OptimizerSettings::none();
initialize_llvm(PolkaVMTarget::PVM, "resolc", Default::default());
initialize_llvm(
PolkaVMTarget::PVM,
"resolc",
optimizer_settings.level_middle_end_size,
false,
Default::default(),
);
group
.sample_size(90)
+3
View File
@@ -12,6 +12,9 @@ pub static EXTENSION_ABI: &str = "abi";
/// The Yul IR file extension.
pub static EXTENSION_YUL: &str = "yul";
/// The newyork IR debug dump file extension.
pub static EXTENSION_NEWYORK: &str = "newyork";
/// The EVM file extension.
pub static EXTENSION_EVM: &str = "evm";
+2
View File
@@ -10,6 +10,7 @@ pub(crate) mod extension;
pub(crate) mod keccak256;
pub(crate) mod metadata;
pub(crate) mod object;
pub(crate) mod solidity_selector;
pub(crate) mod utils;
pub use self::base::*;
@@ -21,5 +22,6 @@ pub use self::extension::*;
pub use self::keccak256::*;
pub use self::metadata::*;
pub use self::object::*;
pub use self::solidity_selector::*;
pub use self::utils::*;
pub use contract_identifier::*;
+22
View File
@@ -0,0 +1,22 @@
//! Solidity ABI selector words used when emitting reverts.
//!
//! Each constant is the 32-byte EVM memory word that stores the corresponding
//! Solidity selector at memory offset 0. The 4-byte selector occupies the
//! high-order bytes (memory addresses 0..4) and the remaining 28 bytes are
//! zero. The hex strings are consumed verbatim by 256-bit integer constructors
//! such as `Context::word_const_str_hex`, so the selector ends up in the most
//! significant bits of the resulting `i256`.
/// 32-byte EVM word holding the Solidity `Panic(uint256)` ABI selector.
///
/// `keccak256("Panic(uint256)")[..4] == 0x4e487b71`, placed in the high-order
/// bytes of a 32-byte word followed by 28 zero bytes.
pub const PANIC_UINT256_SELECTOR_WORD_HEX: &str =
"4e487b7100000000000000000000000000000000000000000000000000000000";
/// 32-byte EVM word holding the Solidity `Error(string)` ABI selector.
///
/// `keccak256("Error(string)")[..4] == 0x08c379a0`, placed in the high-order
/// bytes of a 32-byte word followed by 28 zero bytes.
pub const ERROR_STRING_SELECTOR_WORD_HEX: &str =
"08c379a000000000000000000000000000000000000000000000000000000000";
+3
View File
@@ -7,6 +7,9 @@ repository.workspace = true
authors.workspace = true
description = "revive compiler integration test cases"
[features]
newyork = ["resolc/newyork"]
[dependencies]
alloy-primitives = { workspace = true }
alloy-sol-types = { workspace = true }
+8 -8
View File
@@ -1,10 +1,10 @@
{
"Baseline": 870,
"Computation": 2418,
"DivisionArithmetics": 9368,
"ERC20": 17090,
"Events": 1662,
"FibonacciIterative": 1427,
"Flipper": 2240,
"SHA1": 8056
"Baseline": 838,
"Computation": 2368,
"DivisionArithmetics": 11444,
"ERC20": 18057,
"Events": 1614,
"FibonacciIterative": 1373,
"Flipper": 2205,
"SHA1": 7830
}
+10
View File
@@ -0,0 +1,10 @@
{
"Baseline": 493,
"Computation": 1217,
"DivisionArithmetics": 7370,
"ERC20": 8726,
"Events": 909,
"FibonacciIterative": 969,
"Flipper": 1058,
"SHA1": 6264
}
@@ -0,0 +1,26 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8;
contract ArrayProbe {
uint256[8] fixedArr;
uint256[] dynArr;
mapping(uint256 => uint256) m;
function run(uint256 a, uint256 b, uint256 c) public returns (uint256) {
for (uint256 i = 0; i < 8; i++) { fixedArr[i] = i * a + 1; }
// attacker-controlled index: out-of-bounds must revert (panic 0x32) identically
uint256 r = fixedArr[a & 7];
r = r + fixedArr[b & 7];
// dynamic array push/index
uint256 n = c & 15;
for (uint256 i = 0; i < n; i++) { dynArr.push(i + a); }
if (dynArr.length > 0) { r = r ^ dynArr[b % dynArr.length]; }
// mapping
m[a] = b;
m[b] = c;
r = r + m[a] + m[b];
// explicit bounds hit (reverts when a >= 8)
r = r + fixedArr[a];
return r;
}
}
@@ -0,0 +1,17 @@
/// A copy with a STATIC destination in [0x40, 0x60) but a DYNAMIC length
/// clobbers the free-memory-pointer slot with arbitrary bytes. A subsequent
/// mload(0x40) must observe the full 256-bit word (EVM), but newyork applies
/// its FMP range proof and truncates it because `fmp_could_be_unbounded` is
/// not set for the dynamic-length copy case (only the constant-length case).
object "CalldataCopyFmp" {
code { datacopy(0, dataoffset("CalldataCopyFmp_deployed"), datasize("CalldataCopyFmp_deployed")) return(0, datasize("CalldataCopyFmp_deployed")) }
object "CalldataCopyFmp_deployed" {
code {
let len := calldataload(0)
calldatacopy(0x40, 32, len)
let r := mload(0x40)
mstore(0, r)
return(0, 32)
}
}
}
@@ -0,0 +1,30 @@
/// Soundness PoC (newyork-specific): the `offx` parameter of `probe` is used as
/// an mload offset (case 0) which makes newyork narrow it to i64, and as a
/// `calldatacopy` SOURCE offset (case 1). newyork's CallDataCopy lowering treats
/// the calldata source offset as a heap pointer (`narrow_offset_for_pointer`),
/// so a large source offset traps on the PVM heap-bounds check / mis-sized copy
/// instead of zero-filling like EVM. 4 call sites keep `probe` non-inlined.
/// Reproduces only under RESOLC_USE_NEWYORK=1 (the Yul path is correct).
object "CalldataCopySrcNarrow" {
code { let s := datasize("CalldataCopySrcNarrow_deployed") codecopy(0, dataoffset("CalldataCopySrcNarrow_deployed"), s) return(0, s) }
object "CalldataCopySrcNarrow_deployed" {
code {
let op := calldataload(0)
let off := calldataload(32)
let r0 := probe(op, off)
let r1 := probe(op, off)
let r2 := probe(op, off)
let r3 := probe(op, off)
mstore(0x200, r0)
mstore(0x220, add(r1, add(r2, r3)))
return(0x200, 64)
function probe(opx, offx) -> r {
switch opx
case 0 { r := mload(offx) }
case 1 { calldatacopy(0x80, offx, 32) r := mload(0x80) }
case 2 { r := keccak256(offx, 32) }
default { r := offx }
}
}
}
}
@@ -0,0 +1,11 @@
object "CalldataloadOOB" {
code { datacopy(0, dataoffset("CalldataloadOOB_deployed"), datasize("CalldataloadOOB_deployed")) return(0, datasize("CalldataloadOOB_deployed")) }
object "CalldataloadOOB_deployed" {
code {
let off := calldataload(0) // the offset to test (from calldata[0])
let x := calldataload(off) // EVM: zero-pads beyond calldatasize
mstore(0, x)
return(0, 32)
}
}
}
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8;
// Differential guard against a latent soundness footgun in the codegen for
// __revive_caller: before the fix the helper wrote its result through the
// shared @__address_spill_buffer LLVM global while declaring
// memory(inaccessiblemem: read). The same global is also written by the
// inlined tx.origin and address(this) patterns, so the function attribute
// was a contract violation from LLVM's point of view (globals are Other
// memory, not inaccessiblemem). Any optimizer pass that exploited the wrong
// attribute to CSE / hoist a load of the spill buffer across a
// __revive_caller() call would have miscompiled the surrounding tx.origin /
// address(this) read.
//
// The fix moves __revive_caller's output to a function-local alloca so the
// attribute matches the body. This contract exercises every shape that
// previously shared the spill buffer.
contract CallerOriginAliasing {
function caller_then_origin() external view returns (address, address) {
return (msg.sender, tx.origin);
}
function origin_then_caller() external view returns (address, address) {
return (tx.origin, msg.sender);
}
function caller_address_origin() external view returns (address, address, address) {
return (msg.sender, address(this), tx.origin);
}
function repeated_caller() external view returns (address, address, address) {
return (msg.sender, msg.sender, msg.sender);
}
}
@@ -0,0 +1,23 @@
object "CallvalueRevertData" {
code { let s := datasize("CallvalueRevertData_deployed") codecopy(0, dataoffset("CallvalueRevertData_deployed"), s) return(0, s) }
object "CallvalueRevertData_deployed" {
code {
let sel := calldataload(0)
// One callvalue read per case (separate regions, so CSE keeps them distinct): three reads
// enable the callvalue-check outline.
switch sel
case 1 { if callvalue() { revert(0, 0) } sstore(0, 0x11) }
case 2 { if callvalue() { revert(0, 0) } sstore(0, 0x22) }
case 3 {
// A data-carrying revert whose operands are bound in the enclosing (case) scope. The outline
// must not treat this as an empty revert and drop the returned memory[off, off+len).
mstore(0x80, 0xdeadbeef)
let off := calldataload(32)
let len := calldataload(64)
if callvalue() { revert(off, len) }
sstore(0, 0x33)
}
default { sstore(0, 0xff) }
}
}
}
@@ -0,0 +1,28 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8;
/// Soundness PoC: `to_llvm.rs::get_or_create_return_block` (and the
/// emit-revert sibling) call `context.xlen_type().const_int(const_offset,
/// false)` to build the constant offset/length for the
/// `emit_exit_unchecked` path. `const_int` takes a `u64` and truncates
/// silently to `xlen_type`'s width (`i32`), so any constant
/// offset/length above `u32::MAX` (but still within `u64::MAX` so
/// `try_extract_const_u64` returns `Some`) is wrapped mod `2^32` and the
/// resulting return/revert reads from / aliases the truncated heap
/// offset. EVM expands memory and OOGs.
contract ConstReturnOverflowBug {
function bug() external pure returns (uint256 r) {
assembly {
// Constant offset 2^56 — fits in u64, doesn't fit in u32. With
// the current codegen, the shared `return_shared_…` block
// truncates to i32 (=0) and reads/writes at heap[0..32]
// instead of trapping.
mstore(0x100000000000000, mload(0x100000000000000))
return(0x100000000000000, 32)
// `r` is just to satisfy the signature; control flow exits
// via the inline-assembly `return` above.
r := 0
}
}
}
@@ -0,0 +1,26 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8;
/// Soundness PoC: `mem_opt::optimize_statements` `*Copy` handler
/// (CodeCopy / ExtCodeCopy / ReturnDataCopy / DataCopy /
/// CallDataCopy) only invalidates `memory_state[word(dest)]` when
/// `dest` is statically known. It ignores the *length* entirely, so
/// additional words inside `[dest, dest + length)` keep their stale
/// tracked entries. A subsequent `mload` at the exact tracked offset
/// is forwarded to the pre-overwrite value while EVM reads the
/// actually-overwritten bytes from the copy.
///
/// The dynamic `length` parameter defeats solc's dead-store
/// elimination of the sentinel — solc can't prove that
/// `calldatacopy(0xc0, 0, length)` overwrites bytes `0xe0..0x100`
/// without knowing `length >= 0x40`.
contract CopyOverlapBug {
function bug(uint256 length) external pure returns (bytes32 r) {
assembly {
mstore(0xe0, 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa)
calldatacopy(0xc0, 0, length)
r := mload(0xe0)
}
}
}
@@ -0,0 +1,16 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8;
/// Probe: custom error revert data must match EVM (selector + ABI-encoded args)
/// for edge-case argument values. Targets newyork's custom-error outlining.
contract CustomErrorArgs {
error E1(uint256 a);
error E2(uint256 a, uint256 b, address c);
error E3(uint8 x, bytes32 y);
function f(uint256 sel, uint256 a, uint256 b) external pure {
if (sel == 0) revert E1(a);
if (sel == 1) revert E2(a, b, address(uint160(a)));
if (sel == 2) revert E3(uint8(a), bytes32(b));
revert E1(~a);
}
}
@@ -0,0 +1,14 @@
object "CustomErrorDupArg" {
code { let s := datasize("CustomErrorDupArg_deployed") codecopy(0, dataoffset("CustomErrorDupArg_deployed"), s) return(0, s) }
object "CustomErrorDupArg_deployed" {
code {
// Custom-error revert shape (selector + one uint256 argument), but the argument word is written
// twice. EVM last-write-wins, so the revert argument is 0xbbbb. A reverse scan that keeps the
// first (earliest) match would collapse this to an error carrying 0xaaaa.
mstore(0, 0x1234567800000000000000000000000000000000000000000000000000000000)
mstore(4, 0xaaaa)
mstore(4, 0xbbbb)
revert(0, 0x24)
}
}
}
@@ -26,4 +26,20 @@ contract DivisionArithmetics {
r := smod(n, d)
}
}
/// Regression / soundness PoC: newyork simplifier folds `div(x, x) → 1`
/// and `sdiv(x, x) → 1` for any runtime value `x`, but EVM defines
/// division by zero to return 0, so `div(0, 0) = 0`. The fold is
/// unsound when `x == 0`.
///
/// `mod(x, x) → 0` is sound because EVM also returns 0 for mod by zero.
function divSelf(uint x) external pure returns (uint r) {
assembly { r := div(x, x) }
}
function sdivSelf(int x) external pure returns (int r) {
assembly { r := sdiv(x, x) }
}
function modSelf(uint x) external pure returns (uint r) {
assembly { r := mod(x, x) }
}
}
@@ -0,0 +1,16 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8;
/// Probe: abi.encodePacked tight-packing of mixed-width values + keccak256
/// (signature/CREATE2/mapping-key hashing). Must match EVM exactly.
contract EncodePackedHash {
function h(uint8 a, uint256 b, address c, uint16 d, bytes memory e) external pure returns (bytes32) {
return keccak256(abi.encodePacked(a, b, c, d, e));
}
function h2(uint256 a, uint256 b) external pure returns (bytes32) {
return keccak256(abi.encodePacked(a, b));
}
function h3(string memory s, uint8 n) external pure returns (bytes32) {
return keccak256(abi.encodePacked(s, n));
}
}
@@ -0,0 +1,26 @@
object "EscapeProbe" {
code {
datacopy(0, dataoffset("EscapeProbe_deployed"), datasize("EscapeProbe_deployed"))
return(0, datasize("EscapeProbe_deployed"))
}
object "EscapeProbe_deployed" {
code {
let op := calldataload(0)
let a := calldataload(32)
switch op
case 0 { mstore(0, a) log0(0, 32) return(0, 0) }
case 1 { mstore(0, a) log1(0, 32, 0x42) return(0, 0) }
case 2 { mstore(0x40, a) log0(0x40, 32) return(0, 0) }
case 3 { let p := mload(0x40) mstore(p, a) log0(p, 64) return(0, 0) }
case 4 { mstore(0, a) mstore(32, not(a)) log0(0, 64) return(0, 0) }
case 5 { mstore(0, a) revert(0, 32) }
case 6 { mstore(0x40, a) mstore8(0x60, 0xCD) log0(0x40, 33) return(0, 0) }
case 7 { mstore(0, a) return(0, 32) }
case 8 { mstore(0x40, a) return(0x40, 32) }
case 9 { mstore(0, a) mstore8(7, 0x99) revert(0, 32) }
case 10 { mstore(0, a) log2(0, 32, a, 0x11) return(0, 0) }
case 11 { let p := mload(0x40) mstore(p, a) mstore(add(p, 32), a) return(p, 64) }
default { return(0, 0) }
}
}
}
+9
View File
@@ -9,6 +9,15 @@ contract ExtCode {
}
}
// Two distinct addresses queried in a single function body: regression guard
// for the `code_size` import memory-attribute bug where LLVM CSE'd the two
// syscalls into one and returned `2 * extcodesize(a)`.
function ExtCodeSizeSum(address a, address b) public view returns (uint ret) {
assembly {
ret := add(extcodesize(a), extcodesize(b))
}
}
function CodeSize() public pure returns (uint ret) {
assembly {
ret := codesize()
@@ -0,0 +1,42 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8;
// Regression test: factorial with uint8 parameter and uint64 result.
// Bug: newyork pipeline doesn't revert for out-of-range uint8 parameter,
// causing the function to return 1 (factorial of truncated 0) instead of
// reverting.
/* runner.json
{
"differential": true,
"actions": [
{
"Instantiate": {
"code": { "Solidity": { "contract": "Factorial", "solc_optimizer": false } }
}
},
{
"Call": {
"dest": { "Instantiated": 0 },
"data": "ebbee3910000000000000000000000000000000000000000000000000000000000000005"
}
},
{
"Call": {
"dest": { "Instantiated": 0 },
"data": "ebbee3910000000000000000000000000000000000000000000000000000000100000000"
}
}
]
}
*/
contract Factorial {
function main(uint8 n) public pure returns (uint64) {
uint64 fact = 1;
for (uint8 i = 1; i <= n; i++) {
fact *= i;
}
return fact;
}
}
+19
View File
@@ -0,0 +1,19 @@
object "FmpBig" {
code { datacopy(0, dataoffset("FmpBig_deployed"), datasize("FmpBig_deployed")) return(0, datasize("FmpBig_deployed")) }
object "FmpBig_deployed" {
code {
let op := calldataload(0)
let v := calldataload(32)
let r := 0
switch op
// direct untrusted store to FMP then load
case 0 { mstore(0x40, v) r := mload(0x40) }
// allocator-looking store (add(mload(0x40), v)) then load
case 1 { mstore(0x40, add(mload(0x40), v)) r := mload(0x40) }
// store big, use as offset
case 2 { mstore(0x40, v) let p := mload(0x40) r := p }
mstore(0, r)
return(0, 32)
}
}
}
@@ -0,0 +1,37 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8;
contract FmpCrossObjectBug {
// Recursive so solc can't inline; the function survives to
// newyork IR as `fun_rec` with parameter ValueIds starting at 0,
// colliding with the deploy (parent) object's `v0 := 0x80`.
function rec(uint256 value, uint256 depth) internal view returns (uint256 r) {
if (depth == 0) {
assembly {
mstore(0x40, value)
// Force the same byte-swap-mode path as FmpRangeProofBug
// so the range proof is the only optimization controlling
// the loaded value.
let _success := staticcall(gas(), origin(), 0, 0x80, 0, 0)
r := mload(0x40)
}
return r;
}
return rec(value, depth - 1);
}
fallback() external {
uint256 value;
uint256 depth;
assembly {
value := calldataload(0)
depth := calldataload(0x20)
}
uint256 result = rec(value, depth);
assembly {
mstore(0x100, result)
return(0x100, 32)
}
}
}
@@ -0,0 +1,24 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8;
/// Soundness PoC: same shape as `FmpRevertBug` but with *dynamic*
/// offset and length on the revert. The original `mark_escaping_range`
/// only sets `has_dynamic_escapes` for fully-dynamic escapes
/// (`(None, _)` arm) — it does not lower `min_dynamic_escape_start`,
/// so `fmp_native_safe()` doesn't reject and `mstore(0x40, …)` still
/// encodes as a 4-byte i32 LE native store. With the caller passing
/// `offset = 0` and `length = 96`, the revert covers the FMP slot
/// and the BE/LE encoding mismatch is observed.
contract FmpDynRevertBug {
fallback() external {
uint256 offset;
uint256 length;
assembly {
mstore(0x40, 0xdeadbeef)
offset := calldataload(0)
length := calldataload(32)
revert(offset, length)
}
}
}
@@ -0,0 +1,41 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8;
/// Soundness PoC: `mem_opt::FmpPropagation::propagate_statements`
/// tracks the known FMP value across statements. Its MStore handler
/// detects FMP writes by `region == FreePointerSlot ||
/// resolved_offset == Some(0x40)`. For an mstore with a *dynamic*
/// offset whose runtime value happens to be 0x40 (but the simplifier
/// can't see that — e.g. `calldataload(...)`), neither condition
/// fires: the simplifier leaves `region == Unknown` because the
/// offset isn't a literal, and `resolve_offset` returns None because
/// the value isn't tracked in the local constants map. The
/// `fmp_value` stays stale.
///
/// A subsequent `mload(0x40)` IS recognized as an FMP load (because
/// 0x40 is a literal). FmpPropagation folds it to the stale literal
/// `fmp_value` at IR level — diverging from EVM which reads the
/// overwritten memory.
///
/// Both values (`0x100`, `0x200`) fit in the heap-size range so the
/// FMP-load range proof (`to_llvm.rs::apply_range_proof`) doesn't
/// affect this test.
contract FmpDynStoreBug {
fallback() external {
uint256 offset;
assembly {
// Set FMP via a recognized FMP-slot mstore.
mstore(0x40, 0x100)
// Caller passes 0x40 as calldata so offset = 0x40 at
// runtime, but the simplifier sees an opaque calldataload.
offset := calldataload(0)
// Dynamic-offset mstore. FmpPropagation can't tell this
// hits 0x40, so it doesn't invalidate the known fmp_value.
mstore(offset, 0x200)
let v := mload(0x40)
mstore(0x100, v)
return(0x100, 32)
}
}
}
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8;
contract FmpNativeStoreBug {
// Recursive — solc can't inline.
function loop(uint depth) internal pure returns (uint) {
if (depth == 0) return 0;
return loop(depth - 1) + depth;
}
fallback() external {
uint256 v;
uint256 depth;
assembly {
v := calldataload(0)
depth := calldataload(0x20)
mstore(0x40, v)
}
// Recursive call: mem_opt clears memory_state, no escape so
// fmp_word_escapes stays false, fmp_native_safe stays true.
uint256 unused = loop(depth);
uint256 r;
assembly {
r := mload(0x40)
// use unused so solc doesn't DCE the call
r := add(r, mul(unused, 0))
mstore(0x100, r)
return(0x100, 32)
}
}
}
@@ -0,0 +1,15 @@
/// Track an FMP value, then overwrite the FMP slot via a calldatacopy whose
/// source is beyond calldatasize (zero-fill). A stale FMP-propagation would
/// forward the tracked value; EVM reads the (now zeroed) slot.
object "FmpPropCopy" {
code { datacopy(0, dataoffset("FmpPropCopy_deployed"), datasize("FmpPropCopy_deployed")) return(0, datasize("FmpPropCopy_deployed")) }
object "FmpPropCopy_deployed" {
code {
mstore(0x40, 0x1234)
calldatacopy(0x40, 1000, 32) // src 1000 >= calldatasize -> zero-fills heap[0x40..0x60]
let p := mload(0x40) // EVM: 0; stale FmpProp would give 0x1234
mstore(0x80, p)
return(0x80, 32)
}
}
}
@@ -0,0 +1,20 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8;
contract FmpRangeProofBug {
fallback() external {
assembly {
let v := calldataload(0)
mstore(0x40, v)
// Force fmp_word_escapes via a staticcall whose args cover
// memory[0..0x80] (so 0x40 is in the escape range). That
// disables FMP-native-mode storage and forces ByteSwap mode
// where the range-proof bug lives.
let _success := staticcall(gas(), origin(), 0, 0x80, 0, 0)
let r := mload(0x40)
mstore(0x100, r)
return(0x100, 32)
}
}
}
@@ -0,0 +1,36 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8;
/// Soundness PoC: `heap_opt.rs::fmp_native_safe()` does NOT check
/// `tainted_regions` or `escaping_regions`. A `revert(0, 96)` that
/// covers the FMP slot (0x40) only calls `mark_escaping_range`
/// (line 241-243), which adds the FMP word to `escaping_regions` /
/// `tainted_regions` — but `fmp_native_safe()` only looks at four flags
/// (variable_accessed_offsets, has_dynamic_accesses,
/// has_return_covering_fmp, min_dynamic_escape_start) and returns
/// true when the static `revert(0, 96)` set none of them.
///
/// `Statement::Return` correctly sets `has_return_covering_fmp` in this
/// case (line 252-254). `Statement::Revert` does not, so a contract
/// that reverts with data covering the FMP gets the FMP slot stored
/// as native i32 little-endian (4 bytes at heap[0x40..0x44]) instead
/// of the big-endian 32-byte word EVM produces.
///
/// EVM revert data [0x40..0x60) = 32-byte BE encoding of FMP value.
/// PVM revert data [0x40..0x60) = 4 bytes LE i32, then 28 bytes
/// of uninitialized/zero heap memory.
contract FmpRevertBug {
fallback() external {
assembly {
// Set FMP to 0xdeadbeef. With newyork's InlineNative mode,
// this becomes a 4-byte i32 LE store at heap[0x40..0x44].
mstore(0x40, 0xdeadbeef)
// Revert with 96 bytes covering [0x00..0x60). EVM caller
// sees 32-byte BE encoding of FMP at bytes [0x40..0x60).
// PVM caller sees the 4-byte LE encoding at [0x40..0x44)
// followed by 28 uninitialized bytes — different bytes.
revert(0, 96)
}
}
}
@@ -0,0 +1,18 @@
/// Soundness PoC candidate: store a meaningful value into the free-memory-
/// pointer slot (0x40) and return a region covering it. If heap native-mode
/// stores 0x40 little-endian while the `return` reads it big-endian, the
/// returned bytes are byte-swapped relative to EVM.
object "FmpSlotReturnByteOrder" {
code {
let size := datasize("FmpSlotReturnByteOrder_deployed")
codecopy(0, dataoffset("FmpSlotReturnByteOrder_deployed"), size)
return(0, size)
}
object "FmpSlotReturnByteOrder_deployed" {
code {
let v := calldataload(0)
mstore(0x40, v)
return(0x40, 32)
}
}
}
@@ -0,0 +1,33 @@
/// Soundness PoC: newyork forward inference gives a `FreePointerSlot` `mload`
/// (the free-memory pointer at 0x40) the width I32, sound only while the FMP
/// holds a Solidity-allocator pointer (`< heap_size`). When a write to 0x40 is
/// not provably sbrk-bounded, `heap_opt` sets `fmp_could_be_unbounded` and
/// codegen loads the *full* FMP word (no `FMP < heap_size` range proof) — but
/// inference still said I32, so `gt(v, 0xffffffff)` was evaluated at 32 bits and
/// truncated the live value.
///
/// `mstore(0x40, calldataload(0))` taints the FMP with an arbitrary value; the
/// `staticcall` is a memory barrier so the following `mload(0x40)` is a real load
/// rather than store-forwarded. With `v = 2^40` (> 2^32) the comparison must be
/// `1`; the bug truncates `v` to its low 32 bits (`0`) and yields `0`.
object "FmpUnboundedCompare" {
code {
let size := datasize("FmpUnboundedCompare_deployed")
codecopy(0, dataoffset("FmpUnboundedCompare_deployed"), size)
return(0, size)
}
object "FmpUnboundedCompare_deployed" {
code {
let taint := calldataload(0)
mstore(0x40, taint)
let ok := staticcall(gas(), 0, 0, 0, 0, 0)
let v := mload(0x40)
let out := 0
if gt(v, 0xffffffff) {
out := 1
}
mstore(0, out)
return(0, 32)
}
}
}
+23
View File
@@ -0,0 +1,23 @@
object "GF" {
code { datacopy(0, dataoffset("GF_deployed"), datasize("GF_deployed")) return(0, datasize("GF_deployed")) }
object "GF_deployed" {
code {
function f(p0, p1) -> r0, r1 {
r0 := 0 r1 := 0
if xor(4294967295, 32) { r0 := p1 leave }
if shr(p1, p1) { r0 := p1 leave }
if 32 { r1 := and(sub(340282366920938463463374607431768211456, 1461501637330902918203684832716283019655932542975), 0xFF) leave }
if sar(255, p1) { r1 := and(smod(5315799647723455265, p1), 0xFF) leave }
}
let a := calldataload(0)
let b := calldataload(32)
let c := calldataload(64)
let d := calldataload(96)
let s0, s1 := f(a, b)
let t0, t1 := f(c, d)
let r := xor(xor(s0, s1), xor(t0, t1))
mstore(0, r)
return(0, 32)
}
}
}
@@ -0,0 +1,33 @@
/// Soundness/robustness PoC: newyork's SSA builder (`ssa.rs`) does not place a
/// variable declared in a `for`-loop INIT block into the scope visible to the
/// loop BODY and POST blocks. In Yul, the init-block declarations are scoped to
/// the entire `for` statement (condition, body, post). The canonical Yul loop
///
/// for { let j := 0 } lt(j, a) { j := add(j, 1) } { ... use j ... }
///
/// therefore panics with `ICE: SsaBuilder::assign called for undeclared
/// variable 'j'` when the body/post references `j`.
///
/// solc's ForLoopInitRewriter hoists init declarations out of the init block
/// (emitting an empty init), so the Solidity frontend — even inline assembly —
/// never produces this shape. Direct Yul input (`resolc --yul` / `Code::Yul`)
/// does, and fails to compile. Fail-safe (ICE, not a silent miscompile), but a
/// valid program the newyork pipeline cannot compile.
object "ForInitScopeIce" {
code {
let size := datasize("ForInitScopeIce_deployed")
codecopy(0, dataoffset("ForInitScopeIce_deployed"), size)
return(0, size)
}
object "ForInitScopeIce_deployed" {
code {
let a := calldataload(0)
let sum := 0
for { let j := 0 } lt(j, a) { j := add(j, 1) } {
sum := add(sum, j)
}
mstore(0x80, sum)
return(0x80, 32)
}
}
}
@@ -0,0 +1,26 @@
object "ForLoopFmp" {
code { let s := datasize("ForLoopFmp_deployed") codecopy(0, dataoffset("ForLoopFmp_deployed"), s) return(0, s) }
object "ForLoopFmp_deployed" {
code {
// Establish a known free-memory pointer so FMP propagation tracks it as a constant.
mstore(0x40, 0x80)
// Dynamic trip count from calldata keeps the loop from being unrolled (which would hide the
// per-iteration re-read of the FMP).
let n := calldataload(0)
// Each iteration allocates a fresh 32-byte slot at the current FMP and bumps the pointer.
// The loop-top `mload(0x40)` must observe the bump from the previous iteration; if FMP
// propagation rewrites it to the pre-loop constant, every iteration aliases the first slot.
for { let i := 0 } lt(i, n) { i := add(i, 1) } {
let p := mload(0x40)
mstore(p, add(i, 1))
mstore(0x40, add(p, 0x20))
}
// Read back the first three allocated slots. Correct: [1, 2, 3] for n >= 3.
// With the aliasing bug: [n, 0, 0].
mstore(0x200, mload(0x80))
mstore(0x220, mload(0xa0))
mstore(0x240, mload(0xc0))
return(0x200, 0x60)
}
}
}
@@ -0,0 +1,66 @@
/// Soundness PoC: newyork's fuzzy function deduplication
/// (`simplify.rs::deduplicate_functions_fuzzy`) abstracts `switch` case
/// match values into parameterizable literal positions
/// (`fuzzy_encode_statement` emits a placeholder per case value), so two
/// functions that are structurally identical *except for their switch case
/// match values* hash to the same fuzzy form and are merged.
///
/// However `replace_literals_with_params` advances the literal counter for
/// each switch case but NEVER substitutes `case.value`. The merged canonical
/// function therefore keeps ITS OWN case values; the removed function's call
/// sites are redirected to the canonical function with the (now ignored)
/// differing values appended as dead extra arguments.
///
/// Result: `g`'s callers silently execute `f`'s switch dispatch. `g(111)`
/// should hit `g`'s `case 111` and return 1111, but after the merge it falls
/// through to the default of `f` (whose cases are 100/200/300) and returns
/// the default value. EVM (and unoptimized semantics) returns 1111.
object "FuzzySwitchBug" {
code {
let size := datasize("FuzzySwitchBug_deployed")
codecopy(0, dataoffset("FuzzySwitchBug_deployed"), size)
return(0, size)
}
object "FuzzySwitchBug_deployed" {
code {
let sel := calldataload(0)
let x := calldataload(32)
let r := 0
switch sel
case 0 { r := f(x) }
case 1 { r := f(x) }
case 2 { r := g(x) }
case 3 { r := g(x) }
default { r := 0 }
mstore(0, r)
return(0, 32)
function f(a) -> out {
let t := add(a, 1)
t := mul(t, 2)
t := add(t, 3)
t := xor(t, 4)
t := add(t, 5)
out := 0
switch a
case 100 { out := add(t, 1000) }
case 200 { out := add(t, 2000) }
case 300 { out := add(t, 3000) }
default { out := 9999 }
}
function g(a) -> out {
let t := add(a, 1)
t := mul(t, 2)
t := add(t, 3)
t := xor(t, 4)
t := add(t, 5)
out := 0
switch a
case 111 { out := add(t, 1000) }
case 222 { out := add(t, 2000) }
case 333 { out := add(t, 3000) }
default { out := 9999 }
}
}
}
}
@@ -0,0 +1,17 @@
object "HugeConstOffsetStore" {
code { let s := datasize("HugeConstOffsetStore_deployed") codecopy(0, dataoffset("HugeConstOffsetStore_deployed"), s) return(0, s) }
object "HugeConstOffsetStore_deployed" {
code {
let x := calldataload(0)
// A dynamic store keeps the object out of all-native mode, so the huge CONSTANT-offset store
// below takes the inline (unchecked) path rather than the bounds-checked AllNative path.
mstore(calldataload(32), 7)
// Huge constant offset: far past the fixed heap. EVM charges astronomical memory-expansion gas
// and runs out of gas; the inline unchecked GEP would instead write out of the heap global. It
// must trap (out-of-gas), not corrupt memory and fall through to the sstore.
mstore(0xFFFFFFF0, x)
sstore(0, 1)
return(0, 0)
}
}
}
@@ -0,0 +1,26 @@
object "IfCallFmp" {
code { let s := datasize("IfCallFmp_deployed") codecopy(0, dataoffset("IfCallFmp_deployed"), s) return(0, s) }
object "IfCallFmp_deployed" {
code {
// Known free-memory pointer, tracked as a constant by FMP propagation.
mstore(0x40, 0x80)
let cnt := calldataload(0)
// The branch calls an internal allocator that bumps the FMP. `allocate` is recursive, so it is
// never inlined: the `mstore(0x40, ...)` lives inside the callee, not inside this `if` body.
// Region FMP invalidation must therefore consult `fmp_writers` (the call), not just look for a
// direct FMP store in the branch — otherwise the post-`if` `mload(0x40)` is forwarded the stale
// pre-branch constant 0x80 even when `cnt > 0` bumped the pointer.
if cnt { allocate(cnt) }
let p := mload(0x40)
mstore(0x200, p)
return(0x200, 0x20)
function allocate(n) {
if n {
let f := mload(0x40)
mstore(0x40, add(f, 0x20))
allocate(sub(n, 1))
}
}
}
}
}
@@ -0,0 +1,57 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8;
// Regression test: contracts whose Yul IR generates internal helper functions
// (e.g. abi_encode_string, extract_byte_array_length) that survive the newyork
// inliner require a function scope in the LLVM context during code generation.
// Without it, declaring frontend functions in generate_object triggers an ICE:
// "function scope must be pushed before declaring frontend functions".
/* runner.json
{
"differential": false,
"actions": [
{
"Instantiate": {}
},
{
"Call": {
"dest": { "Instantiated": 0 },
"data": "e942b51600000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000568656c6c6f0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005776f726c64000000000000000000000000000000000000000000000000000000"
}
},
{
"VerifyCall": {
"success": true
}
},
{
"Call": {
"dest": { "Instantiated": 0 },
"data": "6d4ce63c"
}
},
{
"VerifyCall": {
"success": true,
"output": "00000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000568656c6c6f0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005776f726c64000000000000000000000000000000000000000000000000000000"
}
}
]
}
*/
contract InternalFn {
string public a;
string public b;
function set(string calldata _a, string calldata _b) external {
a = _a;
b = _b;
}
function get() external view returns (string memory, string memory) {
return (a, b);
}
}
@@ -0,0 +1,36 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8;
contract KeccakFuseBug {
address public target;
constructor() {
target = address(this);
}
function probe(uint256[8] calldata seeds)
external view returns (uint256 r, bytes32 sink_out)
{
bytes32 sink;
uint256 s0 = seeds[0]; uint256 s1 = seeds[1]; uint256 s2 = seeds[2];
uint256 s3 = seeds[3]; uint256 s4 = seeds[4]; uint256 s5 = seeds[5];
uint256 s6 = seeds[6]; uint256 s7 = seeds[7];
assembly {
mstore(0, s0) sink := xor(sink, keccak256(0, 32))
mstore(0, s1) sink := xor(sink, keccak256(0, 32))
mstore(0, s2) sink := xor(sink, keccak256(0, 32))
mstore(0, s3) sink := xor(sink, keccak256(0, 32))
mstore(0, s4) sink := xor(sink, keccak256(0, 32))
mstore(0, s5) sink := xor(sink, keccak256(0, 32))
mstore(0, s6) sink := xor(sink, keccak256(0, 32))
mstore(0, s7) sink := xor(sink, keccak256(0, 32))
}
(bool ok,) = target.staticcall("");
ok;
assembly {
r := mload(0)
}
sink_out = sink;
}
}
@@ -0,0 +1,44 @@
/// Soundness PoC: newyork forward type inference widens an `if`/`switch`
/// output only from the then/else region *yields*, never from `inputs`. But on
/// a missing `else`/`default` edge codegen routes `inputs` straight through to
/// `outputs` (`to_llvm` phi construction; the IR `If` "defaults to yielding
/// inputs unchanged" contract). The `leave`-elimination wrapper
/// (`inline.rs::wrap_remaining_in_guard`) builds exactly that shape:
/// `else_region: None`, non-empty `outputs`, where `inputs` are the
/// pre-`leave` accumulators and the then-region yields the fall-through values.
///
/// `f(v)` sets `ret := v` (full width), takes `leave` for `v > 1000` (carrying
/// the wide `ret`), and otherwise falls through to `ret := 7` (narrow). Inference
/// widened the output only from the `7` yield → i8, so codegen truncates the
/// leave-edge value to its low byte. `from_yul` always emits an explicit else,
/// so only the post-inlining `leave` shape is affected.
///
/// Called with `v = 2^200` (> 1000): EVM returns `2^200`; the bug returns
/// `2^200 mod 256 == 0`.
object "LeaveWideOutput" {
code {
let size := datasize("LeaveWideOutput_deployed")
codecopy(0, dataoffset("LeaveWideOutput_deployed"), size)
return(0, size)
}
object "LeaveWideOutput_deployed" {
code {
let x := calldataload(0)
let r := f(x)
let out := 0
if gt(r, 5) {
out := 1
}
mstore(0, out)
return(0, 32)
function f(v) -> ret {
ret := v
if gt(v, 1000) {
leave
}
ret := 7
}
}
}
}
@@ -0,0 +1,14 @@
/// Store a value at 0x80, then overwrite [0x80,0xA0) with calldata via calldatacopy.
/// A stale load-forward would return the original stored value.
object "LoadFwdCopy" {
code { datacopy(0, dataoffset("LoadFwdCopy_deployed"), datasize("LoadFwdCopy_deployed")) return(0, datasize("LoadFwdCopy_deployed")) }
object "LoadFwdCopy_deployed" {
code {
mstore(0x80, 0x1234)
calldatacopy(0x80, 0, 32)
let p := mload(0x80) // EVM: calldata[0..32]; stale fwd: 0x1234
mstore(0x100, p)
return(0x100, 32)
}
}
}
+11 -1
View File
@@ -4,7 +4,7 @@ pragma solidity ^0.8;
/* runner.json
{
"differential": true,
"differential": false,
"actions": [
{
"Instantiate": {
@@ -24,6 +24,11 @@ pragma solidity ^0.8;
"data": "f016832c"
}
},
{
"VerifyCall": {
"success": true
}
},
{
"Call": {
"dest": {
@@ -31,6 +36,11 @@ pragma solidity ^0.8;
},
"data": "f4a63aa5"
}
},
{
"VerifyCall": {
"success": true
}
}
]
}
+15
View File
@@ -0,0 +1,15 @@
object "Mem27" {
code { datacopy(0, dataoffset("Mem27_deployed"), datasize("Mem27_deployed")) return(0, datasize("Mem27_deployed")) }
object "Mem27_deployed" {
code {
let r := 0
r := xor(r, mload(16))
r := xor(r, keccak256(16, 29))
r := xor(r, mload(16))
r := add(r, sload(13))
r := xor(r, keccak256(0, 15))
mstore(0, r)
return(0, 32)
}
}
}
@@ -0,0 +1,36 @@
/// Regression fixture for the dead-store-elimination key-mismatch bug in the
/// newyork mem_opt pass. `MemoryOptimizer.pending_stores` is keyed by the exact
/// store offset, but the load handler removed entries by the word-aligned offset.
///
/// For a store at the non-word-aligned offset `0x104`, the exact `mload(0x104)`
/// forwards the stored value but (with the bug) removes the word-aligned key
/// `0x100` instead of `0x104`, so the store is never marked read. The later
/// same-offset `mstore(0x104, v2)` then finds the still-pending entry and
/// dead-eliminates the first store — even though `mload(0x108)` (overlapping,
/// non-forwarded) read its bytes. The eliminated store makes `y` observe fresh
/// memory instead of `v1`, diverging from the EVM reference.
///
/// The `keccak256(0x104, 0x40)` forces the region to big-endian (escaping)
/// memory, so the only behavioural divergence under the bug is the dropped
/// store, not memory byte order. Compiled directly through revive's Yul path so
/// the exact statement shape reaches the newyork mem_opt pass intact.
object "MemOptOverlapDeadStore" {
code {
let size := datasize("MemOptOverlapDeadStore_deployed")
codecopy(0, dataoffset("MemOptOverlapDeadStore_deployed"), size)
return(0, size)
}
object "MemOptOverlapDeadStore_deployed" {
code {
let v1 := calldataload(0)
let v2 := calldataload(32)
mstore(0x104, v1)
let x := mload(0x104)
let y := mload(0x108)
mstore(0x104, v2)
let h := keccak256(0x104, 0x40)
mstore(0, xor(xor(x, y), h))
return(0, 32)
}
}
}
+34
View File
@@ -0,0 +1,34 @@
object "MemProbe" {
code {
datacopy(0, dataoffset("MemProbe_deployed"), datasize("MemProbe_deployed"))
return(0, datasize("MemProbe_deployed"))
}
object "MemProbe_deployed" {
code {
let op := calldataload(0)
let a := calldataload(32)
let r := 0
switch op
case 0 { mstore(0, a) r := keccak256(0, 32) }
case 1 { mstore(0, a) mstore(32, a) r := keccak256(0, 64) }
case 2 { mstore(0, a) r := mload(0) }
case 3 { mstore(0, a) mstore(16, a) r := mload(0) }
case 4 { mstore8(0, a) r := mload(0) }
case 5 { mstore(0, a) r := keccak256(0, 1) }
case 6 { mstore(0, a) calldatacopy(32, calldatasize(), 32) r := keccak256(0, 64) }
case 7 { mstore(0, a) mcopy(32, 0, 32) r := keccak256(32, 32) }
case 8 { mstore(0, a) r := msize() }
case 9 { mstore(0x80, a) r := msize() }
case 10 { mstore8(100, 0xAB) r := msize() }
case 11 { mstore(0, a) mstore(32, a) mcopy(8, 0, 40) r := keccak256(0, 72) }
case 12 { mstore(0, a) r := keccak256(0, 0) }
case 13 { let p := mload(0x40) mstore(p, a) r := keccak256(p, 32) }
case 14 { mstore(0, a) r := keccak256(3, 17) }
case 15 { mstore(0, a) mstore8(5, 0xEE) r := mload(0) }
case 16 { mstore(0, a) r := keccak256(31, 1) }
case 17 { mstore(10, a) r := mload(0) }
mstore(0, r)
return(0, 32)
}
}
}
@@ -0,0 +1,16 @@
object "MsizeCopy" {
code { let s := datasize("MsizeCopy_deployed") codecopy(0, dataoffset("MsizeCopy_deployed"), s) return(0, s) }
object "MsizeCopy_deployed" {
code {
let op := calldataload(0)
let r := 0
switch op
case 0 { calldatacopy(0x81, 0, 5) r := msize() } // dest 0x81 len 5 -> end 0x86 -> EVM 0xA0
case 1 { mstore(0x80, 1) mcopy(0x141, 0x80, 7) r := msize() } // dest 0x141 len 7 -> 0x148 -> 0x160
case 2 { calldatacopy(0x100, 0, 33) r := msize() } // 0x100+33=0x121 -> 0x140
default { r := 0 }
mstore(0x400, r)
return(0x400, 32)
}
}
}
@@ -0,0 +1,23 @@
object "MsizeForCondition" {
code { let s := datasize("MsizeForCondition_deployed") codecopy(0, dataoffset("MsizeForCondition_deployed"), s) return(0, s) }
object "MsizeForCondition_deployed" {
code {
let x := calldataload(0)
// Touch memory at 0x80 so the EVM free-memory watermark (msize) is a
// nonzero 0xA0, regardless of `x`.
mstore(0x80, x)
let ran := 0
// A *bare* `msize()` for-loop condition: this is the only expression
// position the IR does not materialize into a preceding `let`. The body
// runs once iff `msize()` is truthy. If a native store fails to update
// the heap-size watermark (because `has_msize` missed this position),
// `msize()` reads stale 0, the body is skipped, and `ran` diverges.
for {} msize() {} {
ran := add(ran, 1)
break
}
mstore(0x400, ran)
return(0x400, 32)
}
}
}
@@ -0,0 +1,25 @@
/// Probe: msize after various memory-touching ops. EVM msize = highest accessed
/// byte rounded up to a word. newyork tracks msize via ensure_heap_size; any
/// divergence corrupts Solidity's allocator (which reads msize/FMP).
object "MsizeProbe" {
code { let s := datasize("MsizeProbe_deployed") codecopy(0, dataoffset("MsizeProbe_deployed"), s) return(0, s) }
object "MsizeProbe_deployed" {
code {
let op := calldataload(0)
let x := calldataload(32)
let r := 0
switch op
case 0 { mstore(0x80, x) r := msize() }
case 1 { mstore8(0x95, x) r := msize() }
case 2 { let _v := mload(0x100) r := msize() }
case 3 { mstore(0x80, x) calldatacopy(0x200, 0, 16) r := msize() }
case 4 { r := msize() }
case 5 { mstore(0x80, x) let _v := mload(0x81) r := msize() }
case 6 { mstore8(0x100, x) r := msize() }
case 7 { mstore(and(x, 0xfe0), x) r := msize() }
default { r := 0 }
mstore(0x400, r)
return(0x400, 32)
}
}
}
@@ -0,0 +1,16 @@
object "MsizeReadOps" {
code { let s := datasize("MsizeReadOps_deployed") codecopy(0, dataoffset("MsizeReadOps_deployed"), s) return(0, s) }
object "MsizeReadOps_deployed" {
code {
let op := calldataload(0)
let r := 0
switch op
case 0 { mstore(0,0) let h := keccak256(0x81, 5) r := msize() } // reads 0x81..0x86 -> EVM 0xA0
case 1 { log0(0x81, 7) r := msize() } // 0x81..0x88 -> EVM 0xA0
case 2 { let h := keccak256(0xc1, 33) r := msize() } // 0xc1..0xe2 -> EVM 0x100
default { r := 0 }
mstore(0x400, r)
return(0x400, 32)
}
}
}
@@ -0,0 +1,18 @@
object "MsizeUnaligned" {
code { let s := datasize("MsizeUnaligned_deployed") codecopy(0, dataoffset("MsizeUnaligned_deployed"), s) return(0, s) }
object "MsizeUnaligned_deployed" {
code {
let op := calldataload(0)
let x := calldataload(32)
let r := 0
switch op
case 0 { mstore(0x81, x) r := msize() } // EVM: ceil(0xA1)->0xC0 (192)
case 1 { mstore8(0x85, x) r := msize() } // EVM: ceil(0x86)->0xA0 (160)
case 2 { mstore(0x80, x) r := msize() } // aligned: 0xA0 (160)
case 3 { mstore(0xc1, x) r := msize() } // EVM: ceil(0xE1)->0x100 (256)
default { r := 0 }
mstore(0x400, r)
return(0x400, 32)
}
}
}
@@ -0,0 +1,20 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8;
/// Soundness PoC: newyork's `narrow_function_returns` only inspects the
/// function's fall-through `return_values`, ignoring values returned by EARLY
/// `leave` (return) statements. When the fall-through returns a small constant
/// (here `return 0`, min_width I1) but earlier branches return full-width
/// values, the function's return type is narrowed to i32 and EVERY early-leave
/// result is truncated to 32 bits. `op(a,b)` for a = 2^256-1 returns
/// 0xffffffff instead of 2^256-1.
contract MultiLeaveReturnNarrow {
function run(uint256 op, uint256 a, uint256 b) external pure returns (uint256) {
if (op == 0) return a + b;
if (op == 1) return a - b;
if (op == 2) return a * b;
if (op == 3) return a / b;
if (op == 4) return a % b;
return 0; // small-constant fall-through
}
}
@@ -0,0 +1,44 @@
object "NarrowProbe" {
code {
datacopy(0, dataoffset("NarrowProbe_deployed"), datasize("NarrowProbe_deployed"))
return(0, datasize("NarrowProbe_deployed"))
}
object "NarrowProbe_deployed" {
code {
let op := calldataload(0)
let a := calldataload(32)
let r := 0
switch op
// high-bit round trip: narrow via shr then widen back via shl
case 0 { r := shl(200, shr(200, a)) }
// narrow to i32 then square (needs up to 64 bits)
case 1 { let t := and(a, 0xFFFFFFFF) r := mul(t, t) }
// narrow to i64 then shift into the high half
case 2 { let t := shr(192, a) r := shl(192, t) }
// narrow store then load round trip (i16)
case 3 { let t := and(a, 0xFFFF) mstore(0, t) r := mload(0) }
// top byte (i8) shifted back to the top
case 4 { let t := byte(0, a) r := shl(248, t) }
// narrow to i8 then storage round trip
case 5 { let t := and(a, 0xFF) sstore(5, t) r := sload(5) }
// narrow then negate: 0 - small => full-width large value
case 6 { let t := shr(128, a) r := sub(0, t) }
// not of a narrow value => high bits all set (full width)
case 7 { let t := and(a, 0xFFFFFFFF) r := not(t) }
// i64 + i64 => up to 65 bits
case 8 { let t := and(a, 0xFFFFFFFFFFFFFFFF) r := add(t, t) }
// signextend over a narrowed value
case 9 { let t := shr(64, a) r := signextend(7, t) }
// narrow then compare-against-large: lt(small, big) must be 1
case 10 { let t := and(a, 0xFF) r := lt(t, 0x100000000) }
// div of a narrowed dividend by 1 then widen-add a big constant
case 11 { let t := and(a, 0xFFFF) r := add(div(t, 1), shl(200, 1)) }
// mulmod where two operands are narrow but the product overflows 256
case 12 { let t := and(a, 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF) r := mulmod(t, t, 0x100000000000000000000000000000000) }
// narrow value used as exp base, large exponent
case 13 { let t := and(a, 0xFF) r := exp(t, 64) }
mstore(0, r)
return(0, 32)
}
}
}
+36
View File
@@ -0,0 +1,36 @@
object "OpProbe" {
code {
datacopy(0, dataoffset("OpProbe_deployed"), datasize("OpProbe_deployed"))
return(0, datasize("OpProbe_deployed"))
}
object "OpProbe_deployed" {
code {
let op := calldataload(0)
let a := calldataload(32)
let b := calldataload(64)
let c := calldataload(96)
let r := 0
switch op
case 0 { r := shl(a, b) }
case 1 { r := shr(a, b) }
case 2 { r := sar(a, b) }
case 3 { r := byte(a, b) }
case 4 { r := signextend(a, b) }
case 5 { r := sdiv(a, b) }
case 6 { r := smod(a, b) }
case 7 { r := exp(a, b) }
case 8 { r := addmod(a, b, c) }
case 9 { r := mulmod(a, b, c) }
case 10 { r := div(a, b) }
case 11 { r := mod(a, b) }
case 12 { r := slt(a, b) }
case 13 { r := sgt(a, b) }
case 14 { r := lt(a, b) }
case 15 { r := gt(a, b) }
case 16 { r := not(a) }
case 17 { r := addmod(mul(a, b), c, a) }
mstore(0, r)
return(0, 32)
}
}
}
@@ -0,0 +1,28 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8;
/// Soundness PoC: the panic-pattern outliner in
/// `simplify.rs::find_panic_pattern_backwards` extracts the panic code
/// from `mstore(4, code)` via `code_val.to_u64_digits().first()` and a
/// `<= 0xFF` check. That returns the LEAST-significant u64 digit, so any
/// 256-bit `code` value whose low byte is in `[0, 0xFF]` is mis-classified
/// as a small canonical Solidity panic code regardless of its higher bits.
///
/// Codegen for `Statement::PanicRevert { code }` emits the canonical
/// 36-byte Solidity panic format (selector + zero-padded `code`), so the
/// high bits of the original mstored value are silently dropped from the
/// revert data observed by the caller. EVM emits the original bytes.
contract PanicCodeBug {
fallback() external {
assembly {
// canonical panic selector at offset 0
mstore(0, 0x4e487b7100000000000000000000000000000000000000000000000000000000)
// "code" value 0x10_00000000_00000000_00000000_00000042 — its low
// byte (0x42) passes the simplifier's `<= 0xFF` check, but byte
// position 20 of the revert encoding holds the 0x10 nibble.
mstore(4, 0x10000000000000000000000000000042)
revert(0, 0x24)
}
}
}
@@ -0,0 +1,19 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8;
/// Probe: less-common panic codes' revert data must match EVM.
contract PanicCodes {
enum E { A, B, C }
function enumConv(uint256 x) external pure returns (E) { return E(x); } // 0x21 if x>2
function uninitFp() external pure returns (uint256) {
function(uint256) internal pure returns (uint256) fp;
return fp(5); // 0x51
}
function overAlloc(uint256 n) external pure returns (uint256) {
uint256[] memory a = new uint256[](n); // 0x41 if huge
return a.length;
}
function shiftEdge(uint256 a, uint256 s) external pure returns (uint256) {
return a << s; // no panic, edge
}
}
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8;
/// Soundness PoC: `simplify.rs::find_panic_pattern_backwards` walks
/// backward from `revert(0, 0x24)` skipping every mstore whose offset
/// is not exactly 0 or 4, and the final `only_safe` gate only checks
/// that intermediate statements are `Let`/`MStore`/`Expression`. An
/// `mstore(p, …)` with `p ∈ (0, 4) ∪ (4, 0x24)` partially overwrites
/// the panic-encoded revert data EVM emits, but the simplifier still
/// matches the canonical panic shape and replaces the whole sequence
/// with `Statement::PanicRevert { code }` — dropping the corrupting
/// mstore from the revert data observed by the caller.
contract PanicInterveneBug {
fallback() external {
assembly {
// Canonical panic selector + code prelude.
mstore(0, 0x4e487b7100000000000000000000000000000000000000000000000000000000)
mstore(4, 0x01)
// Unaligned mstore overwriting bytes 7..38 — bytes 7..35 of
// this end up in the revert(0, 0x24) data on EVM. PVM emits
// canonical PanicRevert(0x01) instead, dropping these bytes.
mstore(7, 0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef)
revert(0, 0x24)
}
}
}
@@ -0,0 +1,20 @@
object "PanicOutlineCall" {
code { let s := datasize("PanicOutlineCall_deployed") codecopy(0, dataoffset("PanicOutlineCall_deployed"), s) return(0, s) }
object "PanicOutlineCall_deployed" {
code {
// A canonical Panic(uint256) shape, but with an internal call between the selector store and
// the revert. `inner` is recursive so it is never inlined; it reverts with distinct data before
// the panic revert is reached. Collapsing this window into a single PanicRevert would drop the
// call and revert with the wrong (panic) payload.
mstore(0, 0x4e487b7100000000000000000000000000000000000000000000000000000000)
inner(0)
mstore(4, 0x11)
revert(0, 0x24)
function inner(n) {
if n { inner(sub(n, 1)) }
mstore(0x80, 0xdeadbeef)
revert(0x80, 0x20)
}
}
}
}
@@ -0,0 +1,21 @@
object "PanicOutlineYield" {
code { let s := datasize("PanicOutlineYield_deployed") codecopy(0, dataoffset("PanicOutlineYield_deployed"), s) return(0, s) }
object "PanicOutlineYield_deployed" {
code {
let cond := calldataload(0)
let r := 0
// `r` is reassigned inside the branch, between the panic selector store and the revert, and is
// read after the branch — so the branch region yields that in-window definition of `r`.
// Collapsing the window into a PanicRevert drops the definition; without a zero-binding rescue
// the surviving yield references an undefined value and the SSA validator panics.
if cond {
mstore(0, 0x4e487b7100000000000000000000000000000000000000000000000000000000)
r := 7
mstore(4, 0x11)
revert(0, 0x24)
}
sstore(0, r)
return(0, 0)
}
}
}
@@ -0,0 +1,14 @@
object "PanicOverwriteSelector" {
code { let s := datasize("PanicOverwriteSelector_deployed") codecopy(0, dataoffset("PanicOverwriteSelector_deployed"), s) return(0, s) }
object "PanicOverwriteSelector_deployed" {
code {
// Canonical panic selector + code, but the selector word is then overwritten before the
// revert. Last-write-wins means the EVM revert data starts with 0xdeadbeef, not the panic
// selector — so this must NOT be collapsed into a canonical Panic(0x11).
mstore(0, 0x4e487b7100000000000000000000000000000000000000000000000000000000)
mstore(4, 0x11)
mstore(0, 0xdeadbeef)
revert(0, 0x24)
}
}
}
@@ -0,0 +1,59 @@
/// Soundness PoC: newyork parameter narrowing
/// (`type_inference.rs::narrow_function_params`) narrows a parameter to I64/I32
/// whenever its `max_width` is lowered below I256 by a use that re-validates the
/// value (a memory offset bounded by `safe_truncate_int_to_xlen`). The call
/// boundary then emits a *checked* truncation that traps on an out-of-range
/// argument (`to_llvm.rs::checked_truncate_to`).
///
/// The bug: `max_width` was lowered by ANY narrowing use, including one reached
/// only on a conditional path. `store_if(p, c)` uses `p` as a memory offset only
/// inside `if c`. EVM skips the store (and never touches `p`) when `c == 0`, so a
/// `p >= 2^64` argument is harmless. But the narrowed `i64` signature truncates
/// `p` unconditionally at every call site, trapping before `c` is even tested.
///
/// Every caller passes a `calldataload`-derived `p` (forward width I256, so the
/// caller-driven narrowing path cannot narrow it) and four distinct call sites
/// plus the body's arithmetic chain keep `store_if` out of the inliner, so the
/// narrowed parameter reaches codegen.
///
/// Called with `c == 0` and `p == 2^64`: EVM returns `4` (each `store_if`
/// returns its else-path value); the buggy narrowing traps at the first call.
object "ParamCondNarrow" {
code {
let size := datasize("ParamCondNarrow_deployed")
codecopy(0, dataoffset("ParamCondNarrow_deployed"), size)
return(0, size)
}
object "ParamCondNarrow_deployed" {
code {
let c := calldataload(0)
let acc := 0
acc := add(acc, store_if(calldataload(32), c))
acc := add(acc, store_if(calldataload(64), c))
acc := add(acc, store_if(calldataload(96), c))
acc := add(acc, store_if(calldataload(128), c))
mstore(0, acc)
return(0, 32)
function store_if(p, g) -> r {
r := 1
if g {
// `p` is used as a memory offset only on this conditional path.
mstore(p, 0x42)
r := mload(p)
}
// Live arithmetic chain on the condition keeps the body over the
// always-inline size so the narrowed parameter reaches codegen.
let a1 := add(g, 1)
let a2 := mul(a1, 3)
let a3 := xor(a2, 7)
let a4 := add(a3, 9)
let a5 := or(a4, 13)
let a6 := add(a5, 17)
let a7 := mul(a6, 19)
let a8 := xor(a7, 23)
r := add(r, a8)
}
}
}
}
@@ -0,0 +1,26 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8;
/// Soundness PoC: function parameter used only as `mload` offset gets
/// silently narrowed from i256 to i64 by `narrow_function_params`. The
/// call-site bare truncate drops the upper 192 bits BEFORE the use-site
/// `safe_truncate_int_to_xlen` can observe them, so `mload(2^64)` aliases
/// to `mload(0)` and returns the zero-initialised scratch slot instead of
/// OOGing on memory expansion the way EVM does.
///
/// `fetch` is intentionally recursive so the early newyork inliner refuses
/// to inline it, preserving the narrowable `mload(x)`-only parameter shape.
contract ParamMload {
function fetch(uint depth, uint x) internal pure returns (uint r) {
if (depth == 0) {
assembly { r := mload(x) }
return r;
}
return fetch(depth - 1, x);
}
function tryFetch(uint x) external pure returns (uint) {
return fetch(1, x);
}
}
@@ -0,0 +1,11 @@
object "PastHeapConstReturn" {
code { let s := datasize("PastHeapConstReturn_deployed") codecopy(0, dataoffset("PastHeapConstReturn_deployed"), s) return(0, s) }
object "PastHeapConstReturn_deployed" {
code {
// Return a constant 32-byte range starting exactly at the heap end (heap is 128 KiB = 0x20000),
// with no prior store. The inline unchecked seal_return would read one-past-heap into the
// returndata (information leak); it must instead trap via the bounds-checked path.
return(0x20000, 0x20)
}
}
}
@@ -0,0 +1,22 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8;
contract SDivNarrowBug {
/// Forces LLVM IR of the form
/// sdiv i256 (and i256 %a, 0xff..ff_u64), (and i256 %b, 0xff..ff_u64)
///
/// `provable_bit_width` accepts the AND-mask proof and lets revive's
/// `narrow_divrem_instructions` rewrite this to `sdiv i64 (trunc..), (trunc..)`
/// followed by `sext` back to i256. The truncated low-64 view of an i256
/// whose bit 63 is set looks negative under i64, but the i256 value is
/// non-negative (high 192 bits are zero by the AND), so the narrowed
/// signed division returns the wrong sign.
function sdiv_masked(uint256 a, uint256 b) external pure returns (int256 q) {
assembly {
let am := and(a, 0xffffffffffffffff)
let bm := and(b, 0xffffffffffffffff)
q := sdiv(am, bm)
}
}
}
@@ -0,0 +1,55 @@
/// Soundness PoC: newyork forward-width rule for `sar` with a constant shift
/// (`type_inference.rs::infer_expression_width`, the `Shr | Sar` arm):
///
/// if let Some(shift) = known_constants[lhs] {
/// ... else { BitWidth::from_bits((256 - shift).max(1)) }
/// } else { rhs_width }
///
/// For logical `shr` this is correct (the shifted-in high bits are zero, so the
/// result fits in `256 - shift` bits). For ARITHMETIC `sar` it is NOT: when the
/// shifted value is negative, `sar` sign-extends, so the high bits stay set and
/// the result is full-width (~2^256). The rule ignores the operand's sign and
/// caps the width at `256 - shift`. `sar` also never marks the value
/// `is_signed`, so return-type narrowing then truncates the sign bits.
///
/// `doSar` returns `sar(250, value)`. For `value = -1` (2^256-1) the result is
/// `-1` (all ones) on EVM, but the width rule reports `from_bits(6) = I8`
/// (clamped to I32), so the narrowed return drops everything above 32 bits and
/// yields `0xffffffff`.
object "SarReturnNarrow" {
code {
let size := datasize("SarReturnNarrow_deployed")
codecopy(0, dataoffset("SarReturnNarrow_deployed"), size)
return(0, size)
}
object "SarReturnNarrow_deployed" {
code {
let z0, w0 := doSar(calldataload(0), calldataload(32))
let z1, w1 := doSar(calldataload(64), calldataload(96))
let z2, w2 := doSar(calldataload(128), calldataload(160))
let z3, w3 := doSar(calldataload(192), calldataload(224))
mstore(0, z0)
mstore(32, w0)
mstore(64, z1)
mstore(96, w1)
mstore(128, z2)
mstore(160, w2)
mstore(192, z3)
mstore(224, w3)
return(0, 256)
function doSar(value, y) -> z, w {
z := sar(250, value)
let a1 := add(y, 1)
let a2 := mul(a1, 3)
let a3 := xor(a2, 7)
let a4 := add(a3, 9)
let a5 := or(a4, 13)
let a6 := add(a5, 17)
let a7 := mul(a6, 19)
let a8 := xor(a7, 23)
w := add(a8, 29)
}
}
}
}
@@ -0,0 +1,15 @@
/// Register 0x20 (< 0x60, exempt from dynamic-escape guard) as an aligned native candidate.
object "ScratchCopyTaint" {
code { datacopy(0, dataoffset("ScratchCopyTaint_deployed"), datasize("ScratchCopyTaint_deployed")) return(0, datasize("ScratchCopyTaint_deployed")) }
object "ScratchCopyTaint_deployed" {
code {
mstore(0x20, 0)
// Overwrite scratch [0x00,0x40) with big-endian calldata. Taints only word_align(0)=0.
calldatacopy(0, 0, 64)
// Read 0x20: if native-LE, byte-reverses the BE calldata word.
let b := mload(0x20)
mstore(0x80, b)
return(0x80, 32)
}
}
}
@@ -0,0 +1,50 @@
/// Soundness PoC: the under-estimated forward width of `sdiv` (Bug #7) is also
/// consumed by codegen's comparison-operand narrowing
/// (`to_llvm.rs::try_narrow_comparison`), a separate amplifier from return-type
/// narrowing. When a comparison's operand has an (incorrectly) narrow inferred
/// width, both operands are truncated to that width before comparing — so a
/// full-width value masquerading as i32 is silently truncated, giving the wrong
/// comparison result and hence wrong control flow.
///
/// `doCmp` returns `lt(sdiv(x, y), 0xffffffff)`. With `x` narrowed (callers pass
/// `and(calldataload, 0xff)`) and `y = -1`, `sdiv(5, -1) = -5 = 2^256-5`, which
/// is NOT `< 0xffffffff` (EVM result 0). But the comparison truncates the
/// quotient to i32 (`0xfffffffb`) and computes `lt(0xfffffffb, 0xffffffff) = 1`.
object "SdivCompareNarrow" {
code {
let size := datasize("SdivCompareNarrow_deployed")
codecopy(0, dataoffset("SdivCompareNarrow_deployed"), size)
return(0, size)
}
object "SdivCompareNarrow_deployed" {
code {
let r0, w0 := doCmp(and(calldataload(0), 0xff), calldataload(32))
let r1, w1 := doCmp(and(calldataload(64), 0xff), calldataload(96))
let r2, w2 := doCmp(and(calldataload(128), 0xff), calldataload(160))
let r3, w3 := doCmp(and(calldataload(192), 0xff), calldataload(224))
mstore(0x80, r0)
mstore(0xa0, w0)
mstore(0xc0, r1)
mstore(0xe0, w1)
mstore(0x100, r2)
mstore(0x120, w2)
mstore(0x140, r3)
mstore(0x160, w3)
return(0x80, 256)
function doCmp(x, y) -> r, w {
let s := sdiv(x, y)
r := lt(s, 0xffffffff)
let a1 := add(y, 1)
let a2 := mul(a1, 3)
let a3 := xor(a2, 7)
let a4 := add(a3, 9)
let a5 := or(a4, 13)
let a6 := add(a5, 17)
let a7 := mul(a6, 19)
let a8 := xor(a7, 23)
w := add(a8, 29)
}
}
}
}
@@ -0,0 +1,62 @@
/// Soundness PoC: newyork return-type narrowing
/// (`type_inference.rs::narrow_function_returns`) narrows a function's return
/// type to its forward `min_width` whenever `min_width < I256` and the return
/// value is not flagged `is_signed`.
///
/// The forward width rule for `sdiv` is `lhs_width` (the dividend's width)
/// (`infer_expression_width`: `Div | SDiv | Mod | SMod => lhs_width`). For
/// *unsigned* div/mod and for `smod` (result takes the dividend's sign) this is
/// a correct upper bound. For **signed division `sdiv`** it is NOT: when the
/// dividend is a small non-negative value (narrow width) but the divisor is
/// negative, the quotient is negative — a full-width two's-complement value
/// (~2^256). `sdiv` never marks its result `is_signed`, so the guard misses it.
///
/// `doSdiv` returns `sdiv(x, y)`; every caller passes `and(calldataload,0xff)`
/// so the dividend param `x` narrows, the return min_width is computed as
/// `x`'s width, and the return type is clamped/narrowed — truncating the
/// negative quotient. Four distinct call sites + body size keep the function
/// out of the inliner (CostBenefit -> LLVM noinline), so the narrowed return
/// survives to codegen.
///
/// `sdiv(5, -1) = -5 = 2^256 - 5` on EVM; the narrowed return drops the high
/// bits, returning 0x...fffffffb instead.
object "SdivReturnNarrow" {
code {
let size := datasize("SdivReturnNarrow_deployed")
codecopy(0, dataoffset("SdivReturnNarrow_deployed"), size)
return(0, size)
}
object "SdivReturnNarrow_deployed" {
code {
let z0, w0 := doSdiv(and(calldataload(0), 0xff), calldataload(32))
let z1, w1 := doSdiv(and(calldataload(64), 0xff), calldataload(96))
let z2, w2 := doSdiv(and(calldataload(128), 0xff), calldataload(160))
let z3, w3 := doSdiv(and(calldataload(192), 0xff), calldataload(224))
mstore(0, z0)
mstore(32, w0)
mstore(64, z1)
mstore(96, w1)
mstore(128, z2)
mstore(160, w2)
mstore(192, z3)
mstore(224, w3)
return(0, 256)
function doSdiv(x, y) -> z, w {
z := sdiv(x, y)
// Live chain on the divisor `y` (kept observable via `w`) to
// push the function body over the always-inline size, so the
// narrowed return type reaches codegen.
let a1 := add(y, 1)
let a2 := mul(a1, 3)
let a3 := xor(a2, 7)
let a4 := add(a3, 9)
let a5 := or(a4, 13)
let a6 := add(a5, 17)
let a7 := mul(a6, 19)
let a8 := xor(a7, 23)
w := add(a8, 29)
}
}
}
}
+28
View File
@@ -0,0 +1,28 @@
object "SelfCall" {
code { datacopy(0, dataoffset("SelfCall_deployed"), datasize("SelfCall_deployed")) return(0, datasize("SelfCall_deployed")) }
object "SelfCall_deployed" {
code {
let flag := calldataload(0)
let value := calldataload(32)
switch flag
// leaf: echo the value back as return data
case 0 {
mstore(0, value)
return(0, 32)
}
// recurse: build [0, value] calldata, self-call, return the returndata word
default {
mstore(0x80, 0)
mstore(0xA0, value)
let ok := call(gas(), address(), 0, 0x80, 64, 0x100, 32)
// mix in returndatasize and a returndatacopy round-trip
returndatacopy(0x200, 0, returndatasize())
let r := mload(0x100)
r := xor(r, mload(0x200))
r := add(r, ok)
mstore(0, r)
return(0, 32)
}
}
}
}
@@ -0,0 +1,31 @@
object "SelfCallRd" {
code { datacopy(0, dataoffset("SelfCallRd_deployed"), datasize("SelfCallRd_deployed")) return(0, datasize("SelfCallRd_deployed")) }
object "SelfCallRd_deployed" {
code {
let flag := calldataload(0)
switch flag
// leaf: return a chunk of size = calldataload(32) (bounded), filled with calldataload(64)
case 0 {
let sz := and(calldataload(32), 0xFF)
mstore(0, calldataload(64))
mstore(32, not(calldataload(64)))
mstore(64, calldataload(64))
return(0, sz)
}
// caller: self-call leaf, then returndatacopy(dest, off, len) with bounded params
default {
mstore(0x80, 0)
mstore(0xA0, calldataload(32))
mstore(0xC0, calldataload(64))
let ok := call(gas(), address(), 0, 0x80, 96, 0x100, 0)
let rds := returndatasize()
let off := and(calldataload(96), 0xFF)
let len := and(calldataload(128), 0xFF)
returndatacopy(0x200, off, len)
let r := xor(mload(0x200), add(rds, ok))
mstore(0, r)
return(0, 32)
}
}
}
}
@@ -0,0 +1,32 @@
object "ShiftParamNarrow" {
code { datacopy(0, dataoffset("ShiftParamNarrow_deployed"), datasize("ShiftParamNarrow_deployed")) return(0, datasize("ShiftParamNarrow_deployed")) }
object "ShiftParamNarrow_deployed" {
code {
let opx := calldataload(0)
let shamt := calldataload(32)
let xv := calldataload(64)
let r0 := f(opx, shamt, xv)
let r1 := f(opx, shamt, xv)
let r2 := f(opx, shamt, xv)
let r3 := f(opx, shamt, xv)
mstore(0x200, r0)
mstore(0x220, add(r1, add(r2, r3)))
return(0x200, 64)
function f(op, s, v) -> r {
switch op
case 0 { r := mload(s) } // narrows s to i64
case 1 { r := shl(s, v) }
case 2 { r := shr(s, v) }
case 3 { r := sar(s, v) }
case 4 {
// bulk to push f over the inline threshold (keep f a real function)
mstore(0x300, s) mstore(0x320, v) mstore(0x340, op)
r := keccak256(0x300, 0x60)
r := add(r, keccak256(0x300, 0x40))
r := add(r, keccak256(0x300, 0x20))
}
default { r := s }
}
}
}
}
@@ -0,0 +1,19 @@
object "SignedCmpNarrow" {
code { datacopy(0, dataoffset("SignedCmpNarrow_deployed"), datasize("SignedCmpNarrow_deployed")) return(0, datasize("SignedCmpNarrow_deployed")) }
object "SignedCmpNarrow_deployed" {
code {
let op := calldataload(0)
let a := calldataload(32)
let b := calldataload(64)
let r := 0
switch op
// i1 operands: sgt(0, 1) must be 0 (EVM), not 1
case 0 { r := sgt(eq(a, b), gt(a, b)) }
case 1 { r := slt(gt(a, b), eq(a, b)) }
// i8 operands with top bit set: sgt(0xC8, 0x05) must be 1 (both positive)
case 2 { r := sgt(and(a, 0xFF), and(b, 0xFF)) }
case 3 { r := slt(and(a, 0xFF), and(b, 0xFF)) }
mstore(0, r) return(0, 32)
}
}
}
@@ -0,0 +1,28 @@
/// Probe: if newyork narrows the signextend BYTE-INDEX parameter to i64, a huge
/// index (>=31, which EVM treats as "leave value unchanged") truncated to a
/// small index would sign-extend from a low byte instead, changing the value.
/// `pp` is used as mload offset (case 0) to drive narrowing, and as the
/// signextend index (case 1). value 0x80 has bit 7 set, so signextend(0, 0x80)
/// = 0xff..ff80 while signextend(huge, 0x80) = 0x80.
object "SignextendIndexNarrow" {
code { let s := datasize("SignextendIndexNarrow_deployed") codecopy(0, dataoffset("SignextendIndexNarrow_deployed"), s) return(0, s) }
object "SignextendIndexNarrow_deployed" {
code {
let op := calldataload(0)
let p := calldataload(32)
let r0 := probe(op, p)
let r1 := probe(op, p)
let r2 := probe(op, p)
let r3 := probe(op, p)
mstore(0x200, r0) mstore(0x220, add(r1, add(r2, r3)))
return(0x200, 64)
function probe(opx, pp) -> r {
switch opx
case 0 { r := mload(pp) }
case 1 { r := signextend(pp, 0x80) }
case 2 { r := keccak256(pp, 32) }
default { r := pp }
}
}
}
}
@@ -0,0 +1,34 @@
object "StorageProbe" {
code {
datacopy(0, dataoffset("StorageProbe_deployed"), datasize("StorageProbe_deployed"))
return(0, datasize("StorageProbe_deployed"))
}
object "StorageProbe_deployed" {
code {
let k := calldataload(0)
let v := calldataload(32)
// >= 9 mapping-style sstores in the `let h := keccak256(..); sstore(h, _)`
// form so compound_outlining's keccak256_pair + sstore -> mapping_sstore
// fusion (T9, total mapping ops >= 9) triggers. Distinct base slots so
// the slots do not collide. The differential runner compares the full
// storage state between newyork-PVM and solc-EVM.
mstore(0, k) mstore(0x20, 0) let s0 := keccak256(0, 64) sstore(s0, v)
mstore(0, k) mstore(0x20, 1) let s1 := keccak256(0, 64) sstore(s1, add(v, 1))
mstore(0, k) mstore(0x20, 2) let s2 := keccak256(0, 64) sstore(s2, not(v))
mstore(0, k) mstore(0x20, 3) let s3 := keccak256(0, 64) sstore(s3, shl(200, v))
mstore(0, k) mstore(0x20, 4) let s4 := keccak256(0, 64) sstore(s4, and(v, 0xFF))
mstore(0, add(k, 1)) mstore(0x20, 0) let s5 := keccak256(0, 64) sstore(s5, v)
mstore(0, add(k, 2)) mstore(0x20, 0) let s6 := keccak256(0, 64) sstore(s6, sub(0, v))
mstore(0, not(k)) mstore(0x20, 7) let s7 := keccak256(0, 64) sstore(s7, v)
mstore(0, k) mstore(0x20, 8) let s8 := keccak256(0, 64) sstore(s8, v)
mstore(0, k) mstore(0x20, 9) let s9 := keccak256(0, 64) sstore(s9, exp(v, 3))
// also read one back through the fused mapping_sload path and store it plainly
mstore(0, k) mstore(0x20, 0) let r := sload(keccak256(0, 64))
sstore(0xFFFF, r)
return(0, 0)
}
}
}
@@ -0,0 +1,11 @@
object "Store8Fmp" {
code { datacopy(0, dataoffset("Store8Fmp_deployed"), datasize("Store8Fmp_deployed")) return(0, datasize("Store8Fmp_deployed")) }
object "Store8Fmp_deployed" {
code {
mstore8(0x40, 0xAB)
let r := mload(0x40)
mstore(0, r)
return(0, 32)
}
}
}
@@ -0,0 +1,73 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8;
// Regression test: ABI parameter validation for sub-256-bit types.
// Solidity 0.8+ reverts when a calldataload value exceeds the range of its
// declared type (e.g., passing 256 to a uint8 parameter).
// Bug: the newyork pipeline silently truncates instead of reverting.
/* runner.json
{
"differential": true,
"actions": [
{
"Instantiate": {
"code": { "Solidity": { "contract": "SubTypeValidation", "solc_optimizer": false } }
}
},
{
"Call": {
"dest": { "Instantiated": 0 },
"data": "d552145b0000000000000000000000000000000000000000000000000000000100000000"
}
},
{
"Call": {
"dest": { "Instantiated": 0 },
"data": "7440c86b0000000000000000000000000000000000000000000000010000000000000000"
}
},
{
"Call": {
"dest": { "Instantiated": 0 },
"data": "0850cee20000000000000000000000000000000100000000000000000000000000000000"
}
},
{
"Call": {
"dest": { "Instantiated": 0 },
"data": "d552145b0000000000000000000000000000000000000000000000000000000000000005"
}
},
{
"Call": {
"dest": { "Instantiated": 0 },
"data": "7440c86b0000000000000000000000000000000000000000000000000000000000000005"
}
},
{
"Call": {
"dest": { "Instantiated": 0 },
"data": "0850cee20000000000000000000000000000000000000000000000000000000000000005"
}
}
]
}
*/
contract SubTypeValidation {
// Should revert when called with value > 0xFF
function narrow_uint8(uint8 x) public pure returns (uint8) {
return x;
}
// Should revert when called with value > 0xFFFFFFFFFFFFFFFF
function narrow_uint64(uint64 x) public pure returns (uint64) {
return x;
}
// Should revert when called with value > 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
function narrow_uint128(uint128 x) public pure returns (uint128) {
return x;
}
}
@@ -0,0 +1,47 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8;
// Regression test: sub-underflow result widened to uint256 must preserve EVM
// modular semantics.
//
// Bug (crates/newyork/src/to_llvm.rs ~5977-5999): when `BinaryOperation::Sub`
// is codegen'd with both operands LLVM-narrowed to <= 64 bits (via
// `try_narrow_let_binding` Strategy 1 — structural proof from `and value, mask`)
// and the consumer's demand for the let binding holding the sub result is None
// (here: the result is mstored as a uint256), codegen takes the i128 fast-path:
// sub at i128 of (0, 1) → 2^128 - 1 (high bit set)
// Later widening via ensure_word_type → build_int_z_extend produces
// 0x...0000FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF instead of 2^256 - 1.
//
// Inline assembly is used so the masks land as IR constants (Solidity's
// non-asm path wraps integer literals through a `convert_rational_*` function,
// which defeats the structural narrowness proof).
/* runner.json
{
"differential": true,
"actions": [
{
"Instantiate": {
"code": { "Solidity": { "contract": "SubUnderflowZext", "solc_optimizer": false } }
}
},
{
"Call": {
"dest": { "Instantiated": 0 },
"data": "0aa7a67700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001"
}
}
]
}
*/
contract SubUnderflowZext {
function sub_underflow(uint256 a, uint256 b) external pure returns (uint256 r) {
assembly {
let x := and(a, 0xff)
let y := and(b, 0xff)
r := sub(x, y)
}
}
}
@@ -0,0 +1,17 @@
object "SwitchCvCseDangling" {
code { let s := datasize("SwitchCvCseDangling_deployed") codecopy(0, dataoffset("SwitchCvCseDangling_deployed"), s) return(0, s) }
object "SwitchCvCseDangling_deployed" {
code {
let sel := calldataload(0)
// Every branch (cases + default) starts with an equivalent non-payable callvalue check, so the
// check is fully hoisted above the switch and dropped from each branch. The cases also read
// `callvalue()` a SECOND time; environment CSE rewrites that read to reuse the first binding.
// Hoisting must redirect that reuse to the hoisted binding, otherwise it references the
// dropped definition and trips the SSA validator.
switch sel
case 1 { let cv := callvalue() if cv { revert(0, 0) } let x := callvalue() sstore(0, add(x, 0x100)) }
case 2 { let cv := callvalue() if cv { revert(0, 0) } let y := callvalue() sstore(0, add(y, 0x200)) }
default { let cv := callvalue() if cv { revert(0, 0) } sstore(0, 0xdd) }
}
}
}
@@ -0,0 +1,17 @@
object "SwitchCvFallthrough" {
code { let s := datasize("SwitchCvFallthrough_deployed") codecopy(0, dataoffset("SwitchCvFallthrough_deployed"), s) return(0, s) }
object "SwitchCvFallthrough_deployed" {
code {
let sel := calldataload(0)
switch sel
// Each selector is non-payable: it reverts if value was sent.
case 1 { let cv := callvalue() if cv { revert(0, 0) } sstore(0, 0x11) return(0, 0) }
case 2 { let cv := callvalue() if cv { revert(0, 0) } sstore(0, 0x22) return(0, 0) }
// There is NO default. A no-match selector must fall through to here. Hoisting the cases'
// callvalue check above the switch would make this fall-through path revert on a nonzero
// value, even though it never had a callvalue check of its own.
sstore(0, 0xff)
return(0, 0)
}
}
}
@@ -0,0 +1,13 @@
/// Force scrutinee to i64 via an explicit 64-bit mask.
object "SwitchWideLabel" {
code { datacopy(0, dataoffset("SwitchWideLabel_deployed"), datasize("SwitchWideLabel_deployed")) return(0, datasize("SwitchWideLabel_deployed")) }
object "SwitchWideLabel_deployed" {
code {
let x := and(calldataload(0), 0xffffffffffffffff)
switch x
case 0 { mstore(0, 0xAA) return(0, 32) }
case 0x10000000000000000 { mstore(0, 0xBB) return(0, 32) }
default { mstore(0, 0xCC) return(0, 32) }
}
}
}
@@ -0,0 +1,31 @@
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0;
/// Regression reproducer for a newyork SSA-validation ICE that surfaced only with
/// the solc optimizer disabled (`--disable-solc-optimizer`).
///
/// `try ... catch { return <constant>; }` lowers (with the solc optimizer off) to a
/// switch whose default region is `let r := if (true) { ...; leave } else { ... }`
/// followed by `yield r`. The inliner's leave-elimination produces the
/// constant-condition `if` with an output; the simplifier folds it, appending the
/// output binding after the branch's `leave`. The dead-code pass then truncated
/// everything after that terminator — including the binding the `yield` still
/// referenced — leaving `yield r` dangling ("value used before definition").
///
/// Both switch arms `leave`, so the yielded fall-through value is provably never
/// observed; the fix zero-binds the rescued value before the terminator so SSA
/// stays valid. Instantiated with the solc optimizer disabled to hit the path.
contract TryCatchCatchReturn {
function boom() external pure returns (uint256) {
revert("boom");
}
function run() public returns (uint256) {
try this.boom() returns (uint256 value) {
return value;
} catch {
return 200;
}
}
}
@@ -0,0 +1,19 @@
object "TstoreProbe" {
code { datacopy(0, dataoffset("TstoreProbe_deployed"), datasize("TstoreProbe_deployed")) return(0, datasize("TstoreProbe_deployed")) }
object "TstoreProbe_deployed" {
code {
let a := calldataload(0)
let b := calldataload(32)
let r := 0
tstore(a, b)
tstore(add(a, 1), not(b))
r := xor(r, tload(a))
r := xor(r, tload(add(a, 1)))
r := xor(r, tload(add(a, 2)))
tstore(a, and(b, 0xFF))
r := add(r, tload(a))
mstore(0, r)
return(0, 32)
}
}
}
@@ -0,0 +1,75 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8;
// Regression test: arithmetic with uint128 types.
// Bug: newyork pipeline produces wrong results for uint128 arithmetic with
// values that trigger overflow checking in Solidity 0.8+.
/* runner.json
{
"differential": true,
"actions": [
{
"Instantiate": {
"code": { "Solidity": { "contract": "Uint128Arithmetic", "solc_optimizer": false } }
}
},
{
"Call": {
"dest": { "Instantiated": 0 },
"data": "ba517410000000000000000000000000000000000000000000000000000000000000002100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000100000000000000000000000000000000"
}
},
{
"Call": {
"dest": { "Instantiated": 0 },
"data": "ba51741000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000001f"
}
}
]
}
*/
contract Uint128Arithmetic {
uint128 constant EPS = 1E10;
uint128 constant PRECISION = 100;
uint128 constant MAX_U128_SQRT = 18446744073709551615;
function entry(uint128 a, uint128 mb, uint128 c) public pure returns (bool, uint128, uint128) {
(bool p, uint128 x1, uint128 x2) = main(a, mb, c);
x1 *= PRECISION;
x1 /= EPS;
x2 *= PRECISION;
x2 /= EPS;
return (p, x1, x2);
}
function sqrt(uint128 n) private pure returns (uint128) {
uint128 l = 0;
uint128 r = MAX_U128_SQRT;
while (l < r) {
uint128 m = (l + r + 1) / 2;
if (m * m <= n) {
l = m;
} else {
r = m - 1;
}
}
if (n - l * l < (l + 1) * (l + 1) - n) {
return l;
} else {
return l + 1;
}
}
function main(uint128 a, uint128 mb, uint128 c) private pure returns (bool, uint128, uint128) {
if (mb * mb < 4 * a * c) {
return (false, 0, 0);
}
uint128 d = (mb * mb - 4 * a * c) * EPS * EPS;
uint128 sd = sqrt(d);
uint128 x1 = (mb * EPS + sd) / 2 / a;
uint128 x2 = (mb * EPS - sd) / 2 / a;
return (true, x1, x2);
}
}
@@ -0,0 +1,23 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8;
/// Soundness PoC: `mem_opt::optimize_statements` MStore8 handler only
/// invalidates `memory_state[word_offset(offset)]`. An unaligned
/// `mstore(0x70, AA…)` leaves a tracked entry at word 0x60 with
/// `tracked.offset = 0x70`, whose 32-byte write range covers byte
/// 0x80. A later `mstore8(0x80, 0xCC)` overwrites that byte but only
/// removes `memory_state[0x80]`, not the overlapping entry at
/// `memory_state[0x60]`. A subsequent `mload(0x70)` matches the
/// stale tracked entry by exact-offset comparison and is forwarded
/// to the pre-overwrite value, dropping the single-byte overwrite.
/// EVM observes byte 0x80 = `0xCC` in the actual memory read.
contract UnalignedMStore8Bug {
function bug() external pure returns (bytes32 r) {
assembly {
mstore(0x70, 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa)
mstore8(0x80, 0xcc)
r := mload(0x70)
}
}
}
@@ -0,0 +1,28 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8;
/// Soundness PoC: `mem_opt.rs` only updates `memory_state[word_offset]` when
/// recording an mstore, where `word_offset = static_offset / 32 * 32`. An
/// *unaligned* `mstore(0x70, v)` writes 32 bytes from byte 0x70 to 0x90, so
/// it overwrites the lower half of the word starting at 0x80. mem_opt only
/// touches `memory_state[0x60]` and leaves `memory_state[0x80]` intact.
///
/// A subsequent `mload(0x80)` finds the still-cached tracked entry at 0x80
/// (offset == 0x80, no aliasing check across overlap with 0x70) and is
/// forwarded to the *pre-overwrite* stored value. EVM reads the actual
/// memory which contains the lower half of the unaligned store plus the
/// untouched upper half of the original.
contract UnalignedMStoreBug {
function bug() external pure returns (bytes32 r) {
assembly {
// 32 byte sentinel at the aligned word 0x80.
mstore(0x80, 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa)
// Unaligned write: 16 bytes of 0xbb at 0x70..0x80 and 16 bytes of
// 0xcc at 0x80..0x90 (overwriting the lower half of the sentinel).
mstore(0x70, 0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbcccccccccccccccccccccccccccccccc)
// mem_opt forwards the stale sentinel, missing the partial overwrite.
r := mload(0x80)
}
}
}
+218 -1
View File
@@ -179,12 +179,222 @@ sol!(
function mod(uint n, uint d) public pure returns (uint r);
function smod(int n, int d) public pure returns (int r);
function divSelf(uint256 x) external pure returns (uint256 r);
function sdivSelf(int256 x) external pure returns (int256 r);
function modSelf(uint256 x) external pure returns (uint256 r);
}
);
case!("DivisionArithmetics.sol", DivisionArithmetics, divCall, division_arithmetics_div, n: U256, d: U256);
case!("DivisionArithmetics.sol", DivisionArithmetics, sdivCall, division_arithmetics_sdiv, n: I256, d: I256);
case!("DivisionArithmetics.sol", DivisionArithmetics, modCall, division_arithmetics_mod, n: U256, d: U256);
case!("DivisionArithmetics.sol", DivisionArithmetics, smodCall, division_arithmetics_smod, n: I256, d: I256);
case!("DivisionArithmetics.sol", DivisionArithmetics, divSelfCall, division_arithmetics_div_self, x: U256);
case!("DivisionArithmetics.sol", DivisionArithmetics, sdivSelfCall, division_arithmetics_sdiv_self, x: I256);
case!("DivisionArithmetics.sol", DivisionArithmetics, modSelfCall, division_arithmetics_mod_self, x: U256);
sol!(
contract SDivNarrowBug {
function sdiv_masked(uint256 a, uint256 b) external pure returns (int256 q);
}
);
case!("SDivNarrowBug.sol", SDivNarrowBug, sdiv_maskedCall, sdiv_narrow_bug_masked, a: U256, b: U256);
sol!(
contract KeccakFuseBug {
function probe(uint256[8] calldata seeds) external view returns (uint256 r, bytes32 sink_out);
}
);
case!(
"KeccakFuseBug.sol",
KeccakFuseBug,
probeCall,
keccak_fuse_bug_probe,
seeds: [U256; 8]
);
sol!(
contract ParamMload {
function tryFetch(uint256 x) external pure returns (uint256);
}
);
case!("ParamMload.sol", ParamMload, tryFetchCall, param_mload_try_fetch, x: U256);
// `PanicCodeBug` has only a `fallback()` — invoked by sending raw
// calldata that doesn't match any function selector. Empty calldata
// suffices.
case!(
"PanicCodeBug.sol",
"PanicCodeBug",
vec![],
panic_code_bug_trigger
);
// `FmpDynStoreBug` is invoked via empty fallback, passing 0x40 as the
// only calldata word so the inline-asm `calldataload(0)` resolves to
// 0x40 at runtime but stays opaque to the simplifier.
case!(
"FmpDynStoreBug.sol",
"FmpDynStoreBug",
{
let mut bytes = vec![0u8; 32];
bytes[31] = 0x40;
bytes
},
fmp_dyn_store_bug
);
// `FmpRangeProofBug` reads the 32-byte calldata as the value to put
// into the FMP slot. Use `0x100000007 = 2^32 + 7` (well beyond the
// 17-bit heap-size range) to make the range-proof truncation visible.
case!(
"FmpRangeProofBug.sol",
"FmpRangeProofBug",
{
let mut bytes = vec![0u8; 32];
bytes[31] = 0x07;
bytes[27] = 0x01;
bytes
},
fmp_range_proof_bug
);
// `FmpNativeStoreBug` calldata: first word = FMP value `0x100000007`,
// second word = non-zero condition to force the if-branch.
case!(
"FmpNativeStoreBug.sol",
"FmpNativeStoreBug",
{
let mut bytes = vec![0u8; 64];
bytes[31] = 0x07;
bytes[27] = 0x01;
bytes[63] = 0x01;
bytes
},
fmp_native_store_bug
);
// `FmpCrossObjectBug` calldata: first word = FMP value `0x100000007`,
// second word = recursion depth (0 to hit the inner inline-asm branch).
case!(
"FmpCrossObjectBug.sol",
"FmpCrossObjectBug",
{
let mut bytes = vec![0u8; 64];
bytes[31] = 0x07;
bytes[27] = 0x01;
bytes
},
fmp_cross_object_bug
);
// `FmpRevertBug` is invoked via empty calldata into its `fallback()`.
case!("FmpRevertBug.sol", "FmpRevertBug", vec![], fmp_revert_bug);
// `FmpDynRevertBug` reads `offset` and `length` from calldata: pass
// 64 bytes encoding `offset=0`, `length=96` so the dynamic revert
// covers the FMP slot.
case!(
"FmpDynRevertBug.sol",
"FmpDynRevertBug",
{
let mut bytes = vec![0u8; 64];
bytes[63] = 96;
bytes
},
fmp_dyn_revert_bug
);
sol!(
contract UnalignedMStoreBug {
function bug() external pure returns (bytes32);
}
);
case!(
"UnalignedMStoreBug.sol",
UnalignedMStoreBug,
bugCall,
unaligned_mstore_bug,
);
sol!(
contract ConstReturnOverflowBug {
function bug() external pure returns (uint256);
}
);
case!(
"ConstReturnOverflowBug.sol",
ConstReturnOverflowBug,
bugCall,
const_return_overflow_bug,
);
// `PanicInterveneBug` has only a `fallback()` — invoked by sending
// raw calldata that doesn't match any selector. Empty calldata works.
case!(
"PanicInterveneBug.sol",
"PanicInterveneBug",
vec![],
panic_intervene_bug
);
sol!(
contract UnalignedMStore8Bug {
function bug() external pure returns (bytes32);
}
);
case!(
"UnalignedMStore8Bug.sol",
UnalignedMStore8Bug,
bugCall,
unaligned_mstore8_bug,
);
sol!(
contract CopyOverlapBug {
function bug(uint256 length) external pure returns (bytes32);
}
);
case!(
"CopyOverlapBug.sol",
CopyOverlapBug,
bugCall,
copy_overlap_bug,
length: U256
);
sol!(
contract CallerOriginAliasing {
function caller_then_origin() external view returns (address, address);
function origin_then_caller() external view returns (address, address);
function caller_address_origin() external view returns (address, address, address);
function repeated_caller() external view returns (address, address, address);
}
);
case!(
"CallerOriginAliasing.sol",
CallerOriginAliasing,
caller_then_originCall,
caller_origin_aliasing_caller_then_origin,
);
case!(
"CallerOriginAliasing.sol",
CallerOriginAliasing,
origin_then_callerCall,
caller_origin_aliasing_origin_then_caller,
);
case!(
"CallerOriginAliasing.sol",
CallerOriginAliasing,
caller_address_originCall,
caller_origin_aliasing_caller_address_origin,
);
case!(
"CallerOriginAliasing.sol",
CallerOriginAliasing,
repeated_callerCall,
caller_origin_aliasing_repeated_caller,
);
sol!(
contract DivConst {
@@ -293,6 +503,8 @@ sol!(
contract ExtCode {
function ExtCodeSize(address who) public view returns (uint ret);
function ExtCodeSizeSum(address a, address b) public view returns (uint ret);
function CodeSize() public pure returns (uint ret);
function ExtCodeHash(address who) public view returns (bytes32 ret);
@@ -301,6 +513,7 @@ sol!(
}
);
case!("ExtCode.sol", ExtCode, ExtCodeSizeCall, ext_code_size, address: Address);
case!("ExtCode.sol", ExtCode, ExtCodeSizeSumCall, ext_code_size_sum, a: Address, b: Address);
case!("ExtCode.sol", ExtCode, CodeSizeCall, code_size,);
case!("ExtCode.sol", ExtCode, ExtCodeHashCall, ext_code_hash, address: Address);
case!("ExtCode.sol", ExtCode, CodeHashCall, code_hash,);
@@ -388,7 +601,11 @@ mod tests {
#[test]
fn codesize() {
let path = "codesize.json";
let path = if cfg!(feature = "newyork") {
"codesize_newyork.json"
} else {
"codesize.json"
};
let existing = File::open(path)
.map(|file| {
File diff suppressed because it is too large. Load diff
@@ -1,7 +1,8 @@
//! The debug IR type.
use revive_common::{
EXTENSION_LLVM_SOURCE, EXTENSION_OBJECT, EXTENSION_POLKAVM_ASSEMBLY, EXTENSION_YUL,
EXTENSION_LLVM_SOURCE, EXTENSION_NEWYORK, EXTENSION_OBJECT, EXTENSION_POLKAVM_ASSEMBLY,
EXTENSION_YUL,
};
/// The debug IR type.
@@ -16,6 +17,8 @@ pub enum IRType {
Assembly,
/// Whether to dump the ELF shared object
Object,
/// Whether to dump the newyork IR.
NewYork,
}
impl IRType {
@@ -26,6 +29,7 @@ impl IRType {
Self::LLVM => EXTENSION_LLVM_SOURCE,
Self::Assembly => EXTENSION_POLKAVM_ASSEMBLY,
Self::Object => EXTENSION_OBJECT,
Self::NewYork => EXTENSION_NEWYORK,
}
}
}
@@ -115,6 +115,29 @@ impl DebugConfig {
Ok(())
}
/// Dumps a newyork IR debug artifact for the current contract.
///
/// The file name is derived from the contract path (set via [`Self::set_contract_path`]) so it
/// matches the other dumps and maps back to the source. `suffix` distinguishes artifacts
/// (`"snapshot"`, `"heap"`, `"mem"`); `None` is the final optimized IR. A no-op when no output
/// directory or contract path is configured.
pub fn dump_newyork(&self, suffix: Option<&str>, text: &str) -> anyhow::Result<()> {
if let Some(contract_path) = self.contract_path.as_ref() {
let stem = contract_path
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or_default();
let extension = IRType::NewYork.file_extension();
let file_name = match suffix {
Some(suffix) => format!("{stem}.{suffix}.{extension}"),
None => format!("{stem}.{extension}"),
};
std::fs::write(contract_path.with_file_name(file_name), text)?;
}
Ok(())
}
/// Creates a full file name, given the contract full path, suffix, and extension.
fn full_file_name(contract_path: &str, suffix: Option<&str>, ir_type: IRType) -> String {
let mut full_file_name = contract_path.replace('/', "_").replace(':', ".");
+59 -4
View File
@@ -14,6 +14,7 @@ pub use self::polkavm::build as polkavm_build;
pub use self::polkavm::context::address_space::AddressSpace as PolkaVMAddressSpace;
pub use self::polkavm::context::argument::Argument as PolkaVMArgument;
pub use self::polkavm::context::attribute::Attribute as PolkaVMAttribute;
pub use self::polkavm::context::attribute::MemoryEffect as PolkaVMMemoryEffect;
pub use self::polkavm::context::build::Build as PolkaVMBuild;
pub use self::polkavm::context::code_type::CodeType as PolkaVMCodeType;
pub use self::polkavm::context::debug_info::DebugInfo;
@@ -27,7 +28,14 @@ pub use self::polkavm::context::function::runtime::arithmetics::SignedDivision a
pub use self::polkavm::context::function::runtime::arithmetics::SignedRemainder as PolkaVMSignedRemainderFunction;
pub use self::polkavm::context::function::runtime::deploy_code::DeployCode as PolkaVMDeployCodeFunction;
pub use self::polkavm::context::function::runtime::entry::Entry as PolkaVMEntryFunction;
pub use self::polkavm::context::function::runtime::revive::CallDataLoad as PolkaVMCallDataLoadFunction;
pub use self::polkavm::context::function::runtime::revive::CallValue as PolkaVMCallValueFunction;
pub use self::polkavm::context::function::runtime::revive::CallValueNonzero as PolkaVMCallValueNonzeroFunction;
pub use self::polkavm::context::function::runtime::revive::Caller as PolkaVMCallerFunction;
pub use self::polkavm::context::function::runtime::revive::Exit as PolkaVMExitFunction;
pub use self::polkavm::context::function::runtime::revive::Revert as PolkaVMRevertFunction;
pub use self::polkavm::context::function::runtime::revive::RevertEmpty as PolkaVMRevertEmptyFunction;
pub use self::polkavm::context::function::runtime::revive::RevertPanic as PolkaVMRevertPanicFunction;
pub use self::polkavm::context::function::runtime::revive::WordToPointer as PolkaVMWordToPointerFunction;
pub use self::polkavm::context::function::runtime::runtime_code::RuntimeCode as PolkaVMRuntimeCodeFunction;
pub use self::polkavm::context::function::runtime::sbrk::Sbrk as PolkaVMSbrkFunction;
@@ -37,8 +45,12 @@ pub use self::polkavm::context::function::runtime::FUNCTION_RUNTIME_CODE as Polk
pub use self::polkavm::context::function::yul_data::YulData as PolkaVMFunctionYulData;
pub use self::polkavm::context::function::Function as PolkaVMFunction;
pub use self::polkavm::context::global::Global as PolkaVMGlobal;
pub use self::polkavm::context::pointer::heap::Keccak256OneWord as PolkaVMKeccak256OneWordFunction;
pub use self::polkavm::context::pointer::heap::Keccak256TwoWords as PolkaVMKeccak256TwoWordsFunction;
pub use self::polkavm::context::pointer::heap::LoadWord as PolkaVMLoadHeapWordFunction;
pub use self::polkavm::context::pointer::heap::LoadWordNative as PolkaVMLoadHeapWordNativeFunction;
pub use self::polkavm::context::pointer::heap::StoreWord as PolkaVMStoreHeapWordFunction;
pub use self::polkavm::context::pointer::heap::StoreWordNative as PolkaVMStoreHeapWordNativeFunction;
pub use self::polkavm::context::pointer::storage::LoadTransientWord as PolkaVMLoadTransientStorageWordFunction;
pub use self::polkavm::context::pointer::storage::LoadWord as PolkaVMLoadStorageWordFunction;
pub use self::polkavm::context::pointer::storage::StoreTransientWord as PolkaVMStoreTransientStorageWordFunction;
@@ -84,19 +96,62 @@ pub(crate) mod target_machine;
static DID_INITIALIZE: OnceLock<()> = OnceLock::new();
/// The global LLVM options applied at [`OptimizerSettingsSizeLevel::Z`] **on the newyork path**.
///
/// These trade execution speed for code size: the machine outliner replaces repeated
/// instruction sequences with calls, disabling LICM avoids i256 register pressure on the
/// PVM target (rv64e) where hoisting causes excessive stack spills, and `ext-tsp-for-size`
/// optimizes block layout for size instead of speed. At any other optimization level the
/// upstream LLVM defaults apply: paying execution gas for smaller code is only acceptable
/// when the user explicitly asked for `-Oz`.
///
/// They are applied only for modules produced by the newyork IR generator. Like the
/// aggressive code-size pass pipeline in [`crate::optimizer::Optimizer::run`] and the
/// `+unaligned-scalar-mem` target feature, these knobs are tuned and validated against
/// newyork's outlined code; the stock Yul path keeps the upstream LLVM defaults it was
/// validated against (matching `main`).
const SIZE_LEVEL_Z_ARGUMENTS: &[&str] = &[
"--disable-licm-promotion",
"--disable-machine-licm",
"--enable-machine-outliner",
"--machine-outliner-reruns=2",
"--disable-early-taildup=true",
"--apply-ext-tsp-for-size=true",
"--attributor-allow-deep-wrappers=true",
"--hoist-common-insts=true",
];
/// Initializes the LLVM compiler backend.
///
/// This is a no-op if called subsequentially.
///
/// `llvm_arguments` are passed as-is to the LLVM CL options parser.
pub fn initialize_llvm(target: PolkaVMTarget, name: &str, llvm_arguments: &[String]) {
///
/// When `use_newyork` is set and the size level is [`OptimizerSettingsSizeLevel::Z`], the
/// `SIZE_LEVEL_Z_ARGUMENTS` are prepended. They are gated on the newyork path because they
/// are validated against its codegen; the stock Yul path must keep the upstream defaults.
/// LLVM parses command line options once per process, so the first caller wins; production
/// builds are unaffected because every contract is compiled in its own recursive process
/// with its own optimizer settings and IR generator.
pub fn initialize_llvm(
target: PolkaVMTarget,
name: &str,
size_level: OptimizerSettingsSizeLevel,
use_newyork: bool,
llvm_arguments: &[String],
) {
let Ok(_) = DID_INITIALIZE.set(()) else {
return; // Tests don't go through a recursive process
};
let argv = [name.to_string()]
.iter()
.chain(llvm_arguments)
let size_arguments = if use_newyork && size_level == OptimizerSettingsSizeLevel::Z {
SIZE_LEVEL_Z_ARGUMENTS
} else {
&[]
};
let argv = std::iter::once(name)
.chain(size_arguments.iter().copied())
.chain(llvm_arguments.iter().map(String::as_str))
.map(|arg| CString::new(arg.as_bytes()).unwrap())
.collect::<Vec<_>>();
let argv: Vec<*const libc::c_char> = argv.iter().map(|arg| arg.as_ptr()).collect();
Loaded 100 of 183 files, more files were not shown because too many files have changed in this diff. Show more