diff --git a/Cargo.lock b/Cargo.lock index bef186c5..6f2c1d29 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -525,6 +525,9 @@ name = "arbitrary" version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] [[package]] name = "ark-bls12-377" @@ -2983,6 +2986,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "0.99.20" @@ -9504,6 +9518,27 @@ dependencies = [ "tempfile", ] +[[package]] +name = "revive-fuzz" +version = "1.0.0" +dependencies = [ + "alloy-primitives", + "anyhow", + "arbitrary", + "hex", + "log", + "resolc", + "revive-differential", + "revive-llvm-context", + "revive-runner", + "revive-solc-json-interface", + "revive-yul", + "serde", + "serde_json", + "thiserror 2.0.18", + "which", +] + [[package]] name = "revive-integration" version = "1.3.0" diff --git a/Cargo.toml b/Cargo.toml index 73fcc293..0d2afc0e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ revive-build-utils = { version = "1.2.0", path = "crates/build-utils" } revive-builtins = { version = "1.1.0", path = "crates/builtins" } revive-common = { version = "1.1.0", path = "crates/common" } revive-differential = { version = "1.0.0", path = "crates/differential" } +revive-fuzz = { version = "1.0.0", path = "crates/fuzz" } revive-integration = { version = "1.3.0", path = "crates/integration" } revive-linker = { version = "1.0.0", path = "crates/linker" } revive-llvm-context = { version = "1.3.0", path = "crates/llvm-context" } diff --git a/Makefile b/Makefile index ff43d02e..356e75f7 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,7 @@ install-wasm \ install-llvm-builder \ install-llvm \ + install-llvm-sancov \ install-revive-runner \ format \ clippy \ @@ -19,6 +20,7 @@ test-wasm \ test-llvm-builder \ test-book \ + fuzz-libfuzzer \ bench \ bench-pvm \ bench-evm \ @@ -45,6 +47,15 @@ install-llvm: install-llvm-builder git submodule update --init --recursive --depth 1 revive-llvm build --llvm-projects lld --llvm-projects clang +# LLVM built with `-fsanitize=fuzzer-no-link` so libFuzzer sees C++ +# edges. Shares `target-llvm//` with `install-llvm` — run +# `revive-llvm clean` when switching. Resulting archives only link +# into fuzz-target binaries (which supply libFuzzer's runtime). +# `JOBS=N` caps thread count. +install-llvm-sancov: install-llvm-builder + git submodule update --init --recursive --depth 1 + CMAKE_BUILD_PARALLEL_LEVEL=$(JOBS) revive-llvm build --llvm-projects lld --llvm-projects clang --enable-sancov + install-revive-runner: cargo install --locked --force --path crates/runner --no-default-features @@ -91,6 +102,13 @@ test-book: cargo install mdbook --version 0.5.1 --locked mdbook test book +# Coverage-guided differential fuzzer (libFuzzer + SanCov). Generates +# Solidity, runs solc → EVM and resolc → PVM, diffs the executions. +# `JOBS=N` shards across N forked workers. Requires `solc` and geth's +# `evm` in $PATH. Toolchain pinned via `fuzz/rust-toolchain.toml`. +fuzz-libfuzzer: + cd fuzz && cargo +nightly fuzz run solidity_differential -- -fork=$(or $(JOBS),4) -ignore_crashes=0 + bench: install-bin cargo criterion --all --all-features --message-format=json \ | criterion-table > crates/benchmarks/BENCHMARKS.md diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index 7d538254..693dec19 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -17,6 +17,7 @@ - [IR reference](./developer_guide/newyork_ir.md) - [PVM and the pallet-revive runtime target](./developer_guide/target.md) - [Testing strategy](./developer_guide/testing.md) + - [Differential fuzzing](./developer_guide/fuzzing.md) - [Cross compilation](./developer_guide/cross_compilation.md) - [FAQ](./faq.md) - [Roadmap and Vision](./roadmap.md) diff --git a/book/src/developer_guide/fuzzing.md b/book/src/developer_guide/fuzzing.md new file mode 100644 index 00000000..b74aa4fd --- /dev/null +++ b/book/src/developer_guide/fuzzing.md @@ -0,0 +1,277 @@ +# Differential fuzzing + +`revive` ships a coverage-guided differential fuzzer that compares the +same logical contract execution between resolc's PVM lowering and +solc's EVM lowering. Any byte-level mismatch on revert flags or +return data is treated as a backend bug. + +The harness uses libFuzzer with SanitizerCoverage feedback over every +Rust crate in resolc's dep graph, so the mutation engine learns from +edge coverage in the parser, IR lowering, and pallet-revive +simulation. + +> [!TIP] +> +> The fuzzer shells out to `solc` and geth's `evm`. Both must be on +> `$PATH`. See the [Testing strategy](./testing.md) chapter for the +> geth EVM-tool installation snippet. + +## Running the fuzzer + +```bash +# 4 forked workers, runs until interrupted +make fuzz-libfuzzer + +# Tune fork count +make fuzz-libfuzzer JOBS=8 + +# Equivalent direct invocation (gives access to every libFuzzer flag) +cd fuzz +cargo +nightly fuzz run solidity_differential -- -fork=8 +``` + +Useful libFuzzer flags (everything after the bare `--` is passed +through to the libFuzzer runtime): + +| Flag | Effect | +|---|---| +| `-fork=N` | N parallel forked workers, sharing the corpus dir. | +| `-max_total_time=S` | Wall-clock budget in seconds. | +| `-runs=N` | Iteration budget instead of wall-clock. | +| `-max_len=N` | Cap input length (default 4096). | +| `-rss_limit_mb=N` | OOM threshold per worker (default 2048). | +| `-ignore_crashes=1` | Keep running after a crash instead of stopping. | +| `-print_final_stats=1` | Print coverage / corpus stats at exit. | + +> [!NOTE] +> +> libFuzzer needs a nightly Rust toolchain because the SanitizerCoverage +> flags it relies on (`-Zsanitizer-coverage-*`, `-Cpasses=sancov-module`, +> etc.) are nightly-only. The `rust-toolchain.toml` inside `fuzz/` +> scopes nightly to that directory — the rest of the workspace stays +> on stable. + +### Reproducing a crash + +libFuzzer writes crash inputs to +`fuzz/artifacts/solidity_differential/crash-`. The bytes are +deterministic — re-feeding them produces the same `SolidityCase`: + +```bash +cd fuzz +cargo +nightly fuzz run solidity_differential \ + artifacts/solidity_differential/crash- +``` + +The panic message embeds the rendered contract source plus the action +sequence in hex — enough to file an issue without keeping the input +bytes around. + +## What the fuzzer does + +```text + libFuzzer mutator (random bytes) + │ + ▼ + Unstructured ──► TemplateKind::arbitrary + │ + ▼ pick template + per-template op selectors + SolidityCase { source, constructor_args, actions } + │ + ┌───────────┴────────────┐ + ▼ ▼ + resolc → PVM blob solc → EVM bytecode + │ │ + ▼ ▼ + revive_runner::Specs.run geth `evm` subprocess + (pallet-revive sim) (constructor + per-action calls) + │ │ + └───────────┬────────────┘ + ▼ + compare() + ├─ deploy_reverted flag + ├─ per-action revert flag + └─ per-action return-data bytes + │ + any mismatch → Divergence → panic +``` + +Both observers replay the same calldata sequence (`constructor_args` +concatenated as constructor input; `fn_0(uint256)` selector + 32-byte +arg per subsequent action). State carries across actions on both +backends. The harness compares revert flags and return-data bytes +only — gas-cost differences between geth `evm` and pallet-revive sim +are by design not part of the comparison. + +## Coverage signal + +cargo-fuzz compiles every Rust crate in the dep graph with +SanitizerCoverage, so the libFuzzer mutation engine sees edges in: + +* `revive-yul` parser +* `resolc` standard-json pipeline +* `revive-llvm-context` codegen (every lowering pattern) +* `revive-runner` / pallet-revive simulation +* `arbitrary` and the generator itself + +Opaque (not instrumented by default): + +* `solc` subprocess +* geth `evm` subprocess +* LLVM C++ libraries inside resolc + +To extend SanCov instrumentation into LLVM's C++ codebase, rebuild +LLVM via `make install-llvm-sancov`. That target adds +`-fsanitize=fuzzer-no-link` to LLVM's C/CXX flags, so every basic +block in the resulting static archives carries libFuzzer edge +counters. The `-no-link` suffix avoids requiring `libclang_rt.fuzzer.a` +at LLVM-build time — the libFuzzer runtime comes from the fuzz-target +binary. + +> [!WARNING] +> +> A SanCov-instrumented LLVM at `$LLVM_SYS_221_PREFIX` will break +> non-fuzz `cargo build` invocations: the linker needs +> `__sanitizer_cov_*` symbols that only the libFuzzer runtime +> supplies. Keep two LLVM trees if you need both: switch via +> `LLVM_SYS_221_PREFIX`. + +This is the right shape: the engine explores **resolc's** Rust-side +input space (and optionally LLVM internals), not solc's or geth's. A +5-minute run on 4 forks against a non-instrumented LLVM loads ~1.17M +edges, of which the templated generator reaches ~28.5K. + +## Generator + +`crates/fuzz/src/templates.rs` defines eight contract templates. Each +exposes the same wire shape: + +```solidity +constructor(uint256 seed) { ... } +function fn_0(uint256 arg) external returns (T) { ... } +``` + +so the observer doesn't need to know which template it's running. + +| Kind | What it exercises | +|---|---| +| `Srem` | `int256` storage slot + `slot0 % arg` — the original [paritytech/revive#527](https://github.com/paritytech/revive/pull/527) probe | +| `ArithChain` | Two storage slots, three signed-arithmetic ops chained | +| `UncheckedArith` | `unchecked { … }` wrapping arithmetic on `uint256` | +| `Mapping` | `mapping(uint256 => uint256)` increment — exercises keccak-derived storage slots | +| `DynArray` | Dynamic `uint256[]` push + indexed read — exercises array layout + length update | +| `RequireGuard` | `require(predicate, "guard")` with eight predicate shapes | +| `LoopAccum` | Bounded `for` accumulator (`bound = arg & 0x1F`, ≤ 31 iterations) | +| `Bitwise` | Pure-bitwise composition (`& \| ^` + `<<` / `>>`) | + +Op selectors inside each template are themselves `arbitrary`-driven, +so one template covers many distinct opcode lowerings. The +`#[ignore]`d `every_template_compiles` test pipes each template +through `solc --standard-json` and asserts no fatal errors: + +```bash +cargo test -p revive-fuzz --lib -- --ignored every_template_compiles +``` + +A 256-bit boundary-value pool (`0`, `1`, `-1`, `INT_MIN`, `INT_MAX`, +`2^128`, `2^64`, alternating-bit patterns, …) is mixed into operands +with 1-in-5 probability so corner-case pairs surface within a minute +under pure-random. libFuzzer's mutator preserves the biasing because +it operates on the same byte tape `Unstructured` consumes. + +## Divergence taxonomy + +`Divergence` (in `crates/fuzz/src/differential.rs`) categorises every +outcome: + +| Variant | Meaning | libFuzzer treatment | +|---|---|---| +| `EvmCompile(msg)` | `solc → EVM` panicked. Almost always a **generator bug** (template emitted Solidity solc rejects). | **Silent skip** in the libFuzzer panic helper — doesn't burn a corpus slot. | +| `PvmCompile(msg)` | `resolc → PVM` panicked. solc accepted but resolc choked. | **Crash** — exactly the kind of resolc ICE the fuzzer is meant to find. | +| `DeployRevert { … }` | Constructor reverted on one backend but not the other. | Crash. | +| `ActionCount { … }` | Action result vectors of unequal length. Defensive; should not happen. | Crash. | +| `ActionRevert { … }` | One backend reverted on a call the other completed. | Crash. | +| `ActionReturnData { … }` | Both completed; return-data bytes differ. | Crash. | + +Compile failures used to panic the whole process via +`.expect("source should compile")` inside resolc's `test_utils`. The +harness wraps both calls in `std::panic::catch_unwind` and routes the +payload into a dedicated variant, so a generator bug doesn't poison +the whole libFuzzer run. + +## Performance + +Templated Solidity on an M-class laptop: + +| Step | Cost | +|---|---| +| `arbitrary → SolidityCase` | <1 ms | +| `solc → EVM` (subprocess) | ~80–100 ms | +| `resolc → PVM` (in-process) | ~150 ms | +| geth `evm` per action | ~10 ms × 2–4 actions | +| `revive_runner::Specs.run()` per action | ~5 ms | + +≈ 250 ms / iter end-to-end. With `-fork=12`: ~30–40 iter/sec total. + +Five-minute runs from an empty corpus, four forks: + +| Generator | Iters | `cov` (edges) | `ft` (features) | Corpus | +|---|---|---|---|---| +| SREM-only | 14,077 | 26,669 | 32,392 | 101 | +| Templated | 6,087 | **28,566** | **46,530** | **313** | + +The templated generator opens ~2K more edges and 14K more features +than the SREM-only baseline, and keeps a 3× larger corpus. + +> [!WARNING] +> +> libFuzzer is single-threaded per process. Use `-fork=N` for +> parallelism — not Rust threads, not rayon. Rayon inside a fuzz +> target would interleave coverage counters and produce useless data. + +## Known limitations + +* **Subprocess overhead dominates.** `solc` + `evm` subprocess costs + cap throughput at ~30 iter/sec on 12 cores. A native-Rust EVM on the + EVM side would be ~10× faster but is out of scope. +* **Recursive resolc isn't instrumented.** `resolc::test_utils` spawns + the installed `~/.cargo/bin/resolc` as a subprocess via + `--recursive-process` for per-contract lowering. Only the + in-process call sites carry SanCov instrumentation; the subprocess + is opaque to libFuzzer. `revive_fuzz::warn_if_resolc_stale` logs a + warning when the installed binary is older than workspace source, + to flag the case where a local fix isn't visible to the fuzzer. +* **One external function shape.** The harness hardcodes + `fn_0(uint256)` so the observer doesn't have to vary calldata + encoding. Removing that assumption requires generalising + `observe::action_calldata`. +* **Solc internals are opaque.** libFuzzer can't see solc's Yul + optimiser. Fine for resolc-side bug finding; not useful for solc + bug finding. +* **Stack traces aren't captured** in `catch_unwind` payloads. Easy + follow-up to wire `std::backtrace::Backtrace::capture()` through. + +## Code map + +```text +crates/fuzz/ # revive-fuzz harness library (stable Rust, main workspace) +├── Cargo.toml # `panic-on-divergence` feature +├── src/ +│ ├── lib.rs # re-exports + `panic_on_divergence` helper +│ ├── generator.rs # SolidityCase + Arbitrary impl +│ ├── templates.rs # 8 template renderers + solc self-test +│ ├── pipeline.rs # solc / resolc invocation helpers +│ ├── observe.rs # observe_evm / observe_pvm +│ ├── differential.rs # Divergence + run_case_solc_evm +│ └── stale.rs # `warn_if_resolc_stale` + +fuzz/ # cargo-fuzz package (separate workspace, nightly) +├── Cargo.toml # libfuzzer-sys + path-dep on revive-fuzz +├── rust-toolchain.toml # nightly, scoped here only +└── fuzz_targets/ + └── solidity_differential.rs # libFuzzer entry +``` + +The split exists because cargo-fuzz needs a nightly toolchain and +pulls in `libfuzzer-sys` — keeping that in a separate workspace +prevents either from leaking into the main `cargo build`. diff --git a/crates/fuzz/Cargo.toml b/crates/fuzz/Cargo.toml new file mode 100644 index 00000000..4c03fdef --- /dev/null +++ b/crates/fuzz/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "revive-fuzz" +version = "1.0.0" +license.workspace = true +edition.workspace = true +repository.workspace = true +authors.workspace = true +description = "Differential fuzzer harness; libFuzzer target lives in fuzz/" + +[features] +default = [] +# Surface `Divergence` and compile failures as panics so libFuzzer treats +# them as crashes and saves the input bytes to `fuzz/artifacts/`. +panic-on-divergence = [] + +[dependencies] +resolc = { workspace = true } +revive-yul = { workspace = true } +revive-differential = { workspace = true } +revive-llvm-context = { workspace = true } +revive-runner = { workspace = true } +revive-solc-json-interface = { workspace = true } + +alloy-primitives = { workspace = true } +anyhow = { workspace = true } +arbitrary = { version = "1.4", features = ["derive"] } +hex = { workspace = true } +log = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +which = { workspace = true } diff --git a/crates/fuzz/src/differential.rs b/crates/fuzz/src/differential.rs new file mode 100644 index 00000000..f9881a0a --- /dev/null +++ b/crates/fuzz/src/differential.rs @@ -0,0 +1,93 @@ +//! Differential driver: compile both backends, execute, compare. + +use thiserror::Error; + +use crate::generator::SolidityCase; +use crate::observe::{observe_evm, observe_pvm, Outcome}; +use crate::pipeline::{resolc_pvm, solc_evm}; + +#[derive(Debug, Error)] +pub enum Divergence { + /// solc rejected the template — generator bug, skipped by libFuzzer. + #[error("solc EVM compile failed: {0}")] + EvmCompile(String), + + /// resolc choked on input solc accepted — real backend find. + #[error("resolc PVM compile failed: {0}")] + PvmCompile(String), + + #[error("deploy_reverted mismatch — evm={evm} pvm={pvm}")] + DeployRevert { evm: bool, pvm: bool }, + + /// Defensive — both observers push one result per queued action. + #[error("action count mismatch — evm={evm} pvm={pvm}")] + ActionCount { evm: usize, pvm: usize }, + + #[error("action[{index}] revert mismatch — evm={evm} pvm={pvm}")] + ActionRevert { index: usize, evm: bool, pvm: bool }, + + #[error("action[{index}] return-data mismatch (lengths: evm={a}, pvm={b})")] + ActionReturnData { + index: usize, + a: usize, + b: usize, + full: Box<(Vec, Vec)>, + }, +} + +#[derive(Debug)] +pub struct CompareReport { + pub evm: Outcome, + pub pvm: Outcome, +} + +/// EVM via direct solc — pure backend-vs-backend, no printer noise. +pub fn run_case_solc_evm(case: &SolidityCase) -> Result { + let evm_bytes = solc_evm(&case.contract_name, &case.source) + .map_err(|error| Divergence::EvmCompile(error.to_string()))?; + let pvm_blob = resolc_pvm(&case.contract_name, &case.source) + .map_err(|error| Divergence::PvmCompile(error.to_string()))?; + + let evm = observe_evm(evm_bytes, case); + let pvm = observe_pvm(pvm_blob, case); + + compare(&evm, &pvm)?; + + Ok(CompareReport { evm, pvm }) +} + +fn compare(evm: &Outcome, pvm: &Outcome) -> Result<(), Divergence> { + if evm.deploy_reverted != pvm.deploy_reverted { + return Err(Divergence::DeployRevert { + evm: evm.deploy_reverted, + pvm: pvm.deploy_reverted, + }); + } + if evm.deploy_reverted { + return Ok(()); + } + if evm.actions.len() != pvm.actions.len() { + return Err(Divergence::ActionCount { + evm: evm.actions.len(), + pvm: pvm.actions.len(), + }); + } + for (index, (a, b)) in evm.actions.iter().zip(pvm.actions.iter()).enumerate() { + if a.reverted != b.reverted { + return Err(Divergence::ActionRevert { + index, + evm: a.reverted, + pvm: b.reverted, + }); + } + if a.return_data != b.return_data { + return Err(Divergence::ActionReturnData { + index, + a: a.return_data.len(), + b: b.return_data.len(), + full: Box::new((a.return_data.clone(), b.return_data.clone())), + }); + } + } + Ok(()) +} diff --git a/crates/fuzz/src/generator.rs b/crates/fuzz/src/generator.rs new file mode 100644 index 00000000..8dd87e99 --- /dev/null +++ b/crates/fuzz/src/generator.rs @@ -0,0 +1,143 @@ +//! `Arbitrary`-driven Solidity generator. Every [`SolidityCase`] is +//! one of N templates ([`crate::templates`]) sharing the wire shape +//! `constructor(uint256 seed)` + `fn_0(uint256 arg)`. Multiple +//! templates × per-template op menus give libFuzzer enough surface +//! to drive into different resolc lowering paths. + +use arbitrary::{Arbitrary, Unstructured}; + +use crate::templates::{self, TemplateKind}; + +/// Number of actions issued against each deployed contract. +const ACTION_COUNT_RANGE: std::ops::RangeInclusive = 2..=4; + +/// 1-in-5 chance to pull a sentinel instead of a uniform random +/// 32-byte word. Surfaces a given corner pair (e.g. `INT_MIN % -1`) +/// in ~1 minute at 8 threads × ~30 cases/sec; uniform-random +/// would essentially never hit it. +const INTERESTING_RATIO_NUM: u8 = 1; +const INTERESTING_RATIO_DEN: u8 = 5; + +/// 256-bit big-endian `int256` sentinels: zero, ±1, ±2, INT_MIN, +/// INT_MAX, powers of two at word-half boundaries, alternating bits. +fn interesting_value(index: u8) -> [u8; 32] { + let mut v = [0u8; 32]; + match index { + 0 => {} + 1 => v[31] = 1, + 2 => v[31] = 2, + 3 => v.fill(0xff), // -1 + 4 => { + v.fill(0xff); + v[31] = 0xfe; + } // -2 + 5 => v[0] = 0x80, // INT_MIN + 6 => { + v[0] = 0x80; + v[31] = 1; + } // INT_MIN + 1 + 7 => { + v.fill(0xff); + v[0] = 0x7f; + } // INT_MAX + 8 => { + v.fill(0xff); + v[0] = 0x7f; + v[31] = 0xfe; + } // INT_MAX - 1 + 9 => v[15] = 0x01, // 2^128 + 10 => v[16..].fill(0xff), // 2^128 - 1 + 11 => v[23] = 0x01, // 2^64 + 12 => v[24..].fill(0xff), // 2^64 - 1 + 13 => v.fill(0x55), + 14 => v.fill(0xaa), + _ => unreachable!("interesting_value index out of range"), + } + v +} + +const N_INTERESTING: u8 = 15; + +/// Sentinel with `INTERESTING_RATIO_NUM/INTERESTING_RATIO_DEN` +/// probability, otherwise uniform random. +fn pick_operand(u: &mut Unstructured<'_>) -> arbitrary::Result<[u8; 32]> { + if u.ratio(INTERESTING_RATIO_NUM, INTERESTING_RATIO_DEN)? { + let idx = u.int_in_range(0..=(N_INTERESTING - 1))?; + Ok(interesting_value(idx)) + } else { + <[u8; 32]>::arbitrary(u) + } +} + +#[derive(Debug, Clone)] +pub struct SolidityCase { + pub contract_name: String, + pub source: String, + pub constructor_args: Vec<[u8; 32]>, + pub actions: Vec, +} + +#[derive(Debug, Clone)] +pub struct Action { + pub argument: [u8; 32], +} + +impl<'a> Arbitrary<'a> for SolidityCase { + fn arbitrary(u: &mut Unstructured<'a>) -> arbitrary::Result { + let template = TemplateKind::arbitrary(u)?; + let rendered = templates::render(template, u)?; + let constructor_seed = pick_operand(u)?; + let action_count = u.int_in_range(ACTION_COUNT_RANGE)? as usize; + let mut actions = Vec::with_capacity(action_count); + for _ in 0..action_count { + actions.push(Action { + argument: pick_operand(u)?, + }); + } + Ok(Self { + contract_name: rendered.name, + source: rendered.source, + constructor_args: vec![constructor_seed], + actions, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn arbitrary_smoke() { + let mut seed = [0u8; 4096]; + for byte in seed.iter_mut().enumerate() { + *byte.1 = (byte.0 * 17 + 31) as u8; + } + let mut u = Unstructured::new(&seed); + let case = SolidityCase::arbitrary(&mut u).expect("arbitrary should succeed"); + // Lock the wire shape — observer assumes both. + assert!(case.source.contains("contract ")); + assert!(case.source.contains("fn_0(")); + assert!(case.source.contains("constructor(uint256 seed)")); + assert!(!case.actions.is_empty()); + assert_eq!(case.constructor_args.len(), 1); + } + + /// Spot-check the sentinel encodings — easy to typo a byte index. + #[test] + fn interesting_pool_shape() { + let zero = interesting_value(0); + assert!(zero.iter().all(|&b| b == 0)); + + let neg_one = interesting_value(3); + assert!(neg_one.iter().all(|&b| b == 0xff)); + + let int_min = interesting_value(5); + assert_eq!(int_min[0], 0x80); + assert!(int_min[1..].iter().all(|&b| b == 0)); + + let int_max = interesting_value(7); + assert_eq!(int_max[0], 0x7f); + assert!(int_max[1..].iter().all(|&b| b == 0xff)); + } +} diff --git a/crates/fuzz/src/lib.rs b/crates/fuzz/src/lib.rs new file mode 100644 index 00000000..8b9114e8 --- /dev/null +++ b/crates/fuzz/src/lib.rs @@ -0,0 +1,63 @@ +//! libFuzzer-driven differential fuzzer. +//! +//! For each [`SolidityCase`]: PVM via `resolc → revive-runner`, EVM +//! via [`run_case_solc_evm`] (direct solc — pure backend-vs-backend). +//! Mismatch on `(deploy_reverted, per-action reverted, return_data)` +//! → [`Divergence`]. The libfuzzer-sys target under `fuzz/` uses +//! [`panic_on_divergence::run_solidity_case_panic`]. + +pub mod differential; +pub mod generator; +pub mod observe; +pub mod pipeline; +pub mod stale; +pub mod templates; + +pub use differential::{run_case_solc_evm, CompareReport, Divergence}; +pub use generator::{Action, SolidityCase}; +pub use observe::{ActionResult, Outcome}; +pub use stale::warn_if_resolc_stale; + +/// Surface divergences as panics so libFuzzer saves the input as +/// a crash artifact. The panic message embeds the rendered source +/// + action sequence so the crash + log are enough to reproduce. +#[cfg(feature = "panic-on-divergence")] +pub mod panic_on_divergence { + use std::fmt::Write; + + use crate::{run_case_solc_evm, warn_if_resolc_stale, SolidityCase}; + + /// Direct-solc EVM path keeps revive-yul printer bugs out of the + /// noise floor. `EvmCompile` (solc rejected the template) is a + /// generator bug → silently skipped. + pub fn run_solidity_case_panic(case: &SolidityCase) { + warn_if_resolc_stale(); + let result = run_case_solc_evm(case); + if let Err(crate::Divergence::EvmCompile(_)) = &result { + return; + } + if let Err(divergence) = result { + let mut args = String::new(); + for (i, arg) in case.constructor_args.iter().enumerate() { + let _ = writeln!(args, " [{i}] 0x{}", hex::encode(arg)); + } + let mut actions = String::new(); + for (i, action) in case.actions.iter().enumerate() { + let _ = writeln!(actions, " [{i}] fn_0(0x{})", hex::encode(action.argument)); + } + panic!( + "solidity differential divergence: {divergence}\n\ + contract: {}\n\ + source:\n{}\n\ + constructor_args ({}):\n{}\ + actions ({}):\n{}", + case.contract_name, + case.source, + case.constructor_args.len(), + args, + case.actions.len(), + actions, + ); + } + } +} diff --git a/crates/fuzz/src/observe.rs b/crates/fuzz/src/observe.rs new file mode 100644 index 00000000..86a7edf9 --- /dev/null +++ b/crates/fuzz/src/observe.rs @@ -0,0 +1,137 @@ +//! Run compiled bytes on EVM + PVM, capture observations. + +use alloy_primitives::{keccak256, Bytes}; +use revive_differential::Evm; +use revive_runner::{Code, OptionalHex, Specs, SpecsAction, TestAddress, ALICE}; + +use crate::generator::{Action, SolidityCase}; + +/// What the differential reads back. No `final_storage` — pallet- +/// revive's storage is only queryable inside its `ExtBuilder` +/// closure. Storage divergence surfaces through later action +/// return-data, provided each template's `fn_0` reads its state. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Outcome { + /// Constructor reverted. + pub deploy_reverted: bool, + /// One entry per `case.actions` (empty if the deploy reverted). + pub actions: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ActionResult { + pub reverted: bool, + pub return_data: Vec, +} + +/// Constructor calldata = concatenation of 32-byte arguments. +pub fn constructor_calldata(case: &SolidityCase) -> Bytes { + let mut buf = Vec::with_capacity(case.constructor_args.len() * 32); + for arg in &case.constructor_args { + buf.extend_from_slice(arg); + } + buf.into() +} + +/// `selector(fn_0(uint256)) || arg32`. Every template exposes `fn_0`. +pub fn action_calldata(action: &Action) -> Bytes { + let selector = &keccak256(b"fn_0(uint256)")[..4]; + let mut buf = Vec::with_capacity(36); + buf.extend_from_slice(selector); + buf.extend_from_slice(&action.argument); + buf.into() +} + +/// Deploy + replay on geth's `evm`; state threaded via `from_genesis`. +pub fn observe_evm(deploy_code: Vec, case: &SolidityCase) -> Outcome { + let constructor = constructor_calldata(case); + // geth `evm` expects deploy bytes as hex-ASCII on stdin. + let deploy_blob = hex::encode(&deploy_code).into_bytes(); + let mut builder = Evm::default().code_blob(deploy_blob).deploy(true); + if !constructor.is_empty() { + builder = builder.input(constructor); + } + let deploy_log = builder.run(); + if deploy_log.output.error.is_some() || deploy_log.account_deployed.is_none() { + return Outcome { + deploy_reverted: true, + actions: vec![], + }; + } + let address = deploy_log.account_deployed.expect("checked above"); + let mut state = deploy_log.state_dump; + + let mut results = Vec::with_capacity(case.actions.len()); + for action in &case.actions { + let log = Evm::from_genesis(state.clone().into()) + .receiver(address) + .input(action_calldata(action)) + .run(); + results.push(ActionResult { + reverted: log.output.error.is_some(), + return_data: log.output.output.to_vec(), + }); + state = log.state_dump; + } + + Outcome { + deploy_reverted: false, + actions: results, + } +} + +/// Same on `revive-runner`'s pallet-revive sim. We orchestrate the +/// EVM side ourselves, so `differential: false` here. +pub fn observe_pvm(pvm_blob: Vec, case: &SolidityCase) -> Outcome { + let constructor = constructor_calldata(case).to_vec(); + let mut actions = vec![SpecsAction::Instantiate { + origin: TestAddress::default(), + value: 0, + gas_limit: None, + storage_deposit_limit: None, + code: Code::Bytes(pvm_blob), + data: constructor, + salt: OptionalHex::default(), + }]; + for action in &case.actions { + actions.push(SpecsAction::Call { + origin: TestAddress::default(), + dest: TestAddress::Instantiated(0), + value: 0, + gas_limit: None, + storage_deposit_limit: None, + data: action_calldata(action).to_vec(), + }); + } + + let mut results = Specs { + balances: vec![(ALICE, 1_000_000_000_000)], + actions, + // Default VerifyCall(success: true) would abort on every + // revert; we read revert flags from CallResult directly. + verify_each_call: false, + ..Default::default() + } + .run() + .into_iter(); + + let deploy_result = results.next().expect("instantiate produced no result"); + if deploy_result.did_revert() { + return Outcome { + deploy_reverted: true, + actions: vec![], + }; + } + + let action_results = results + .map(|call| ActionResult { + reverted: call.did_revert(), + return_data: call.output(), + }) + .collect(); + + Outcome { + deploy_reverted: false, + actions: action_results, + } +} diff --git a/crates/fuzz/src/pipeline.rs b/crates/fuzz/src/pipeline.rs new file mode 100644 index 00000000..e846d435 --- /dev/null +++ b/crates/fuzz/src/pipeline.rs @@ -0,0 +1,130 @@ +//! Compile paths. Each helper returns `Result` — never panics: +//! resolc/solc internals use `.expect()` on degenerate inputs, so +//! we [`catch_unwind`] and convert to `Err`. +//! +//! Uncached on purpose — `resolc::test_utils::{compile_blob, ...}` +//! memoizes by `(name, source)`, which never repeats under fresh +//! per-case suffixes. + +use std::any::Any; +use std::collections::BTreeMap; +use std::panic::{catch_unwind, AssertUnwindSafe}; + +use resolc::test_utils::{build_solidity_with_options, build_solidity_with_options_evm}; +use revive_llvm_context::OptimizerSettings; +use revive_solc_json_interface::{SolcStandardJsonInputSource, SolcStandardJsonOutputErrorHandler}; + +const FILE_NAME: &str = "contract.sol"; + +fn sources(source: &str) -> BTreeMap { + BTreeMap::from([( + FILE_NAME.to_owned(), + SolcStandardJsonInputSource::from(source.to_owned()), + )]) +} + +fn panic_to_string(payload: Box) -> String { + if let Some(s) = payload.downcast_ref::<&str>() { + (*s).to_string() + } else if let Some(s) = payload.downcast_ref::() { + s.clone() + } else { + "".to_string() + } +} + +/// Direct `solc → EVM`. Used by `run_case_solc_evm` / libFuzzer. +pub fn solc_evm(contract_name: &str, source: &str) -> anyhow::Result> { + let result = catch_unwind(AssertUnwindSafe(|| { + build_solidity_with_options_evm( + sources(source), + Default::default(), + Default::default(), + true, + ) + })) + .map_err(|payload| { + anyhow::anyhow!("solc EVM compile panicked: {}", panic_to_string(payload)) + })?; + let contracts = result.map_err(|error| anyhow::anyhow!("solc EVM compile: {error}"))?; + let (bytecode, _runtime) = contracts + .get(contract_name) + .ok_or_else(|| anyhow::anyhow!("contract {contract_name} missing from solc EVM output"))?; + hex::decode(bytecode.object.as_str()) + .map_err(|error| anyhow::anyhow!("solc EVM bytecode hex decode: {error}")) +} + +/// `resolc → PVM`. Returns a PolkaVM blob. +pub fn resolc_pvm(contract_name: &str, source: &str) -> anyhow::Result> { + let result = catch_unwind(AssertUnwindSafe(|| { + build_solidity_with_options( + sources(source), + Default::default(), + Default::default(), + OptimizerSettings::cycles(), + true, + Default::default(), + ) + })) + .map_err(|payload| { + anyhow::anyhow!("resolc PVM compile panicked: {}", panic_to_string(payload)) + })?; + let output = result.map_err(|error| anyhow::anyhow!("resolc PVM compile: {error}"))?; + if output.has_errors() { + anyhow::bail!("resolc PVM compile reported errors"); + } + let bytecode = output + .contracts + .get(FILE_NAME) + .and_then(|m| m.get(contract_name)) + .and_then(|c| c.evm.as_ref()) + .and_then(|e| e.bytecode.as_ref()) + .ok_or_else(|| anyhow::anyhow!("PVM bytecode missing for {contract_name}"))?; + hex::decode(bytecode.object.as_str()) + .map_err(|error| anyhow::anyhow!("PVM bytecode hex decode: {error}")) +} + +#[cfg(test)] +mod tests { + //! E2E self-tests; ignored by default (need solc + LLVM). + //! `cargo test -p revive-fuzz --lib -- --ignored`. + use super::*; + + const FIXTURE: &str = r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8; +contract Probe { + uint256 public slot; + constructor(uint256 seed) { slot = seed; } + function fn_0(uint256 arg) external returns (uint256) { + unchecked { slot = slot + arg; } + return slot; + } +} +"#; + + #[test] + #[ignore = "requires solc"] + fn solc_evm_returns_evm_bytecode() { + let bytes = solc_evm("Probe", FIXTURE).expect("solc_evm"); + assert!(!bytes.is_empty(), "EVM bytecode empty"); + // Solidity 0.8 deploy code starts with PUSH0/PUSH1. + assert!( + bytes[0] == 0x5f || bytes[0] == 0x60, + "unexpected first opcode 0x{:02x}", + bytes[0] + ); + } + + #[test] + #[ignore = "requires solc + LLVM_SYS_221_PREFIX"] + fn resolc_pvm_returns_pvm_blob() { + let bytes = resolc_pvm("Probe", FIXTURE).expect("resolc_pvm"); + assert!( + bytes.len() >= 3, + "PVM blob too small: {} bytes", + bytes.len() + ); + assert_eq!(&bytes[..3], b"PVM", "PVM magic missing"); + } +} diff --git a/crates/fuzz/src/stale.rs b/crates/fuzz/src/stale.rs new file mode 100644 index 00000000..fa2b6186 --- /dev/null +++ b/crates/fuzz/src/stale.rs @@ -0,0 +1,114 @@ +//! Warn if the `resolc` on `$PATH` is older than any workspace +//! source: `Project::compile` spawns it via `--recursive-process`, +//! so a stale binary silently masks local source changes. + +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +/// One-shot resolc-staleness check (idempotent via `Once`). +pub fn warn_if_resolc_stale() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(check); +} + +fn check() { + let resolc_path = match which::which("resolc") { + Ok(path) => path, + Err(_) => { + log::warn!("`resolc` not found on $PATH; `make install-bin` first."); + return; + } + }; + let Ok(resolc_mtime) = resolc_path.metadata().and_then(|m| m.modified()) else { + return; + }; + + // crates/fuzz/.. = crates/ — the source root to scan. + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let Some(crates_dir) = manifest_dir.parent().map(Path::to_path_buf) else { + return; + }; + if !crates_dir.exists() { + return; + } + let Some(newest) = newest_source_mtime(&crates_dir) else { + return; + }; + + if newest > resolc_mtime { + log::warn!( + "installed `resolc` at {} is older than revive source ({} vs {}).", + resolc_path.display(), + format_age_since(resolc_mtime), + format_age_since(newest), + ); + log::warn!( + "the fuzzer shells out to this binary — run `make install-bin` to rebuild before trusting results." + ); + } +} + +/// Newest mtime under `crates//{src/**, Cargo.toml}`, minus +/// `crates/fuzz` (editing it isn't a reason to warn). +fn newest_source_mtime(crates_dir: &Path) -> Option { + let mut newest: Option = None; + let entries = std::fs::read_dir(crates_dir).ok()?; + for entry in entries.flatten() { + let crate_dir = entry.path(); + if !crate_dir.is_dir() { + continue; + } + if crate_dir.file_name().is_some_and(|n| n == "fuzz") { + continue; + } + update_newest_recursive(&crate_dir.join("src"), &mut newest); + if let Ok(meta) = crate_dir.join("Cargo.toml").metadata() { + if let Ok(mtime) = meta.modified() { + bump(&mut newest, mtime); + } + } + } + newest +} + +fn update_newest_recursive(dir: &Path, newest: &mut Option) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let Ok(meta) = entry.metadata() else { continue }; + if meta.is_dir() { + update_newest_recursive(&path, newest); + } else if meta.is_file() { + if let Ok(mtime) = meta.modified() { + bump(newest, mtime); + } + } + } +} + +fn bump(newest: &mut Option, candidate: SystemTime) { + match newest { + Some(current) if *current >= candidate => {} + _ => *newest = Some(candidate), + } +} + +fn format_age_since(mtime: SystemTime) -> String { + match SystemTime::now().duration_since(mtime) { + Ok(duration) => { + let secs = duration.as_secs(); + if secs < 60 { + format!("{secs}s ago") + } else if secs < 3600 { + format!("{}m ago", secs / 60) + } else if secs < 86400 { + format!("{}h ago", secs / 3600) + } else { + format!("{}d ago", secs / 86400) + } + } + Err(_) => "in the future".into(), + } +} diff --git a/crates/fuzz/src/templates.rs b/crates/fuzz/src/templates.rs new file mode 100644 index 00000000..14b4762e --- /dev/null +++ b/crates/fuzz/src/templates.rs @@ -0,0 +1,397 @@ +//! Solidity contract templates. Each renders a `pragma solidity ^0.8` +//! contract with the harness-required shape: +//! `constructor(uint256 seed)` + `fn_0(uint256 arg) external returns +//! (...)`. Templates must compile clean under solc 0.8.x, terminate +//! deterministically on every 256-bit input pair, and avoid block +//! context (`block.number`, `gas`, …) — that differs between geth's +//! `evm` and pallet-revive's sim and surfaces as benign divergence. + +use std::fmt::Write; + +use arbitrary::{Arbitrary, Unstructured}; + +/// Template family. Variant name is embedded in the contract name so +/// a divergence report identifies which family produced it. +#[derive(Debug, Clone, Copy)] +pub enum TemplateKind { + /// `slot0 % arg` — original SREM probe (paritytech/revive#527). + Srem, + ArithChain, + UncheckedArith, + Mapping, + DynArray, + RequireGuard, + LoopAccum, + Bitwise, +} + +impl TemplateKind { + const ALL: &'static [TemplateKind] = &[ + TemplateKind::Srem, + TemplateKind::ArithChain, + TemplateKind::UncheckedArith, + TemplateKind::Mapping, + TemplateKind::DynArray, + TemplateKind::RequireGuard, + TemplateKind::LoopAccum, + TemplateKind::Bitwise, + ]; + + fn name_prefix(self) -> &'static str { + match self { + TemplateKind::Srem => "Srem", + TemplateKind::ArithChain => "Arith", + TemplateKind::UncheckedArith => "UArith", + TemplateKind::Mapping => "Mapping", + TemplateKind::DynArray => "DynArray", + TemplateKind::RequireGuard => "Require", + TemplateKind::LoopAccum => "Loop", + TemplateKind::Bitwise => "Bitwise", + } + } +} + +impl<'a> Arbitrary<'a> for TemplateKind { + fn arbitrary(u: &mut Unstructured<'a>) -> arbitrary::Result { + Ok(*u.choose(TemplateKind::ALL)?) + } +} + +pub struct RenderedContract { + pub name: String, + pub source: String, +} + +/// Render a contract; sub-choices (op selectors, etc.) drawn from `u`. +/// Name carries a random suffix to avoid blob-cache collisions across +/// concurrent fuzz cases. +pub fn render(kind: TemplateKind, u: &mut Unstructured<'_>) -> arbitrary::Result { + let suffix: u32 = u.arbitrary()?; + let name = format!("Fuzz{}_{:08x}", kind.name_prefix(), suffix); + let source = match kind { + TemplateKind::Srem => render_srem(&name), + TemplateKind::ArithChain => render_arith_chain(u, &name)?, + TemplateKind::UncheckedArith => render_unchecked_arith(u, &name)?, + TemplateKind::Mapping => render_mapping(&name), + TemplateKind::DynArray => render_dyn_array(&name), + TemplateKind::RequireGuard => render_require_guard(u, &name)?, + TemplateKind::LoopAccum => render_loop_accum(u, &name)?, + TemplateKind::Bitwise => render_bitwise(u, &name)?, + }; + Ok(RenderedContract { name, source }) +} + +// ── Op menus ─────────────────────────────────────────────────────────── + +const SIGNED_BIN_OPS: &[&str] = &["+", "-", "*", "/", "%"]; +const UNSIGNED_BIN_OPS: &[&str] = &["+", "-", "*", "/", "%"]; +const BITWISE_OPS: &[&str] = &["&", "|", "^"]; +const SHIFT_OPS: &[&str] = &["<<", ">>"]; + +/// `require` predicates; `a`, `b` are template-exposed locals. +const PREDICATES: &[&str] = &[ + "a < b", + "a > b", + "a <= b", + "a >= b", + "a == b", + "a != b", + "(a & b) != 0", + "(a ^ b) != 0", +]; + +fn pick<'b>(u: &mut Unstructured<'_>, choices: &[&'b str]) -> arbitrary::Result<&'b str> { + Ok(*u.choose(choices)?) +} + +// ── Common header ────────────────────────────────────────────────────── + +fn header(s: &mut String, name: &str) { + let _ = writeln!(s, "// SPDX-License-Identifier: MIT"); + let _ = writeln!(s, "pragma solidity ^0.8;"); + let _ = writeln!(s, "contract {name} {{"); +} + +fn footer(s: &mut String) { + let _ = writeln!(s, "}}"); +} + +// ── Templates ────────────────────────────────────────────────────────── + +fn render_srem(name: &str) -> String { + let mut s = String::with_capacity(512); + header(&mut s, name); + let _ = writeln!(s, " int256 public slot0;"); + let _ = writeln!( + s, + " constructor(uint256 seed) {{ slot0 = int256(seed); }}" + ); + let _ = writeln!( + s, + " function fn_0(uint256 arg) external returns (int256) {{" + ); + let _ = writeln!(s, " int256 result = slot0 % int256(arg);"); + let _ = writeln!(s, " slot0 = result;"); + let _ = writeln!(s, " return result;"); + let _ = writeln!(s, " }}"); + footer(&mut s); + s +} + +fn render_arith_chain(u: &mut Unstructured<'_>, name: &str) -> arbitrary::Result { + let op_a = pick(u, SIGNED_BIN_OPS)?; + let op_b = pick(u, SIGNED_BIN_OPS)?; + let op_c = pick(u, SIGNED_BIN_OPS)?; + let mut s = String::with_capacity(640); + header(&mut s, name); + let _ = writeln!(s, " int256 public s0;"); + let _ = writeln!(s, " int256 public s1;"); + let _ = writeln!(s, " constructor(uint256 seed) {{"); + let _ = writeln!(s, " s0 = int256(seed);"); + let _ = writeln!(s, " s1 = int256(seed ^ 0x55);"); + let _ = writeln!(s, " }}"); + let _ = writeln!( + s, + " function fn_0(uint256 arg) external returns (int256) {{" + ); + let _ = writeln!(s, " int256 a = int256(arg);"); + // Div/mod by zero revert identically on both backends — fine. + let _ = writeln!(s, " int256 mix = s1 {op_a} a;"); + let _ = writeln!(s, " int256 r = s0 {op_b} mix;"); + let _ = writeln!(s, " s0 = s0 {op_c} r;"); + let _ = writeln!(s, " s1 = r;"); + let _ = writeln!(s, " return r;"); + let _ = writeln!(s, " }}"); + footer(&mut s); + Ok(s) +} + +fn render_unchecked_arith(u: &mut Unstructured<'_>, name: &str) -> arbitrary::Result { + let op_a = pick(u, UNSIGNED_BIN_OPS)?; + let op_b = pick(u, UNSIGNED_BIN_OPS)?; + let mut s = String::with_capacity(512); + header(&mut s, name); + let _ = writeln!(s, " uint256 public slot;"); + let _ = writeln!(s, " constructor(uint256 seed) {{ slot = seed; }}"); + let _ = writeln!( + s, + " function fn_0(uint256 arg) external returns (uint256) {{" + ); + let _ = writeln!(s, " unchecked {{"); + // `%` and `/` revert on zero divisor even inside unchecked. + let _ = writeln!(s, " uint256 r = slot {op_a} arg;"); + let _ = writeln!(s, " r = r {op_b} (arg | 1);"); + let _ = writeln!(s, " slot = r;"); + let _ = writeln!(s, " return r;"); + let _ = writeln!(s, " }}"); + let _ = writeln!(s, " }}"); + footer(&mut s); + Ok(s) +} + +fn render_mapping(name: &str) -> String { + let mut s = String::with_capacity(512); + header(&mut s, name); + let _ = writeln!(s, " mapping(uint256 => uint256) public m;"); + let _ = writeln!(s, " uint256 public lastKey;"); + let _ = writeln!(s, " constructor(uint256 seed) {{ lastKey = seed; }}"); + let _ = writeln!( + s, + " function fn_0(uint256 arg) external returns (uint256) {{" + ); + let _ = writeln!(s, " uint256 key = arg ^ lastKey;"); + let _ = writeln!(s, " unchecked {{ m[key] = m[key] + 1; }}"); + let _ = writeln!(s, " lastKey = key;"); + let _ = writeln!(s, " return m[key];"); + let _ = writeln!(s, " }}"); + footer(&mut s); + s +} + +fn render_dyn_array(name: &str) -> String { + let mut s = String::with_capacity(640); + header(&mut s, name); + let _ = writeln!(s, " uint256[] public arr;"); + let _ = writeln!(s, " constructor(uint256 seed) {{ arr.push(seed); }}"); + let _ = writeln!( + s, + " function fn_0(uint256 arg) external returns (uint256) {{" + ); + // 64-element cap on push so growth doesn't OOG either backend. + let _ = writeln!(s, " if ((arg & 1) == 0 && arr.length < 64) {{"); + let _ = writeln!(s, " arr.push(arg);"); + let _ = writeln!(s, " }} else if (arr.length > 0) {{"); + let _ = writeln!(s, " uint256 idx = arg % arr.length;"); + let _ = writeln!(s, " arr[idx] = arr[idx] ^ arg;"); + let _ = writeln!(s, " }}"); + let _ = writeln!( + s, + " return arr.length == 0 ? 0 : arr[arr.length - 1];" + ); + let _ = writeln!(s, " }}"); + footer(&mut s); + s +} + +fn render_require_guard(u: &mut Unstructured<'_>, name: &str) -> arbitrary::Result { + let predicate = pick(u, PREDICATES)?; + let op = pick(u, UNSIGNED_BIN_OPS)?; + let mut s = String::with_capacity(512); + header(&mut s, name); + let _ = writeln!(s, " uint256 public slot;"); + let _ = writeln!(s, " constructor(uint256 seed) {{ slot = seed; }}"); + let _ = writeln!( + s, + " function fn_0(uint256 arg) external returns (uint256) {{" + ); + let _ = writeln!(s, " uint256 a = slot;"); + let _ = writeln!(s, " uint256 b = arg;"); + let _ = writeln!(s, " require({predicate}, \"guard\");"); + let _ = writeln!(s, " unchecked {{ slot = a {op} (b | 1); }}"); + let _ = writeln!(s, " return slot;"); + let _ = writeln!(s, " }}"); + footer(&mut s); + Ok(s) +} + +fn render_loop_accum(u: &mut Unstructured<'_>, name: &str) -> arbitrary::Result { + let op = pick(u, BITWISE_OPS)?; + let mut s = String::with_capacity(640); + header(&mut s, name); + let _ = writeln!(s, " uint256 public acc;"); + let _ = writeln!(s, " constructor(uint256 seed) {{ acc = seed; }}"); + let _ = writeln!( + s, + " function fn_0(uint256 arg) external returns (uint256) {{" + ); + // Cap at 31 iterations — keeps gas spend bounded. + let _ = writeln!(s, " uint256 bound = arg & 0x1F;"); + let _ = writeln!(s, " uint256 a = acc;"); + let _ = writeln!(s, " for (uint256 i = 0; i < bound; i++) {{"); + let _ = writeln!(s, " unchecked {{ a = (a {op} (arg + i)) + i; }}"); + let _ = writeln!(s, " }}"); + let _ = writeln!(s, " acc = a;"); + let _ = writeln!(s, " return a;"); + let _ = writeln!(s, " }}"); + footer(&mut s); + Ok(s) +} + +fn render_bitwise(u: &mut Unstructured<'_>, name: &str) -> arbitrary::Result { + let op_a = pick(u, BITWISE_OPS)?; + let op_b = pick(u, BITWISE_OPS)?; + let shift = pick(u, SHIFT_OPS)?; + let mut s = String::with_capacity(512); + header(&mut s, name); + let _ = writeln!(s, " uint256 public slot;"); + let _ = writeln!(s, " constructor(uint256 seed) {{ slot = seed; }}"); + let _ = writeln!( + s, + " function fn_0(uint256 arg) external returns (uint256) {{" + ); + // Cap shift at 8 bits — larger shifts saturate to 0. + let _ = writeln!(s, " uint256 sh = arg & 0xff;"); + let _ = writeln!( + s, + " uint256 r = (slot {op_a} arg) {op_b} (arg {shift} sh);" + ); + let _ = writeln!(s, " slot = r;"); + let _ = writeln!(s, " return r;"); + let _ = writeln!(s, " }}"); + footer(&mut s); + Ok(s) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn seed_unstructured() -> Vec { + (0..4096u32) + .flat_map(|i| i.wrapping_mul(1103515245).wrapping_add(12345).to_le_bytes()) + .collect() + } + + #[test] + fn every_template_renders() { + let tape = seed_unstructured(); + for &kind in TemplateKind::ALL { + let mut u = Unstructured::new(&tape); + let rendered = render(kind, &mut u).expect("render"); + assert!(rendered.source.contains("contract ")); + assert!(rendered.source.contains("fn_0(")); + assert!(rendered.source.contains("constructor")); + assert!( + rendered.name.starts_with("Fuzz") && rendered.name.contains('_'), + "name shape: {}", + rendered.name + ); + } + } + + #[test] + fn srem_template_uses_modulo() { + let s = render_srem("FuzzSrem_deadbeef"); + assert!(s.contains("slot0 % int256(arg)")); + assert!(s.contains("int256 public slot0")); + } + + /// Sanity-check that every template is solc-accepted. Without it, + /// a template typo would surface as a libFuzzer crash storm + /// misattributed to the backend. + #[test] + #[ignore = "requires solc on PATH"] + fn every_template_compiles() { + use std::io::Write; + use std::process::{Command, Stdio}; + + let tape = seed_unstructured(); + for &kind in TemplateKind::ALL { + let mut u = Unstructured::new(&tape); + let rendered = render(kind, &mut u).expect("render"); + let standard_json = serde_json::json!({ + "language": "Solidity", + "sources": { "fuzz.sol": { "content": rendered.source } }, + "settings": { "outputSelection": { "*": { "*": ["evm.bytecode.object"] } } } + }); + let mut child = Command::new("solc") + .args(["--standard-json"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn solc"); + child + .stdin + .as_mut() + .unwrap() + .write_all(standard_json.to_string().as_bytes()) + .expect("write to solc stdin"); + let output = child.wait_with_output().expect("wait solc"); + assert!( + output.status.success(), + "solc rejected {:?}:\n{}", + kind, + String::from_utf8_lossy(&output.stderr), + ); + // solc exits 0 even on `Error:` — has to be detected via + // the `errors[].severity == "error"` field of the JSON. + let stdout = String::from_utf8_lossy(&output.stdout); + let parsed: serde_json::Value = + serde_json::from_str(&stdout).expect("solc stdout is json"); + if let Some(errors) = parsed.get("errors").and_then(|e| e.as_array()) { + let fatal: Vec<&serde_json::Value> = errors + .iter() + .filter(|e| e.get("severity").and_then(|s| s.as_str()) == Some("error")) + .collect(); + assert!( + fatal.is_empty(), + "solc errors on {:?}:\nsource:\n{}\nerrors: {:#?}", + kind, + rendered.source, + fatal, + ); + } + } + } +} diff --git a/crates/llvm-builder/src/lib.rs b/crates/llvm-builder/src/lib.rs index f1b9bd90..8bad5403 100644 --- a/crates/llvm-builder/src/lib.rs +++ b/crates/llvm-builder/src/lib.rs @@ -57,6 +57,7 @@ pub fn build( default_target: Option, enable_tests: bool, enable_coverage: bool, + enable_sancov: bool, extra_args: &[String], ccache_variant: Option, enable_assertions: bool, @@ -71,6 +72,7 @@ pub fn build( log::trace!("default target: {default_target:?}"); log::trace!("eneable tests: {enable_tests:?}"); log::trace!("enable_coverage: {enable_coverage:?}"); + log::trace!("enable_sancov: {enable_sancov:?}"); log::trace!("extra args: {extra_args:?}"); log::trace!("sanitzer: {sanitizer:?}"); log::trace!("enable valgrind: {enable_valgrind:?}"); @@ -97,6 +99,7 @@ pub fn build( default_target, enable_tests, enable_coverage, + enable_sancov, extra_args, ccache_variant, enable_assertions, @@ -112,6 +115,7 @@ pub fn build( default_target, enable_tests, enable_coverage, + enable_sancov, extra_args, ccache_variant, enable_assertions, @@ -127,6 +131,7 @@ pub fn build( default_target, enable_tests, enable_coverage, + enable_sancov, extra_args, ccache_variant, enable_assertions, @@ -145,6 +150,7 @@ pub fn build( default_target, enable_tests, enable_coverage, + enable_sancov, extra_args, ccache_variant, enable_assertions, @@ -159,6 +165,7 @@ pub fn build( default_target, enable_tests, enable_coverage, + enable_sancov, extra_args, ccache_variant, enable_assertions, @@ -178,6 +185,7 @@ pub fn build( default_target, enable_tests, enable_coverage, + enable_sancov, extra_args, ccache_variant, enable_assertions, @@ -193,6 +201,7 @@ pub fn build( default_target, enable_tests, enable_coverage, + enable_sancov, extra_args, ccache_variant, enable_assertions, @@ -212,6 +221,7 @@ pub fn build( default_target, enable_tests, enable_coverage, + enable_sancov, extra_args, ccache_variant, enable_assertions, @@ -227,6 +237,7 @@ pub fn build( default_target, enable_tests, enable_coverage, + enable_sancov, extra_args, ccache_variant, enable_assertions, diff --git a/crates/llvm-builder/src/platforms/aarch64_linux_gnu.rs b/crates/llvm-builder/src/platforms/aarch64_linux_gnu.rs index 4bda3b71..5eeb8f7f 100644 --- a/crates/llvm-builder/src/platforms/aarch64_linux_gnu.rs +++ b/crates/llvm-builder/src/platforms/aarch64_linux_gnu.rs @@ -21,6 +21,7 @@ pub fn build( default_target: Option, enable_tests: bool, enable_coverage: bool, + enable_sancov: bool, extra_args: &[String], ccache_variant: Option, enable_assertions: bool, @@ -83,6 +84,9 @@ pub fn build( .args(crate::platforms::shared::shared_build_opts_coverage( enable_coverage, )) + .args(crate::platforms::shared::shared_build_opts_sancov( + enable_sancov, + )) .args(crate::platforms::shared::SHARED_BUILD_OPTS) .args(crate::platforms::shared::SHARED_BUILD_OPTS_NOT_MUSL) .args(crate::platforms::shared::shared_build_opts_werror( diff --git a/crates/llvm-builder/src/platforms/aarch64_linux_musl.rs b/crates/llvm-builder/src/platforms/aarch64_linux_musl.rs index a59c26b6..0a5f990e 100644 --- a/crates/llvm-builder/src/platforms/aarch64_linux_musl.rs +++ b/crates/llvm-builder/src/platforms/aarch64_linux_musl.rs @@ -21,6 +21,7 @@ pub fn build( default_target: Option, enable_tests: bool, enable_coverage: bool, + enable_sancov: bool, extra_args: &[String], ccache_variant: Option, enable_assertions: bool, @@ -80,6 +81,7 @@ pub fn build( llvm_target_host.as_path(), enable_tests, enable_coverage, + enable_sancov, extra_args, ccache_variant, enable_assertions, @@ -271,6 +273,7 @@ fn build_target( host_target_directory: &Path, enable_tests: bool, enable_coverage: bool, + enable_sancov: bool, extra_args: &[String], ccache_variant: Option, enable_assertions: bool, @@ -343,6 +346,9 @@ fn build_target( .args(crate::platforms::shared::shared_build_opts_coverage( enable_coverage, )) + .args(crate::platforms::shared::shared_build_opts_sancov( + enable_sancov, + )) .args(extra_args) .args(crate::platforms::shared::shared_build_opts_ccache( ccache_variant, diff --git a/crates/llvm-builder/src/platforms/aarch64_macos.rs b/crates/llvm-builder/src/platforms/aarch64_macos.rs index a33b827d..4fbb313e 100644 --- a/crates/llvm-builder/src/platforms/aarch64_macos.rs +++ b/crates/llvm-builder/src/platforms/aarch64_macos.rs @@ -21,6 +21,7 @@ pub fn build( default_target: Option, enable_tests: bool, enable_coverage: bool, + enable_sancov: bool, extra_args: &[String], ccache_variant: Option, enable_assertions: bool, @@ -77,6 +78,9 @@ pub fn build( .args(crate::platforms::shared::shared_build_opts_coverage( enable_coverage, )) + .args(crate::platforms::shared::shared_build_opts_sancov( + enable_sancov, + )) .args(crate::platforms::shared::SHARED_BUILD_OPTS) .args(crate::platforms::shared::SHARED_BUILD_OPTS_NOT_MUSL) .args(crate::platforms::shared::shared_build_opts_werror( diff --git a/crates/llvm-builder/src/platforms/shared.rs b/crates/llvm-builder/src/platforms/shared.rs index 38362ce5..c57eeb14 100644 --- a/crates/llvm-builder/src/platforms/shared.rs +++ b/crates/llvm-builder/src/platforms/shared.rs @@ -194,6 +194,23 @@ pub fn shared_build_opts_coverage(enabled: bool) -> Vec { )] } +/// SanitizerCoverage flags for libFuzzer mutation feedback. +/// `-fsanitize=fuzzer-no-link` adds edge counters without pulling +/// libFuzzer runtime (the fuzz-target binary supplies it). Bypasses +/// `LLVM_USE_SANITIZE_COVERAGE` because that also builds +/// `clang-fuzzer`, which needs a compiler-rt archive that hasn't been +/// produced yet at this point in the build. +pub fn shared_build_opts_sancov(enabled: bool) -> Vec { + if !enabled { + return vec![]; + } + const FLAG: &str = "-fsanitize=fuzzer-no-link"; + vec![ + format!("-DCMAKE_C_FLAGS={FLAG}"), + format!("-DCMAKE_CXX_FLAGS={FLAG}"), + ] +} + /// Use of compiler cache (ccache) to speed up the build process. pub fn shared_build_opts_ccache(ccache_variant: Option) -> Vec { match ccache_variant { diff --git a/crates/llvm-builder/src/platforms/wasm32_emscripten.rs b/crates/llvm-builder/src/platforms/wasm32_emscripten.rs index 93cdc39d..fbc7a6cc 100644 --- a/crates/llvm-builder/src/platforms/wasm32_emscripten.rs +++ b/crates/llvm-builder/src/platforms/wasm32_emscripten.rs @@ -14,6 +14,7 @@ pub fn build( default_target: Option, enable_tests: bool, enable_coverage: bool, + enable_sancov: bool, extra_args: &[String], ccache_variant: Option, enable_assertions: bool, @@ -57,6 +58,7 @@ pub fn build( llvm_target_host.as_path(), enable_tests, enable_coverage, + enable_sancov, extra_args, ccache_variant, enable_assertions, @@ -126,6 +128,7 @@ fn build_target( host_target_directory: &Path, enable_tests: bool, enable_coverage: bool, + enable_sancov: bool, extra_args: &[String], ccache_variant: Option, enable_assertions: bool, @@ -198,6 +201,9 @@ fn build_target( .args(crate::platforms::shared::shared_build_opts_coverage( enable_coverage, )) + .args(crate::platforms::shared::shared_build_opts_sancov( + enable_sancov, + )) .args(extra_args) .args(crate::platforms::shared::shared_build_opts_ccache( ccache_variant, diff --git a/crates/llvm-builder/src/platforms/x86_64_linux_gnu.rs b/crates/llvm-builder/src/platforms/x86_64_linux_gnu.rs index c3aa7333..fc8a9948 100644 --- a/crates/llvm-builder/src/platforms/x86_64_linux_gnu.rs +++ b/crates/llvm-builder/src/platforms/x86_64_linux_gnu.rs @@ -21,6 +21,7 @@ pub fn build( default_target: Option, enable_tests: bool, enable_coverage: bool, + enable_sancov: bool, extra_args: &[String], ccache_variant: Option, enable_assertions: bool, @@ -83,6 +84,9 @@ pub fn build( .args(crate::platforms::shared::shared_build_opts_coverage( enable_coverage, )) + .args(crate::platforms::shared::shared_build_opts_sancov( + enable_sancov, + )) .args(crate::platforms::shared::shared_build_opts_ccache( ccache_variant, )) diff --git a/crates/llvm-builder/src/platforms/x86_64_linux_musl.rs b/crates/llvm-builder/src/platforms/x86_64_linux_musl.rs index c33c9468..101b22a1 100644 --- a/crates/llvm-builder/src/platforms/x86_64_linux_musl.rs +++ b/crates/llvm-builder/src/platforms/x86_64_linux_musl.rs @@ -22,6 +22,7 @@ pub fn build( default_target: Option, enable_tests: bool, enable_coverage: bool, + enable_sancov: bool, extra_args: &[String], ccache_variant: Option, enable_assertions: bool, @@ -83,6 +84,7 @@ pub fn build( llvm_target_host.as_path(), enable_tests, enable_coverage, + enable_sancov, extra_args, ccache_variant, enable_assertions, @@ -269,6 +271,7 @@ fn build_target( host_target_directory: &Path, enable_tests: bool, enable_coverage: bool, + enable_sancov: bool, extra_args: &[String], ccache_variant: Option, enable_assertions: bool, @@ -343,6 +346,9 @@ fn build_target( .args(crate::platforms::shared::shared_build_opts_coverage( enable_coverage, )) + .args(crate::platforms::shared::shared_build_opts_sancov( + enable_sancov, + )) .args(extra_args) .args(crate::platforms::shared::shared_build_opts_ccache( ccache_variant, diff --git a/crates/llvm-builder/src/platforms/x86_64_macos.rs b/crates/llvm-builder/src/platforms/x86_64_macos.rs index 4a037511..b9987083 100644 --- a/crates/llvm-builder/src/platforms/x86_64_macos.rs +++ b/crates/llvm-builder/src/platforms/x86_64_macos.rs @@ -21,6 +21,7 @@ pub fn build( default_target: Option, enable_tests: bool, enable_coverage: bool, + enable_sancov: bool, extra_args: &[String], ccache_variant: Option, enable_assertions: bool, @@ -77,6 +78,9 @@ pub fn build( .args(crate::platforms::shared::shared_build_opts_coverage( enable_coverage, )) + .args(crate::platforms::shared::shared_build_opts_sancov( + enable_sancov, + )) .args(crate::platforms::shared::SHARED_BUILD_OPTS) .args(crate::platforms::shared::SHARED_BUILD_OPTS_NOT_MUSL) .args(crate::platforms::shared::shared_build_opts_werror( diff --git a/crates/llvm-builder/src/platforms/x86_64_windows_msvc.rs b/crates/llvm-builder/src/platforms/x86_64_windows_msvc.rs index b96ac337..7d93a3d5 100644 --- a/crates/llvm-builder/src/platforms/x86_64_windows_msvc.rs +++ b/crates/llvm-builder/src/platforms/x86_64_windows_msvc.rs @@ -25,6 +25,7 @@ pub fn build( default_target: Option, enable_tests: bool, enable_coverage: bool, + enable_sancov: bool, extra_args: &[String], ccache_variant: Option, enable_assertions: bool, @@ -89,6 +90,9 @@ pub fn build( .args(crate::platforms::shared::shared_build_opts_coverage( enable_coverage, )) + .args(crate::platforms::shared::shared_build_opts_sancov( + enable_sancov, + )) .args(crate::platforms::shared::SHARED_BUILD_OPTS) .args(crate::platforms::shared::SHARED_BUILD_OPTS_NOT_MUSL) .args(crate::platforms::shared::shared_build_opts_werror( diff --git a/crates/llvm-builder/src/revive_llvm/arguments.rs b/crates/llvm-builder/src/revive_llvm/arguments.rs index b4fe0e5a..315d024d 100644 --- a/crates/llvm-builder/src/revive_llvm/arguments.rs +++ b/crates/llvm-builder/src/revive_llvm/arguments.rs @@ -48,6 +48,14 @@ pub enum Subcommand { #[arg(long)] enable_coverage: bool, + /// Build LLVM with SanitizerCoverage edge-tracking + /// (`-fsanitize=fuzzer-no-link`) so libFuzzer's mutation + /// engine sees LLVM C++ edges when these archives are linked + /// into a fuzz-target binary. Disjoint from + /// `--enable-coverage` (which targets `llvm-cov` reports). + #[arg(long)] + enable_sancov: bool, + /// Extra arguments to pass to CMake. /// A leading backslash will be unescaped. #[arg(long)] diff --git a/crates/llvm-builder/src/revive_llvm/main.rs b/crates/llvm-builder/src/revive_llvm/main.rs index 224bbba8..1b683756 100644 --- a/crates/llvm-builder/src/revive_llvm/main.rs +++ b/crates/llvm-builder/src/revive_llvm/main.rs @@ -45,6 +45,7 @@ fn main_inner() -> anyhow::Result<()> { default_target, enable_tests, enable_coverage, + enable_sancov, extra_args, ccache_variant, enable_assertions, @@ -102,6 +103,7 @@ fn main_inner() -> anyhow::Result<()> { default_target, enable_tests, enable_coverage, + enable_sancov, &extra_args_unescaped, ccache_variant, enable_assertions, diff --git a/crates/runner/src/lib.rs b/crates/runner/src/lib.rs index 79ee18d6..988ac7dd 100644 --- a/crates/runner/src/lib.rs +++ b/crates/runner/src/lib.rs @@ -226,7 +226,7 @@ pub enum CallResult { impl CallResult { /// Check if the call was successful - fn did_revert(&self) -> bool { + pub fn did_revert(&self) -> bool { match self { Self::Exec { result, .. } => result .result @@ -242,7 +242,7 @@ impl CallResult { } /// Get the output of the call - fn output(&self) -> Vec { + pub fn output(&self) -> Vec { match self { Self::Exec { result, .. } => result .result @@ -258,7 +258,7 @@ impl CallResult { } /// Get the gas consumed by the call - fn gas_consumed(&self) -> u128 { + pub fn gas_consumed(&self) -> u128 { match self { Self::Exec { result, .. } => result.gas_consumed, Self::Instantiate { result, .. } => result.gas_consumed, diff --git a/crates/runner/src/specs.rs b/crates/runner/src/specs.rs index 63c276a2..0cfb7135 100644 --- a/crates/runner/src/specs.rs +++ b/crates/runner/src/specs.rs @@ -203,6 +203,17 @@ pub struct Specs { pub balances: Vec<(H160, Balance)>, /// List of actions to perform pub actions: Vec, + /// When `true` (the default), `actions()` auto-injects a + /// `VerifyCall(success: true)` assertion after every Instantiate / + /// Call that does not already have one. Set to `false` for callers + /// that need to observe revert outcomes without panicking — e.g. + /// the differential fuzzer in `revive-fuzz`. + #[serde(default = "default_verify_each_call")] + pub verify_each_call: bool, +} + +fn default_verify_each_call() -> bool { + true } impl Default for Specs { @@ -215,6 +226,7 @@ impl Default for Specs { (CHARLIE, 1_000_000_000_000), ], actions: Default::default(), + verify_each_call: true, } } } @@ -229,10 +241,12 @@ impl Specs { .enumerate() .flat_map(|(index, item)| { let next_item = self.actions.get(index + 1); - if matches!( - item, - SpecsAction::Instantiate { .. } | SpecsAction::Call { .. } - ) && !matches!(next_item, Some(SpecsAction::VerifyCall { .. })) + if self.verify_each_call + && matches!( + item, + SpecsAction::Instantiate { .. } | SpecsAction::Call { .. } + ) + && !matches!(next_item, Some(SpecsAction::VerifyCall { .. })) && !self.differential { return vec![ diff --git a/docs/404.html b/docs/404.html index 70e1867d..46649624 100644 --- a/docs/404.html +++ b/docs/404.html @@ -36,10 +36,10 @@ const path_to_root = ""; const default_light_theme = "light"; const default_dark_theme = "navy"; - window.path_to_searchindex_js = "searchindex-339ac392.js"; + window.path_to_searchindex_js = "searchindex-7761a3ba.js"; - +
@@ -201,22 +201,6 @@

- - - +
@@ -212,22 +212,6 @@

Developer gui - - - +
@@ -245,22 +245,6 @@

Cus - - - +
@@ -258,22 +258,6 @@

AI policy

- - - +
@@ -200,7 +200,7 @@

musl libc

@@ -261,22 +261,6 @@

- - - +
@@ -224,22 +224,6 @@

- - - +
@@ -227,22 +227,6 @@

About

- - - +
@@ -4819,6 +4819,275 @@

Revive Differential Tests follow the exact same strategy but implement a much more powerful test spec format, spec runner and reports. This allows differentially testing much more complex test cases (for example testing Uniswap pair creations and swaps), executed via transactions sent to actual blockchain nodes.

+

Differential fuzzing

+

revive ships a coverage-guided differential fuzzer that compares the +same logical contract execution between resolc’s PVM lowering and +solc’s EVM lowering. Any byte-level mismatch on revert flags or +return data is treated as a backend bug.

+

The harness uses libFuzzer with SanitizerCoverage feedback over every +Rust crate in resolc’s dep graph, so the mutation engine learns from +edge coverage in the parser, IR lowering, and pallet-revive +simulation.

+
+

Tip

+

The fuzzer shells out to solc and geth’s evm. Both must be on +$PATH. See the Testing strategy chapter for the +geth EVM-tool installation snippet.

+
+

Running the fuzzer

+
# 4 forked workers, runs until interrupted
+make fuzz-libfuzzer
+
+# Tune fork count
+make fuzz-libfuzzer JOBS=8
+
+# Equivalent direct invocation (gives access to every libFuzzer flag)
+cd fuzz
+cargo +nightly fuzz run solidity_differential -- -fork=8
+
+

Useful libFuzzer flags (everything after the bare -- is passed +through to the libFuzzer runtime):

+
+ + + + + + + + + + + + + +
FlagEffect
-fork=NN parallel forked workers, sharing the corpus dir.
-max_total_time=SWall-clock budget in seconds.
-runs=NIteration budget instead of wall-clock.
-max_len=NCap input length (default 4096).
-rss_limit_mb=NOOM threshold per worker (default 2048).
-ignore_crashes=1Keep running after a crash instead of stopping.
-print_final_stats=1Print coverage / corpus stats at exit.
+
+
+

Note

+

libFuzzer needs a nightly Rust toolchain because the SanitizerCoverage +flags it relies on (-Zsanitizer-coverage-*, -Cpasses=sancov-module, +etc.) are nightly-only. The rust-toolchain.toml inside fuzz/ +scopes nightly to that directory — the rest of the workspace stays +on stable.

+
+

Reproducing a crash

+

libFuzzer writes crash inputs to +fuzz/artifacts/solidity_differential/crash-<sha256>. The bytes are +deterministic — re-feeding them produces the same SolidityCase:

+
cd fuzz
+cargo +nightly fuzz run solidity_differential \
+  artifacts/solidity_differential/crash-<sha256>
+
+

The panic message embeds the rendered contract source plus the action +sequence in hex — enough to file an issue without keeping the input +bytes around.

+

What the fuzzer does

+
                  libFuzzer mutator (random bytes)
+                            │
+                            ▼
+                Unstructured ──► TemplateKind::arbitrary
+                            │
+                            ▼  pick template + per-template op selectors
+            SolidityCase { source, constructor_args, actions }
+                            │
+                ┌───────────┴────────────┐
+                ▼                        ▼
+         resolc → PVM blob           solc → EVM bytecode
+                │                        │
+                ▼                        ▼
+      revive_runner::Specs.run     geth `evm` subprocess
+      (pallet-revive sim)          (constructor + per-action calls)
+                │                        │
+                └───────────┬────────────┘
+                            ▼
+                       compare()
+                  ├─ deploy_reverted flag
+                  ├─ per-action revert flag
+                  └─ per-action return-data bytes
+                            │
+                  any mismatch → Divergence → panic
+
+

Both observers replay the same calldata sequence (constructor_args +concatenated as constructor input; fn_0(uint256) selector + 32-byte +arg per subsequent action). State carries across actions on both +backends. The harness compares revert flags and return-data bytes +only — gas-cost differences between geth evm and pallet-revive sim +are by design not part of the comparison.

+

Coverage signal

+

cargo-fuzz compiles every Rust crate in the dep graph with +SanitizerCoverage, so the libFuzzer mutation engine sees edges in:

+
    +
  • revive-yul parser
  • +
  • resolc standard-json pipeline
  • +
  • revive-llvm-context codegen (every lowering pattern)
  • +
  • revive-runner / pallet-revive simulation
  • +
  • arbitrary and the generator itself
  • +
+

Opaque (not instrumented by default):

+
    +
  • solc subprocess
  • +
  • geth evm subprocess
  • +
  • LLVM C++ libraries inside resolc
  • +
+

To extend SanCov instrumentation into LLVM’s C++ codebase, rebuild +LLVM via make install-llvm-sancov. That target adds +-fsanitize=fuzzer-no-link to LLVM’s C/CXX flags, so every basic +block in the resulting static archives carries libFuzzer edge +counters. The -no-link suffix avoids requiring libclang_rt.fuzzer.a +at LLVM-build time — the libFuzzer runtime comes from the fuzz-target +binary.

+
+

Warning

+

A SanCov-instrumented LLVM at $LLVM_SYS_221_PREFIX will break +non-fuzz cargo build invocations: the linker needs +__sanitizer_cov_* symbols that only the libFuzzer runtime +supplies. Keep two LLVM trees if you need both: switch via +LLVM_SYS_221_PREFIX.

+
+

This is the right shape: the engine explores resolc’s Rust-side +input space (and optionally LLVM internals), not solc’s or geth’s. A +5-minute run on 4 forks against a non-instrumented LLVM loads ~1.17M +edges, of which the templated generator reaches ~28.5K.

+

Generator

+

crates/fuzz/src/templates.rs defines eight contract templates. Each +exposes the same wire shape:

+
constructor(uint256 seed) { ... }
+function fn_0(uint256 arg) external returns (T) { ... }
+
+

so the observer doesn’t need to know which template it’s running.

+
+ + + + + + + + + + + + + + +
KindWhat it exercises
Sremint256 storage slot + slot0 % arg — the original paritytech/revive#527 probe
ArithChainTwo storage slots, three signed-arithmetic ops chained
UncheckedArithunchecked { … } wrapping arithmetic on uint256
Mappingmapping(uint256 => uint256) increment — exercises keccak-derived storage slots
DynArrayDynamic uint256[] push + indexed read — exercises array layout + length update
RequireGuardrequire(predicate, "guard") with eight predicate shapes
LoopAccumBounded for accumulator (bound = arg & 0x1F, ≤ 31 iterations)
BitwisePure-bitwise composition (& | ^ + << / >>)
+
+

Op selectors inside each template are themselves arbitrary-driven, +so one template covers many distinct opcode lowerings. The +#[ignore]d every_template_compiles test pipes each template +through solc --standard-json and asserts no fatal errors:

+
cargo test -p revive-fuzz --lib -- --ignored every_template_compiles
+
+

A 256-bit boundary-value pool (0, 1, -1, INT_MIN, INT_MAX, +2^128, 2^64, alternating-bit patterns, …) is mixed into operands +with 1-in-5 probability so corner-case pairs surface within a minute +under pure-random. libFuzzer’s mutator preserves the biasing because +it operates on the same byte tape Unstructured consumes.

+

Divergence taxonomy

+

Divergence (in crates/fuzz/src/differential.rs) categorises every +outcome:

+
+ + + + + + + + + + + + +
VariantMeaninglibFuzzer treatment
EvmCompile(msg)solc → EVM panicked. Almost always a generator bug (template emitted Solidity solc rejects).Silent skip in the libFuzzer panic helper — doesn’t burn a corpus slot.
PvmCompile(msg)resolc → PVM panicked. solc accepted but resolc choked.Crash — exactly the kind of resolc ICE the fuzzer is meant to find.
DeployRevert { … }Constructor reverted on one backend but not the other.Crash.
ActionCount { … }Action result vectors of unequal length. Defensive; should not happen.Crash.
ActionRevert { … }One backend reverted on a call the other completed.Crash.
ActionReturnData { … }Both completed; return-data bytes differ.Crash.
+
+

Compile failures used to panic the whole process via +.expect("source should compile") inside resolc’s test_utils. The +harness wraps both calls in std::panic::catch_unwind and routes the +payload into a dedicated variant, so a generator bug doesn’t poison +the whole libFuzzer run.

+

Performance

+

Templated Solidity on an M-class laptop:

+
+ + + + + + + + + + + +
StepCost
arbitrary → SolidityCase<1 ms
solc → EVM (subprocess)~80–100 ms
resolc → PVM (in-process)~150 ms
geth evm per action~10 ms × 2–4 actions
revive_runner::Specs.run() per action~5 ms
+
+

≈ 250 ms / iter end-to-end. With -fork=12: ~30–40 iter/sec total.

+

Five-minute runs from an empty corpus, four forks:

+
+ + + + + + + + +
GeneratorIterscov (edges)ft (features)Corpus
SREM-only14,07726,66932,392101
Templated6,08728,56646,530313
+
+

The templated generator opens ~2K more edges and 14K more features +than the SREM-only baseline, and keeps a 3× larger corpus.

+
+

Warning

+

libFuzzer is single-threaded per process. Use -fork=N for +parallelism — not Rust threads, not rayon. Rayon inside a fuzz +target would interleave coverage counters and produce useless data.

+
+

Known limitations

+
    +
  • Subprocess overhead dominates. solc + evm subprocess costs +cap throughput at ~30 iter/sec on 12 cores. A native-Rust EVM on the +EVM side would be ~10× faster but is out of scope.
  • +
  • Recursive resolc isn’t instrumented. resolc::test_utils spawns +the installed ~/.cargo/bin/resolc as a subprocess via +--recursive-process for per-contract lowering. Only the +in-process call sites carry SanCov instrumentation; the subprocess +is opaque to libFuzzer. revive_fuzz::warn_if_resolc_stale logs a +warning when the installed binary is older than workspace source, +to flag the case where a local fix isn’t visible to the fuzzer.
  • +
  • One external function shape. The harness hardcodes +fn_0(uint256) so the observer doesn’t have to vary calldata +encoding. Removing that assumption requires generalising +observe::action_calldata.
  • +
  • Solc internals are opaque. libFuzzer can’t see solc’s Yul +optimiser. Fine for resolc-side bug finding; not useful for solc +bug finding.
  • +
  • Stack traces aren’t captured in catch_unwind payloads. Easy +follow-up to wire std::backtrace::Backtrace::capture() through.
  • +
+

Code map

+
crates/fuzz/                         # revive-fuzz harness library (stable Rust, main workspace)
+├── Cargo.toml                       # `panic-on-divergence` feature
+├── src/
+│   ├── lib.rs                       # re-exports + `panic_on_divergence` helper
+│   ├── generator.rs                 # SolidityCase + Arbitrary impl
+│   ├── templates.rs                 # 8 template renderers + solc self-test
+│   ├── pipeline.rs                  # solc / resolc invocation helpers
+│   ├── observe.rs                   # observe_evm / observe_pvm
+│   ├── differential.rs              # Divergence + run_case_solc_evm
+│   └── stale.rs                     # `warn_if_resolc_stale`
+
+fuzz/                                # cargo-fuzz package (separate workspace, nightly)
+├── Cargo.toml                       # libfuzzer-sys + path-dep on revive-fuzz
+├── rust-toolchain.toml              # nightly, scoped here only
+└── fuzz_targets/
+    └── solidity_differential.rs     # libFuzzer entry
+
+

The split exists because cargo-fuzz needs a nightly toolchain and +pulls in libfuzzer-sys — keeping that in a separate workspace +prevents either from leaking into the main cargo build.

+

Cross compilation

We cross-compile the resolc.js frontend executable to Wasm for running it in a Node.js or browser environment.

The musl target is used to obtain statically linked ELF binaries for Linux.

@@ -4888,22 +5157,6 @@

Roadmap

- - - +
@@ -239,22 +239,6 @@

Example

- - - +
@@ -212,22 +212,6 @@

Roadmap

- - - +
@@ -216,22 +216,6 @@

- - - +
@@ -307,22 +307,6 @@

Deplo - - - +
@@ -304,22 +304,6 @@

- - - +
@@ -231,22 +231,6 @@

- - - +
@@ -219,22 +219,6 @@

JS NPM package< - - - +
@@ -220,22 +220,6 @@

- - - +
@@ -288,22 +288,6 @@

- - - +
@@ -222,22 +222,6 @@

Remix IDE

- - - +
@@ -227,22 +227,6 @@

About

- -