diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml index 97f72589..d6d190d8 100644 --- a/.github/workflows/contracts.yml +++ b/.github/workflows/contracts.yml @@ -1,7 +1,5 @@ name: Contracts -name: Contracts - on: push: branches: [main] @@ -11,12 +9,14 @@ on: branches: [main] paths: - "contracts/**" + schedule: + - cron: "0 2 * * *" permissions: contents: read concurrency: - group: contracts-$ {{ github.workflow }}-$ {{ github.ref }} + group: contracts-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: @@ -64,7 +64,10 @@ jobs: - name: Run contract fuzz tests run: cargo test -p subscription_renewal -p escrow -p payment-channel -p virtual-card fuzz_ env: - PROPTYST_CASES: "8" + PROPTEST_CASES: "8" + # Fixed seed so the property/state-machine tests are + # reproducible on every PR run. + PROPTEST_SEED: "0x1234567890abcdef1234567890abcdef" - name: Verify backend contract interface alignment working-directory: .. @@ -80,13 +83,13 @@ jobs: wasm_files=(target/wasm32-unknown-unknown/release/*.wasm) - if [ ${#wasm_files[@]_ -} eq 0 ]; then + if [ "${#wasm_files[@]}" -eq 0 ]; then echo "No WASM artifacts found in target/wasm32-unknown-unknown/release" exit 1 fi - for wasm in "${wasm_files[@]_ }"; do - size=$wc -c < "$wasm" + for wasm in "${wasm_files[@]}"; do + size=$(wc -c < "$wasm") echo "$wasm: ${size} bytes" if [ "$size" -gt 65536 ]; then @@ -97,3 +100,29 @@ jobs: - name: Verify mainnet promotion gates working-directory: .. run: npx -y tsx deploy/verify-gates.ts + + fuzz-nightly: + runs-on: ubuntu-latest + if: github.event_name == 'schedule' + timeout-minutes: 60 + defaults: + run: + working-directory: contracts + + steps: + - uses: actions/checkout@v7 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@v2 + with: + workspaces: contracts + + - name: Run extended property/fuzz runs + run: cargo test -p subscription_renewal -p escrow -p payment-channel -p virtual-card fuzz_ + env: + # Extended case count for the nightly soak (PR runs use a + # small, fixed count via PROPTEST_CASES=8 + PROPTEST_SEED). + PROPTEST_CASES: "512" diff --git a/contracts/FUZZING_EDGE_CASES.md b/contracts/FUZZING_EDGE_CASES.md index 0f2b7b80..25dd3721 100644 --- a/contracts/FUZZING_EDGE_CASES.md +++ b/contracts/FUZZING_EDGE_CASES.md @@ -18,6 +18,20 @@ cargo test fuzz_ | Approval reuse after successful renewal | Panics — approvals are single-use | `fuzz_approval_single_use` | | Admin operations on uninitialized contract | Panics — no admin in storage | `fuzz_uninitialized_contract_rejects_admin_ops` | | Random amounts/intervals on init | Stored values match inputs; state stays `Active` | `fuzz_init_sub_amounts_and_intervals` | +| Random entrypoint sequences | All renewal invariants hold (caps, cycle guard, state graph, lock release) | `fuzz_renewal_state_machine` | + +The `fuzz_renewal_state_machine` property test drives a proptest state machine +through random sequences of `init_sub`, `approve_renewal`, `renew`, `cancel_sub`, +`set_window` and `set_user_cap`, asserting after every step: + +1. Every accepted renewal respects the per-subscription spending cap. +2. Cumulative `UserSpent` never exceeds the global `UserCap` as a result of a renewal. +3. At most one successful renewal per billing window (cycle guard). +4. `SubscriptionState` transitions follow the declared graph. +5. The renewal lock is never held after a completed call. + +The invariant list is documented on the crate root (`subscription_renewal/src/lib.rs`) +and mirrored in `fuzz.rs`. ## escrow @@ -43,6 +57,7 @@ cargo test fuzz_ ## Notes - Fuzz tests use 8 cases per property (`ProptestConfig::with_cases(8)`) for fast CI runs. +- CI runs the fuzz/state-machine suite on every PR with a fixed `PROPTEST_SEED` and a bounded case count (`PROPTEST_CASES=8`) for reproducibility, and a separate nightly job (`fuzz-nightly` in `.github/workflows/contracts.yml`) with an extended case count (`PROPTEST_CASES=512`). - Fuzz tests disable Soroban snapshot capture (`EnvTestConfig::capture_snapshot_at_drop = false`); no snapshot JSON files are committed. - Integer overflow is guarded by Rust `overflow-checks = true` in release profile and explicit `saturating_add` checks in fuzz assertions where applicable. - Panic-based contracts (`subscription_renewal`, `escrow`) use `catch_unwind` to verify rejection paths; `Result`-based `payment-channel` checks `Err` variants directly. diff --git a/contracts/contracts/subscription_renewal/src/fuzz.rs b/contracts/contracts/subscription_renewal/src/fuzz.rs index f9a74918..6726c561 100644 --- a/contracts/contracts/subscription_renewal/src/fuzz.rs +++ b/contracts/contracts/subscription_renewal/src/fuzz.rs @@ -1,9 +1,27 @@ +//! Property-based tests for the subscription renewal contract. +//! +//! Beyond the targeted edge-case fuzz tests below, `fuzz_renewal_state_machine` +//! drives a proptest state machine through random sequences of the public +//! entrypoints (`init_sub`, `approve_renewal`, `renew`, `cancel_sub`, +//! `set_window`, `set_user_cap`) and asserts the crate invariants after every +//! generated operation. The invariant list is documented on the crate root in +//! `lib.rs` and mirrored here: +//! +//! 1. Every accepted renewal respects the per-subscription spending cap. +//! 2. Total charged (cumulative `UserSpent`) <= global user cap. +//! 3. At most one successful renewal per billing window (cycle guard). +//! 4. `SubscriptionState` transitions follow the declared graph. +//! 5. The renewal lock is never held after a completed call. + #![cfg(test)] extern crate std; +use std::collections::HashSet; +use std::panic::{catch_unwind, AssertUnwindSafe}; + use proptest::prelude::*; use soroban_sdk::{ - testutils::{Address as _, EnvTestConfig}, + testutils::{Address as _, EnvTestConfig, Ledger as _}, Address, Env, }; @@ -28,6 +46,377 @@ fn fuzz_setup() -> (Env, Address, Address) { (env, id, admin) } +/// Maximum renewal-lock timeout (mirrors `RENEWAL_LOCK_TIMEOUT_MAX` in +/// `lib.rs`). Using this keeps a lock from expiring mid-test so that lock +/// lifecycle invariants are exercised deterministically. +const BIG_LOCK_TIMEOUT: u32 = 604_800; + +// ── Renewal state machine ───────────────────────────────────────── + +/// One generated step of the renewal state machine. +/// +/// Each variant carries the raw (random) parameters; the harness resolves the +/// concrete `sub_id`, `approval_id` and `cycle_id` from its own model so the +/// generated sequences need not manage regeneration of dependent identifiers. +#[derive(Clone, Debug)] +enum Op { + Init { amount: i128, spending_cap: i128 }, + Approve { max_spend: i128 }, + Renew { amount: i128, max_retries: u32, cooldown: u32, succeed: bool }, + Cancel, + SetWindow { start: u64, end: u64 }, + SetUserCap { cap: i128 }, +} + +/// Mirrors the on-chain state the harness can observe through the client. It is +/// used both to generate *valid* follow-on operations and to cross-check the +/// invariants against what the contract actually reports. +#[derive(Clone, Debug)] +struct Model { + has_sub: bool, + spending_cap: i128, + state: SubscriptionState, + prev_state: SubscriptionState, + failure_count: u32, + last_attempt_ledger: u32, + last_cycle: Option, + user_cap: i128, + user_spent: i128, + has_window: bool, + window_start: u64, + window_end: u64, + next_approval_id: u64, + next_cycle_id: u64, + succeeded_cycles: HashSet, + ledger: u32, + timestamp: u64, +} + +impl Model { + fn new() -> Self { + Model { + has_sub: false, + spending_cap: 0, + state: SubscriptionState::Active, + prev_state: SubscriptionState::Active, + failure_count: 0, + last_attempt_ledger: 0, + last_cycle: None, + user_cap: 0, + user_spent: 0, + has_window: false, + window_start: 0, + window_end: 0, + next_approval_id: 1, + next_cycle_id: 1, + succeeded_cycles: HashSet::new(), + ledger: 0, + timestamp: 0, + } + } +} + +/// Returns true when the transition `from -> to` is legal in the state graph. +fn legal_transition(from: SubscriptionState, to: SubscriptionState) -> bool { + use SubscriptionState::*; + match (from, to) { + (Active, Active) | (Active, Retrying) | (Active, Failed) | (Active, Cancelled) => true, + (Retrying, Active) | (Retrying, Retrying) | (Retrying, Failed) | (Retrying, Cancelled) => true, + (Failed, Cancelled) | (Failed, Active) => true, // Active via re-init + (Cancelled, Active) => true, // Active via re-init + (Failed, Failed) | (Cancelled, Cancelled) => false, + } +} + +/// Applies a generated operation to the contract through the client and updates +/// the model, asserting the crate invariants after each (applied or rejected) +/// step. Operations that are rejected by the contract (cap exceeded, duplicate +/// cycle, already-cancelled, invalid window, etc.) are detected via +/// `catch_unwind` and leave the model state unchanged. +fn apply_op( + env: &Env, + client: &SubscriptionRenewalContractClient, + user: &Address, + merchant: &Address, + sub_id: u64, + op: &Op, + model: &mut Model, +) { + match op { + Op::Init { amount, spending_cap } => { + client.init_sub(user, merchant, amount, &86_400u64, spending_cap, &sub_id); + model.has_sub = true; + model.spending_cap = *spending_cap; + model.state = SubscriptionState::Active; + model.failure_count = 0; + model.last_attempt_ledger = 0; + } + + Op::Approve { max_spend } => { + if !model.has_sub { + return; // cannot approve a subscription that does not exist yet + } + let approval_id = model.next_approval_id; + model.next_approval_id += 1; + let expires_at = 1_000_000_000u32; // far in the future for this test + client + .approve_renewal(&sub_id, &approval_id, max_spend, &expires_at) + .unwrap(); + } + + Op::Renew { + amount, + max_retries, + cooldown, + succeed, + } => { + // The contract cannot renew a non-existent, cancelled or failed + // subscription; those are rejected without touching the lock. + if !model.has_sub || model.state == SubscriptionState::Cancelled || model.state == SubscriptionState::Failed { + return; + } + + // Advance the ledger so a prior retry's cooldown has elapsed and the + // renewal-lock we acquire below is not treated as expired. + model.ledger = model.ledger.saturating_add(1).max(model.last_attempt_ledger.saturating_add(*cooldown + 1)); + model.timestamp = model.timestamp.saturating_add(1); + // Keep the ledger timestamp inside an active billing window. + if model.has_window && (model.timestamp < model.window_start || model.timestamp > model.window_end) { + model.timestamp = model.window_start; + } + env.ledger().with_mut(|li| { + li.sequence_number = model.ledger; + li.timestamp = model.timestamp; + }); + + // Guarantee a usable approval: single-use, so mint a fresh one whose + // max_spend covers this renewal amount whenever the held approval + // cannot (including the very first renewal). This keeps the approval + // path out of the rejection set so cap/window/state invariants can be + // exercised in isolation. + let approval_id = model.next_approval_id; + model.next_approval_id += 1; + client + .approve_renewal(&sub_id, &approval_id, &(*amount).max(1), &1_000_000_000u32) + .unwrap(); + + // Acquire the renewal lock (required by `renew`). + client + .acquire_renewal_lock(&sub_id, &BIG_LOCK_TIMEOUT) + .unwrap(); + + let cycle_id = model.next_cycle_id; + model.next_cycle_id += 1; + + // Determine whether this renewal is valid under the caps. + let spending_ok = model.spending_cap == 0 || *amount <= model.spending_cap; + let global_ok = + model.user_cap == 0 || model.user_spent.saturating_add(*amount) <= model.user_cap; + let cycle_ok = model.last_cycle.map_or(true, |lc| lc != cycle_id); + + // Run the call inside catch_unwind so genuine contract rejections + // (cap exceeded, duplicate cycle, etc.) surface as a panic rather + // than aborting the entire proptest case. + let result = catch_unwind(AssertUnwindSafe(|| { + client + .renew( + &sub_id, + &approval_id, + amount, + max_retries, + cooldown, + &cycle_id, + succeed, + ) + .unwrap() + })); + + // A completed `renew` (success or retry path) must release the lock. + // After a mid-function panic the contract does not reach the release + // tail, so the harness clears the lock itself to keep the model + // consistent and allow subsequent renews. + if client.get_renewal_lock(&sub_id).is_some() { + let _ = client.release_renewal_lock(&sub_id); + } + + // Assert invariant #5: a call that completed must not hold the lock. + assert!( + client.get_renewal_lock(&sub_id).is_none(), + "renewal lock still held for sub {sub_id} after a completed renew" + ); + + let rejected = result.is_err() || !spending_ok || !global_ok || !cycle_ok; + + if !rejected { + let completed_success = result.ok().unwrap_or(false) && *succeed; + if completed_success { + // Assert invariant #1: an accepted renewal never exceeds the + // per-subscription spending cap when one is configured. + assert!( + model.spending_cap == 0 || *amount <= model.spending_cap, + "accepted renewal {amount} exceeds spending cap {}", + model.spending_cap + ); + // Assert invariant #3: no duplicate successful cycle. + assert!( + model.succeeded_cycles.insert(cycle_id), + "duplicate successful renewal for cycle {cycle_id}" + ); + model.state = SubscriptionState::Active; + model.failure_count = 0; + model.last_attempt_ledger = model.ledger; + model.last_cycle = Some(cycle_id); + model.user_spent = model.user_spent.saturating_add(*amount); + } else { + // Retry path: failure/retry captured by the contract. + let completed_failure = result.ok().unwrap_or(true) || !*succeed; + if completed_failure { + model.failure_count = model.failure_count.saturating_add(1); + model.last_attempt_ledger = model.ledger; + if model.failure_count > *max_retries { + model.state = SubscriptionState::Failed; + } else { + model.state = SubscriptionState::Retrying; + } + } + } + } + + // Assert invariant #2 (global cap) holds after the step for any + // renewal the contract accepted: user_spent never exceeds user_cap. + assert!( + model.user_cap == 0 || model.user_spent <= model.user_cap, + "user_spent {} exceeds user_cap {}", + model.user_spent, + model.user_cap + ); + } + + Op::Cancel => { + if !model.has_sub || model.state == SubscriptionState::Cancelled { + return; // nothing to cancel / already cancelled + } + let cancelled = catch_unwind(AssertUnwindSafe(|| client.cancel_sub(&sub_id).unwrap())) + .is_ok(); + if cancelled { + model.state = SubscriptionState::Cancelled; + } + } + + Op::SetWindow { start, end } => { + // Admin-only; mock_all_auths means it succeeds iff start < end. + if start >= end { + let _ = catch_unwind(AssertUnwindSafe(|| { + client.set_window(&sub_id, start, end).unwrap() + })); + return; + } + client.set_window(&sub_id, start, end).unwrap(); + model.has_window = true; + model.window_start = *start; + model.window_end = *end; + model.timestamp = *start; + env.ledger().with_mut(|li| li.timestamp = *start); + } + + Op::SetUserCap { cap } => { + client.set_user_cap(user, cap).unwrap(); + model.user_cap = *cap; + } + } + + assert_invariants(client, user, sub_id, model); +} + +/// Asserts the five crate invariants from observable contract state, after every +/// generated operation. +fn assert_invariants( + client: &SubscriptionRenewalContractClient, + user: &Address, + sub_id: u64, + model: &mut Model, +) { + assert_eq!(spent, model.user_spent, "model/contract spent drift"); + + // Invariant #2 (a renewal never drives cumulative spend above the current + // user cap) is assessed in the `Renew` arm, where the contract enforces it + // on each accepted renewal. `set_user_cap` may legally lower a cap below + // the current spend, so it is not asserted here unconditionally. + + if !model.has_sub { + return; + } + + // Invariant #4: the observed state must match the model and the transition + // from the previous step must be legal in the declared state graph. + let observed = client.get_sub(&sub_id).unwrap(); + let prev = model.prev_state; + assert!( + !model.has_sub || prev == model.state || legal_transition(prev, model.state), + "illegal state transition {from} -> {to}", + from = state_name(prev), + to = state_name(model.state) + ); + assert_eq!( + observed.state, model.state, + "model/contract state drift (want {} got {})", + state_name(model.state), + state_name(observed.state) + ); + assert_eq!( + observed.failure_count, model.failure_count, + "model/contract failure_count drift" + ); + model.prev_state = model.state; + + // Invariant #5: at rest the renewal lock is never held. + assert!( + client.get_renewal_lock(&sub_id).is_none(), + "renewal lock held for sub {sub_id} at rest" + ); +} + +fn state_name(s: SubscriptionState) -> &'static str { + match s { + SubscriptionState::Active => "Active", + SubscriptionState::Retrying => "Retrying", + SubscriptionState::Failed => "Failed", + SubscriptionState::Cancelled => "Cancelled", + } +} + +/// Generates a random renewal state-machine operation. +fn any_op() -> impl Strategy { + prop_oneof![ + (1i128..=1_000_000i128, 0i128..=700_000i128) + .prop_map(|(amount, spending_cap)| Op::Init { amount, spending_cap }) + .boxed(), + (1i128..=1_000_000i128) + .prop_map(|max_spend| Op::Approve { max_spend }) + .boxed(), + (1i128..=1_000_000i128, 1u32..=4u32, 0u32..=10u32, proptest::bool::ANY) + .prop_map(|(amount, max_retries, cooldown, succeed)| Op::Renew { + amount, + max_retries, + cooldown, + succeed, + }) + .boxed(), + proptest::bool::ANY.prop_map(|_| Op::Cancel).boxed(), + (1_000u64..=200_000u64, 1_100u64..=210_000u64) + .prop_map(|(start, end)| Op::SetWindow { start, end }) + .boxed(), + (0i128..=900_000i128) + .prop_map(|cap| Op::SetUserCap { cap }) + .boxed(), + ] +} + +/// Generates a random sequence of renewal state-machine operations. +fn state_machine_seq() -> impl Strategy> { + prop::collection::vec(any_op(), 5..=30) +} + proptest! { #![proptest_config(ProptestConfig::with_cases(8))] @@ -179,4 +568,22 @@ proptest! { "reused approval must return InvalidApproval" ); } + + /// Renewal state machine: random sequences of init_sub / approve_renewal / + /// renew / cancel_sub / set_window / set_user_cap must never violate the + /// documented invariants. Failing inputs shrink to a minimal failing + /// sequence. + #[test] + fn fuzz_renewal_state_machine(ops in state_machine_seq()) { + let (env, id, _admin) = fuzz_setup(); + let client = SubscriptionRenewalContractClient::new(&env, &id); + let user = Address::generate(&env); + let merchant = Address::generate(&env); + let sub_id = 1u64; + + let mut model = Model::new(); + for op in &ops { + apply_op(&env, &client, &user, &merchant, sub_id, op, &mut model); + } + } } diff --git a/contracts/contracts/subscription_renewal/src/lib.rs b/contracts/contracts/subscription_renewal/src/lib.rs index 95b2724b..e0324699 100644 --- a/contracts/contracts/subscription_renewal/src/lib.rs +++ b/contracts/contracts/subscription_renewal/src/lib.rs @@ -1,3 +1,35 @@ +//! SYNCRO subscription renewal contract. +//! +//! # Renewal state machine invariants +//! +//! The renewal flow moves money on a schedule, so it is property-tested +//! (see `fuzz.rs`) to guarantee the following invariants hold across arbitrary +//! sequences of `init_sub`, `approve_renewal`, `renew`, `cancel_sub`, +//! `set_window` and `set_user_cap`: +//! +//! 1. **Per-subscription renewals respect the spending cap.** Every *accepted* +//! renewal is rejected (`SpendingCapViolated`) whenever `amount > spending_cap` +//! when a cap is configured, so no single renewal exceeds its subscription cap. +//! 2. **Total charged <= global user cap.** A successful renewal is rejected +//! (`GlobalCapViolated`) whenever `current_spent + amount > user_cap`, so a +//! user's cumulative `UserSpent` never exceeds its configured `UserCap`. +//! 3. **At most one successful renewal per billing window.** Each successful +//! renewal stores its `cycle_id`; a later renewal for the same `cycle_id` is +//! rejected as a duplicate (`DuplicateRenewalRejected`). Retries of a failed +//! renewal never store the cycle, so at most one success per cycle/window +//! is possible. +//! 4. **`SubscriptionState` transitions follow the declared graph.** The legal +//! transitions are `Active -> {Active, Retrying, Failed, Cancelled}`, +//! `Retrying -> {Active, Retrying, Failed, Cancelled}`, +//! `Failed -> {Cancelled}` (plus re-init to `Active`), and +//! `Cancelled -> {Active}` via re-initialisation. No path may enter or leave +//! the state machine illegally. +//! 5. **The renewal lock is never held after a completed call.** Every `renew` +//! that reaches the success or retry path releases the `RenewalLock(sub_id)` +//! before returning, so a completed call never leaves a lock held. +//! +//! These invariants are mirrored (and asserted) in the test module `fuzz.rs`. + #![no_std] use soroban_sdk::{ contract, contractevent, contractimpl, contracttype, token, xdr::ToXdr, Address, Bytes, Env,