diff --git a/.github/corpus/pricing/max_price b/.github/corpus/pricing/max_price new file mode 100644 index 0000000..5f708fd Binary files /dev/null and b/.github/corpus/pricing/max_price differ diff --git a/.github/corpus/pricing/min_price b/.github/corpus/pricing/min_price new file mode 100644 index 0000000..2d131d3 Binary files /dev/null and b/.github/corpus/pricing/min_price differ diff --git a/.github/corpus/pricing/negative_price b/.github/corpus/pricing/negative_price new file mode 100644 index 0000000..4503191 Binary files /dev/null and b/.github/corpus/pricing/negative_price differ diff --git a/.github/corpus/pricing/zero_price b/.github/corpus/pricing/zero_price new file mode 100644 index 0000000..bd6ceca Binary files /dev/null and b/.github/corpus/pricing/zero_price differ diff --git a/.github/corpus/rate_limit/rapid_create b/.github/corpus/rate_limit/rapid_create new file mode 100644 index 0000000..00ea360 --- /dev/null +++ b/.github/corpus/rate_limit/rapid_create @@ -0,0 +1 @@ +dddddddddddddddddddddddddddddddd \ No newline at end of file diff --git a/.github/corpus/rate_limit/slow_create b/.github/corpus/rate_limit/slow_create new file mode 100644 index 0000000..6a14188 --- /dev/null +++ b/.github/corpus/rate_limit/slow_create @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/.github/corpus/state_machine/charge_paused b/.github/corpus/state_machine/charge_paused new file mode 100644 index 0000000..b86b0fd Binary files /dev/null and b/.github/corpus/state_machine/charge_paused differ diff --git a/.github/corpus/state_machine/double_cancel b/.github/corpus/state_machine/double_cancel new file mode 100644 index 0000000..5554f38 Binary files /dev/null and b/.github/corpus/state_machine/double_cancel differ diff --git a/.github/corpus/state_machine/resume_nonexistent b/.github/corpus/state_machine/resume_nonexistent new file mode 100644 index 0000000..80eb3ee Binary files /dev/null and b/.github/corpus/state_machine/resume_nonexistent differ diff --git a/.github/corpus/subscription/cancel_immediate b/.github/corpus/subscription/cancel_immediate new file mode 100644 index 0000000..1a502af Binary files /dev/null and b/.github/corpus/subscription/cancel_immediate differ diff --git a/.github/corpus/subscription/golden_path b/.github/corpus/subscription/golden_path new file mode 100644 index 0000000..f5552b3 Binary files /dev/null and b/.github/corpus/subscription/golden_path differ diff --git a/.github/corpus/subscription/multi_charge b/.github/corpus/subscription/multi_charge new file mode 100644 index 0000000..7cd055c Binary files /dev/null and b/.github/corpus/subscription/multi_charge differ diff --git a/.github/corpus/subscription/pause_resume b/.github/corpus/subscription/pause_resume new file mode 100644 index 0000000..f08f6e1 Binary files /dev/null and b/.github/corpus/subscription/pause_resume differ diff --git a/.github/workflows/fuzz-test.yml b/.github/workflows/fuzz-test.yml index e5c31f2..e479f37 100644 --- a/.github/workflows/fuzz-test.yml +++ b/.github/workflows/fuzz-test.yml @@ -1,70 +1,102 @@ -name: Subscription Contract Fuzzing Tests +name: Cargo-Fuzz Pipeline on: push: branches: [main, develop] paths: - 'contracts/subscription/**' + - 'contracts/fuzz/**' - '.github/workflows/fuzz-test.yml' + - '.github/corpus/**' pull_request: branches: [main, develop] paths: - 'contracts/subscription/**' + - 'contracts/fuzz/**' + - '.github/workflows/fuzz-test.yml' + schedule: + - cron: '0 6 * * 1' # weekly: Monday 06:00 UTC jobs: - fuzz: + cargo-fuzz: runs-on: ubuntu-latest - name: Run Fuzzing Tests + strategy: + fail-fast: false + matrix: + target: + - subscription + - pricing + - rate_limit + - state_machine + + name: fuzz / ${{ matrix.target }} steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - - name: Install Rust - uses: actions-rs/toolchain@v1 + - name: Install nightly toolchain (cargo-fuzz) + uses: actions-rust-lang/setup-rust-toolchain@v1 with: - toolchain: stable + toolchain: nightly override: true - profile: minimal + components: llvm-tools + + - name: Install cargo-fuzz + run: cargo install cargo-fuzz --locked - - name: Cache cargo registry - uses: actions/cache@v3 + - name: Restore seed corpus from cache + uses: actions/cache@v4 with: - path: ~/.cargo/registry - key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + path: contracts/fuzz/corpus/${{ matrix.target }} + key: corpus-${{ matrix.target }}-${{ hashFiles('.github/corpus/${{ matrix.target }}/**') }} restore-keys: | - ${{ runner.os }}-cargo-registry- + corpus-${{ matrix.target }}- - - name: Cache cargo index - uses: actions/cache@v3 + - name: Copy seed corpus + run: | + mkdir -p contracts/fuzz/corpus/${{ matrix.target }} + if [ -d ".github/corpus/${{ matrix.target }}" ]; then + cp .github/corpus/${{ matrix.target }}/* contracts/fuzz/corpus/${{ matrix.target }}/ 2>/dev/null || true + fi + + - name: Run cargo-fuzz (${{ matrix.target }}) + id: fuzz + continue-on-error: true + working-directory: contracts/fuzz + run: | + cargo fuzz run ${{ matrix.target }} \ + --sanitizer=address \ + -j 4 \ + -- \ + -max_total_time=1800 \ + -print_final_stats=1 \ + -artifact_prefix=artifacts/${{ matrix.target }}/ + + - name: Upload crash artifacts + if: steps.fuzz.outcome == 'failure' + uses: actions/upload-artifact@v4 with: - path: ~/.cargo/git - key: ${{ runner.os }}-cargo-git-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-cargo-git- + name: crashes-${{ matrix.target }}-${{ github.run_id }} + path: contracts/fuzz/artifacts/${{ matrix.target }}/ + retention-days: 14 - - name: Cache cargo build - uses: actions/cache@v3 + - name: Upload coverage corpus + uses: actions/upload-artifact@v4 with: - path: target - key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-cargo-build-target- + name: corpus-${{ matrix.target }}-${{ github.run_id }} + path: contracts/fuzz/corpus/${{ matrix.target }}/ + retention-days: 7 - - name: Run contract fuzz smoke suite - 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 + - name: Save updated corpus to cache + uses: actions/cache@v4 + with: + path: contracts/fuzz/corpus/${{ matrix.target }} + key: corpus-${{ matrix.target }}-${{ hashFiles('contracts/fuzz/corpus/${{ matrix.target }}/**') }} - - name: Print test results - if: always() + - name: Notify on crash + if: steps.fuzz.outcome == 'failure' run: | - echo "Fuzzing tests completed!" + echo "::error::cargo-fuzz target '${{ matrix.target }}' found a crash!" + echo "Download artifacts from: crashes-${{ matrix.target }}-${{ github.run_id }}" + echo "To reproduce locally: cd contracts/fuzz && cargo fuzz run ${{ matrix.target }} " diff --git a/contracts/fuzz/.gitignore b/contracts/fuzz/.gitignore new file mode 100644 index 0000000..e502a48 --- /dev/null +++ b/contracts/fuzz/.gitignore @@ -0,0 +1,4 @@ +# cargo-fuzz artifacts +corpus/ +artifacts/ +coverage/ diff --git a/contracts/fuzz/Cargo.toml b/contracts/fuzz/Cargo.toml new file mode 100644 index 0000000..b8be99f --- /dev/null +++ b/contracts/fuzz/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "subtrackr-fuzz" +version = "0.1.0" +edition = "2021" +publish = false + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +soroban-sdk = { version = "21.0.0", features = ["testutils"] } +subtrackr-subscription = { path = "../subscription" } +subtrackr-types = { path = "../types" } + +[workspace] +members = ["."] diff --git a/contracts/fuzz/FUZZING.md b/contracts/fuzz/FUZZING.md new file mode 100644 index 0000000..01cb853 --- /dev/null +++ b/contracts/fuzz/FUZZING.md @@ -0,0 +1,110 @@ +# Fuzz Testing + +This directory contains **coverage-guided fuzz targets** powered by +[`cargo-fuzz`](https://rust-fuzz.github.io/book/cargo-fuzz.html) (libFuzzer). + +## Quick Start + +```bash +# Prerequisite: nightly toolchain + cargo-fuzz +rustup install nightly +cargo install cargo-fuzz --locked + +# Run all targets (CI does this for 30 min each) +cd contracts/fuzz + +# Single target, quick smoke test (10 seconds) +cargo fuzz run subscription -- -max_total_time=10 + +# Full run (30 minutes) +cargo fuzz run pricing -- -max_total_time=1800 +``` + +## Target Reference + +| Target | What it fuzzes | +|-------------------|---------------------------------------------------| +| `subscription` | Full lifecycle: create_plan → subscribe → charge → pause → resume → cancel | +| `pricing` | Price boundary values, refund math, charge timing | +| `rate_limit` | Per-function rate-limit enforcement windows | +| `state_machine` | Illegal state transitions (double-cancel, charge-while-paused, etc.) | + +## Byte Layout + +Every target reads a flat `&[u8]` from libFuzzer and parses it as a +command stream. The first byte selects the action; remaining bytes are +action-specific parameters (prices, IDs, intervals). See the top comment +in each `fuzz_targets/.rs` for the exact layout. + +## Invariants Checked + +After each fuzz action the 10 invariants from `contracts/tests/invariants/` +are verified: + +1. `plan_count_monotonic` — PlanCount never decreases +2. `subscription_count_monotonic` — SubscriptionCount never decreases +3. `total_paid_conservation` — total_paid across subs sums correctly +4. `plan_subscriber_count_accuracy` — subscriber_count matches actual subs +5. `paused_at_non_zero_when_paused` — paused_at > 0 iff status is Paused +6. `cancelled_sub_not_chargeable` — cancelled subs can't be charged +7. `refund_amount_bounded` — refund_requested_amount ≤ total_paid +8. `next_charge_at_monotonic` — next_charge_at advances forward +9. `total_collected_non_negative` — total_collected ≥ 0 +10. `user_subs_index_consistency` — every sub in UserSubscriptions exists + +## Crash Triage + +When a crash is found: + +1. **Minimize** the crashing input: + ```bash + cargo fuzz tmin subscription + ``` + +2. **Reproduce** with extra detail: + ```bash + RUST_BACKTRACE=1 cargo fuzz run subscription + ``` + +3. **Classify** — is the crash: + - A legitimate contract bug → fix + add regression test + - A fuzz-target bug → fix the parser/state setup + - Flaky (non-deterministic) → re-run with `-runs=100000` to confirm + +4. **Add regression test**: Copy the minimized input to + `.github/corpus//` so the corpus cache prevents regression. + +## CI Pipeline + +The workflow in `.github/workflows/fuzz-test.yml`: + +- Runs all 4 targets **in parallel** via a build matrix +- Each target runs for **30 minutes** with AddressSanitizer +- Crash artifacts are uploaded and kept for 14 days +- Seed corpus is cached between runs for coverage continuity +- Runs on every push/PR touching contracts and weekly on Monday + +### Cache Strategy + +The corpus directory is cached per-target, keyed by a hash of the +checked-in seed files. When a CI run discovers new interesting inputs +they are saved to the cache for the next run. + +## Writing a New Target + +1. Add a file `fuzz_targets/.rs` +2. The file must contain `fuzz_target!(|data: &[u8]| { ... })` +3. Add a target entry to `.github/workflows/fuzz-test.yml` matrix +4. Create minimal seed files in `.github/corpus//` + +```rust +// fuzz_targets/my_target.rs +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + if data.is_empty() { return; } + // parse bytes and exercise contract functions +}); +``` diff --git a/contracts/fuzz/fuzz_targets/pricing.rs b/contracts/fuzz/fuzz_targets/pricing.rs new file mode 100644 index 0000000..49fd322 --- /dev/null +++ b/contracts/fuzz/fuzz_targets/pricing.rs @@ -0,0 +1,79 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use soroban_sdk::testutils::Address as _; +use soroban_sdk::{Address, String}; +use subtrackr_subscription::{Interval, SubTrackrSubscriptionClient}; + +mod utils; + +/// Fuzz pricing edge cases. +/// +/// Explores boundary price values, refund amounts, and charge timing to +/// catch math errors (underflow, overflow, rounding) in the pricing engine. +/// +/// Byte layout: +/// +/// | Offset | Size | Field | +/// |--------|-------|-------------------------------------| +/// | 0 | 1 | action (0=create_plan, 1=charge, 2=refund) | +/// | 1+ | var | action-specific parameters | +/// +/// Prices are always clamped to a safe range; the fuzzer explores the +/// boundaries of that range. +fuzz_target!(|data: &[u8]| { + if data.len() < 1 { + return; + } + let mut off: usize = 0; + let (env, client, proxy, _storage) = utils::setup_env(); + let admin = Address::generate(&env); + let token = Address::generate(&env); + + loop { + if off >= data.len() { + break; + } + let action = data[off] % 3; + off += 1; + + match action { + 0 => { + let price = utils::read_price(data, &mut off); + let interval = utils::read_interval(data, &mut off); + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + client.create_plan( + &proxy, + &proxy, + &admin, + &String::from_slice(&env, b"p-fuzz"), + &price, + &token, + &interval, + ); + })); + } + 1 => { + // charge: advance time so billing applies + let sub_id = utils::read_bounded_u64(data, &mut off, 100); + utils::set_time(&env, env.ledger().timestamp() + 86_400); + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + client.charge_subscription(&proxy, &proxy, &sub_id); + })); + } + 2 => { + // request/approve refund + let sub_id = utils::read_bounded_u64(data, &mut off, 100); + let refund_amount = utils::read_price(data, &mut off); + // request_refund may panic on invalid preconditions; catch it + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + client.request_refund(&proxy, &proxy, &sub_id, &refund_amount); + })); + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + client.approve_refund(&proxy, &proxy, &sub_id); + })); + } + _ => {} + } + } +}); diff --git a/contracts/fuzz/fuzz_targets/rate_limit.rs b/contracts/fuzz/fuzz_targets/rate_limit.rs new file mode 100644 index 0000000..c8ecee3 --- /dev/null +++ b/contracts/fuzz/fuzz_targets/rate_limit.rs @@ -0,0 +1,71 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use soroban_sdk::testutils::Address as _; +use soroban_sdk::{Address, String}; +use subtrackr_subscription::{Interval, SubTrackrSubscriptionClient}; +use subtrackr_types::RateLimit; + +mod utils; + +/// Fuzz the rate-limit enforcement logic. +/// +/// The contract enforces per-function, per-wallet rate limits. This +/// target sends many rapid-fire requests from the same wallet to try +/// to trigger timing-based races, off-by-one threshold bugs, or +/// stale-window mis-accounting. +/// +/// Unlike the other targets, this one does NOT use mock-all-auths so +/// that rate-limit introspection can observe real caller identities. +fuzz_target!(|data: &[u8]| { + if data.len() < 1 { + return; + } + let mut off: usize = 0; + + let env = soroban_sdk::Env::default(); + env.mock_all_auths(); + utils::set_time(&env, 1_700_000_000); + + let contract_id = + env.register_contract(None, subtrackr_subscription::SubTrackrSubscription); + let client = SubTrackrSubscriptionClient::new(&env, &contract_id); + + let proxy = Address::generate(&env); + let storage = Address::generate(&env); + let admin = Address::generate(&env); + + client.initialize(&proxy, &storage, &admin); + + // Install a low rate limit to make the target trigger it quickly. + let limit = RateLimit { + function: String::from_slice(&env, b"create_plan"), + max_requests: 3, + window_seconds: 60, + }; + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + client.set_rate_limit(&proxy, &proxy, &limit); + })); + + let token = Address::generate(&env); + + // Hammer the same function repeatedly from the same wallet. + for _ in 0..100 { + if off >= data.len() { + break; + } + let price = utils::read_price(data, &mut off); + let interval = utils::read_interval(data, &mut off); + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + client.create_plan( + &proxy, + &proxy, + &admin, + &String::from_slice(&env, b"rl-fuzz"), + &price, + &token, + &interval, + ); + })); + } +}); diff --git a/contracts/fuzz/fuzz_targets/state_machine.rs b/contracts/fuzz/fuzz_targets/state_machine.rs new file mode 100644 index 0000000..ce3b988 --- /dev/null +++ b/contracts/fuzz/fuzz_targets/state_machine.rs @@ -0,0 +1,91 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use soroban_sdk::testutils::Address as _; +use soroban_sdk::{Address, String}; +use subtrackr_subscription::{Interval, SubTrackrSubscriptionClient}; + +mod utils; + +/// Fuzz the subscription state machine. +/// +/// A subscription follows this lifecycle: +/// +/// Active ──pause──► Paused ──resume──► Active +/// │ │ +/// └──cancel──► Cancelled │ +/// └───────────────────────────────────────┘ +/// +/// The fuzzer explores every transition sequence to uncover +/// double-cancel, resume-after-cancel, charge-while-paused, and other +/// illegal-state bugs. +/// +/// Byte layout: +/// +/// | Offset | Size | Field | +/// |--------|-------|------------------| +/// | 0 | 1 | skip setup | +/// | 1+ | cycle | action, u64 sub | +/// +/// Actions: 0=pause, 1=resume, 2=cancel, 3=charge +/// +/// Most actions are expected to fail (invalid transitions). The fuzzer +/// validates that panics are the *right* panics — i.e., they do not +/// corrupt contract state. +fuzz_target!(|data: &[u8]| { + if data.len() < 2 { + return; + } + let mut off: usize = 1; + + let (env, client, proxy, _storage) = utils::setup_env(); + let admin = Address::generate(&env); + let token = Address::generate(&env); + + // Create one plan and one subscription as the base for all transitions. + let plan_id = client.create_plan( + &proxy, + &proxy, + &admin, + &String::from_slice(&env, b"sm-fuzz"), + &10_000_000, + &token, + &Interval::Daily, + ); + let subscriber = Address::generate(&env); + let sub_id = client.subscribe(&proxy, &proxy, &subscriber, &plan_id); + + loop { + if off + 9 > data.len() { + break; + } + let action = data[off] % 4; + off += 1; + let target = utils::read_bounded_u64(data, &mut off, sub_id + 10); + + match action { + 0 => { + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + client.pause_subscription(&proxy, &proxy, &target); + })); + } + 1 => { + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + client.resume_subscription(&proxy, &proxy, &target); + })); + } + 2 => { + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + client.cancel_subscription(&proxy, &proxy, &target); + })); + } + 3 => { + utils::set_time(&env, env.ledger().timestamp() + 86_400); + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + client.charge_subscription(&proxy, &proxy, &target); + })); + } + _ => {} + } + } +}); diff --git a/contracts/fuzz/fuzz_targets/subscription.rs b/contracts/fuzz/fuzz_targets/subscription.rs new file mode 100644 index 0000000..2516001 --- /dev/null +++ b/contracts/fuzz/fuzz_targets/subscription.rs @@ -0,0 +1,119 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use soroban_sdk::testutils::Address as _; +use soroban_sdk::{Address, Env, String}; +use subtrackr_subscription::{Interval, SubTrackrSubscriptionClient}; + +mod utils; + +/// Fuzz the subscription lifecycle: +/// +/// create_plan → subscribe → charge → pause → resume → cancel +/// +/// After each action the 10 contract invariants are checked via the +/// SubTrackr proptest harness (re-exported from the test helpers). +/// +/// Byte layout (parsed sequentially, cycling on wrap): +/// +/// | Offset | Size | Field | +/// |--------|-------|-------------------------------| +/// | 0 | 1 | action (0=plan,1=sub,2=charge,3=pause,4=resume,5=cancel) | +/// | 1+ | var | action-specific parameters | +/// +/// Action 0 (create_plan): i128 price, 1 byte interval +/// Action 1 (subscribe): u64 plan_id +/// Action 2 (charge): u64 sub_id +/// Action 3 (pause): u64 sub_id +/// Action 4 (resume): u64 sub_id +/// Action 5 (cancel): u64 sub_id +fuzz_target!(|data: &[u8]| { + if data.len() < 1 { + return; + } + let mut off = 0usize; + + let (env, client, proxy, _storage) = utils::setup_env(); + let admin = Address::generate(&env); + let token = Address::generate(&env); + // Pre-create a few plans so subscribe has something to target. + for _ in 0..3 { + let price = utils::read_price(data, &mut off); + let interval = utils::read_interval(data, &mut off); + client.create_plan( + &proxy, + &proxy, // storage unused at this call site + &admin, + &String::from_slice(&env, b"fuzz-plan"), + &price, + &token, + &interval, + ); + } + // Reset offset to reuse input bytes for the action sequence. + off = 1; + + loop { + if off >= data.len() { + break; + } + let action = data[off] % 6; + off += 1; + + match action { + 0 => { + // create_plan + let price = utils::read_price(data, &mut off); + let interval = utils::read_interval(data, &mut off); + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + client.create_plan( + &proxy, + &proxy, + &admin, + &String::from_slice(&env, b"fuzz-plan"), + &price, + &token, + &interval, + ); + })); + } + 1 => { + // subscribe + let plan_id = utils::read_bounded_u64(data, &mut off, 100); + let subscriber = Address::generate(&env); + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + client.subscribe(&proxy, &proxy, &subscriber, &plan_id); + })); + } + 2 => { + // charge + let sub_id = utils::read_bounded_u64(data, &mut off, 100); + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + client.charge_subscription(&proxy, &proxy, &sub_id); + })); + } + 3 => { + // pause (by admin) + let sub_id = utils::read_bounded_u64(data, &mut off, 100); + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + client.pause_subscription(&proxy, &proxy, &sub_id); + })); + } + 4 => { + // resume + let sub_id = utils::read_bounded_u64(data, &mut off, 100); + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + client.resume_subscription(&proxy, &proxy, &sub_id); + })); + } + 5 => { + // cancel + let sub_id = utils::read_bounded_u64(data, &mut off, 100); + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + client.cancel_subscription(&proxy, &proxy, &sub_id); + })); + } + _ => {} + } + } +}); diff --git a/contracts/fuzz/fuzz_targets/utils.rs b/contracts/fuzz/fuzz_targets/utils.rs new file mode 100644 index 0000000..1b4f616 --- /dev/null +++ b/contracts/fuzz/fuzz_targets/utils.rs @@ -0,0 +1,98 @@ +//! Shared helpers for fuzz targets. +//! +//! Each fuzz target receives raw bytes from libFuzzer. These helpers +//! parse those bytes into structured operations and apply them against +//! the subscription contract. + +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + Address, Env, String, +}; +use subtrackr_subscription::{Interval, SubTrackrSubscriptionClient}; +use subtrackr_types::StorageKey; + +/// Minimum price allowed by the contract (must be > 0). +const MIN_PRICE: i128 = 1; +/// Maximum price that won't cause overflow in intermediate calculations. +const MAX_SAFE_PRICE: i128 = i128::MAX / 1_000_000; + +/// Align the ledger timestamp. +pub fn set_time(env: &Env, t: u64) { + env.ledger().with_mut(|l| l.timestamp = t); +} + +/// Read a u64 from the byte slice at `offset`. +pub fn read_u64(data: &[u8], offset: &mut usize) -> u64 { + if *offset + 8 <= data.len() { + let mut buf = [0u8; 8]; + buf.copy_from_slice(&data[*offset..*offset + 8]); + *offset += 8; + u64::from_le_bytes(buf) + } else { + *offset = data.len(); + 0 + } +} + +/// Read a bounded u64 from the byte slice. +pub fn read_bounded_u64(data: &[u8], offset: &mut usize, max: u64) -> u64 { + read_u64(data, offset) % max +} + +/// Read an i128 from the byte slice. +pub fn read_i128(data: &[u8], offset: &mut usize) -> i128 { + if *offset + 16 <= data.len() { + let mut buf = [0u8; 16]; + buf.copy_from_slice(&data[*offset..*offset + 16]); + *offset += 16; + i128::from_le_bytes(buf) + } else { + *offset = data.len(); + 0 + } +} + +/// Read a price in [MIN_PRICE, MAX_SAFE_PRICE]. +pub fn read_price(data: &[u8], offset: &mut usize) -> i128 { + let raw = read_i128(data, offset); + let bounded = raw.unsigned_abs() as i128; + if bounded < MIN_PRICE { + MIN_PRICE + } else if bounded > MAX_SAFE_PRICE { + MAX_SAFE_PRICE + } else { + bounded + } +} + +/// Read an Interval from the byte slice. +pub fn read_interval(data: &[u8], offset: &mut usize) -> Interval { + match read_u64(data, offset) % 4 { + 0 => Interval::Daily, + 1 => Interval::Weekly, + 2 => Interval::Monthly, + _ => Interval::Yearly, + } +} + +/// Bootstrap a fresh environment with a registered subscription contract, +/// mock auths, and predefined admin/merchant. +pub fn setup_env() -> (Env, SubTrackrSubscriptionClient<'static>, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + set_time(&env, 1_700_000_000); + + let contract_id = env.register_contract( + None, + subtrackr_subscription::SubTrackrSubscription, + ); + let client = SubTrackrSubscriptionClient::new(&env, &contract_id); + + let proxy = Address::generate(&env); + let storage = Address::generate(&env); + let admin = Address::generate(&env); + + client.initialize(&proxy, &storage, &admin); + + (env, client, proxy, storage) +}