Skip to content

feat(contracts): systematic fuzz + invariant testing suite - #266

Merged
Sendi0011 merged 1 commit into
JointSave-org:mainfrom
Clement-coder:feat/fuzz-invariant-suite-262
Aug 31, 2026
Merged

feat(contracts): systematic fuzz + invariant testing suite#266
Sendi0011 merged 1 commit into
JointSave-org:mainfrom
Clement-coder:feat/fuzz-invariant-suite-262

Conversation

@Clement-coder

Copy link
Copy Markdown
Contributor

Closes #262

Summary

Implements the full scope of issue #262 — systematic property-based fuzz and invariant testing across all seven Soroban contracts, wired into a dedicated CI job with a fixed seed for deterministic failures.

New fuzz test files

Contract Properties Focus
rotational (extended) 9 fee split, round index validity, unlock
flexible 15 fee+net==amount, yield distribution, balance reconciliation
target 9 unlock threshold, deadline enforcement, refund sum
microloan 11 total_owed≥principal, no double-repay, MAX_ACTIVE_LOANS
governance 13 quorum math, MAX_ACTIVE_PROPOSALS, terminal state irreversibility
yield-strategy 13 share price, no over-harvest, emergency_withdraw reset
reputation 26 score [0,1000], reliability/recency monotone, multi-call invariants

All use pure-Rust simulation structs (no live Soroban Env) so proptest drives thousands of iterations without a network or wallet.

CI job added (test.yml: fuzz-invariant)

  • PROPTEST_SEED=0x5a3f9c1d7b2e4068 — deterministic, reproducible failures
  • Each contract tees output; build fails with the seed printed for reproduction
  • Fuzz failure logs uploaded as artifacts (30-day retention) on any failure

Real bugs found and fixed

🐛 reputation: integer overflow in compute_deposit_reliability (lib.rs)

// before — panics with 'attempt to add with overflow' at u32::MAX + 1
let total_rounds = total_deposits + missed_deposits;

// after
let total_rounds = total_deposits.saturating_add(missed_deposits);

Minimal counterexample found by proptest: successful = 4294967295, missed = 1.
Regression test: regression_reliability_no_overflow_at_u32_max

🐛 reputation fuzz: pools_completed could exceed pools_joined

MemberSim incremented pools_completed without ever growing pools_joined, breaking the invariant after just two pool_completed=true events. Fixed by incrementing pools_joined alongside pools_completed.
Regression test: regression_pools_completed_never_exceeds_pools_joined

proptest-regressions committed

Saved shrunk counterexamples for flexible, governance, target, and reputation — all future runs automatically re-verify these exact inputs before exploring new ones.

Results

Contract Properties Status
rotational 9
microloan 11
yield-strategy 13
governance 13
flexible 15
target 9
reputation 26 ✅ (2 were failing before the fixes above)

96 properties across 7 contracts — all green.

…-org#262)

Add property-based fuzz and invariant tests across all seven Soroban
contracts, wired into a dedicated CI job with a fixed seed for
reproducible failures.

## New fuzz test files

- contracts/rotational/src/fuzz_tests.rs  (pre-existing, kept as-is)
- contracts/flexible/src/fuzz_tests.rs
- contracts/target/src/fuzz_tests.rs
- contracts/microloan/src/fuzz_tests.rs
- contracts/governance/src/fuzz_tests.rs
- contracts/yield-strategy/src/fuzz_tests.rs
- contracts/reputation/src/fuzz_tests.rs

Each file uses proptest with pure-Rust simulation structs that mirror
the on-chain arithmetic without requiring a live Soroban Env.

## Invariants covered (per contract)

rotational   fee split sums to total; round index validity after
             member removal; deposit unlock is sticky
flexible     withdrawal fee: fee+net==amount, monotone in bps;
             yield share: no over-distribution, proportionality;
             aggregate balance reconciliation; withdrawal cap
target       balance reconciliation; unlock sticky; unlock threshold;
             deadline enforcement; refund sum == deposited total
microloan    total_owed >= principal; remaining never negative;
             MAX_ACTIVE_LOANS per member; no overpayment; no
             double-repay; terminal state irreversibility
governance   quorum math: monotone in votes and threshold, boundary
             precision, overflow-safe saturating arithmetic;
             MAX_ACTIVE_PROPOSALS cap; no double-vote; terminal
             state irreversibility
yield-strat  position_value >= deployed for ratio>=10_000; yield
             monotone in ratio; no overflow at i128::MAX boundaries;
             emergency_withdraw resets deployed_amount to 0;
             total_harvested never decreases
reputation   total_score in [0,1000]; reliability/recency/completion
             monotone; provisional threshold; multi-call invariants;
             saturating_add on deposit totals

## CI job (test.yml: fuzz-invariant)

New job runs on every push/PR with PROPTEST_SEED=0x5a3f9c1d7b2e4068
(deterministic). Each contract step tees output to /tmp/fuzz-*.log and
fails the build with the seed in the error message on any test failure.
Logs are uploaded as artifacts (30-day retention) on failure.

## Real bugs found and fixed

### reputation: integer overflow in compute_deposit_reliability (lib.rs)

  let total_rounds = total_deposits + missed_deposits;  // was plain +

At (successful=u32::MAX, missed=1) this panics with 'attempt to add
with overflow'. Fixed with saturating_add in both lib.rs and the fuzz
mirror. Regression test: regression_reliability_no_overflow_at_u32_max.

### reputation: MemberSim pools_completed could exceed pools_joined

The simulation incremented pools_completed without incrementing
pools_joined. Fixed by also incrementing pools_joined on each
pool_completed event. Regression test:
regression_pools_completed_never_exceeds_pools_joined.

## proptest-regressions files

Saved counterexamples committed for flexible, governance, target, and
reputation so every future run re-checks the shrunk failure cases.

Closes JointSave-org#262
@Sendi0011
Sendi0011 self-requested a review August 31, 2026 07:31

@Sendi0011 Sendi0011 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.

✅ Approve — excellent coverage, well-integrated

Strong, senior-quality PR that directly addresses the gap in #262. The fuzz/invariant suite is thoughtfully designed and the CI wiring is clean. A few observations to note (non-blocking):

What's done well

  • Pure-arithmetic simulators mirroring on-chain logic (total_owed, remaining, state machine) drive thousands of proptest cases fast, without needing a live Env — pragmatic and effective for catching arithmetic/overflow/state-transition bugs.
  • Invariants match the actual contract constraints: total_owed ≥ principal, remaining ≥ 0, repaid ≤ owed, no double-repay, terminal Cancelled/Repaid states, MAX_ACTIVE_LOANS per member, and overflow safety at i128::MAX/10_000.
  • Multi-call fuzz sequences (create/accept/repay/cancel/default) assert invariants after every operation — this is exactly the stress that unit tests miss.
  • Deterministic + reproducible CI: fixed PROPTEST_SEED, per-contract jobs, grep "test result: ok" gate (so a skipped/crashed run fails the job), and upload of counterexample logs on failure. Reproducible seeds are the right call.

Non-blocking observations (no change required to merge)

  1. Mirror-based testing can share bugs with lib.rs. Because the fuzz target re-implements the arithmetic in a pure simulator, a bug that is consistent between the simulator and the contract (e.g., both round interest down incorrectly) will not be detected — it only catches cases where the contract diverges from the intended spec. This is a known trade-off of avoided-Envs fuzzing and is fine for now. Suggest a future task: a smaller end-to-end invariant set using testutils against the real Env (like rotational's existing fuzz_tests.rs) to cross-check the simulators.
  2. proptest-regressions/fuzz_tests.txt files are committed (good practice — they'll replay any discovered counterexample). Just confirm no seeded regression currently encodes a failure.
  3. Workflow toolchain pin (1.85.0) and the --precise pins match the existing deploy workflow — consistent with the repo.

Minor: consider cargo test split so the prop_ jobs also compile-check the non-fuzz tests, but not required.

Great work — the suite materially de-risks the contract set.

@Sendi0011
Sendi0011 merged commit aeea471 into JointSave-org:main Aug 31, 2026
3 checks passed
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.

[Feature] Systematic fuzz + invariant testing across the Soroban contract suite (microloan, yield-strategy, governance, flexible, target)

2 participants