From c81ea858ad796a53135e6fb2ee0d0d09d0735b1f Mon Sep 17 00:00:00 2001 From: Tu Pham Date: Mon, 25 May 2026 19:28:44 +0700 Subject: [PATCH] Add subscription contract fuzzing pipeline --- .github/workflows/fuzz-test.yml | 136 ++++++---- contracts/Cargo.toml | 1 + contracts/fuzz/Cargo.toml | 37 +++ .../create_subscribe_charge.seed | 1 + .../pricing-boundaries.seed | 1 + .../subscription_rate_limits/rate-limit.seed | 1 + contracts/fuzz/fuzz_targets/common.rs | 131 ++++++++++ .../fuzz_targets/subscription_lifecycle.rs | 122 +++++++++ .../fuzz/fuzz_targets/subscription_pricing.rs | 47 ++++ .../fuzz_targets/subscription_rate_limits.rs | 38 +++ contracts/fuzz/scripts/triage-crash.sh | 32 +++ contracts/subscription/Cargo.toml | 2 + contracts/subscription/FUZZING.md | 160 ++++-------- contracts/subscription/src/lib.rs | 3 + contracts/subscription/tests/fuzz_smoke.rs | 78 ++++++ contracts/tests/fuzz.rs | 232 ++---------------- 16 files changed, 641 insertions(+), 381 deletions(-) create mode 100644 contracts/fuzz/Cargo.toml create mode 100644 contracts/fuzz/corpus/subscription_lifecycle/create_subscribe_charge.seed create mode 100644 contracts/fuzz/corpus/subscription_pricing/pricing-boundaries.seed create mode 100644 contracts/fuzz/corpus/subscription_rate_limits/rate-limit.seed create mode 100644 contracts/fuzz/fuzz_targets/common.rs create mode 100644 contracts/fuzz/fuzz_targets/subscription_lifecycle.rs create mode 100644 contracts/fuzz/fuzz_targets/subscription_pricing.rs create mode 100644 contracts/fuzz/fuzz_targets/subscription_rate_limits.rs create mode 100755 contracts/fuzz/scripts/triage-crash.sh create mode 100644 contracts/subscription/tests/fuzz_smoke.rs diff --git a/.github/workflows/fuzz-test.yml b/.github/workflows/fuzz-test.yml index e5c31f2..a3cf6ca 100644 --- a/.github/workflows/fuzz-test.yml +++ b/.github/workflows/fuzz-test.yml @@ -1,70 +1,112 @@ -name: Subscription Contract Fuzzing Tests +name: Subscription Contract Fuzzing on: - push: + pull_request: branches: [main, develop] paths: - - 'contracts/subscription/**' - - '.github/workflows/fuzz-test.yml' - pull_request: + - "contracts/subscription/**" + - "contracts/proxy/**" + - "contracts/storage/**" + - "contracts/types/**" + - "contracts/fuzz/**" + - ".github/workflows/fuzz-test.yml" + push: branches: [main, develop] paths: - - 'contracts/subscription/**' + - "contracts/subscription/**" + - "contracts/proxy/**" + - "contracts/storage/**" + - "contracts/types/**" + - "contracts/fuzz/**" + - ".github/workflows/fuzz-test.yml" + schedule: + - cron: "17 3 * * 1" + workflow_dispatch: + inputs: + fuzz_seconds: + description: "Seconds per fuzz target" + required: false + default: "1800" + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + FUZZ_SECONDS: ${{ github.event.inputs.fuzz_seconds || '1800' }} jobs: fuzz: + name: coverage-guided fuzzing runs-on: ubuntu-latest - name: Run Fuzzing Tests + timeout-minutes: 45 + + strategy: + fail-fast: false + matrix: + target: + - subscription_lifecycle + - subscription_pricing + - subscription_rate_limits steps: - - name: Checkout code - uses: actions/checkout@v3 + - name: Checkout + uses: actions/checkout@v4 - name: Install Rust - uses: actions-rs/toolchain@v1 - with: - toolchain: stable - override: true - profile: minimal + uses: dtolnay/rust-toolchain@nightly - - name: Cache cargo registry - uses: actions/cache@v3 + - name: Cache cargo + uses: actions/cache@v4 with: - path: ~/.cargo/registry - key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + path: | + ~/.cargo/bin + ~/.cargo/registry + ~/.cargo/git + contracts/target + contracts/fuzz/target + key: ${{ runner.os }}-cargo-fuzz-${{ hashFiles('contracts/**/Cargo.lock', 'contracts/**/Cargo.toml') }} restore-keys: | - ${{ runner.os }}-cargo-registry- + ${{ runner.os }}-cargo-fuzz- - - name: Cache cargo index - uses: actions/cache@v3 - with: - path: ~/.cargo/git - key: ${{ runner.os }}-cargo-git-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-cargo-git- + - name: Install cargo-fuzz + run: | + if ! command -v cargo-fuzz >/dev/null 2>&1; then + cargo +nightly install cargo-fuzz --locked + fi - - name: Cache cargo build - uses: actions/cache@v3 - with: - path: target - key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-cargo-build-target- + - name: Build subscription fuzz smoke test + run: cargo +nightly test --manifest-path contracts/subscription/Cargo.toml --test fuzz_smoke --no-run + + - name: Run subscription fuzz smoke test + run: cargo +nightly test --manifest-path contracts/subscription/Cargo.toml --test fuzz_smoke -- --nocapture + + - name: List fuzz targets + working-directory: contracts + run: cargo +nightly fuzz list - - name: Run contract fuzz smoke suite + - name: Generate seed corpus + shell: bash run: | - cd contracts - cargo test --lib - for target in fuzz pricing_fuzz rate_limit_fuzz; do - if cargo test --test "$target" --no-run >/dev/null 2>&1; then - cargo test --test "$target" - else - echo "::warning::Cargo test target '$target' is not registered; running workspace tests instead." - fi - done - cargo test --verbose + mkdir -p "contracts/fuzz/corpus/${{ matrix.target }}" + printf "seed:%s:%s\n" "${{ matrix.target }}" "${{ github.sha }}" \ + > "contracts/fuzz/corpus/${{ matrix.target }}/ci-generated.seed" - - name: Print test results - if: always() + - name: Run cargo-fuzz target + working-directory: contracts run: | - echo "Fuzzing tests completed!" + mkdir -p fuzz/artifacts/${{ matrix.target }} + cargo +nightly fuzz run "${{ matrix.target }}" \ + "fuzz/corpus/${{ matrix.target }}" \ + -- \ + -max_total_time="${FUZZ_SECONDS}" \ + -artifact_prefix="fuzz/artifacts/${{ matrix.target }}/" \ + -print_final_stats=1 + + - name: Upload corpus and crashes + if: always() + uses: actions/upload-artifact@v4 + with: + name: fuzz-${{ matrix.target }}-${{ github.run_id }} + path: | + contracts/fuzz/corpus/${{ matrix.target }} + contracts/fuzz/artifacts/${{ matrix.target }} + if-no-files-found: ignore diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index 94c17e6..35ba1a2 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -7,6 +7,7 @@ members = [ "fraud", "types", ] +exclude = ["fuzz"] [profile.release] opt-level = "z" diff --git a/contracts/fuzz/Cargo.toml b/contracts/fuzz/Cargo.toml new file mode 100644 index 0000000..9f8903c --- /dev/null +++ b/contracts/fuzz/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "subtrackr-contract-fuzz" +version = "0.0.0" +edition = "2021" +publish = false + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +soroban-sdk = { version = "21.0.0", features = ["testutils"] } +subtrackr-proxy = { path = "../proxy" } +subtrackr-storage = { path = "../storage" } +subtrackr-subscription = { path = "../subscription" } +subtrackr-types = { path = "../types" } + +[[bin]] +name = "subscription_lifecycle" +path = "fuzz_targets/subscription_lifecycle.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "subscription_pricing" +path = "fuzz_targets/subscription_pricing.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "subscription_rate_limits" +path = "fuzz_targets/subscription_rate_limits.rs" +test = false +doc = false +bench = false diff --git a/contracts/fuzz/corpus/subscription_lifecycle/create_subscribe_charge.seed b/contracts/fuzz/corpus/subscription_lifecycle/create_subscribe_charge.seed new file mode 100644 index 0000000..b144b80 --- /dev/null +++ b/contracts/fuzz/corpus/subscription_lifecycle/create_subscribe_charge.seed @@ -0,0 +1 @@ +create-plan subscribe charge refund pause resume cancel diff --git a/contracts/fuzz/corpus/subscription_pricing/pricing-boundaries.seed b/contracts/fuzz/corpus/subscription_pricing/pricing-boundaries.seed new file mode 100644 index 0000000..f85bab9 --- /dev/null +++ b/contracts/fuzz/corpus/subscription_pricing/pricing-boundaries.seed @@ -0,0 +1 @@ +price:1 price:100 price:1000000 interval:monthly yearly diff --git a/contracts/fuzz/corpus/subscription_rate_limits/rate-limit.seed b/contracts/fuzz/corpus/subscription_rate_limits/rate-limit.seed new file mode 100644 index 0000000..97de5dd --- /dev/null +++ b/contracts/fuzz/corpus/subscription_rate_limits/rate-limit.seed @@ -0,0 +1 @@ +create_plan rate_limit immediate_retry delayed_retry diff --git a/contracts/fuzz/fuzz_targets/common.rs b/contracts/fuzz/fuzz_targets/common.rs new file mode 100644 index 0000000..fa4dfaa --- /dev/null +++ b/contracts/fuzz/fuzz_targets/common.rs @@ -0,0 +1,131 @@ +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token, Address, Env, String, +}; +use subtrackr_proxy::{UpgradeableProxy, UpgradeableProxyClient}; +use subtrackr_storage::SubTrackrStorage; +use subtrackr_subscription::SubTrackrSubscription; +use subtrackr_types::{Interval, SubscriptionStatus}; + +const START_TS: u64 = 1_700_000_000; +const USER_COUNT: usize = 8; + +pub struct Harness { + pub env: Env, + pub proxy_id: Address, + pub token_id: Address, + pub users: [Address; USER_COUNT], +} + +impl Harness { + pub fn new() -> Self { + let env = Env::default(); + env.mock_all_auths_allowing_non_root_auth(); + env.ledger().set_timestamp(START_TS); + + let admin = Address::generate(&env); + let token_admin = Address::generate(&env); + let users = [ + Address::generate(&env), + Address::generate(&env), + Address::generate(&env), + Address::generate(&env), + Address::generate(&env), + Address::generate(&env), + Address::generate(&env), + Address::generate(&env), + ]; + + let storage_id = env.register_contract(None, SubTrackrStorage); + let implementation_id = env.register_contract(None, SubTrackrSubscription); + let proxy_id = env.register_contract(None, UpgradeableProxy); + let proxy = UpgradeableProxyClient::new(&env, &proxy_id); + proxy.initialize(&admin, &storage_id, &implementation_id, &0u64, &0u64); + + let token_contract = env.register_stellar_asset_contract_v2(token_admin); + let token_id = token_contract.address(); + let token_admin_client = token::StellarAssetClient::new(&env, &token_id); + for user in users.iter() { + token_admin_client.mint(user, &10_000_000_000); + } + + Self { + env, + proxy_id, + token_id, + users, + } + } + + pub fn proxy(&self) -> UpgradeableProxyClient<'_> { + UpgradeableProxyClient::new(&self.env, &self.proxy_id) + } + + pub fn user(&self, raw: u8) -> Address { + self.users[usize::from(raw) % USER_COUNT].clone() + } + + pub fn interval(raw: u8) -> Interval { + match raw % 5 { + 0 => Interval::Daily, + 1 => Interval::Weekly, + 2 => Interval::Monthly, + 3 => Interval::Quarterly, + _ => Interval::Yearly, + } + } + + pub fn bounded_price(raw: u32) -> i128 { + i128::from(raw % 1_000_000).saturating_add(1) + } + + pub fn advance_time(&self, secs: u64) { + let now = self.env.ledger().timestamp(); + self.env + .ledger() + .set_timestamp(now.saturating_add(secs % (Interval::Yearly.seconds() * 2))); + } +} + +pub fn ignore_expected_panic(f: impl FnOnce() -> T) -> Option { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).ok() +} + +pub fn plan_name(env: &Env, byte: u8) -> String { + match byte % 4 { + 0 => String::from_str(env, "basic"), + 1 => String::from_str(env, "pro"), + 2 => String::from_str(env, "team"), + _ => String::from_str(env, "enterprise"), + } +} + +pub fn assert_subscription_invariants(h: &Harness) { + let proxy = h.proxy(); + let plan_count = proxy.get_plan_count(); + let sub_count = proxy.get_subscription_count(); + + let mut plan_id = 1u64; + while plan_id <= plan_count.min(32) { + let plan = proxy.get_plan(&plan_id); + assert!(plan.price > 0); + assert!(plan.created_at >= START_TS); + plan_id += 1; + } + + let mut sub_id = 1u64; + while sub_id <= sub_count.min(32) { + let sub = proxy.get_subscription(&sub_id); + assert!(sub.next_charge_at >= sub.started_at); + assert!(sub.total_paid >= 0); + assert!(sub.refund_requested_amount >= 0); + assert!(sub.refund_requested_amount <= sub.total_paid); + match sub.status { + SubscriptionStatus::Active + | SubscriptionStatus::Paused + | SubscriptionStatus::Cancelled + | SubscriptionStatus::PastDue => {} + } + sub_id += 1; + } +} diff --git a/contracts/fuzz/fuzz_targets/subscription_lifecycle.rs b/contracts/fuzz/fuzz_targets/subscription_lifecycle.rs new file mode 100644 index 0000000..02e5f6a --- /dev/null +++ b/contracts/fuzz/fuzz_targets/subscription_lifecycle.rs @@ -0,0 +1,122 @@ +#![no_main] + +mod common; + +use common::{assert_subscription_invariants, ignore_expected_panic, plan_name, Harness}; +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + let h = Harness::new(); + let proxy = h.proxy(); + let mut plan_ids = [0u64; 16]; + let mut plan_len = 0usize; + let mut sub_ids = [0u64; 32]; + let mut sub_len = 0usize; + + for chunk in data.chunks(8).take(64) { + let op = chunk.get(0).copied().unwrap_or(0) % 8; + match op { + 0 => { + let raw_price = u32::from_le_bytes([ + chunk.get(1).copied().unwrap_or(0), + chunk.get(2).copied().unwrap_or(0), + chunk.get(3).copied().unwrap_or(0), + chunk.get(4).copied().unwrap_or(0), + ]); + if let Some(plan_id) = ignore_expected_panic(|| { + proxy.create_plan( + &h.user(chunk.get(5).copied().unwrap_or(0)), + &plan_name(&h.env, chunk.get(6).copied().unwrap_or(0)), + &Harness::bounded_price(raw_price), + &h.token_id, + &Harness::interval(chunk.get(7).copied().unwrap_or(0)), + ) + }) { + if plan_len < plan_ids.len() { + plan_ids[plan_len] = plan_id; + plan_len += 1; + } + } + } + 1 => { + if plan_len > 0 { + let idx = usize::from(chunk.get(1).copied().unwrap_or(0)) % plan_len; + if let Some(sub_id) = ignore_expected_panic(|| { + proxy.subscribe(&h.user(chunk.get(2).copied().unwrap_or(0)), &plan_ids[idx]) + }) { + if sub_len < sub_ids.len() { + sub_ids[sub_len] = sub_id; + sub_len += 1; + } + } + } + } + 2 => { + if sub_len > 0 { + let idx = usize::from(chunk.get(1).copied().unwrap_or(0)) % sub_len; + let duration = u64::from(chunk.get(2).copied().unwrap_or(0)) * 86_400; + let _ = ignore_expected_panic(|| { + proxy.pause_by_subscriber( + &h.user(chunk.get(3).copied().unwrap_or(0)), + &sub_ids[idx], + &duration, + ) + }); + } + } + 3 => { + if sub_len > 0 { + let idx = usize::from(chunk.get(1).copied().unwrap_or(0)) % sub_len; + let _ = ignore_expected_panic(|| { + proxy.resume_subscription( + &h.user(chunk.get(2).copied().unwrap_or(0)), + &sub_ids[idx], + ) + }); + } + } + 4 => { + if sub_len > 0 { + let idx = usize::from(chunk.get(1).copied().unwrap_or(0)) % sub_len; + let _ = ignore_expected_panic(|| { + proxy.cancel_subscription( + &h.user(chunk.get(2).copied().unwrap_or(0)), + &sub_ids[idx], + ) + }); + } + } + 5 => { + if sub_len > 0 { + h.advance_time(u64::from_le_bytes([ + chunk.get(1).copied().unwrap_or(0), + chunk.get(2).copied().unwrap_or(0), + chunk.get(3).copied().unwrap_or(0), + chunk.get(4).copied().unwrap_or(0), + 0, + 0, + 0, + 0, + ])); + let idx = usize::from(chunk.get(5).copied().unwrap_or(0)) % sub_len; + let _ = ignore_expected_panic(|| proxy.charge_subscription(&sub_ids[idx])); + } + } + 6 => { + if sub_len > 0 { + let idx = usize::from(chunk.get(1).copied().unwrap_or(0)) % sub_len; + let amount = i128::from(chunk.get(2).copied().unwrap_or(0)); + let _ = ignore_expected_panic(|| proxy.request_refund(&sub_ids[idx], &amount)); + } + } + _ => { + if sub_len > 0 { + let idx = usize::from(chunk.get(1).copied().unwrap_or(0)) % sub_len; + let _ = ignore_expected_panic(|| proxy.approve_refund(&sub_ids[idx])); + } + } + } + + assert_subscription_invariants(&h); + } +}); diff --git a/contracts/fuzz/fuzz_targets/subscription_pricing.rs b/contracts/fuzz/fuzz_targets/subscription_pricing.rs new file mode 100644 index 0000000..8544ee3 --- /dev/null +++ b/contracts/fuzz/fuzz_targets/subscription_pricing.rs @@ -0,0 +1,47 @@ +#![no_main] + +mod common; + +use common::{assert_subscription_invariants, ignore_expected_panic, plan_name, Harness}; +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + let h = Harness::new(); + let proxy = h.proxy(); + + for chunk in data.chunks(12).take(32) { + let raw_price = u32::from_le_bytes([ + chunk.get(0).copied().unwrap_or(0), + chunk.get(1).copied().unwrap_or(0), + chunk.get(2).copied().unwrap_or(0), + chunk.get(3).copied().unwrap_or(0), + ]); + let price = Harness::bounded_price(raw_price); + let merchant = h.user(chunk.get(4).copied().unwrap_or(0)); + let subscriber = h.user(chunk.get(5).copied().unwrap_or(1)); + let interval = Harness::interval(chunk.get(6).copied().unwrap_or(0)); + let charge_rounds = chunk.get(7).copied().unwrap_or(0) % 4; + + if let Some(plan_id) = ignore_expected_panic(|| { + proxy.create_plan( + &merchant, + &plan_name(&h.env, chunk.get(8).copied().unwrap_or(0)), + &price, + &h.token_id, + &interval, + ) + }) { + if let Some(sub_id) = ignore_expected_panic(|| proxy.subscribe(&subscriber, &plan_id)) { + for round in 0..charge_rounds { + h.advance_time(interval.seconds().saturating_add(u64::from(round))); + let _ = ignore_expected_panic(|| proxy.charge_subscription(&sub_id)); + } + let sub = proxy.get_subscription(&sub_id); + assert!(sub.total_paid >= 0); + assert!(sub.total_paid <= price.saturating_mul(i128::from(charge_rounds))); + } + } + + assert_subscription_invariants(&h); + } +}); diff --git a/contracts/fuzz/fuzz_targets/subscription_rate_limits.rs b/contracts/fuzz/fuzz_targets/subscription_rate_limits.rs new file mode 100644 index 0000000..4324c4e --- /dev/null +++ b/contracts/fuzz/fuzz_targets/subscription_rate_limits.rs @@ -0,0 +1,38 @@ +#![no_main] + +mod common; + +use common::{assert_subscription_invariants, ignore_expected_panic, plan_name, Harness}; +use libfuzzer_sys::fuzz_target; +use soroban_sdk::String; + +fuzz_target!(|data: &[u8]| { + let h = Harness::new(); + let proxy = h.proxy(); + let merchant = h.user(data.get(0).copied().unwrap_or(0)); + let min_interval = u64::from(data.get(1).copied().unwrap_or(0)); + + let _ = ignore_expected_panic(|| { + proxy.set_rate_limit(&String::from_str(&h.env, "create_plan"), &min_interval) + }); + + for chunk in data.get(2..).unwrap_or_default().chunks(6).take(32) { + let price = Harness::bounded_price(u32::from_le_bytes([ + chunk.get(0).copied().unwrap_or(0), + chunk.get(1).copied().unwrap_or(0), + 0, + 0, + ])); + let _ = ignore_expected_panic(|| { + proxy.create_plan( + &merchant, + &plan_name(&h.env, chunk.get(2).copied().unwrap_or(0)), + &price, + &h.token_id, + &Harness::interval(chunk.get(3).copied().unwrap_or(0)), + ) + }); + h.advance_time(u64::from(chunk.get(4).copied().unwrap_or(0))); + assert_subscription_invariants(&h); + } +}); diff --git a/contracts/fuzz/scripts/triage-crash.sh b/contracts/fuzz/scripts/triage-crash.sh new file mode 100755 index 0000000..da21f90 --- /dev/null +++ b/contracts/fuzz/scripts/triage-crash.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$#" -ne 2 ]; then + echo "usage: $0 " >&2 + exit 2 +fi + +target="$1" +crash_file="$2" +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +regression_dir="$repo_root/contracts/subscription/tests/regressions" +mkdir -p "$regression_dir" + +sha="$(sha256sum "$crash_file" | awk '{print $1}')" +out="$regression_dir/${target}_${sha}.bin" +cp "$crash_file" "$out" + +cat </`. CI also adds a generated seed per run so target-specific corpora are never empty. -Tests basic input validation and state transitions. +## Run Locally -**Tests:** - -- `test_negative_prices()` - Reject negative prices -- `test_huge_prices()` - Handle very large numbers -- `test_pause_duration_limits()` - Enforce pause duration limits -- `test_invalid_state_transitions()` - Prevent double cancellations -- `test_refund_limits()` - Prevent refunds exceeding total paid - -### 2. `tests/pricing_fuzz.rs` - Pricing Differential Fuzzing - -Tests pricing calculations across different price points and intervals. - -**Tests:** - -- `test_pricing_calculations()` - Test all price × interval combinations -- `test_subscriptions_with_different_prices()` - Multiple subscriptions with different prices -- `test_price_boundaries()` - Test minimum and maximum prices - -### 3. `tests/rate_limit_fuzz.rs` - Rate Limit Fuzzing - -Tests rate limiting functionality. - -**Tests:** - -- `test_rate_limit_intervals()` - Test various rate limit intervals -- `test_rate_limit_removal()` - Test rate limit removal -- `test_multiple_rate_limits()` - Test multiple function rate limits - -## Running Tests - -### Run All Tests - -```bash -cd contracts/subscription -cargo test -``` - -### Run Specific Test File +Install nightly Rust and `cargo-fuzz`: ```bash -cargo test --test fuzz_tests -cargo test --test pricing_fuzz_tests -cargo test --test rate_limit_fuzz_tests +rustup toolchain install nightly +cargo +nightly install cargo-fuzz --locked ``` -### Run Specific Test +Run the deterministic smoke replay: ```bash -cargo test test_negative_prices +cargo test --manifest-path contracts/subscription/Cargo.toml --test fuzz_smoke -- --nocapture ``` -### Run With Output +Run a fuzz target: ```bash -cargo test -- --nocapture +cd contracts +cargo +nightly fuzz run subscription_lifecycle fuzz/corpus/subscription_lifecycle -- -max_total_time=1800 ``` -### Run Using Script - -```bash -bash scripts/run_fuzz_tests.sh -``` - -## Test Coverage - -| Category | Tests | Status | -| ---------------- | ------ | ------ | -| Input Validation | 5 | ✅ | -| Pricing | 3 | ✅ | -| Rate Limiting | 3 | ✅ | -| **Total** | **11** | **✅** | - -## Key Findings +Use the same pattern for `subscription_pricing` and `subscription_rate_limits`. -### ✅ Vulnerabilities Tested +## CI -- Zero price validation -- Negative price handling -- Integer overflow scenarios -- Pause duration limits -- Double cancellation prevention -- Refund amount validation -- Rate limit enforcement +`.github/workflows/fuzz-test.yml` runs: -### ✅ Edge Cases Covered +- The smoke replay test. +- `cargo fuzz list` to verify target registration. +- Each coverage-guided target for 1800 seconds by default. +- Corpus and crash artifact upload for triage. -- Minimum price ($1) -- Maximum price (i128::MAX / 2) -- Pause duration boundaries (30 days) -- All subscription intervals (Day, Week, Month, Year) -- Multiple concurrent rate limits +The workflow runs on PRs and pushes touching contract or fuzzing files, weekly on a schedule, and manually with a configurable `fuzz_seconds` input. -## CI/CD Integration +## Crash Triage -Tests automatically run on: +When CI uploads a crash artifact: -- Push to `main` or `develop` -- Pull requests to `main` or `develop` -- Changes to `contracts/subscription/**` - -**Workflow:** `.github/workflows/fuzz-tests.yml` - -## Expected Results +```bash +cd contracts +cargo +nightly fuzz run subscription_lifecycle path/to/crash +cargo +nightly fuzz tmin subscription_lifecycle path/to/crash +``` -All tests should pass: +The helper script copies a crash into a regression location and prints replay commands: +```bash +bash contracts/fuzz/scripts/triage-crash.sh subscription_lifecycle path/to/crash ``` -running 11 tests -test pricing_fuzz_tests::test_pricing_calculations ... ok -test pricing_fuzz_tests::test_price_boundaries ... ok -test pricing_fuzz_tests::test_subscriptions_with_different_prices ... ok -test rate_limit_fuzz_tests::test_multiple_rate_limits ... ok -test rate_limit_fuzz_tests::test_rate_limit_intervals ... ok -test rate_limit_fuzz_tests::test_rate_limit_removal ... ok -test fuzz_tests::test_huge_prices ... ok -test fuzz_tests::test_invalid_state_transitions ... ok -test fuzz_tests::test_negative_prices ... ok -test fuzz_tests::test_pause_duration_limits ... ok -test fuzz_tests::test_refund_limits ... ok - -test result: ok. 11 passed; 0 failed -``` - -## Future Improvements -- [ ] Property-based fuzzing with `proptest` -- [ ] Symbolic execution for pricing logic -- [ ] Formal verification of contract transitions -- [ ] Continuous fuzzing infrastructure -- [ ] Coverage metrics reporting +Promote minimized crashes into deterministic tests under `contracts/subscription/tests/` by replaying the target logic against the saved bytes. Keep the original minimized crash file in `contracts/subscription/tests/regressions/` when the byte sequence matters. -## Issues Found & Fixed +## Adding Targets -None at this time. ✅ +1. Add a new `[[bin]]` entry in `contracts/fuzz/Cargo.toml`. +2. Create `contracts/fuzz/fuzz_targets/.rs`. +3. Add at least one corpus seed under `contracts/fuzz/corpus//`. +4. Add the target to the workflow matrix. +5. Document the invariant and expected panic handling here. -All tests pass successfully! +Fuzz targets should catch expected contract panics for invalid user actions, then assert invariants after each successful or rejected operation. Unexpected invariant failures should remain crashes. diff --git a/contracts/subscription/src/lib.rs b/contracts/subscription/src/lib.rs index 75ccad9..3f03ee5 100644 --- a/contracts/subscription/src/lib.rs +++ b/contracts/subscription/src/lib.rs @@ -2,6 +2,9 @@ mod gas_profiler; mod gas_storage; mod gas_optimization; +mod quota; +mod revenue; +mod usage; use soroban_sdk::{token, Address, Env, IntoVal, String, TryFromVal, Val, Vec}; use subtrackr_types::{ Interval, Invoice, Plan, StorageKey, Subscription, SubscriptionStatus, TimeRange, diff --git a/contracts/subscription/tests/fuzz_smoke.rs b/contracts/subscription/tests/fuzz_smoke.rs new file mode 100644 index 0000000..9c555f3 --- /dev/null +++ b/contracts/subscription/tests/fuzz_smoke.rs @@ -0,0 +1,78 @@ +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token, Address, Env, String, +}; +use subtrackr_proxy::{UpgradeableProxy, UpgradeableProxyClient}; +use subtrackr_storage::SubTrackrStorage; +use subtrackr_subscription::SubTrackrSubscription; +use subtrackr_types::{Interval, SubscriptionStatus}; + +struct Setup { + env: Env, + proxy_id: Address, + merchant: Address, + subscriber: Address, + token_id: Address, +} + +impl Setup { + fn proxy(&self) -> UpgradeableProxyClient<'_> { + UpgradeableProxyClient::new(&self.env, &self.proxy_id) + } +} + +fn setup() -> Setup { + let env = Env::default(); + env.mock_all_auths_allowing_non_root_auth(); + env.ledger().set_timestamp(1_700_000_000); + + let admin = Address::generate(&env); + let merchant = Address::generate(&env); + let subscriber = Address::generate(&env); + let token_admin = Address::generate(&env); + + let storage_id = env.register_contract(None, SubTrackrStorage); + let implementation_id = env.register_contract(None, SubTrackrSubscription); + let proxy_id = env.register_contract(None, UpgradeableProxy); + let proxy = UpgradeableProxyClient::new(&env, &proxy_id); + proxy.initialize(&admin, &storage_id, &implementation_id, &0u64, &0u64); + + let token = env.register_stellar_asset_contract_v2(token_admin); + let token_id = token.address(); + token::StellarAssetClient::new(&env, &token_id).mint(&subscriber, &1_000_000); + + Setup { + env, + proxy_id, + merchant, + subscriber, + token_id, + } +} + +#[test] +fn fuzz_smoke_replays_subscription_lifecycle_seed() { + let setup = setup(); + let proxy = setup.proxy(); + + let plan_id = proxy.create_plan( + &setup.merchant, + &String::from_str(&setup.env, "fuzz-smoke"), + &500, + &setup.token_id, + &Interval::Monthly, + ); + let sub_id = proxy.subscribe(&setup.subscriber, &plan_id); + + setup + .env + .ledger() + .set_timestamp(1_700_000_000 + Interval::Monthly.seconds() + 1); + proxy.charge_subscription(&sub_id); + + let sub = proxy.get_subscription(&sub_id); + assert_eq!(sub.status, SubscriptionStatus::Active); + assert_eq!(sub.total_paid, 500); + assert_eq!(sub.charge_count, 1); + assert!(sub.next_charge_at > sub.last_charged_at); +} diff --git a/contracts/tests/fuzz.rs b/contracts/tests/fuzz.rs index 0370f7d..f63c857 100644 --- a/contracts/tests/fuzz.rs +++ b/contracts/tests/fuzz.rs @@ -1,218 +1,14 @@ -#[cfg(test)] -mod fuzz_tests { - use soroban_sdk::{testutils::*, Address, Env, String}; - use subtrackr_subscription::SubTrackrSubscription; - use subtrackr_types::Interval; - - // ════════════════════════════════════════════════════════════════ - // TEST 1: Negative Prices (Step 4) - // ════════════════════════════════════════════════════════════════ - - #[test] - fn test_negative_prices() { - let env = Env::default(); - let contract = SubTrackrSubscription; - - let proxy = Address::random(&env); - let storage = Address::random(&env); - let merchant = Address::random(&env); - - contract.initialize( - env.clone(), - proxy.clone(), - storage.clone(), - merchant.clone(), - ); - - // Try NEGATIVE price (should fail!) - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - contract.create_plan( - env.clone(), - proxy.clone(), - storage.clone(), - merchant.clone(), - String::from_str(&env, "bad_plan"), - -1000, // ⚠️ NEGATIVE price - Address::random(&env), - Interval::Month, - ); - })); - - assert!(result.is_err(), "Should reject negative price!"); - } - - // ════════════════════════════════════════════════════════════════ - // TEST 2: Huge Numbers (Step 5) - // ════════════════════════════════════════════════════════════════ - - #[test] - fn test_huge_prices() { - let env = Env::default(); - let contract = SubTrackrSubscription; - - let proxy = Address::random(&env); - let storage = Address::random(&env); - let merchant = Address::random(&env); - - contract.initialize( - env.clone(), - proxy.clone(), - storage.clone(), - merchant.clone(), - ); - - // Try HUGE number (might cause overflow!) - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - contract.create_plan( - env.clone(), - proxy.clone(), - storage.clone(), - merchant.clone(), - String::from_str(&env, "expensive_plan"), - i128::MAX, // ⚠️ BIGGEST NUMBER - Address::random(&env), - Interval::Month, - ); - })); - - // Should either work OR fail gracefully, not crash! - println!("Result: {:?}", result); - } - - // ════════════════════════════════════════════════════════════════ - // TEST 3: Pause Duration Limits (Step 6) - // ════════════════════════════════════════════════════════════════ - - #[test] - fn test_pause_duration_limits() { - let env = Env::default(); - let contract = SubTrackrSubscription; - - let proxy = Address::random(&env); - let storage = Address::random(&env); - let admin = Address::random(&env); - let merchant = Address::random(&env); - let subscriber = Address::random(&env); - - contract.initialize( - env.clone(), - proxy.clone(), - storage.clone(), - admin.clone(), - ); - - // Create a plan and subscribe - let plan_id = contract.create_plan( - env.clone(), - proxy.clone(), - storage.clone(), - merchant, - String::from_str(&env, "plan"), - 100, - Address::random(&env), - Interval::Month, - ); - - let sub_id = contract.subscribe( - env.clone(), - proxy.clone(), - storage.clone(), - subscriber.clone(), - plan_id, - ); - - // Try to pause for TOO LONG (30 days + 1 second = should fail!) - let too_long = 2_592_001; // 30 days + 1 second - - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - contract.pause_by_subscriber( - env.clone(), - proxy.clone(), - storage.clone(), - subscriber.clone(), - sub_id, - too_long, // ⚠️ TOO LONG - ); - })); - - assert!(result.is_err(), "Should reject pause duration > 30 days!"); - } - - // ════════════════════════════════════════════════════════════════ - // TEST 4: Invalid State Transitions - Cancel Twice (Step 7) - // ════════════════════════════════════════════════════════════════ - - #[test] - fn test_invalid_state_transitions() { - let env = Env::default(); - let contract = SubTrackrSubscription; - - let proxy = Address::random(&env); - let storage = Address::random(&env); - let admin = Address::random(&env); - let merchant = Address::random(&env); - let subscriber = Address::random(&env); - - contract.initialize( - env.clone(), - proxy.clone(), - storage.clone(), - admin.clone(), - ); - - let plan_id = contract.create_plan( - env.clone(), - proxy.clone(), - storage.clone(), - merchant, - String::from_str(&env, "plan"), - 100, - Address::random(&env), - Interval::Month, - ); - - let sub_id = contract.subscribe( - env.clone(), - proxy.clone(), - storage.clone(), - subscriber.clone(), - plan_id, - ); - - // Cancel subscription (first time - should work) - contract.cancel_subscription( - env.clone(), - proxy.clone(), - storage.clone(), - subscriber.clone(), - sub_id, - ); - - // Try to cancel AGAIN (should fail!) - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - contract.cancel_subscription( - env.clone(), - proxy.clone(), - storage.clone(), - subscriber.clone(), - sub_id, - ); - })); - - assert!(result.is_err(), "Cannot cancel already cancelled subscription!"); - } - - // ════════════════════════════════════════════════════════════════ - // TEST 5: Refund Limits (Step 8) - // ════════════════════════════════════════════════════════════════ - - #[test] - fn test_refund_limits() { - let env = Env::default(); - - // Test scenario: Customer paid $100, tries to refund $200 - let total_paid = 100i128; - let refund_requested = 200i128; - - // This should fail because ref - \ No newline at end of file +//! Compatibility notes for the subscription fuzzing suite. +//! +//! The executable coverage-guided fuzz targets live under `contracts/fuzz`. +//! The deterministic CI smoke replay lives at +//! `contracts/subscription/tests/fuzz_smoke.rs`. +//! +//! Keep this file as the top-level pointer requested by the contract fuzzing +//! issue so contributors looking in `contracts/tests` find the maintained +//! harness locations. + +#[test] +fn fuzz_harness_locations_are_documented() { + assert!(true); +}