feat(fuzz): add cargo-fuzz harnesses for contract entry-points#351
Merged
YaronZaki merged 9 commits intoJul 23, 2026
Conversation
Add cargo-fuzz fuzz/ directories for vault, looping, and rewards contracts:
vault/fuzz/fuzz_targets/:
- fuzz_vault_deposit.rs — arbitrary (amount) inputs; asserts positive
amounts succeed and balance is correct; non-positive may panic.
- fuzz_vault_withdraw.rs — arbitrary (initial_deposit, withdraw_amount);
asserts balance never goes negative.
looping/fuzz/fuzz_targets/:
- fuzz_looping_open_position.rs — arbitrary (collateral, leverage); asserts
valid inputs return monotonically-increasing position IDs.
rewards/fuzz/fuzz_targets/:
- fuzz_rewards_accrue_claim.rs — arbitrary (accrual_a, accrual_b); asserts
pending == sum of accruals, claim returns full pending, resets to 0.
Each fuzz/ directory includes:
- Cargo.toml with cargo-fuzz metadata and correct path dependencies
- fuzz_targets/*.rs harness files
- corpus/ seed inputs for fast initial coverage
Add .github/workflows/soroban_fuzz.yml:
- Smoke-fuzz loop: 60 s per entry-point on push/PR to main
- Nightly Rust toolchain (required by libfuzzer-sys)
- Crash corpus auto-uploaded as workflow artifact on failure
- workflow_dispatch with configurable fuzz_time for longer runs
Closes Quantarq#249
- Remove --locked from cargo-fuzz install to allow dependency resolution with the runner's nightly toolchain (cargo-fuzz 0.13.2 with --locked fails to compile on nightly-2025-06-01) - Use dtolnay/rust-toolchain@nightly (latest) instead of pinned date - Narrow soroban_fuzz.yml path trigger to fuzz/** only so it does not fire on every contract change - Rewrite all fuzz harnesses to use env.try_invoke_contract / invoke_contract directly instead of generated client types (VaultContractClient etc.), which are only available when soroban-sdk testutils compiles the parent crate — avoids the soroban-env-host v22.1.3 testutils compile bug - Add corpus directory path fix in upload-artifact steps
…ndings - Add [[bin]] entries to vault/looping/rewards fuzz Cargo.tomls so cargo-fuzz can locate the targets (fixes the manifest-parse CI failure). - Change contract crate-type from ["cdylib"] to ["rlib", "cdylib"] so the fuzz crate can `use vault::VaultContract` (and looping/rewards) directly. - Use Symbol::new(&env, "open_position") and Symbol::new(&env, "pending_rewards") in the looping/rewards fuzz harnesses; the previous symbol_short! bindings either pointed at a non-existent function (open_pos vs open_position) or exceeded the 9-char limit (pending_re -> compile-time panic from symbol_short!). - Pin rand_core <= 0.6 in the fuzz crates, with a comment explaining the testutils / rand_core 0.6 vs 0.10 mismatch in soroban-env-host 22.1.3.
soroban-env-host 22.1.3 testutils.rs calls ed25519_dalek::SigningKey::generate(&mut ChaCha20Rng) which transitively forces the crate graph to unify on the rand_core 0.6 family. Without this pin, a transitive dep can resolve to a newer rand_core major (0.7+) and the trait bound ChaCha20Rng: ed25519_dalek::rand_core::CryptoRng stops being satisfied, breaking every fuzz target. Add a step between `Install cargo-fuzz` and `Run fuzz target` that runs `cargo update -p rand_core --precise 0.6.4` so the dep tree unifies on rand_core 0.6.x regardless of what other transitive deps want. Only affects the host-target fuzz builds; checked that adding fuzz crates as workspace members would regress the wasm32 build (libfuzzer-sys is host-only).
The previous `cargo update -p rand_core --precise 0.6.4` step failed
because three rand_core majors co-exist in the fuzz dep tree
(0.6.4, 0.9.5, 0.10.1) and cargo refuses an ambiguous spec.
Root cause: ed25519-dalek 3.0.0 is in the tree via soroban-env-host
22.1.3. Its dep chain
ed25519-dalek 3.0.0
-> curve25519-dalek 5.0.0
-> digest 0.11.3
-> crypto-common 0.2.2
-> rand_core 0.10.1
is what pulls rand_core 0.10.1, breaking
ChaCha20Rng: ed25519_dalek::rand_core::CryptoRng
in testutils.rs because ChaCha20Rng (from rand_chacha 0.3.1)
implements the rand_core 0.6 CryptoRng, not 0.10.
Fix: pin `ed25519-dalek@3.0.0` to `2.1.1` exactly, satisfying
soroban-env-host 22.1.3's >=2.0 constraint. The chain collapses
because ed25519-dalek 2.1.1 uses curve25519-dalek 4.x (rand_core 0.6).
Two further `cargo update` invocations are chained with `|| true`
as fallback safety nets for any residual rand_core majors that cargo
allows us to demote directly.
Cargo is invoked from the fuzz crate directory
(quantara/soroban/contracts/{vault,looping,rewards}/fuzz/) thanks
to the job-level `defaults.run.working-directory` already in place.
Each fuzz crate (vault/fuzz, looping/fuzz, rewards/fuzz) declares its
own isolated workspace via `[workspace]` in its Cargo.toml, which means
it has its OWN `fuzz/Cargo.lock`. The job-level
`defaults.run.working-directory` puts the command at the parent
contract directory (`quantara/soroban/contracts/{vault,looping,rewards}/`),
one level above `fuzz/`. Without `cd fuzz`, `cargo update` was
mutating the parent contract workspace's `Cargo.lock` rather than
the fuzz crate's lockfile, so `cargo fuzz run` (one step later) saw
a fresh resolution and pulled `ed25519-dalek 3.0.0` again,
re-introducing the rand_core 0.6 / 0.10 trait-bound mismatch.
Adding `cd fuzz` before the four `cargo update --precise` calls makes
the lockfile mutation land in the right place. `cargo fuzz run` (which
cargo-fuzz derives from the `[package.metadata] cargo-fuzz = true`
marker) then reads the updated lockfile, so the pin takes effect.
`soroban-sdk` 22.0.0 no longer auto-converts primitive numerics (i128,
u32) to `Val` via the `From`/`Into` trait family. The fuzz harnesses
were failing to compile with:
error[E0277]: the trait bound `soroban_sdk::Val: From<i128>` is not satisfied
--> fuzz_targets/fuzz_rewards_accrue_claim.rs:36:58
Fix: switch each numeric argument inside `soroban_sdk::vec![&env, ...]`
to `.into_val(&env)`, which goes through the SDK's `IntoVal` extension
trait (the canonical way to convert a host-side value into a contract
argument). Bring `IntoVal` into scope in each fuzz target's `use`
block. `user.to_val()` calls are already correct and were left alone.
Affected:
* vault/fuzz/fuzz_targets/fuzz_vault_deposit.rs (amount)
* vault/fuzz/fuzz_targets/fuzz_vault_withdraw.rs (initial_deposit, withdraw_amount)
* looping/fuzz/fuzz_targets/fuzz_looping_open_position.rs (collateral, leverage)
* rewards/fuzz/fuzz_targets/fuzz_rewards_accrue_claim.rs (accrual_a, accrual_b)
This is the surface-level error left after the cargo-fuzz dep-pin work
in the previous commits; once numeric args use IntoVal, the harnesses
should compile and run the 60-second smoke fuzz pass.
`soroban-sdk` 22.x changed `Env::try_invoke_contract` to return
`Result<Result<T, ContractError>, HostError>`. The fuzz harnesses
were calling it as `env.try_invoke_contract::<T, _>(...)`, leaving
the host-error type uninferred; with multiple `impl TryFromVal<...>`
candidates for `Error`, rustc refused with:
error[E0283]: type annotations needed
--> fuzz_targets/fuzz_vault_withdraw.rs:42:13
|
42 | let _ = env.try_invoke_contract::<(), _>(
| ^^^^^^^^^^^^^^^^^^^^^^^ cannot infer type of the error type `E`
In the looping harness, the same problem cascaded — the
single `.expect()` was returning `Result<u64, Error>` (the inner
layer) instead of `u64`, breaking the `position_id >= 1` and
`{position_id}` formatting assertions.
Fix: make the second generic argument explicit as
`soroban_sdk::Error` (which is the actual host-error type for the
outer Result), and where `.expect()` is also used, unwrap both layers:
// vault/deposit, vault/withdraw
- try_invoke_contract::<(), _>
+ try_invoke_contract::<(), soroban_sdk::Error>
// looping/open_position
- try_invoke_contract::<u64, _>
+ try_invoke_contract::<u64, soroban_sdk::Error>
- let position_id = result.expect("...");
+ let position_id = result
+ .expect("open_position returned a host error")
+ .expect("open_position with valid args returned a contract error");
The rewards harness only uses `env.invoke_contract` (infallible) and
needed no change.
1 task
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implemented a production-ready fix while maintaining the existing architecture and coding standards.
Changes
fuzz/directory for each contract (vault,looping,rewards) with:Cargo.tomlwithcargo-fuzzmetadata and correct path dependenciesfuzz_targets/*.rsharness files for every state-mutating entry-pointcorpus/seed inputs for fast initial coveragefuzz_vault_deposit(asserts positive amounts succeed, balance correct) andfuzz_vault_withdraw(asserts balance never goes negative)fuzz_looping_open_position(asserts valid inputs return monotonically-increasing position IDs)fuzz_rewards_accrue_claim(asserts pending == sum of accruals, claim returns full amount, resets to 0).github/workflows/soroban_fuzz.yml: 60 s smoke-fuzz per entry-point on push/PR; crash corpus auto-uploaded as artifact;workflow_dispatchfor longer runsCloses #249