diff --git a/smartcontract/.github/workflows/test.yml b/smartcontract/.github/workflows/test.yml index 5b83a99..080e433 100644 --- a/smartcontract/.github/workflows/test.yml +++ b/smartcontract/.github/workflows/test.yml @@ -71,3 +71,112 @@ jobs: run: | ls -lh target/wasm32-unknown-unknown/release/*.wasm echo "All contracts built successfully" + + fuzz-invariant: + name: Fuzz & Invariant Tests + runs-on: ubuntu-latest + # Run fuzz tests on every push/PR; they are deterministic via PROPTEST_SEED. + env: + # Fixed seed ensures reproducible failures. Override with + # cargo test -- --proptest-seed= + # to explore different parts of the input space. + PROPTEST_SEED: "0x5a3f9c1d7b2e4068" + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.85.0" + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-fuzz-${{ hashFiles('**/Cargo.lock') }} + + - name: Pin incompatible dependencies + working-directory: smartcontract + run: | + cargo update serde_with@3.18.0 --precise 3.17.0 || true + cargo update serde_with_macros@3.18.0 --precise 3.17.0 || true + cargo update darling@0.23.0 --precise 0.21.3 || true + cargo update darling_core@0.23.0 --precise 0.21.3 || true + cargo update darling_macro@0.23.0 --precise 0.21.3 || true + cargo update indexmap@2.14.0 --precise 2.6.0 || true + + # ── Rotational (extends existing coverage) ────────────────────────────── + - name: Fuzz – Rotational contract + working-directory: smartcontract + run: | + cargo test prop_ --manifest-path=contracts/rotational/Cargo.toml --release \ + -- --nocapture 2>&1 | tee /tmp/fuzz-rotational.log + grep -q "test result: ok" /tmp/fuzz-rotational.log || \ + { echo "::error::Rotational fuzz tests FAILED (seed: $PROPTEST_SEED)"; exit 1; } + + # ── Microloan (highest priority) ──────────────────────────────────────── + - name: Fuzz – Microloan contract + working-directory: smartcontract + run: | + cargo test prop_ --manifest-path=contracts/microloan/Cargo.toml --release \ + -- --nocapture 2>&1 | tee /tmp/fuzz-microloan.log + grep -q "test result: ok" /tmp/fuzz-microloan.log || \ + { echo "::error::Microloan fuzz tests FAILED (seed: $PROPTEST_SEED)"; exit 1; } + + # ── Yield Strategy (highest priority) ─────────────────────────────────── + - name: Fuzz – Yield Strategy contract + working-directory: smartcontract + run: | + cargo test prop_ --manifest-path=contracts/yield-strategy/Cargo.toml --release \ + -- --nocapture 2>&1 | tee /tmp/fuzz-yield.log + grep -q "test result: ok" /tmp/fuzz-yield.log || \ + { echo "::error::Yield Strategy fuzz tests FAILED (seed: $PROPTEST_SEED)"; exit 1; } + + # ── Governance ────────────────────────────────────────────────────────── + - name: Fuzz – Governance contract + working-directory: smartcontract + run: | + cargo test prop_ --manifest-path=contracts/governance/Cargo.toml --release \ + -- --nocapture 2>&1 | tee /tmp/fuzz-governance.log + grep -q "test result: ok" /tmp/fuzz-governance.log || \ + { echo "::error::Governance fuzz tests FAILED (seed: $PROPTEST_SEED)"; exit 1; } + + # ── Flexible Pool ─────────────────────────────────────────────────────── + - name: Fuzz – Flexible Pool contract + working-directory: smartcontract + run: | + cargo test prop_ --manifest-path=contracts/flexible/Cargo.toml --release \ + -- --nocapture 2>&1 | tee /tmp/fuzz-flexible.log + grep -q "test result: ok" /tmp/fuzz-flexible.log || \ + { echo "::error::Flexible Pool fuzz tests FAILED (seed: $PROPTEST_SEED)"; exit 1; } + + # ── Target Pool ───────────────────────────────────────────────────────── + - name: Fuzz – Target Pool contract + working-directory: smartcontract + run: | + cargo test prop_ --manifest-path=contracts/target/Cargo.toml --release \ + -- --nocapture 2>&1 | tee /tmp/fuzz-target.log + grep -q "test result: ok" /tmp/fuzz-target.log || \ + { echo "::error::Target Pool fuzz tests FAILED (seed: $PROPTEST_SEED)"; exit 1; } + + # ── Reputation Tracker ────────────────────────────────────────────────── + - name: Fuzz – Reputation Tracker contract + working-directory: smartcontract + run: | + cargo test prop_ --manifest-path=contracts/reputation/Cargo.toml --release \ + -- --nocapture 2>&1 | tee /tmp/fuzz-reputation.log + grep -q "test result: ok" /tmp/fuzz-reputation.log || \ + { echo "::error::Reputation fuzz tests FAILED (seed: $PROPTEST_SEED)"; exit 1; } + + # ── Upload counterexample logs on failure ─────────────────────────────── + - name: Upload fuzz failure logs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: fuzz-failure-logs + path: /tmp/fuzz-*.log + retention-days: 30 diff --git a/smartcontract/Cargo.lock b/smartcontract/Cargo.lock index 107ae56..1b83385 100644 --- a/smartcontract/Cargo.lock +++ b/smartcontract/Cargo.lock @@ -686,6 +686,7 @@ dependencies = [ name = "jointsave-flexible" version = "0.1.0" dependencies = [ + "proptest", "soroban-sdk", ] @@ -693,6 +694,7 @@ dependencies = [ name = "jointsave-governance" version = "0.1.0" dependencies = [ + "proptest", "soroban-sdk", ] @@ -701,6 +703,7 @@ name = "jointsave-microloan" version = "0.1.0" dependencies = [ "jointsave-reputation", + "proptest", "soroban-sdk", ] @@ -715,6 +718,7 @@ dependencies = [ name = "jointsave-reputation" version = "0.1.0" dependencies = [ + "proptest", "soroban-sdk", ] @@ -731,6 +735,7 @@ dependencies = [ name = "jointsave-target" version = "0.1.0" dependencies = [ + "proptest", "soroban-sdk", ] @@ -738,6 +743,7 @@ dependencies = [ name = "jointsave-yield-strategy" version = "0.1.0" dependencies = [ + "proptest", "soroban-sdk", ] diff --git a/smartcontract/contracts/flexible/Cargo.toml b/smartcontract/contracts/flexible/Cargo.toml index 49f6bc6..b11cfc3 100644 --- a/smartcontract/contracts/flexible/Cargo.toml +++ b/smartcontract/contracts/flexible/Cargo.toml @@ -11,3 +11,4 @@ soroban-sdk = { version = "21.0.0", features = ["alloc"] } [dev-dependencies] soroban-sdk = { version = "21.0.0", features = ["testutils", "alloc"] } +proptest = "1.4" diff --git a/smartcontract/contracts/flexible/proptest-regressions/fuzz_tests.txt b/smartcontract/contracts/flexible/proptest-regressions/fuzz_tests.txt new file mode 100644 index 0000000..c54f972 --- /dev/null +++ b/smartcontract/contracts/flexible/proptest-regressions/fuzz_tests.txt @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 61f67ce7571e369a7bc502157c97f25843318568e1d271fd32c5e9b709a3cd78 # shrinks to fee_bps = 0, min_deposit = 1, member_count = 2, operations = [(2, 0, 22377), (2, 0, 22552)] diff --git a/smartcontract/contracts/flexible/src/fuzz_tests.rs b/smartcontract/contracts/flexible/src/fuzz_tests.rs new file mode 100644 index 0000000..639d653 --- /dev/null +++ b/smartcontract/contracts/flexible/src/fuzz_tests.rs @@ -0,0 +1,405 @@ +//! Property-based fuzz / invariant tests for JointSave Flexible Pool Contract. +//! +//! Covers: +//! - Aggregate balance reconciliation (sum of member balances == TotalBalance) +//! - Withdrawals always capped by member balance and minimum-deposit rules +//! - Withdrawal fee arithmetic: fee + net == amount, fee always >= 0 +//! - Proportional yield distribution: total distributed ≤ yield_amount (dust only) +//! - No funds created or destroyed across multi-call sequences +//! - Boundary/overflow: fee_bps ∈ [0, 10000], amounts at 0, 1, i128::MAX +//! - Paused pool rejects all state-changing operations +//! +//! Run with: +//! cargo test prop_ --manifest-path=contracts/flexible/Cargo.toml \ +//! --release -- --nocapture + +#[cfg(test)] +mod prop_tests { + use proptest::prelude::*; + + extern crate std; + use std::vec::Vec as StdVec; + use std::vec; + + // ── Pure arithmetic helpers (mirrors on-chain logic) ───────────────────── + + fn withdrawal_fee_split(amount: i128, fee_bps: u32) -> (i128, i128) { + let fee = (amount * fee_bps as i128) / 10_000; + let net = amount - fee; + (fee, net) + } + + fn member_yield_share(yield_amount: i128, member_balance: i128, total_balance: i128) -> i128 { + if total_balance == 0 { + return 0; + } + (yield_amount * member_balance) / total_balance + } + + // ── Withdrawal fee invariants ───────────────────────────────────────────── + + proptest! { + #![proptest_config(ProptestConfig::with_cases(50_000))] + + /// Invariant: fee + net == amount for all valid (amount, fee_bps) pairs. + #[test] + fn prop_withdrawal_fee_sums_to_amount( + amount in 1i128..=1_000_000_000_0000_000i128, + fee_bps in 0u32..=10_000u32, + ) { + let (fee, net) = withdrawal_fee_split(amount, fee_bps); + prop_assert_eq!(fee + net, amount, "fee+net != amount"); + prop_assert!(fee >= 0, "fee is negative: {}", fee); + prop_assert!(net >= 0, "net is negative: {}", net); + } + + /// Invariant: zero fee_bps means full amount is returned to member. + #[test] + fn prop_zero_fee_full_net( + amount in 1i128..=1_000_000_000_0000_000i128, + ) { + let (fee, net) = withdrawal_fee_split(amount, 0); + prop_assert_eq!(fee, 0); + prop_assert_eq!(net, amount); + } + + /// Invariant: 10_000 bps (100%) fee means net == 0. + #[test] + fn prop_max_fee_zero_net( + amount in 1i128..=1_000_000_000_0000_000i128, + ) { + let (fee, net) = withdrawal_fee_split(amount, 10_000); + prop_assert_eq!(net, 0, "100% fee should yield 0 net"); + prop_assert_eq!(fee, amount); + } + + /// Invariant: fee is monotonically non-decreasing with fee_bps. + #[test] + fn prop_fee_monotone_in_bps( + amount in 1i128..=1_000_000_000_0000_000i128, + low_bps in 0u32..=5_000u32, + high_offset in 0u32..=5_000u32, + ) { + let high_bps = (low_bps + high_offset).min(10_000); + let (fee_low, _) = withdrawal_fee_split(amount, low_bps); + let (fee_high, _) = withdrawal_fee_split(amount, high_bps); + prop_assert!( + fee_high >= fee_low, + "fee not monotone: bps {}→{}, fee {}→{}", + low_bps, high_bps, fee_low, fee_high + ); + } + + /// Invariant: net never exceeds amount. + #[test] + fn prop_net_never_exceeds_amount( + amount in 1i128..=1_000_000_000_0000_000i128, + fee_bps in 0u32..=10_000u32, + ) { + let (_, net) = withdrawal_fee_split(amount, fee_bps); + prop_assert!(net <= amount, "net {} > amount {} (bps={})", net, amount, fee_bps); + } + } + + // ── Proportional yield distribution invariants ──────────────────────────── + + proptest! { + #![proptest_config(ProptestConfig::with_cases(10_000))] + + /// Invariant: sum of all member shares ≤ yield_amount (truncation dust only). + #[test] + fn prop_yield_distribution_no_over_distribution( + yield_amount in 1i128..=1_000_000_0000_000i128, + balances in prop::collection::vec(1i128..=100_000_0000_000i128, 1..=20), + ) { + let total_balance: i128 = balances.iter().sum(); + prop_assume!(total_balance > 0); + + let distributed: i128 = balances.iter() + .map(|&b| member_yield_share(yield_amount, b, total_balance)) + .sum(); + + prop_assert!( + distributed <= yield_amount, + "over-distribution: distributed {} > yield_amount {}", + distributed, yield_amount + ); + prop_assert!(distributed >= 0, "negative distribution: {}", distributed); + + // Dust tolerance: at most one stroop per member lost to integer division. + let dust = yield_amount - distributed; + prop_assert!( + dust < balances.len() as i128, + "dust {} >= member count {} (possible overcounting bug)", + dust, balances.len() + ); + } + + /// Invariant: each member's share is non-negative. + #[test] + fn prop_member_yield_always_non_negative( + yield_amount in 1i128..=1_000_000_0000_000i128, + member_balance in 0i128..=100_000_0000_000i128, + total_balance in 1i128..=100_000_0000_000i128, + ) { + let share = member_yield_share(yield_amount, member_balance, total_balance); + prop_assert!(share >= 0, "negative member yield share: {}", share); + } + + /// Invariant: member with zero balance receives zero yield. + #[test] + fn prop_zero_balance_zero_yield( + yield_amount in 1i128..=1_000_000_0000_000i128, + total_balance in 1i128..=100_000_0000_000i128, + ) { + let share = member_yield_share(yield_amount, 0, total_balance); + prop_assert_eq!(share, 0); + } + + /// Invariant: yield shares are proportional (larger balance gets more or equal yield). + #[test] + fn prop_yield_proportionality( + yield_amount in 2i128..=1_000_000i128, + small_balance in 1i128..=500_000i128, + big_balance in 1i128..=500_000i128, + ) { + let total_balance = small_balance + big_balance; + let small_share = member_yield_share(yield_amount, small_balance, total_balance); + let big_share = member_yield_share(yield_amount, big_balance, total_balance); + + if big_balance >= small_balance { + prop_assert!( + big_share >= small_share, + "larger balance {} got smaller share {} vs smaller balance {} share {}", + big_balance, big_share, small_balance, small_share + ); + } + } + } + + // ── Balance reconciliation simulation ──────────────────────────────────── + // + // Uses parallel Vec<(id, balance)> instead of HashMap to stay no_std compatible + // inside the test binary (proptest links std, but we avoid direct HashMap use). + + struct PoolSim { + /// (member_id, balance) pairs + balances: StdVec<(u32, i128)>, + total_balance: i128, + /// Tracks truncation dust accumulated across all yield distributions. + yield_dust: i128, + fee_bps: u32, + min_deposit: i128, + } + + impl PoolSim { + fn new(members: StdVec, fee_bps: u32, min_deposit: i128) -> Self { + let balances = members.iter().map(|&id| (id, 0i128)).collect(); + PoolSim { + balances, + total_balance: 0, + yield_dust: 0, + fee_bps, + min_deposit, + } + } + + fn get_balance(&self, member: u32) -> Option { + self.balances.iter().find(|&&(id, _)| id == member).map(|&(_, b)| b) + } + + fn set_balance(&mut self, member: u32, new_balance: i128) { + if let Some(entry) = self.balances.iter_mut().find(|(id, _)| *id == member) { + entry.1 = new_balance; + } + } + + fn deposit(&mut self, member: u32, amount: i128) -> bool { + if amount < self.min_deposit { + return false; + } + match self.get_balance(member) { + None => false, + Some(bal) => { + self.set_balance(member, bal + amount); + self.total_balance += amount; + true + } + } + } + + fn withdraw(&mut self, member: u32, amount: i128) -> bool { + if amount <= 0 { + return false; + } + match self.get_balance(member) { + None => false, + Some(bal) if bal < amount => false, + Some(bal) => { + self.set_balance(member, bal - amount); + self.total_balance -= amount; + true + } + } + } + + fn distribute_yield(&mut self, yield_amount: i128) -> bool { + if yield_amount <= 0 || self.total_balance == 0 { + return false; + } + let total = self.total_balance; + let ids: StdVec = self.balances.iter().map(|&(id, _)| id).collect(); + let mut distributed = 0i128; + for id in ids { + let bal = self.get_balance(id).unwrap_or(0); + if bal > 0 { + let share = member_yield_share(yield_amount, bal, total); + self.set_balance(id, bal + share); + distributed += share; + } + } + // Record dust (yield_amount - distributed) so reconciliation check can account for it. + self.yield_dust += yield_amount - distributed; + self.total_balance += yield_amount; + true + } + + /// Core invariant: sum(balances) + yield_dust == total_balance. + /// yield_dust accounts for integer-division truncation across all yield distributions. + fn check_reconciliation(&self) -> bool { + let sum: i128 = self.balances.iter().map(|&(_, b)| b).sum(); + sum + self.yield_dust == self.total_balance + } + + fn check_no_negatives(&self) -> bool { + self.balances.iter().all(|&(_, b)| b >= 0) && self.total_balance >= 0 + } + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(3_000))] + + /// Multi-call fuzz: random deposit/withdraw/yield sequences never break + /// balance invariants or create/destroy funds. + #[test] + fn prop_flexible_multicall_balance_invariants( + fee_bps in 0u32..=10_000u32, + min_deposit in 1i128..=1_000i128, + member_count in 2u32..=10u32, + operations in prop::collection::vec( + (0u32..=2u32, 0u32..=9u32, 1i128..=100_000i128), + 1..=40 + ), + ) { + let members: StdVec = (0..member_count).collect(); + let mut pool = PoolSim::new(members, fee_bps, min_deposit); + + // Seed each member with some balance. + for id in 0..member_count { + pool.deposit(id, min_deposit * 100); + } + + for (op, member_offset, amount) in operations { + let member = member_offset % member_count; + match op { + 0 => { pool.deposit(member, amount); } + 1 => { pool.withdraw(member, amount); } + 2 => { pool.distribute_yield(amount); } + _ => {} + } + + prop_assert!(pool.check_no_negatives(), "negative balance detected"); + prop_assert!( + pool.check_reconciliation(), + "balance reconciliation failed: sum={}, total={}", + pool.balances.iter().map(|&(_, b)| b).sum::(), + pool.total_balance + ); + } + } + + /// Invariant: a member can never withdraw more than their balance. + #[test] + fn prop_withdrawal_capped_by_balance( + fee_bps in 0u32..=10_000u32, + deposit_amount in 1i128..=1_000_000i128, + withdraw_attempt in 1i128..=2_000_000i128, + ) { + let mut pool = PoolSim::new(vec![0, 1], fee_bps, 1); + pool.deposit(0, deposit_amount); + + let balance_before = pool.get_balance(0).unwrap(); + let success = pool.withdraw(0, withdraw_attempt); + + if withdraw_attempt > balance_before { + prop_assert!(!success, "should not allow withdrawal exceeding balance"); + } + + let balance_after = pool.get_balance(0).unwrap(); + prop_assert!(balance_after >= 0, "balance went negative"); + prop_assert!(pool.total_balance >= 0, "total_balance went negative"); + } + + /// Invariant: minimum deposit rule is enforced. + #[test] + fn prop_minimum_deposit_enforced( + min_deposit in 1i128..=10_000i128, + deposit_attempt in 0i128..=10_001i128, + ) { + let mut pool = PoolSim::new(vec![0, 1], 0, min_deposit); + let success = pool.deposit(0, deposit_attempt); + + if deposit_attempt < min_deposit { + prop_assert!(!success, "deposit below minimum should be rejected"); + prop_assert_eq!(pool.total_balance, 0, "rejected deposit changed total_balance"); + } else { + prop_assert!(success, "deposit at/above minimum should succeed"); + } + } + } + + // ── Boundary / overflow focus ───────────────────────────────────────────── + + proptest! { + #![proptest_config(ProptestConfig::with_cases(20_000))] + + /// Boundary: fee arithmetic at extreme bps and amount values never overflows i128. + #[test] + fn prop_fee_arithmetic_boundary_no_overflow( + amount in prop::sample::select(vec![ + 1i128, 2i128, 9_999i128, 10_000i128, 10_001i128, + 1_000_000_000_0000_000i128, + i128::MAX / 10_001, + ]), + fee_bps in prop::sample::select(vec![ + 0u32, 1u32, 100u32, 5_000u32, 9_999u32, 10_000u32 + ]), + ) { + let (fee, net) = withdrawal_fee_split(amount, fee_bps); + prop_assert_eq!(fee + net, amount); + prop_assert!(fee >= 0); + prop_assert!(net >= 0); + } + + /// Boundary: yield distribution with single-stroop amounts. + #[test] + fn prop_yield_single_stroop(yield_amount in 1i128..=10i128) { + let share = member_yield_share(yield_amount, 1, 1); + prop_assert_eq!(share, yield_amount, "single member should get full yield"); + } + + /// Boundary: yield distribution with equal balances distributes evenly. + #[test] + fn prop_yield_equal_balances( + yield_amount in 1i128..=1_000_000i128, + member_count in 1u32..=100u32, + balance_per_member in 1i128..=100_000i128, + ) { + let total_balance = balance_per_member * member_count as i128; + let per_share = member_yield_share(yield_amount, balance_per_member, total_balance); + let total_distributed = per_share * member_count as i128; + prop_assert!(total_distributed <= yield_amount); + prop_assert!(per_share >= 0); + } + } +} diff --git a/smartcontract/contracts/flexible/src/lib.rs b/smartcontract/contracts/flexible/src/lib.rs index 0147fee..b32fb51 100644 --- a/smartcontract/contracts/flexible/src/lib.rs +++ b/smartcontract/contracts/flexible/src/lib.rs @@ -780,3 +780,6 @@ impl FlexiblePool { #[cfg(test)] mod tests; + +#[cfg(test)] +mod fuzz_tests; diff --git a/smartcontract/contracts/governance/Cargo.toml b/smartcontract/contracts/governance/Cargo.toml index c3dcdc1..89767c2 100644 --- a/smartcontract/contracts/governance/Cargo.toml +++ b/smartcontract/contracts/governance/Cargo.toml @@ -11,3 +11,4 @@ soroban-sdk = { version = "21.0.0", features = ["alloc"] } [dev-dependencies] soroban-sdk = { version = "21.0.0", features = ["testutils", "alloc"] } +proptest = "1.4" diff --git a/smartcontract/contracts/governance/proptest-regressions/fuzz_tests.txt b/smartcontract/contracts/governance/proptest-regressions/fuzz_tests.txt new file mode 100644 index 0000000..a0da2ba --- /dev/null +++ b/smartcontract/contracts/governance/proptest-regressions/fuzz_tests.txt @@ -0,0 +1,8 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 2c11f880fd9a32c9734f03b85e40a53e5e8628ecc5cee55c4130dd04cea320ab # shrinks to member_count = 101 +cc be2d4146814cf34e06edf32dd493b276604395fc77d514f707ccb7238d7e1b3e # shrinks to member_count = 3, quorum_pct = 67 diff --git a/smartcontract/contracts/governance/src/fuzz_tests.rs b/smartcontract/contracts/governance/src/fuzz_tests.rs new file mode 100644 index 0000000..b60a664 --- /dev/null +++ b/smartcontract/contracts/governance/src/fuzz_tests.rs @@ -0,0 +1,514 @@ +//! Property-based fuzz / invariant tests for JointSave Governance Contract. +//! +//! Covers: +//! - Quorum math: votes × 100 >= quorum_pct × total_members +//! - MAX_ACTIVE_PROPOSALS (3) never exceeded +//! - No double-vote per (proposal, voter) pair +//! - Proposer cannot vote on own proposal +//! - Terminal states (Executed, Rejected, Expired) are irreversible +//! - `meets_quorum` helper: boundary arithmetic, overflow safety +//! - Multi-call fuzz: random proposal/vote/expire sequences never panic +//! +//! Run with: +//! cargo test prop_ --manifest-path=contracts/governance/Cargo.toml \ +//! --release -- --nocapture + +#[cfg(test)] +mod prop_tests { + use proptest::prelude::*; + + // In test binaries the standard library is always available even in no_std crates. + extern crate std; + use std::vec::Vec as StdVec; + use std::vec; // bring in the vec![] macro + + // ── Pure arithmetic helpers mirroring the on-chain implementation ──────── + + /// Mirrors `Governance::meets_quorum`. + fn meets_quorum(votes: u128, quorum_pct: u32, total_members: u128) -> bool { + if total_members == 0 { + return false; + } + votes.saturating_mul(100) >= (quorum_pct as u128).saturating_mul(total_members) + } + + // ── quorum math invariants ──────────────────────────────────────────────── + + proptest! { + #![proptest_config(ProptestConfig::with_cases(50_000))] + + /// Invariant: meets_quorum is never true when total_members == 0. + #[test] + fn prop_quorum_zero_members_always_false( + votes in 0u128..=u128::MAX / 100, + quorum_pct in 1u32..=100u32, + ) { + prop_assert!(!meets_quorum(votes, quorum_pct, 0)); + } + + /// Invariant: unanimous vote always satisfies any quorum ≤ 100%. + #[test] + fn prop_unanimous_vote_passes_any_quorum( + member_count in 1u128..=1000u128, + quorum_pct in 1u32..=100u32, + ) { + prop_assert!(meets_quorum(member_count, quorum_pct, member_count)); + } + + /// Invariant: zero votes never satisfies a non-zero quorum. + #[test] + fn prop_zero_votes_never_passes( + member_count in 1u128..=1000u128, + quorum_pct in 1u32..=100u32, + ) { + prop_assert!(!meets_quorum(0, quorum_pct, member_count)); + } + + /// Invariant: quorum is monotone — more votes makes it *at least as easy* to pass. + #[test] + fn prop_quorum_monotone_in_votes( + votes in 0u128..=999u128, + member_count in 1u128..=1000u128, + quorum_pct in 1u32..=100u32, + ) { + let passes_at_v = meets_quorum(votes, quorum_pct, member_count); + if passes_at_v { + prop_assert!( + meets_quorum(votes + 1, quorum_pct, member_count), + "monotone broken: {} votes passes but {} votes fails \ + (quorum={}%, members={})", + votes, votes + 1, quorum_pct, member_count + ); + } + } + + /// Invariant: stricter quorum is harder (or equal) to satisfy. + #[test] + fn prop_quorum_monotone_in_threshold( + votes in 0u128..=1000u128, + member_count in 1u128..=1000u128, + low_pct in 1u32..=50u32, + high_offset in 0u32..=50u32, + ) { + let high_pct = (low_pct + high_offset).min(100); + let passes_low = meets_quorum(votes, low_pct, member_count); + let passes_high = meets_quorum(votes, high_pct, member_count); + if passes_high { + prop_assert!( + passes_low, + "low quorum {}% fails but high quorum {}% passes \ + (votes={}, members={})", + low_pct, high_pct, votes, member_count + ); + } + } + + /// Invariant: no integer overflow in saturating arithmetic on extreme inputs. + #[test] + fn prop_quorum_no_overflow( + votes in prop::sample::select(vec![ + 0u128, + 1u128, + u128::MAX / 200, + u128::MAX / 100, + u128::MAX, + ]), + member_count in prop::sample::select(vec![ + 1u128, + 2u128, + 100u128, + u128::MAX / 100, + u128::MAX, + ]), + quorum_pct in prop::sample::select(vec![1u32, 51u32, 100u32]), + ) { + // Must not panic — saturating_mul handles overflow. + let _result = meets_quorum(votes, quorum_pct, member_count); + } + + /// Invariant: exact quorum threshold boundary (vote count that just barely passes). + #[test] + fn prop_quorum_boundary_precision( + member_count in 2u128..=100u128, + quorum_pct in 1u32..=100u32, + ) { + // Minimum votes required = ceil(quorum_pct * member_count / 100) + let required = (quorum_pct as u128 * member_count + 99) / 100; + + if required <= member_count { + prop_assert!( + meets_quorum(required, quorum_pct, member_count), + "required votes {} should satisfy quorum {}% with {} members", + required, quorum_pct, member_count + ); + } + + // One less than required should NOT pass (unless required is 0). + if required > 1 { + prop_assert!( + !meets_quorum(required - 1, quorum_pct, member_count), + "required-1 votes {} should NOT satisfy quorum {}% with {} members", + required - 1, quorum_pct, member_count + ); + } + } + } + + // ── Proposal-count / active-proposals state machine (pure simulation) ──── + + const MAX_ACTIVE: usize = 3; + + #[derive(Clone, Copy, Debug, PartialEq)] + enum SimStatus { + Active, + Passed, + Executed, + Expired, + Rejected, + } + + #[derive(Clone, Debug)] + struct SimProposal { + id: u32, + status: SimStatus, + votes_for: u32, + votes_against: u32, + /// Members who have already voted (stored as a StdVec to avoid ambiguity). + voted_members: StdVec, + proposer: u32, + } + + struct SimGov { + proposals: StdVec, + active_ids: StdVec, + next_id: u32, + member_count: u32, + quorum_pct: u32, + } + + impl SimGov { + fn new(member_count: u32, quorum_pct: u32) -> Self { + SimGov { + proposals: StdVec::new(), + active_ids: StdVec::new(), + next_id: 0, + member_count, + quorum_pct, + } + } + + fn create(&mut self, proposer: u32) -> Option { + if self.active_ids.len() >= MAX_ACTIVE { + return None; + } + let id = self.next_id; + self.next_id += 1; + self.proposals.push(SimProposal { + id, + status: SimStatus::Active, + votes_for: 0, + votes_against: 0, + voted_members: StdVec::new(), + proposer, + }); + self.active_ids.push(id); + Some(id) + } + + fn vote(&mut self, proposal_idx: usize, voter: u32, in_favor: bool) { + let p = match self.proposals.get_mut(proposal_idx) { + Some(p) => p, + None => return, + }; + if p.status != SimStatus::Active { + return; + } + if p.proposer == voter { + return; // proposer cannot vote + } + if p.voted_members.contains(&voter) { + return; // already voted + } + p.voted_members.push(voter); + if in_favor { + p.votes_for += 1; + } else { + p.votes_against += 1; + } + + let members = self.member_count as u128; + let quorum = self.quorum_pct; + + if in_favor && meets_quorum(p.votes_for as u128, quorum, members) { + p.status = SimStatus::Passed; + } else if !in_favor && meets_quorum(p.votes_against as u128, quorum, members) { + p.status = SimStatus::Rejected; + let id = p.id; + self.active_ids.retain(|&x| x != id); + } + } + + fn execute(&mut self, proposal_idx: usize) { + let p = match self.proposals.get_mut(proposal_idx) { + Some(p) => p, + None => return, + }; + if p.status != SimStatus::Passed { + return; + } + p.status = SimStatus::Executed; + let id = p.id; + self.active_ids.retain(|&x| x != id); + } + + fn expire(&mut self, proposal_idx: usize) { + let p = match self.proposals.get_mut(proposal_idx) { + Some(p) => p, + None => return, + }; + if p.status != SimStatus::Active && p.status != SimStatus::Passed { + return; + } + p.status = SimStatus::Expired; + let id = p.id; + self.active_ids.retain(|&x| x != id); + } + + // ── Invariant checks ────────────────────────────────────────────── + + fn assert_invariants(&self) { + // 1. Active list never exceeds MAX_ACTIVE_PROPOSALS. + assert!( + self.active_ids.len() <= MAX_ACTIVE, + "active proposals exceeded MAX ({}): {}", + MAX_ACTIVE, + self.active_ids.len() + ); + + // 2. Active list matches proposals whose status is Active or Passed. + let open_count = self + .proposals + .iter() + .filter(|p| p.status == SimStatus::Active || p.status == SimStatus::Passed) + .count(); + assert_eq!( + self.active_ids.len(), + open_count, + "active_ids len {} != open proposal count {}", + self.active_ids.len(), + open_count + ); + + // 3. No terminal proposal is referenced in active_ids. + for &aid in &self.active_ids { + let p = self.proposals.iter().find(|p| p.id == aid).unwrap(); + assert!( + p.status == SimStatus::Active || p.status == SimStatus::Passed, + "terminal proposal {} is still in active_ids", + aid + ); + } + + // 4. No member voted twice on the same proposal (Vec uniqueness check). + for p in &self.proposals { + for (i, &v_i) in p.voted_members.iter().enumerate() { + for (j, &v_j) in p.voted_members.iter().enumerate() { + if i != j { + assert_ne!(v_i, v_j, "member {} voted twice on proposal {}", v_i, p.id); + } + } + } + } + + // 5. Proposer never appears in voted_members. + for p in &self.proposals { + assert!( + !p.voted_members.contains(&p.proposer), + "proposer {} voted on own proposal {}", + p.proposer, + p.id + ); + } + } + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(5_000))] + + /// Multi-call fuzz: random create/vote/execute/expire sequences never + /// violate governance invariants. + #[test] + fn prop_governance_multi_call_invariants( + member_count in 3u32..=20u32, + quorum_pct in 1u32..=100u32, + operations in prop::collection::vec( + (0u32..=4u32, 0u32..=50u32, prop::bool::ANY), + 1..=50 + ), + ) { + let mut gov = SimGov::new(member_count, quorum_pct); + let proposal_count_soft_limit = 100usize; + + for (op, param, flag) in operations { + match op { + 0 => { + let proposer = param % member_count; + gov.create(proposer); + } + 1 => { + if !gov.proposals.is_empty() { + let idx = (param as usize) % gov.proposals.len(); + let voter = param % member_count; + gov.vote(idx, voter, flag); + } + } + 2 => { + if !gov.proposals.is_empty() { + let idx = (param as usize) % gov.proposals.len(); + gov.execute(idx); + } + } + 3 => { + if !gov.proposals.is_empty() { + let idx = (param as usize) % gov.proposals.len(); + gov.expire(idx); + } + } + _ => { + if gov.proposals.len() > proposal_count_soft_limit { + gov.proposals.truncate(proposal_count_soft_limit); + } + } + } + + gov.assert_invariants(); + } + } + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(10_000))] + + /// Invariant: active proposals never exceed MAX_ACTIVE even under maximum + /// creation pressure. + #[test] + fn prop_active_proposals_cap( + create_attempts in 4u32..=200u32, + member_count in 3u32..=50u32, + ) { + let mut gov = SimGov::new(member_count, 51); + + for i in 0..create_attempts { + gov.create(i % member_count); + prop_assert!( + gov.active_ids.len() <= MAX_ACTIVE, + "active proposals {} exceeded cap {} after {} creates", + gov.active_ids.len(), + MAX_ACTIVE, + i + 1 + ); + } + } + + /// Invariant: once a proposal enters a terminal state it cannot revert. + #[test] + fn prop_terminal_states_are_irreversible( + member_count in 3u32..=20u32, + quorum_pct in 1u32..=100u32, + ) { + let mut gov = SimGov::new(member_count, quorum_pct); + + if gov.create(0).is_none() { + return Ok(()); + } + + // Vote until Passed or we've used all eligible voters. + for v in 1..member_count { + gov.vote(0, v, true); + if gov.proposals[0].status == SimStatus::Passed { + break; + } + } + + // Only test terminal-irreversibility if the proposal actually passed. + if gov.proposals[0].status != SimStatus::Passed { + return Ok(()); // quorum not reachable with given params — skip + } + + gov.execute(0); + let after_execute = gov.proposals[0].status; + + // Proposal must now be Executed (a terminal state). + prop_assert_eq!( + after_execute, + SimStatus::Executed, + "expected Executed after execute(), got {:?}", + after_execute + ); + + // Re-execute on an Executed proposal must be a no-op. + gov.execute(0); + prop_assert_eq!( + gov.proposals[0].status, + SimStatus::Executed, + "re-execute changed Executed state" + ); + + // Expire on an Executed proposal must be a no-op. + gov.expire(0); + prop_assert_eq!( + gov.proposals[0].status, + SimStatus::Executed, + "expire changed Executed state" + ); + + gov.assert_invariants(); + } + } + + // ── Boundary tests: quorum_pct edge values ──────────────────────────────── + + proptest! { + #![proptest_config(ProptestConfig::with_cases(20_000))] + + /// Invariant: quorum=1% passes with a single vote when member_count <= 100 + /// (because ceil(1% * 100) = 1). For larger groups more votes are needed. + #[test] + fn prop_quorum_1_pct_passes_with_single_vote( + member_count in 1u128..=100u128, + ) { + prop_assert!(meets_quorum(1, 1, member_count), + "1 vote should satisfy 1% quorum with {} members", member_count); + } + + /// Invariant: quorum=1% requires ceil(member_count/100) votes for any group. + #[test] + fn prop_quorum_1_pct_minimum_votes( + member_count in 1u128..=10_000u128, + ) { + let required = (member_count + 99) / 100; // ceil(1% * n) + prop_assert!(meets_quorum(required, 1, member_count), + "{} votes should satisfy 1% quorum with {} members", required, member_count); + if required > 1 { + prop_assert!(!meets_quorum(required - 1, 1, member_count), + "{} votes should NOT satisfy 1% quorum with {} members", required - 1, member_count); + } + } + + /// Invariant: quorum=100 requires ALL members to vote in favor. + #[test] + fn prop_quorum_100_pct_requires_all_votes( + member_count in 1u128..=1000u128, + ) { + if member_count > 1 { + prop_assert!( + !meets_quorum(member_count - 1, 100, member_count), + "quorum=100% should require all {} votes, {} is not enough", + member_count, + member_count - 1 + ); + } + prop_assert!(meets_quorum(member_count, 100, member_count)); + } + } +} diff --git a/smartcontract/contracts/governance/src/lib.rs b/smartcontract/contracts/governance/src/lib.rs index f5864ee..b1fc238 100644 --- a/smartcontract/contracts/governance/src/lib.rs +++ b/smartcontract/contracts/governance/src/lib.rs @@ -1,5 +1,8 @@ #![no_std] +#[cfg(test)] +mod fuzz_tests; + use soroban_sdk::{ contract, contractimpl, contracttype, symbol_short, Address, Bytes, BytesN, Env, IntoVal, Map, String, Symbol, Vec, diff --git a/smartcontract/contracts/microloan/Cargo.toml b/smartcontract/contracts/microloan/Cargo.toml index 4098543..fd82137 100644 --- a/smartcontract/contracts/microloan/Cargo.toml +++ b/smartcontract/contracts/microloan/Cargo.toml @@ -12,3 +12,4 @@ soroban-sdk = { version = "21.0.0", features = ["alloc"] } [dev-dependencies] soroban-sdk = { version = "21.0.0", features = ["testutils", "alloc"] } jointsave-reputation = { path = "../reputation" } +proptest = "1.4" diff --git a/smartcontract/contracts/microloan/src/fuzz_tests.rs b/smartcontract/contracts/microloan/src/fuzz_tests.rs new file mode 100644 index 0000000..215b1c9 --- /dev/null +++ b/smartcontract/contracts/microloan/src/fuzz_tests.rs @@ -0,0 +1,634 @@ +//! Property-based fuzz / invariant tests for JointSave Microloan Contract. +//! +//! Uses a pure-arithmetic simulation of the loan lifecycle that mirrors the +//! on-chain logic in lib.rs. This avoids the Soroban testutils entirely, +//! which lets proptest generate thousands of random inputs in a normal Rust +//! test environment without requiring a live Env or mock contracts. +//! +//! Covered invariants: +//! - total_owed = principal + (principal × rate / 10_000); always ≥ principal +//! - remaining = total_owed − repaid_amount; never negative +//! - repaid_amount never exceeds total_owed +//! - State machine: Pending → Active → Repaid/Defaulted; Pending → Cancelled +//! - No double-repay beyond total_owed (repaid capped at total_owed) +//! - MAX_ACTIVE_LOANS (3) never exceeded per member +//! - Interest calculation is overflow-safe for inputs up to i128::MAX/10_000 +//! - Multi-call fuzz: create/accept/repay/cancel sequences keep all invariants +//! - Boundary: 0 bps, 5000 bps, amounts at 1, i128::MAX/10_000 +//! +//! Run with: +//! cargo test prop_ --manifest-path=contracts/microloan/Cargo.toml \ +//! --release -- --nocapture + +#[cfg(test)] +mod prop_tests { + use proptest::prelude::*; + + extern crate std; + use std::vec::Vec as StdVec; + macro_rules! std_vec { + ($($x:expr),* $(,)?) => { + >::from([$($x),*]) + }; + } + + // ── On-chain constants (must match lib.rs) ──────────────────────────────── + + const MAX_INTEREST_RATE_BPS: u32 = 5_000; + const MAX_TERM_DAYS: u64 = 365; + const MAX_ACTIVE_LOANS: u32 = 3; + + // ── Pure mirrors of on-chain arithmetic ─────────────────────────────────── + + fn total_owed(principal: i128, interest_rate_bps: u32) -> i128 { + let interest = principal + .checked_mul(interest_rate_bps as i128) + .unwrap_or(0) + / 10_000_i128; + principal + interest + } + + fn remaining(principal: i128, interest_rate_bps: u32, repaid: i128) -> i128 { + let owed = total_owed(principal, interest_rate_bps); + if repaid >= owed { + 0 + } else { + owed - repaid + } + } + + // ── Loan status simulation ──────────────────────────────────────────────── + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + enum SimStatus { + Pending, + Active, + Repaid, + Defaulted, + Cancelled, + } + + #[derive(Clone, Debug)] + struct SimLoan { + id: u32, + borrower: u32, + lender: Option, + amount: i128, + interest_rate_bps: u32, + term_days: u64, + status: SimStatus, + repaid_amount: i128, + due_ts: u64, + } + + impl SimLoan { + fn total_owed(&self) -> i128 { + total_owed(self.amount, self.interest_rate_bps) + } + + fn remaining(&self) -> i128 { + remaining(self.amount, self.interest_rate_bps, self.repaid_amount) + } + } + + struct MicroloanSim { + loans: StdVec, + next_id: u32, + /// Per-member active loan count (member_id → count). + active_counts: StdVec<(u32, u32)>, + } + + impl MicroloanSim { + fn new() -> Self { + MicroloanSim { + loans: StdVec::new(), + next_id: 0, + active_counts: StdVec::new(), + } + } + + fn active_count(&self, member: u32) -> u32 { + self.active_counts + .iter() + .find(|&&(id, _)| id == member) + .map(|&(_, c)| c) + .unwrap_or(0) + } + + fn change_active_count(&mut self, member: u32, delta: i32) { + if let Some(entry) = self.active_counts.iter_mut().find(|(id, _)| *id == member) { + entry.1 = (entry.1 as i32 + delta).max(0) as u32; + } else if delta > 0 { + self.active_counts.push((member, delta as u32)); + } + } + + fn create_loan( + &mut self, + borrower: u32, + amount: i128, + interest_rate_bps: u32, + term_days: u64, + members: &StdVec, + ) -> Option { + // Validation guards + if amount <= 0 { + return None; + } + if interest_rate_bps > MAX_INTEREST_RATE_BPS { + return None; + } + if term_days < 1 || term_days > MAX_TERM_DAYS { + return None; + } + if !members.contains(&borrower) { + return None; + } + if self.active_count(borrower) >= MAX_ACTIVE_LOANS { + return None; + } + + let id = self.next_id; + self.next_id += 1; + self.loans.push(SimLoan { + id, + borrower, + lender: None, + amount, + interest_rate_bps, + term_days, + status: SimStatus::Pending, + repaid_amount: 0, + due_ts: 0, + }); + Some(id) + } + + fn accept_loan( + &mut self, + loan_id: u32, + lender: u32, + now_ts: u64, + members: &StdVec, + ) -> bool { + let idx = match self.loans.iter().position(|l| l.id == loan_id) { + Some(i) => i, + None => return false, + }; + if self.loans[idx].status != SimStatus::Pending { + return false; + } + if self.loans[idx].borrower == lender { + return false; // lender ≠ borrower + } + if !members.contains(&lender) { + return false; + } + if self.active_count(lender) >= MAX_ACTIVE_LOANS { + return false; + } + + let term = self.loans[idx].term_days; + let borrower = self.loans[idx].borrower; + self.loans[idx].lender = Some(lender); + self.loans[idx].due_ts = now_ts + term * 86_400; + self.loans[idx].status = SimStatus::Active; + self.change_active_count(borrower, 1); + self.change_active_count(lender, 1); + true + } + + fn repay_loan(&mut self, loan_id: u32, repay_amount: i128) -> bool { + if repay_amount <= 0 { + return false; + } + let idx = match self.loans.iter().position(|l| l.id == loan_id) { + Some(i) => i, + None => return false, + }; + if self.loans[idx].status != SimStatus::Active { + return false; + } + let remaining = self.loans[idx].remaining(); + if repay_amount > remaining { + return false; // contract panics on this + } + let borrower = self.loans[idx].borrower; + let lender = self.loans[idx].lender; + self.loans[idx].repaid_amount += repay_amount; + if self.loans[idx].repaid_amount >= self.loans[idx].total_owed() { + self.loans[idx].status = SimStatus::Repaid; + self.change_active_count(borrower, -1); + if let Some(l) = lender { + self.change_active_count(l, -1); + } + } + true + } + + fn cancel_loan(&mut self, loan_id: u32) -> bool { + let idx = match self.loans.iter().position(|l| l.id == loan_id) { + Some(i) => i, + None => return false, + }; + if self.loans[idx].status != SimStatus::Pending { + return false; + } + self.loans[idx].status = SimStatus::Cancelled; + true + } + + fn default_loan(&mut self, loan_id: u32, now_ts: u64) -> bool { + let idx = match self.loans.iter().position(|l| l.id == loan_id) { + Some(i) => i, + None => return false, + }; + if self.loans[idx].status != SimStatus::Active { + return false; + } + if now_ts <= self.loans[idx].due_ts { + return false; + } + let borrower = self.loans[idx].borrower; + let lender = self.loans[idx].lender; + self.loans[idx].status = SimStatus::Defaulted; + self.change_active_count(borrower, -1); + if let Some(l) = lender { + self.change_active_count(l, -1); + } + true + } + + fn assert_all_invariants(&self) { + for loan in &self.loans { + let owed = loan.total_owed(); + let rem = loan.remaining(); + + assert!( + owed >= loan.amount, + "total_owed {} < principal {} (id={}, rate={})", + owed, + loan.amount, + loan.id, + loan.interest_rate_bps + ); + assert!( + rem >= 0, + "remaining {} is negative (id={})", + rem, + loan.id + ); + assert!( + loan.repaid_amount >= 0, + "repaid_amount {} is negative (id={})", + loan.repaid_amount, + loan.id + ); + assert!( + loan.repaid_amount <= owed, + "repaid_amount {} > total_owed {} (id={})", + loan.repaid_amount, + owed, + loan.id + ); + + match loan.status { + SimStatus::Pending => { + assert!( + loan.lender.is_none(), + "PENDING loan {} has lender", + loan.id + ); + assert_eq!( + loan.repaid_amount, 0, + "PENDING loan {} has repayments", + loan.id + ); + } + SimStatus::Active => { + assert!( + loan.lender.is_some(), + "ACTIVE loan {} missing lender", + loan.id + ); + assert!( + loan.due_ts > 0, + "ACTIVE loan {} missing due_ts", + loan.id + ); + } + SimStatus::Repaid => { + assert_eq!( + rem, 0, + "REPAID loan {} still has remaining {}", + loan.id, rem + ); + assert!( + loan.lender.is_some(), + "REPAID loan {} missing lender", + loan.id + ); + } + SimStatus::Cancelled => { + assert_eq!( + loan.repaid_amount, 0, + "CANCELLED loan {} has repayments", + loan.id + ); + } + SimStatus::Defaulted => { + assert!( + loan.lender.is_some(), + "DEFAULTED loan {} missing lender", + loan.id + ); + } + } + } + + // MAX_ACTIVE_LOANS never exceeded per member. + for &(member, count) in &self.active_counts { + assert!( + count <= MAX_ACTIVE_LOANS, + "member {} exceeded MAX_ACTIVE_LOANS: {} active", + member, + count + ); + } + } + } + + // ── Core arithmetic invariants ──────────────────────────────────────────── + + proptest! { + #![proptest_config(ProptestConfig::with_cases(50_000))] + + /// Invariant: total_owed = principal + interest; always ≥ principal. + #[test] + fn prop_loan_total_owed_ge_principal( + principal in 1i128..=i128::MAX / 10_000, + rate_bps in 0u32..=MAX_INTEREST_RATE_BPS, + ) { + let owed = total_owed(principal, rate_bps); + prop_assert!(owed >= principal, + "total_owed {} < principal {} at rate_bps={}", owed, principal, rate_bps); + } + + /// Invariant: zero rate means total_owed == principal. + #[test] + fn prop_zero_rate_owed_equals_principal( + principal in 1i128..=i128::MAX / 10_000, + ) { + prop_assert_eq!(total_owed(principal, 0), principal); + } + + /// Invariant: remaining = total_owed − repaid; never negative. + #[test] + fn prop_remaining_never_negative( + principal in 1i128..=i128::MAX / 10_000, + rate_bps in 0u32..=MAX_INTEREST_RATE_BPS, + repaid_ratio in 0.0f64..=1.0f64, + ) { + let owed = total_owed(principal, rate_bps); + let repaid = (owed as f64 * repaid_ratio) as i128; + let rem = remaining(principal, rate_bps, repaid.min(owed)); + prop_assert!(rem >= 0, "remaining {} is negative", rem); + } + + /// Invariant: full repayment leaves remaining == 0. + #[test] + fn prop_full_repayment_zero_remaining( + principal in 1i128..=i128::MAX / 10_000, + rate_bps in 0u32..=MAX_INTEREST_RATE_BPS, + ) { + let owed = total_owed(principal, rate_bps); + prop_assert_eq!(remaining(principal, rate_bps, owed), 0); + } + + /// Invariant: interest is non-decreasing with rate_bps. + #[test] + fn prop_interest_monotone_in_rate( + principal in 1i128..=i128::MAX / 10_000, + low_rate in 0u32..=4999u32, + high_offset in 1u32..=5000u32, + ) { + let high_rate = (low_rate + high_offset).min(MAX_INTEREST_RATE_BPS); + let low_owed = total_owed(principal, low_rate); + let high_owed = total_owed(principal, high_rate); + prop_assert!(high_owed >= low_owed, + "interest not monotone: {} → {} at rate {}→{}", + low_owed, high_owed, low_rate, high_rate); + } + + /// Boundary: overflow-safe with amounts near i128::MAX / 10_000. + #[test] + fn prop_interest_no_overflow( + principal in prop::sample::select(std_vec![ + 1i128, + 1_000i128, + 1_000_000_0000000i128, + i128::MAX / 10_001, + i128::MAX / 10_000, + ]), + rate_bps in prop::sample::select(std_vec![ + 0u32, 1u32, 100u32, 1000u32, MAX_INTEREST_RATE_BPS + ]), + ) { + let owed = total_owed(principal, rate_bps); + prop_assert!(owed >= principal, "overflow: owed {} < principal {}", owed, principal); + prop_assert!(owed >= 0, "owed negative: {}", owed); + } + } + + // ── State-machine / multi-call fuzz ─────────────────────────────────────── + + proptest! { + #![proptest_config(ProptestConfig::with_cases(2_000))] + + /// Multi-call fuzz: random create/accept/repay/cancel sequences must never + /// panic or break any invariant for thousands of iterations. + #[test] + fn prop_microloan_multicall_invariants( + member_count in 2u32..=10u32, + operations in prop::collection::vec( + (0u32..=4u32, 0u32..=9u32, 1i128..=1_000_000i128, 0u32..=5000u32, 1u64..=365u64), + 1..=30 + ), + ) { + let members: StdVec = (0..member_count).collect(); + let mut sim = MicroloanSim::new(); + let mut created_ids: StdVec = StdVec::new(); + let mut now_ts = 1_000_000u64; // epoch start + + for (op, member_offset, amount, rate_bps, term_days) in operations { + let borrower = member_offset % member_count; + let lender = ((member_offset + 1) % member_count).max(0); + + match op { + 0 => { + // Create loan request + let rate = rate_bps % (MAX_INTEREST_RATE_BPS + 1); + if let Some(id) = sim.create_loan(borrower, amount, rate, term_days, &members) { + created_ids.push(id); + } + } + 1 => { + // Accept a loan + if let Some(&id) = created_ids.last() { + sim.accept_loan(id, lender, now_ts, &members); + } + } + 2 => { + // Partial repay on a random loan + if !created_ids.is_empty() { + let idx = (member_offset as usize) % created_ids.len(); + let id = created_ids[idx]; + if let Some(loan) = sim.loans.iter().find(|l| l.id == id) { + let rem = loan.remaining(); + let repay = amount.min(rem); + if repay > 0 { + sim.repay_loan(id, repay); + } + } + } + } + 3 => { + // Cancel a pending loan + if !created_ids.is_empty() { + let idx = (member_offset as usize) % created_ids.len(); + sim.cancel_loan(created_ids[idx]); + } + } + 4 => { + // Advance time and attempt default + now_ts = now_ts.saturating_add(term_days * 86_400 + 1); + if !created_ids.is_empty() { + let idx = (member_offset as usize) % created_ids.len(); + sim.default_loan(created_ids[idx], now_ts); + } + } + _ => {} + } + + sim.assert_all_invariants(); + } + } + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(5_000))] + + /// Invariant: MAX_ACTIVE_LOANS (3) is always respected per member. + #[test] + fn prop_max_active_loans_per_member( + create_attempts in 4u32..=20u32, + principal in 1i128..=1_000_000i128, + ) { + let members = std_vec![0u32, 1u32, 2u32, 3u32]; + let mut sim = MicroloanSim::new(); + + // All requests from member 0 as borrower. + for _ in 0..create_attempts { + sim.create_loan(0, principal, 500, 30, &members); + } + + // Accept them all with member 1 as lender. + let ids: StdVec = sim + .loans + .iter() + .filter(|l| l.status == SimStatus::Pending) + .map(|l| l.id) + .collect(); + + for id in ids { + sim.accept_loan(id, 1, 0, &members); + } + + // Check that active count never exceeded MAX. + for loan in &sim.loans { + if loan.status == SimStatus::Active { + let b_count = sim.active_count(loan.borrower); + prop_assert!(b_count <= MAX_ACTIVE_LOANS, + "borrower {} has {} active loans (max {})", + loan.borrower, b_count, MAX_ACTIVE_LOANS); + if let Some(l) = loan.lender { + let l_count = sim.active_count(l); + prop_assert!(l_count <= MAX_ACTIVE_LOANS, + "lender {} has {} active loans (max {})", + l, l_count, MAX_ACTIVE_LOANS); + } + } + } + } + + /// Invariant: repaying more than remaining is always rejected. + #[test] + fn prop_no_overpayment( + principal in 1i128..=1_000_000i128, + rate_bps in 0u32..=MAX_INTEREST_RATE_BPS, + excess in 1i128..=1_000_000i128, + ) { + let members = std_vec![0u32, 1u32]; + let mut sim = MicroloanSim::new(); + let id = sim.create_loan(0, principal, rate_bps, 30, &members).unwrap(); + sim.accept_loan(id, 1, 0, &members); + + let owed = total_owed(principal, rate_bps); + let over_repay = owed + excess; + + // Attempt over-payment — must be rejected. + let result = sim.repay_loan(id, over_repay); + prop_assert!(!result, "over-repayment should be rejected"); + + // Loan must still be ACTIVE. + let loan = sim.loans.iter().find(|l| l.id == id).unwrap(); + prop_assert_eq!(loan.status, SimStatus::Active); + prop_assert_eq!(loan.repaid_amount, 0); + } + + /// Invariant: Cancelled loan can never be accepted or repaid. + #[test] + fn prop_cancelled_is_terminal( + principal in 1i128..=1_000_000i128, + ) { + let members = std_vec![0u32, 1u32]; + let mut sim = MicroloanSim::new(); + let id = sim.create_loan(0, principal, 500, 30, &members).unwrap(); + assert!(sim.cancel_loan(id)); + + // accept and repay must both be rejected + let accepted = sim.accept_loan(id, 1, 0, &members); + prop_assert!(!accepted, "cancelled loan should not be acceptable"); + + let repaid = sim.repay_loan(id, 1); + prop_assert!(!repaid, "cancelled loan should not be repayable"); + + let loan = sim.loans.iter().find(|l| l.id == id).unwrap(); + prop_assert_eq!(loan.status, SimStatus::Cancelled); + prop_assert_eq!(loan.repaid_amount, 0); + } + + /// Invariant: Repaid loan can never be repaid again (double-repay blocked). + #[test] + fn prop_no_double_repay( + principal in 1i128..=1_000_000i128, + rate_bps in 0u32..=MAX_INTEREST_RATE_BPS, + ) { + let members = std_vec![0u32, 1u32]; + let mut sim = MicroloanSim::new(); + let id = sim.create_loan(0, principal, rate_bps, 30, &members).unwrap(); + sim.accept_loan(id, 1, 0, &members); + + let owed = total_owed(principal, rate_bps); + assert!(sim.repay_loan(id, owed)); + + // Loan now REPAID — second repay must be rejected. + let second = sim.repay_loan(id, 1); + prop_assert!(!second, "double-repay should be rejected"); + + let loan = sim.loans.iter().find(|l| l.id == id).unwrap(); + prop_assert_eq!(loan.status, SimStatus::Repaid); + prop_assert_eq!(loan.remaining(), 0); + } + } +} diff --git a/smartcontract/contracts/microloan/src/lib.rs b/smartcontract/contracts/microloan/src/lib.rs index 55e76a0..85f4eee 100644 --- a/smartcontract/contracts/microloan/src/lib.rs +++ b/smartcontract/contracts/microloan/src/lib.rs @@ -577,3 +577,6 @@ impl MicroloanContract { #[cfg(test)] mod tests; + +#[cfg(test)] +mod fuzz_tests; diff --git a/smartcontract/contracts/reputation/Cargo.toml b/smartcontract/contracts/reputation/Cargo.toml index a510e84..dd1cebf 100644 --- a/smartcontract/contracts/reputation/Cargo.toml +++ b/smartcontract/contracts/reputation/Cargo.toml @@ -11,3 +11,4 @@ soroban-sdk = { version = "21.0.0", features = ["alloc"] } [dev-dependencies] soroban-sdk = { version = "21.0.0", features = ["testutils", "alloc"] } +proptest = "1.4" diff --git a/smartcontract/contracts/reputation/proptest-regressions/fuzz_tests.txt b/smartcontract/contracts/reputation/proptest-regressions/fuzz_tests.txt new file mode 100644 index 0000000..2439cb1 --- /dev/null +++ b/smartcontract/contracts/reputation/proptest-regressions/fuzz_tests.txt @@ -0,0 +1,9 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 6cabe7b0959502907fe160c3df01de331ce322910adf4628c74d62be92d926b1 # shrinks to successful = 4294967295, missed = 1 +# prop_reputation_multicall_score_always_valid: shrinks to ops = [(false, true, 0, 1), (false, true, 0, 1)] +# (pools_completed > pools_joined; fixed by incrementing pools_joined on each pool_completed event) diff --git a/smartcontract/contracts/reputation/src/fuzz_tests.rs b/smartcontract/contracts/reputation/src/fuzz_tests.rs new file mode 100644 index 0000000..517e114 --- /dev/null +++ b/smartcontract/contracts/reputation/src/fuzz_tests.rs @@ -0,0 +1,506 @@ +//! Property-based fuzz / invariant tests for JointSave Reputation Tracker Contract. +//! +//! Covers: +//! - Score formula: total_score always in [0, 1000] +//! - deposit_reliability = successful / (successful + missed) * 1000 +//! - recency_bonus is a non-increasing step function of elapsed time +//! - pools_completed_ratio capped at 1000 (never exceeds pools_joined) +//! - compute_total_score weights: 0.6 * reliability + 0.3 * completion + 0.1 * recency +//! - Successful deposit never lowers reliability; missed deposit never raises it +//! - is_provisional is true for < PROVISIONAL_THRESHOLD deposits, false after +//! - No overflow in total_deposit_amount accumulation (saturating_add) +//! - Multi-call fuzz: random update sequences never produce a score outside [0,1000] +//! +//! Run with: +//! cargo test prop_ --manifest-path=contracts/reputation/Cargo.toml \ +//! --release -- --nocapture + +#[cfg(test)] +mod prop_tests { + use proptest::prelude::*; + + // In test binaries the standard library is always available even in no_std crates. + extern crate std; + use std::vec::Vec as StdVec; + // Bring in the std vec![] macro explicitly to avoid ambiguity with soroban_sdk::vec!. + macro_rules! std_vec { + ($($x:expr),* $(,)?) => { + >::from([$($x),*]) + }; + } + + // ── On-chain constants (must match lib.rs) ──────────────────────────────── + + const PROVISIONAL_THRESHOLD: u32 = 10; + const SECS_PER_DAY: u64 = 86_400; + + // ── Mirrors of pure on-chain helpers ───────────────────────────────────── + + fn compute_deposit_reliability(successful: u32, missed: u32) -> u32 { + // Mirror of lib.rs: use saturating_add to prevent u32 overflow when + // both counters are near u32::MAX (regression for prop_reliability_no_overflow). + let total = successful.saturating_add(missed); + if total == 0 { + return 500; + } + (successful as u64 * 1000 / total as u64).min(1000) as u32 + } + + fn compute_recency_bonus(now: u64, last_activity: u64) -> u32 { + if now < last_activity { + return 1000; + } + let elapsed = now - last_activity; + let days = elapsed / SECS_PER_DAY; + if days < 30 { + 1000 + } else if days < 60 { + 500 + } else if days < 90 { + 250 + } else { + 0 + } + } + + fn compute_total_score(reliability: u32, completion_score: u32, recency: u32) -> u32 { + let r = reliability.min(1000); + let c = completion_score.min(1000); + let n = recency.min(1000); + ((r as u64 * 6 + c as u64 * 3 + n as u64 * 1) / 10) as u32 + } + + fn is_provisional(total_deposits: u32) -> bool { + total_deposits < PROVISIONAL_THRESHOLD + } + + // ── Score-bounds invariants ─────────────────────────────────────────────── + + // Zero-argument assertions must live outside proptest! as plain #[test] functions. + #[test] + fn prop_max_score_requires_all_max() { + let score = compute_total_score(1000, 1000, 1000); + assert_eq!(score, 1000); + } + + #[test] + fn prop_zero_score_when_all_zero() { + let score = compute_total_score(0, 0, 0); + assert_eq!(score, 0); + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(50_000))] + + /// Invariant: total_score always in [0, 1000]. + #[test] + fn prop_total_score_in_range( + reliability in 0u32..=1000u32, + completion in 0u32..=1000u32, + recency in 0u32..=1000u32, + ) { + let score = compute_total_score(reliability, completion, recency); + prop_assert!(score <= 1000, "score {} > 1000", score); + } + + /// Invariant: score is monotone in reliability. + #[test] + fn prop_score_monotone_in_reliability( + low_r in 0u32..=999u32, + high_offset in 1u32..=1000u32, + completion in 0u32..=1000u32, + recency in 0u32..=1000u32, + ) { + let high_r = (low_r + high_offset).min(1000); + let low_score = compute_total_score(low_r, completion, recency); + let high_score = compute_total_score(high_r, completion, recency); + prop_assert!(high_score >= low_score, + "not monotone in reliability: {}→{} at r={}→{}", low_score, high_score, low_r, high_r); + } + + /// Invariant: score is monotone in completion ratio. + #[test] + fn prop_score_monotone_in_completion( + reliability in 0u32..=1000u32, + low_c in 0u32..=999u32, + high_offset in 1u32..=1000u32, + recency in 0u32..=1000u32, + ) { + let high_c = (low_c + high_offset).min(1000); + let low_score = compute_total_score(reliability, low_c, recency); + let high_score = compute_total_score(reliability, high_c, recency); + prop_assert!(high_score >= low_score, + "not monotone in completion: {}→{} at c={}→{}", low_score, high_score, low_c, high_c); + } + + /// Invariant: score is monotone in recency bonus. + #[test] + fn prop_score_monotone_in_recency( + reliability in 0u32..=1000u32, + completion in 0u32..=1000u32, + low_n in 0u32..=999u32, + high_offset in 1u32..=1000u32, + ) { + let high_n = (low_n + high_offset).min(1000); + let low_score = compute_total_score(reliability, completion, low_n); + let high_score = compute_total_score(reliability, completion, high_n); + prop_assert!(high_score >= low_score, + "not monotone in recency: {}→{} at n={}→{}", low_score, high_score, low_n, high_n); + } + } + + // ── deposit_reliability invariants ──────────────────────────────────────── + + proptest! { + #![proptest_config(ProptestConfig::with_cases(50_000))] + + /// Invariant: reliability always in [0, 1000]. + #[test] + fn prop_reliability_in_range( + successful in 0u32..=10_000u32, + missed in 0u32..=10_000u32, + ) { + let r = compute_deposit_reliability(successful, missed); + prop_assert!(r <= 1000, "reliability {} > 1000", r); + } + + /// Invariant: zero history defaults to 500 (neutral). + #[test] + fn prop_reliability_default_500( + _dummy in 0u32..=0u32, + ) { + prop_assert_eq!(compute_deposit_reliability(0, 0), 500); + } + + /// Invariant: all successful → reliability == 1000. + #[test] + fn prop_reliability_all_success(successful in 1u32..=10_000u32) { + prop_assert_eq!(compute_deposit_reliability(successful, 0), 1000); + } + + /// Invariant: all missed → reliability == 0. + #[test] + fn prop_reliability_all_missed(missed in 1u32..=10_000u32) { + prop_assert_eq!(compute_deposit_reliability(0, missed), 0); + } + + /// Invariant: adding a successful deposit cannot decrease reliability. + #[test] + fn prop_reliability_non_decreasing_on_success( + successful in 0u32..=10_000u32, + missed in 0u32..=10_000u32, + ) { + let before = compute_deposit_reliability(successful, missed); + let after = compute_deposit_reliability(successful + 1, missed); + prop_assert!(after >= before, + "reliability decreased after success: {}→{} (s={}, m={})", + before, after, successful, missed); + } + + /// Invariant: adding a missed deposit cannot increase reliability. + #[test] + fn prop_reliability_non_increasing_on_miss( + successful in 0u32..=10_000u32, + missed in 0u32..=10_000u32, + ) { + let before = compute_deposit_reliability(successful, missed); + let after = compute_deposit_reliability(successful, missed + 1); + prop_assert!(after <= before, + "reliability increased after miss: {}→{} (s={}, m={})", + before, after, successful, missed); + } + + /// Boundary: extreme u32 inputs don't overflow. + #[test] + fn prop_reliability_no_overflow( + successful in prop::sample::select(std_vec![0u32, 1u32, u32::MAX / 2, u32::MAX]), + missed in prop::sample::select(std_vec![0u32, 1u32, u32::MAX / 2]), + ) { + let r = compute_deposit_reliability(successful, missed); + prop_assert!(r <= 1000); + } + } + + // ── recency_bonus invariants ────────────────────────────────────────────── + + proptest! { + #![proptest_config(ProptestConfig::with_cases(20_000))] + + /// Invariant: recency_bonus is always in {0, 250, 500, 1000}. + #[test] + fn prop_recency_bonus_valid_values( + now in 0u64..=u64::MAX / 2, + last in 0u64..=u64::MAX / 2, + ) { + let bonus = compute_recency_bonus(now, last); + prop_assert!( + bonus == 0 || bonus == 250 || bonus == 500 || bonus == 1000, + "unexpected recency bonus: {}", bonus + ); + } + + /// Invariant: recency_bonus is non-increasing as time since last activity grows. + #[test] + fn prop_recency_bonus_decreases_with_time( + last_activity in 0u64..=1_000_000u64, + days_delta in 0u64..=200u64, + ) { + let base = last_activity; + let now_a = base + days_delta * SECS_PER_DAY; + let now_b = now_a + 30 * SECS_PER_DAY; + let bonus_a = compute_recency_bonus(now_a, base); + let bonus_b = compute_recency_bonus(now_b, base); + prop_assert!(bonus_b <= bonus_a, + "recency bonus increased with time: {}→{} (delta days={}+30)", + bonus_a, bonus_b, days_delta); + } + + /// Invariant: activity within last 30 days → 1000. + #[test] + fn prop_recent_activity_max_bonus( + days_ago in 0u64..=29u64, + base_time in SECS_PER_DAY * 30..=SECS_PER_DAY * 10_000, + ) { + let last = base_time - days_ago * SECS_PER_DAY; + prop_assert_eq!(compute_recency_bonus(base_time, last), 1000, + "activity {} days ago should give 1000 recency", days_ago); + } + + /// Invariant: activity older than 90 days → 0. + #[test] + fn prop_old_activity_zero_bonus( + days_ago in 90u64..=10_000u64, + base_time in SECS_PER_DAY * 100..=SECS_PER_DAY * 100_000, + ) { + if days_ago * SECS_PER_DAY < base_time { + let last = base_time - days_ago * SECS_PER_DAY; + prop_assert_eq!(compute_recency_bonus(base_time, last), 0, + "activity {} days ago should give 0 recency", days_ago); + } + } + } + + // ── Provisional flag invariants ─────────────────────────────────────────── + + // Zero-argument assertion — plain #[test]. + #[test] + fn prop_provisional_exactly_at_threshold() { + assert!(!is_provisional(PROVISIONAL_THRESHOLD)); + assert!(is_provisional(PROVISIONAL_THRESHOLD - 1)); + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(10_000))] + + #[test] + fn prop_provisional_threshold(deposits in 0u32..=20u32) { + let provisional = is_provisional(deposits); + if deposits < PROVISIONAL_THRESHOLD { + prop_assert!(provisional); + } else { + prop_assert!(!provisional); + } + } + } + + // ── Multi-call simulation ───────────────────────────────────────────────── + + struct MemberSim { + total_deposits: u32, + missed_deposits: u32, + pools_completed: u32, + pools_joined: u32, + total_deposit_amount: i128, + total_score: u32, + } + + impl MemberSim { + fn new() -> Self { + MemberSim { + total_deposits: 0, + missed_deposits: 0, + pools_completed: 0, + pools_joined: 1, + total_deposit_amount: 0, + total_score: 500, + } + } + + fn update(&mut self, deposit_success: bool, pool_completed: bool, now: u64, amount: i128) { + if deposit_success { + self.total_deposits += 1; + self.total_deposit_amount = self.total_deposit_amount.saturating_add(amount); + } else { + self.missed_deposits += 1; + } + if pool_completed { + // A pool_completed event means the member was active in (at least) one + // additional pool. Keep pools_joined in sync so pools_completed can + // never exceed it. + self.pools_joined += 1; + self.pools_completed += 1; + } + + let reliability = compute_deposit_reliability(self.total_deposits, self.missed_deposits); + let completion_score = if self.pools_joined > 0 { + ((self.pools_completed as u64 * 1000) / self.pools_joined as u64).min(1000) as u32 + } else { + 0 + }; + let recency = compute_recency_bonus(now, now); + self.total_score = compute_total_score(reliability, completion_score, recency); + } + + fn check_invariants(&self) -> Result<(), &'static str> { + if self.total_score > 1000 { + return Err("total_score > 1000"); + } + if self.total_deposit_amount < 0 { + return Err("total_deposit_amount negative (should be saturating)"); + } + if self.pools_completed > self.pools_joined { + return Err("pools_completed > pools_joined"); + } + Ok(()) + } + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(5_000))] + + /// Multi-call fuzz: random update sequences never produce out-of-range scores. + #[test] + fn prop_reputation_multicall_score_always_valid( + operations in prop::collection::vec( + (prop::bool::ANY, prop::bool::ANY, 0u64..=200_000u64, 1i128..=1_000_000_0000000i128), + 1..=100 + ), + ) { + let mut member = MemberSim::new(); + let mut timestamp = 0u64; + + for (deposit_success, pool_completed, time_delta, amount) in operations { + timestamp += time_delta; + member.update(deposit_success, pool_completed, timestamp, amount); + member.check_invariants().map_err(|e| TestCaseError::fail(e))?; + } + } + + /// Invariant: saturating_add prevents total_deposit_amount overflow. + #[test] + fn prop_total_deposit_amount_no_overflow( + amounts in prop::collection::vec( + prop::sample::select(std_vec![ + 1i128, 1000i128, 1_000_000_0000000i128, i128::MAX / 2 + ]), + 1..=50 + ), + ) { + let mut member = MemberSim::new(); + let mut now = 0u64; + for amount in amounts { + now += 1; + member.update(true, false, now, amount); + prop_assert!(member.total_deposit_amount >= 0, + "total_deposit_amount went negative: {}", member.total_deposit_amount); + } + } + + /// Invariant: pure deposit history trends toward high score. + #[test] + fn prop_pure_deposit_history_trends_high(n in 10u32..=100u32) { + let mut member = MemberSim::new(); + let mut now = SECS_PER_DAY; // within 30-day recency window + for _ in 0..n { + member.update(true, false, now, 1_000); + now += 60; + } + let reliability = compute_deposit_reliability(member.total_deposits, member.missed_deposits); + prop_assert_eq!(reliability, 1000, "all-success reliability should be 1000"); + prop_assert!(!is_provisional(member.total_deposits)); + prop_assert!(member.total_score >= 600, + "score {} too low after {} clean deposits", member.total_score, n); + } + + /// Invariant: pure miss history trends toward low score. + #[test] + fn prop_all_miss_history_trends_low(n in 10u32..=100u32) { + let mut member = MemberSim::new(); + let mut now = SECS_PER_DAY; + for _ in 0..n { + member.update(false, false, now, 0); + now += 60; + } + let reliability = compute_deposit_reliability(member.total_deposits, member.missed_deposits); + prop_assert_eq!(reliability, 0); + prop_assert!(member.total_score <= 400, + "score {} too high after {} misses", member.total_score, n); + } + } + + // ── Boundary / overflow ─────────────────────────────────────────────────── + + proptest! { + #![proptest_config(ProptestConfig::with_cases(10_000))] + + #[test] + fn prop_total_score_extreme_inputs( + r in prop::sample::select(std_vec![0u32, 1u32, 500u32, 999u32, 1000u32, u32::MAX]), + c in prop::sample::select(std_vec![0u32, 1u32, 500u32, 999u32, 1000u32, u32::MAX]), + n in prop::sample::select(std_vec![0u32, 250u32, 500u32, 1000u32]), + ) { + let score = compute_total_score(r, c, n); + prop_assert!(score <= 1000, "score {} out of range", score); + } + } + + // ── Regression tests (named, always run, no proptest shrinking needed) ──── + + /// Regression for: compute_deposit_reliability panicked with "attempt to add + /// with overflow" when successful=u32::MAX and missed=1. + /// + /// Root cause: `let total = successful + missed` was a plain u32 addition. + /// Fix: use `successful.saturating_add(missed)` in both lib.rs and this mirror. + #[test] + fn regression_reliability_no_overflow_at_u32_max() { + // u32::MAX + 1 would panic without saturating_add. + let r = compute_deposit_reliability(u32::MAX, 1); + assert!(r <= 1000, "reliability {} > 1000 (overflow regression)", r); + + // u32::MAX + u32::MAX also safe. + let r2 = compute_deposit_reliability(u32::MAX, u32::MAX); + assert_eq!(r2, 1000, "all-success at overflow boundary should be 1000"); + + // Zero + u32::MAX stays at 0. + let r3 = compute_deposit_reliability(0, u32::MAX); + assert_eq!(r3, 0, "all-miss at overflow boundary should be 0"); + } + + /// Regression for: prop_reputation_multicall_score_always_valid panicked with + /// "pools_completed > pools_joined" after two consecutive (false, true, ...) ops. + /// + /// Root cause: MemberSim.update() incremented pools_completed without incrementing + /// pools_joined, while pools_joined was initialised to 1 and never grew. + /// Fix: each pool_completed event also increments pools_joined by 1. + #[test] + fn regression_pools_completed_never_exceeds_pools_joined() { + let mut member = MemberSim::new(); + // Two pool_completed events in a row (no deposit, no prior join). + member.update(false, true, 0, 1); + member.update(false, true, 0, 1); + + assert!( + member.pools_completed <= member.pools_joined, + "pools_completed {} > pools_joined {} (regression)", + member.pools_completed, + member.pools_joined, + ); + assert!( + member.total_score <= 1000, + "score {} out of range after regression inputs", + member.total_score, + ); + // Confirm invariant holds via check_invariants as well. + member.check_invariants().expect("invariants failed on regression input"); + } +} diff --git a/smartcontract/contracts/reputation/src/lib.rs b/smartcontract/contracts/reputation/src/lib.rs index 9c70cbb..7c69a6d 100644 --- a/smartcontract/contracts/reputation/src/lib.rs +++ b/smartcontract/contracts/reputation/src/lib.rs @@ -453,7 +453,9 @@ impl ReputationTracker { /// deposit_reliability = (total_deposits / (total_deposits + missed_deposits)) * 1000 /// Returns 500 when no history exists. fn compute_deposit_reliability(total_deposits: u32, missed_deposits: u32) -> u32 { - let total_rounds = total_deposits + missed_deposits; + // Use saturating_add to guard against u32 overflow when both counters are + // near u32::MAX (discovered by prop_reliability_no_overflow fuzz test). + let total_rounds = total_deposits.saturating_add(missed_deposits); if total_rounds == 0 { return 500; } @@ -573,3 +575,6 @@ impl ReputationTracker { #[cfg(test)] mod test; + +#[cfg(test)] +mod fuzz_tests; diff --git a/smartcontract/contracts/target/Cargo.toml b/smartcontract/contracts/target/Cargo.toml index 8e3b497..cf46a7a 100644 --- a/smartcontract/contracts/target/Cargo.toml +++ b/smartcontract/contracts/target/Cargo.toml @@ -11,3 +11,4 @@ soroban-sdk = { version = "21.0.0", features = ["alloc"] } [dev-dependencies] soroban-sdk = { version = "21.0.0", features = ["testutils", "alloc"] } +proptest = "1.4" diff --git a/smartcontract/contracts/target/proptest-regressions/fuzz_tests.txt b/smartcontract/contracts/target/proptest-regressions/fuzz_tests.txt new file mode 100644 index 0000000..2af8c8e --- /dev/null +++ b/smartcontract/contracts/target/proptest-regressions/fuzz_tests.txt @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc ef057c2728454e97cdb6ee53558ddacb40854da0d20e6f0e7a25e1f7571ebd5c # shrinks to target_amount = 10, deadline = 50, member_count = 2, operations = [(0, 6, 10), (1, 6, 1)] diff --git a/smartcontract/contracts/target/src/fuzz_tests.rs b/smartcontract/contracts/target/src/fuzz_tests.rs new file mode 100644 index 0000000..61b9280 --- /dev/null +++ b/smartcontract/contracts/target/src/fuzz_tests.rs @@ -0,0 +1,353 @@ +//! Property-based fuzz / invariant tests for JointSave Target Pool Contract. +//! +//! Covers: +//! - Aggregate balance reconciliation: sum of member balances == TotalDeposited +//! - Unlock is sticky: once target is reached the pool stays unlocked +//! - Unlock cannot happen before target is met +//! - Withdrawal capped by member balance; no funds created or destroyed +//! - Refund path: total refunded == total deposited, zero remaining +//! - Deadline semantics: deposits rejected after deadline +//! - Multi-call fuzz: random deposit/withdraw/refund sequences never panic +//! - Boundary/overflow: target_amount, deposits at 0, 1, i128::MAX/2 +//! +//! Run with: +//! cargo test prop_ --manifest-path=contracts/target/Cargo.toml \ +//! --release -- --nocapture + +#[cfg(test)] +mod prop_tests { + use proptest::prelude::*; + + extern crate std; + use std::vec::Vec as StdVec; + use std::vec; + + // ── Pure simulation ─────────────────────────────────────────────────────── + // + // Uses Vec<(id, balance)> instead of HashMap to stay compatible with + // the no_std crate root (test binaries still link std, but we avoid + // direct std::collections imports to keep the code self-contained). + + struct TargetSim { + /// (member_id, balance) pairs + balances: StdVec<(u32, i128)>, + total_deposited: i128, + target_amount: i128, + deadline: u32, + current_sequence: u32, + unlocked: bool, + active: bool, + /// The total_deposited value at the moment the pool became unlocked. + /// Used by check_unlock_threshold to avoid a false positive: once + /// members withdraw, total_deposited may legally fall below target_amount + /// while unlocked == true. + unlocked_at_total: i128, + } + + impl TargetSim { + fn new(members: StdVec, target_amount: i128, deadline: u32) -> Self { + let balances = members.iter().map(|&id| (id, 0i128)).collect(); + TargetSim { + balances, + total_deposited: 0, + target_amount, + deadline, + current_sequence: 0, + unlocked: false, + active: true, + unlocked_at_total: 0, + } + } + + fn get_balance(&self, member: u32) -> Option { + self.balances.iter().find(|&&(id, _)| id == member).map(|&(_, b)| b) + } + + fn set_balance(&mut self, member: u32, val: i128) { + if let Some(entry) = self.balances.iter_mut().find(|(id, _)| *id == member) { + entry.1 = val; + } + } + + fn advance_ledger(&mut self, steps: u32) { + self.current_sequence = self.current_sequence.saturating_add(steps); + } + + fn deposit(&mut self, member: u32, amount: i128) -> Result { + if !self.active { return Err("pool inactive"); } + if self.unlocked { return Err("pool already unlocked"); } + if self.get_balance(member).is_none() { return Err("not a member"); } + if amount <= 0 { return Err("amount must be > 0"); } + if self.current_sequence > self.deadline { return Err("deadline passed"); } + + let prev = self.get_balance(member).unwrap(); + self.set_balance(member, prev + amount); + self.total_deposited += amount; + + if self.total_deposited >= self.target_amount { + self.unlocked = true; + // Record how much was in the pool at the unlock moment. + self.unlocked_at_total = self.total_deposited; + } + + Ok(self.total_deposited) + } + + fn withdraw(&mut self, member: u32) -> Result { + if !self.unlocked { return Err("target not reached yet"); } + let balance = self.get_balance(member).unwrap_or(0); + if balance <= 0 { return Err("nothing to withdraw"); } + self.set_balance(member, 0); + self.total_deposited -= balance; + Ok(balance) + } + + fn refund(&mut self) -> Result, &'static str> { + if self.unlocked { return Err("target reached, use withdraw"); } + if self.current_sequence <= self.deadline { return Err("deadline not passed"); } + + let mut refunds = StdVec::new(); + for entry in self.balances.iter_mut() { + if entry.1 > 0 { + refunds.push((entry.0, entry.1)); + entry.1 = 0; + } + } + self.total_deposited = 0; + self.active = false; + Ok(refunds) + } + + fn check_reconciliation(&self) -> Result<(), &'static str> { + let sum: i128 = self.balances.iter().map(|&(_, b)| b).sum(); + if sum != self.total_deposited { + return Err("reconciliation broken: sum != total_deposited"); + } + Ok(()) + } + + fn check_no_negatives(&self) -> Result<(), &'static str> { + for &(_id, bal) in &self.balances { + if bal < 0 { + return Err("negative member balance"); + } + } + if self.total_deposited < 0 { + return Err("total_deposited is negative"); + } + Ok(()) + } + + fn check_unlock_sticky(&self, was_unlocked: bool) -> Result<(), &'static str> { + if was_unlocked && !self.unlocked { + return Err("pool became locked again after being unlocked"); + } + Ok(()) + } + + fn check_unlock_threshold(&self) -> Result<(), &'static str> { + // The pool must not have entered the unlocked state unless total_deposited + // was ≥ target_amount at the time of unlock. Note: after valid withdrawals + // total_deposited may legally fall below target_amount while unlocked==true, + // so we compare against the snapshotted unlocked_at_total rather than the + // current total_deposited. + if self.unlocked && self.unlocked_at_total < self.target_amount { + return Err("pool unlocked before target was met"); + } + Ok(()) + } + } + + // ── Invariant tests ─────────────────────────────────────────────────────── + + proptest! { + #![proptest_config(ProptestConfig::with_cases(5_000))] + + /// Multi-call fuzz: random deposit/withdraw sequences never violate invariants. + #[test] + fn prop_target_multicall_invariants( + target_amount in 10i128..=1_000_000i128, + deadline in 50u32..=200u32, + member_count in 2u32..=8u32, + operations in prop::collection::vec( + (0u32..=3u32, 0u32..=7u32, 1i128..=50_000i128), + 1..=40 + ), + ) { + let members: StdVec = (0..member_count).collect(); + let mut sim = TargetSim::new(members, target_amount, deadline); + + for (op, member_offset, amount) in operations { + let member = member_offset % member_count; + let was_unlocked = sim.unlocked; + + match op { + 0 => { let _ = sim.deposit(member, amount); } + 1 => { let _ = sim.withdraw(member); } + 2 => { sim.advance_ledger(amount as u32 % 20); } + 3 => { let _ = sim.refund(); } + _ => {} + } + + sim.check_reconciliation().map_err(|e| TestCaseError::fail(e))?; + sim.check_no_negatives().map_err(|e| TestCaseError::fail(e))?; + sim.check_unlock_sticky(was_unlocked).map_err(|e| TestCaseError::fail(e))?; + sim.check_unlock_threshold().map_err(|e| TestCaseError::fail(e))?; + } + } + + /// Invariant: deposit exactly at target unlocks pool. + #[test] + fn prop_exact_target_unlocks( + target in 1i128..=1_000_000i128, + split_count in 1u32..=10u32, + ) { + let member_count = split_count.max(2); + let members: StdVec = (0..member_count).collect(); + let mut sim = TargetSim::new(members, target, 1000); + prop_assert!(!sim.unlocked); + + let per_member = target / split_count as i128; + let remainder = target - per_member * split_count as i128; + + for id in 0..split_count { + let _ = sim.deposit(id, per_member); + } + if remainder > 0 { + let _ = sim.deposit(0, remainder); + } + + prop_assert!( + sim.total_deposited >= target, + "total {} < target {}", + sim.total_deposited, target + ); + prop_assert!(sim.unlocked, "pool not unlocked after reaching target"); + } + + /// Invariant: unlock is sticky — further actions do not re-lock. + #[test] + fn prop_unlock_sticky_after_target( + target in 1i128..=100_000i128, + extra_deposits in prop::collection::vec(1i128..=10_000i128, 0..=10), + ) { + let mut sim = TargetSim::new(vec![0, 1], target, 1000); + let _ = sim.deposit(0, target); + + if sim.unlocked { + for extra in extra_deposits { + let result = sim.deposit(1, extra); + prop_assert!(result.is_err(), "unlocked pool accepted deposit"); + prop_assert!(sim.unlocked, "pool became locked after extra deposit"); + } + } + } + + /// Invariant: deposits after deadline are rejected, balances unchanged. + #[test] + fn prop_deposit_rejected_after_deadline( + target in 100i128..=1_000_000i128, + deadline in 10u32..=50u32, + ) { + let mut sim = TargetSim::new(vec![0, 1], target, deadline); + sim.advance_ledger(deadline + 1); + + let balance_before = sim.get_balance(0).unwrap(); + let total_before = sim.total_deposited; + + let result = sim.deposit(0, 1); + + prop_assert!(result.is_err(), "deposit should be rejected after deadline"); + prop_assert_eq!(sim.get_balance(0).unwrap(), balance_before); + prop_assert_eq!(sim.total_deposited, total_before); + } + + /// Invariant: after refund, sum of refunded amounts == total that was deposited. + #[test] + fn prop_refund_sum_equals_deposited( + target in 1_000i128..=100_000i128, + member_count in 2u32..=8u32, + deposits in prop::collection::vec(1i128..=500i128, 2..=16), + ) { + let members: StdVec = (0..member_count).collect(); + let deadline = 100u32; + let mut sim = TargetSim::new(members, target, deadline); + + for (i, &amount) in deposits.iter().enumerate() { + let member = (i as u32) % member_count; + let _ = sim.deposit(member, amount); + if sim.unlocked { break; } + } + + if !sim.unlocked { + let recorded_total = sim.total_deposited; + sim.advance_ledger(deadline + 1); + let result = sim.refund(); + prop_assert!(result.is_ok(), "refund should succeed"); + let refunds = result.unwrap(); + let refund_sum: i128 = refunds.iter().map(|&(_, b)| b).sum(); + prop_assert_eq!( + refund_sum, recorded_total, + "refund sum {} != deposited total {}", + refund_sum, recorded_total + ); + prop_assert_eq!(sim.total_deposited, 0); + } + } + } + + // ── Boundary / overflow tests ───────────────────────────────────────────── + + proptest! { + #![proptest_config(ProptestConfig::with_cases(20_000))] + + /// Boundary: deposit of 1 stroop into a target of 1 unlocks immediately. + #[test] + fn prop_single_stroop_target(_unit in 1i128..=1i128) { + let mut sim = TargetSim::new(vec![0, 1], 1, 100); + let _ = sim.deposit(0, 1); + prop_assert!(sim.unlocked); + prop_assert_eq!(sim.total_deposited, 1); + sim.check_reconciliation().map_err(|e| TestCaseError::fail(e))?; + } + + /// Boundary: zero-amount deposit is always rejected. + #[test] + fn prop_zero_amount_rejected(target in 1i128..=100_000i128) { + let mut sim = TargetSim::new(vec![0, 1], target, 100); + let result = sim.deposit(0, 0); + prop_assert!(result.is_err(), "zero amount deposit should be rejected"); + prop_assert_eq!(sim.total_deposited, 0); + } + + /// Boundary: negative-amount deposit is always rejected. + #[test] + fn prop_negative_amount_rejected( + target in 1i128..=100_000i128, + negative_amount in i128::MIN..=-1i128, + ) { + let mut sim = TargetSim::new(vec![0, 1], target, 100); + let result = sim.deposit(0, negative_amount); + prop_assert!(result.is_err(), "negative amount deposit should be rejected"); + prop_assert_eq!(sim.total_deposited, 0); + } + + /// Boundary: large amounts don't overflow total_deposited. + #[test] + fn prop_large_amounts_no_overflow( + target in prop::sample::select(vec![i128::MAX / 4, i128::MAX / 2]), + deposit in prop::sample::select(vec![1i128, i128::MAX / 8, i128::MAX / 4]), + ) { + let mut sim = TargetSim::new(vec![0, 1], target, 1000); + let result = sim.deposit(0, deposit); + match result { + Ok(new_total) => { + prop_assert!(new_total >= deposit); + prop_assert!(new_total >= 0); + } + Err(_) => {} + } + prop_assert!(sim.total_deposited >= 0); + } + } +} diff --git a/smartcontract/contracts/target/src/lib.rs b/smartcontract/contracts/target/src/lib.rs index d8694b0..d036fd0 100644 --- a/smartcontract/contracts/target/src/lib.rs +++ b/smartcontract/contracts/target/src/lib.rs @@ -689,3 +689,6 @@ impl TargetPool { #[cfg(test)] mod tests; + +#[cfg(test)] +mod fuzz_tests; diff --git a/smartcontract/contracts/yield-strategy/Cargo.toml b/smartcontract/contracts/yield-strategy/Cargo.toml index 25c8baa..1522453 100644 --- a/smartcontract/contracts/yield-strategy/Cargo.toml +++ b/smartcontract/contracts/yield-strategy/Cargo.toml @@ -11,3 +11,4 @@ soroban-sdk = { version = "21.0.0", features = ["alloc"] } [dev-dependencies] soroban-sdk = { version = "21.0.0", features = ["testutils", "alloc"] } +proptest = "1.4" diff --git a/smartcontract/contracts/yield-strategy/src/fuzz_tests.rs b/smartcontract/contracts/yield-strategy/src/fuzz_tests.rs new file mode 100644 index 0000000..c96e98c --- /dev/null +++ b/smartcontract/contracts/yield-strategy/src/fuzz_tests.rs @@ -0,0 +1,337 @@ +//! Property-based fuzz / invariant tests for JointSave Yield Strategy Contract. +//! +//! Uses a pure-arithmetic simulation of deployed/harvested tracking that mirrors +//! the on-chain logic in lib.rs. This avoids the Soroban testutils entirely, +//! which lets proptest drive thousands of random inputs without a live Env or +//! mock contracts. +//! +//! Covered invariants: +//! - deployed_amount tracks the sum of all deploy() calls; always ≥ 0 +//! - total_harvested tracks the sum of all harvest() calls; always ≥ 0 +//! - harvested ≤ position_value − deployed (no over-harvest) +//! - share price = position_value / deployed_amount ≥ 1.0; never negative +//! - After emergency_withdraw, deployed_amount resets to 0 +//! - Yield ratio arithmetic: position_value = deployed × ratio / 10_000; ≥ deployed +//! - No overflow in accumulation of deployed / harvested under saturating arithmetic +//! - Multi-call fuzz: deploy/harvest/emergency_withdraw sequences keep all invariants +//! - Boundary: deploy amount 1, i128::MAX/2; yield_ratio 10_000 (0%) to 50_000 (400%) +//! +//! Run with: +//! cargo test prop_ --manifest-path=contracts/yield-strategy/Cargo.toml \ +//! --release -- --nocapture + +#[cfg(test)] +mod prop_tests { + use proptest::prelude::*; + + extern crate std; + use std::vec::Vec as StdVec; + macro_rules! std_vec { + ($($x:expr),* $(,)?) => { + >::from([$($x),*]) + }; + } + + // ── Pure mirrors of on-chain arithmetic ─────────────────────────────────── + // + // The yield strategy uses two persistent values: + // DeployedAmount – running sum of deploy() calls + // TotalHarvested – running sum of harvest() calls + // + // harvest() computes: + // position_value = dex.get_position_value(self) (mocked here as deployed × ratio / 10_000) + // yield_amount = position_value.saturating_sub(deployed) + // assert(yield_amount > 0) + // + // deploy() moves tokens from contract → protocol and adds `amount` to DeployedAmount. + + /// Mock DEX: position_value = deployed * ratio / 10_000. + /// ratio == 10_000 means no yield; ratio == 11_000 means 10% yield. + fn mock_position_value(deployed: i128, ratio: i128) -> i128 { + // Use saturating multiplication to stay safe near i128::MAX. + deployed + .checked_mul(ratio) + .map(|v| v / 10_000) + .unwrap_or(i128::MAX / 2) + } + + fn expected_yield(deployed: i128, ratio: i128) -> i128 { + mock_position_value(deployed, ratio).saturating_sub(deployed) + } + + // ── Arithmetic unit invariants ──────────────────────────────────────────── + + proptest! { + #![proptest_config(ProptestConfig::with_cases(50_000))] + + /// Invariant: position_value ≥ deployed for any ratio ≥ 10_000. + #[test] + fn prop_position_value_ge_deployed_for_positive_yield( + deployed in 1i128..=i128::MAX / 50_001, + ratio in 10_000i128..=50_000i128, + ) { + let pos = mock_position_value(deployed, ratio); + prop_assert!(pos >= deployed, + "position_value {} < deployed {} at ratio {}", pos, deployed, ratio); + } + + /// Invariant: position_value == deployed at ratio 10_000 (no yield). + #[test] + fn prop_no_yield_at_ratio_10000( + deployed in 1i128..=i128::MAX / 10_001, + ) { + let pos = mock_position_value(deployed, 10_000); + prop_assert_eq!(pos, deployed, "expected no yield at ratio 10_000"); + } + + /// Invariant: yield is non-decreasing with ratio. + #[test] + fn prop_yield_monotone_in_ratio( + deployed in 1i128..=i128::MAX / 50_001, + low_ratio in 10_000i128..=49_999i128, + delta in 1i128..=50_000i128, + ) { + let high_ratio = (low_ratio + delta).min(50_000); + let low_yield = expected_yield(deployed, low_ratio); + let high_yield = expected_yield(deployed, high_ratio); + prop_assert!(high_yield >= low_yield, + "yield not monotone in ratio: {} → {} at ratio {}→{}", + low_yield, high_yield, low_ratio, high_ratio); + } + + /// Invariant: yield is never negative for ratio ≥ 10_000. + #[test] + fn prop_yield_never_negative( + deployed in 1i128..=i128::MAX / 50_001, + ratio in 10_000i128..=50_000i128, + ) { + let y = expected_yield(deployed, ratio); + prop_assert!(y >= 0, "yield {} < 0 at ratio {}", y, ratio); + } + + /// Boundary: ratio below 10_000 (theoretical loss scenario) gives negative yield. + #[test] + fn prop_loss_scenario_negative_yield( + deployed in 1i128..=1_000_000i128, + ratio in 1i128..=9_999i128, + ) { + let y = expected_yield(deployed, ratio); + prop_assert!(y <= 0, + "expected non-positive yield in loss scenario, got {}", y); + } + } + + // ── Simulation of deployed/harvested tracking ───────────────────────────── + + struct YieldSim { + deployed_amount: i128, + total_harvested: i128, + } + + impl YieldSim { + fn new() -> Self { + YieldSim { + deployed_amount: 0, + total_harvested: 0, + } + } + + /// Simulate deploy(): add amount to deployed_amount. + fn deploy(&mut self, amount: i128) -> bool { + if amount <= 0 { + return false; + } + self.deployed_amount = self.deployed_amount.saturating_add(amount); + true + } + + /// Simulate harvest(ratio): compute yield, add to total_harvested. + /// Returns the harvested amount, or None if no yield. + fn harvest(&mut self, ratio: i128) -> Option { + if self.deployed_amount <= 0 { + return None; + } + let position_value = mock_position_value(self.deployed_amount, ratio); + let yield_amount = position_value.saturating_sub(self.deployed_amount); + if yield_amount <= 0 { + return None; + } + // On-chain: total_harvested += yield_amount (no reset of deployed). + self.total_harvested = self.total_harvested.saturating_add(yield_amount); + Some(yield_amount) + } + + /// Simulate emergency_withdraw(): resets deployed_amount to 0. + fn emergency_withdraw(&mut self) -> Option { + if self.deployed_amount <= 0 { + return None; + } + let amount = self.deployed_amount; + self.deployed_amount = 0; + Some(amount) + } + + fn assert_invariants(&self) { + assert!(self.deployed_amount >= 0, + "deployed_amount {} is negative", self.deployed_amount); + assert!(self.total_harvested >= 0, + "total_harvested {} is negative", self.total_harvested); + } + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(3_000))] + + /// Multi-call fuzz: random deploy/harvest/emergency_withdraw sequences never + /// break invariants or produce negative values. + #[test] + fn prop_yield_multicall_invariants( + operations in prop::collection::vec( + (0u32..=2u32, 1i128..=1_000_000i128, 9_000i128..=30_000i128), + 1..=30 + ), + ) { + let mut sim = YieldSim::new(); + + for (op, amount, ratio) in &operations { + match op { + 0 => { sim.deploy(*amount); } + 1 => { sim.harvest(*ratio); } + 2 => { sim.emergency_withdraw(); } + _ => {} + } + sim.assert_invariants(); + } + } + + /// Invariant: after emergency_withdraw, deployed_amount == 0. + #[test] + fn prop_emergency_withdraw_resets_deployed( + amounts in prop::collection::vec(1i128..=1_000_000i128, 1..=10), + ) { + let mut sim = YieldSim::new(); + for a in amounts { + sim.deploy(a); + } + if sim.deployed_amount > 0 { + let withdrawn = sim.emergency_withdraw().unwrap(); + prop_assert!(withdrawn > 0, "emergency_withdraw returned 0"); + prop_assert_eq!(sim.deployed_amount, 0); + } + } + + /// Invariant: total_harvested is cumulative and never decreases. + #[test] + fn prop_total_harvested_never_decreases( + deploy_amount in 1i128..=1_000_000i128, + ratios in prop::collection::vec(10_001i128..=20_000i128, 1..=10), + ) { + let mut sim = YieldSim::new(); + sim.deploy(deploy_amount); + + let mut prev_harvested = 0i128; + for ratio in ratios { + if let Some(_) = sim.harvest(ratio) { + prop_assert!(sim.total_harvested >= prev_harvested, + "total_harvested decreased: {} → {}", prev_harvested, sim.total_harvested); + prev_harvested = sim.total_harvested; + } + } + } + + /// Invariant: harvested amount ≤ position_value (can never over-harvest). + #[test] + fn prop_harvest_never_exceeds_position_value( + deploy_amount in 1i128..=i128::MAX / 50_001, + ratio in 10_001i128..=50_000i128, + ) { + let mut sim = YieldSim::new(); + sim.deploy(deploy_amount); + + let pos_value = mock_position_value(deploy_amount, ratio); + let harvested = sim.harvest(ratio).unwrap_or(0); + + prop_assert!(harvested <= pos_value, + "harvested {} > position_value {} at ratio {}", harvested, pos_value, ratio); + prop_assert!(harvested >= 0, "harvested amount is negative: {}", harvested); + } + + /// Invariant: deploying zero or negative amounts is always rejected. + #[test] + fn prop_deploy_zero_or_negative_rejected( + bad_amount in i128::MIN..=0i128, + ) { + let mut sim = YieldSim::new(); + let result = sim.deploy(bad_amount); + prop_assert!(!result, "deploy of {} should be rejected", bad_amount); + prop_assert_eq!(sim.deployed_amount, 0); + } + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(20_000))] + + /// Boundary: no overflow at extreme deploy amounts and high yield ratios. + #[test] + fn prop_no_overflow_at_boundaries( + deploy_amount in prop::sample::select(std_vec![ + 1i128, + 1_000i128, + 1_000_000_0000000i128, + i128::MAX / 50_001, + i128::MAX / 2, + ]), + ratio in prop::sample::select(std_vec![ + 10_000i128, + 10_001i128, + 11_000i128, + 20_000i128, + 50_000i128, + ]), + ) { + let mut sim = YieldSim::new(); + sim.deploy(deploy_amount); + + prop_assert!(sim.deployed_amount >= 0); + + let harvested = sim.harvest(ratio).unwrap_or(0); + prop_assert!(harvested >= 0, "harvested negative at boundary"); + prop_assert!(sim.total_harvested >= 0); + } + + /// Invariant: share price ≥ 1.0 for any ratio ≥ 10_000 and deployed > 0. + /// Share price = position_value / deployed_amount. + #[test] + fn prop_share_price_never_below_one( + deployed in 1i128..=i128::MAX / 50_001, + ratio in 10_000i128..=50_000i128, + ) { + let pos = mock_position_value(deployed, ratio); + // share_price * deployed == pos; price ≥ 1 iff pos ≥ deployed. + prop_assert!(pos >= deployed, + "share price below 1: pos={}, deployed={}, ratio={}", + pos, deployed, ratio); + } + + /// Invariant: multiple sequential harvests are all individually non-negative. + #[test] + fn prop_sequential_harvest_all_non_negative( + deploy_amount in 1i128..=1_000_000i128, + ratio in 10_001i128..=15_000i128, + n in 1u32..=10u32, + ) { + let mut sim = YieldSim::new(); + sim.deploy(deploy_amount); + + // Harvest n times with the same ratio (after first harvest deployed stays + // the same but the mock position value hasn't changed — subsequent harvests + // return None because position_value == deployed after the mock DEX isn't + // updated; that's fine, we just verify no panic and no negatives). + for _ in 0..n { + let h = sim.harvest(ratio).unwrap_or(0); + prop_assert!(h >= 0); + sim.assert_invariants(); + } + } + } +} diff --git a/smartcontract/contracts/yield-strategy/src/lib.rs b/smartcontract/contracts/yield-strategy/src/lib.rs index 5e18412..7bb8a1a 100644 --- a/smartcontract/contracts/yield-strategy/src/lib.rs +++ b/smartcontract/contracts/yield-strategy/src/lib.rs @@ -6,6 +6,9 @@ mod soroswap; mod stellar_amm; mod types; +#[cfg(test)] +mod fuzz_tests; + use soroban_sdk::{contract, contractimpl, token, Address, Env}; use types::{DataKey, StrategyConfig, StrategyType};