Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .github/corpus/pricing/max_price
Binary file not shown.
Binary file added .github/corpus/pricing/min_price
Binary file not shown.
Binary file added .github/corpus/pricing/negative_price
Binary file not shown.
Binary file added .github/corpus/pricing/zero_price
Binary file not shown.
1 change: 1 addition & 0 deletions .github/corpus/rate_limit/rapid_create
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dddddddddddddddddddddddddddddddd
1 change: 1 addition & 0 deletions .github/corpus/rate_limit/slow_create
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

Binary file added .github/corpus/state_machine/charge_paused
Binary file not shown.
Binary file added .github/corpus/state_machine/double_cancel
Binary file not shown.
Binary file added .github/corpus/state_machine/resume_nonexistent
Binary file not shown.
Binary file added .github/corpus/subscription/cancel_immediate
Binary file not shown.
Binary file added .github/corpus/subscription/golden_path
Binary file not shown.
Binary file added .github/corpus/subscription/multi_charge
Binary file not shown.
Binary file added .github/corpus/subscription/pause_resume
Binary file not shown.
112 changes: 72 additions & 40 deletions .github/workflows/fuzz-test.yml
Original file line number Diff line number Diff line change
@@ -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 }} <crash-file>"
4 changes: 4 additions & 0 deletions contracts/fuzz/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# cargo-fuzz artifacts
corpus/
artifacts/
coverage/
17 changes: 17 additions & 0 deletions contracts/fuzz/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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 = ["."]
110 changes: 110 additions & 0 deletions contracts/fuzz/FUZZING.md
Original file line number Diff line number Diff line change
@@ -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/<name>.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 <path/to/crash>
```

2. **Reproduce** with extra detail:
```bash
RUST_BACKTRACE=1 cargo fuzz run subscription <minimized-crash>
```

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/<target>/` 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/<name>.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/<name>/`

```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
});
```
79 changes: 79 additions & 0 deletions contracts/fuzz/fuzz_targets/pricing.rs
Original file line number Diff line number Diff line change
@@ -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);
}));
}
_ => {}
}
}
});
Loading
Loading