Skip to content

feat(fuzz): add cargo-fuzz harnesses for contract entry-points#351

Merged
YaronZaki merged 9 commits into
Quantarq:mainfrom
LaGodxy:fix/issue-249-cargo-fuzz-harness
Jul 23, 2026
Merged

feat(fuzz): add cargo-fuzz harnesses for contract entry-points#351
YaronZaki merged 9 commits into
Quantarq:mainfrom
LaGodxy:fix/issue-249-cargo-fuzz-harness

Conversation

@LaGodxy

@LaGodxy LaGodxy commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Implemented a production-ready fix while maintaining the existing architecture and coding standards.

Changes

  • Added fuzz/ directory for each contract (vault, looping, rewards) with:
    • Cargo.toml with cargo-fuzz metadata and correct path dependencies
    • fuzz_targets/*.rs harness files for every state-mutating entry-point
    • corpus/ seed inputs for fast initial coverage
  • vault: fuzz_vault_deposit (asserts positive amounts succeed, balance correct) and fuzz_vault_withdraw (asserts balance never goes negative)
  • looping: fuzz_looping_open_position (asserts valid inputs return monotonically-increasing position IDs)
  • rewards: fuzz_rewards_accrue_claim (asserts pending == sum of accruals, claim returns full amount, resets to 0)
  • Added .github/workflows/soroban_fuzz.yml: 60 s smoke-fuzz per entry-point on push/PR; crash corpus auto-uploaded as artifact; workflow_dispatch for longer runs
  • Verified linting
  • Verified build
  • Ensured no unrelated changes were introduced

Closes #249

LaGodxy and others added 9 commits July 21, 2026 19:05
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.

@YaronZaki YaronZaki left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@YaronZaki
YaronZaki merged commit 01573cd into Quantarq:main Jul 23, 2026
14 checks passed
@grantfox-oss grantfox-oss Bot mentioned this pull request Jul 23, 2026
1 task
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

cargo-fuzz harness for entry-points

2 participants