From 5a82b4cfabfb27e1afa3a2c6e9e5de3ff6cfd702 Mon Sep 17 00:00:00 2001 From: Kefas Kingsley Date: Wed, 27 May 2026 14:28:43 +0100 Subject: [PATCH 1/2] feat(fuzz): add cargo-fuzz crate with 4 coverage-guided targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - subscription: full lifecycle fuzzing (create_plan → subscribe → charge → pause → resume → cancel) - pricing: boundary price values, refund math, charge timing - rate_limit: per-function rate-limit enforcement windows - state_machine: illegal state transitions (double-cancel, charge-while-paused, etc.) - utils: shared helpers for byte parsing, env setup, time management - .gitignore for cargo-fuzz artifacts and corpus directories - seed corpus files for all 4 targets with edge-case inputs Each target parses raw bytes from libFuzzer into a command stream and catches panics to keep fuzzing on invalid inputs. --- .github/corpus/pricing/max_price | Bin 0 -> 18 bytes .github/corpus/pricing/min_price | Bin 0 -> 18 bytes .github/corpus/pricing/negative_price | Bin 0 -> 18 bytes .github/corpus/pricing/zero_price | Bin 0 -> 18 bytes .github/corpus/rate_limit/rapid_create | 1 + .github/corpus/rate_limit/slow_create | 1 + .github/corpus/state_machine/charge_paused | Bin 0 -> 18 bytes .github/corpus/state_machine/double_cancel | Bin 0 -> 18 bytes .../corpus/state_machine/resume_nonexistent | Bin 0 -> 9 bytes .github/corpus/subscription/cancel_immediate | Bin 0 -> 9 bytes .github/corpus/subscription/golden_path | Bin 0 -> 27 bytes .github/corpus/subscription/multi_charge | Bin 0 -> 27 bytes .github/corpus/subscription/pause_resume | Bin 0 -> 18 bytes contracts/fuzz/.gitignore | 4 + contracts/fuzz/Cargo.toml | 17 +++ contracts/fuzz/FUZZING.md | 110 ++++++++++++++++ contracts/fuzz/fuzz_targets/pricing.rs | 79 ++++++++++++ contracts/fuzz/fuzz_targets/rate_limit.rs | 71 +++++++++++ contracts/fuzz/fuzz_targets/state_machine.rs | 91 ++++++++++++++ contracts/fuzz/fuzz_targets/subscription.rs | 119 ++++++++++++++++++ contracts/fuzz/fuzz_targets/utils.rs | 98 +++++++++++++++ 21 files changed, 591 insertions(+) create mode 100644 .github/corpus/pricing/max_price create mode 100644 .github/corpus/pricing/min_price create mode 100644 .github/corpus/pricing/negative_price create mode 100644 .github/corpus/pricing/zero_price create mode 100644 .github/corpus/rate_limit/rapid_create create mode 100644 .github/corpus/rate_limit/slow_create create mode 100644 .github/corpus/state_machine/charge_paused create mode 100644 .github/corpus/state_machine/double_cancel create mode 100644 .github/corpus/state_machine/resume_nonexistent create mode 100644 .github/corpus/subscription/cancel_immediate create mode 100644 .github/corpus/subscription/golden_path create mode 100644 .github/corpus/subscription/multi_charge create mode 100644 .github/corpus/subscription/pause_resume create mode 100644 contracts/fuzz/.gitignore create mode 100644 contracts/fuzz/Cargo.toml create mode 100644 contracts/fuzz/FUZZING.md create mode 100644 contracts/fuzz/fuzz_targets/pricing.rs create mode 100644 contracts/fuzz/fuzz_targets/rate_limit.rs create mode 100644 contracts/fuzz/fuzz_targets/state_machine.rs create mode 100644 contracts/fuzz/fuzz_targets/subscription.rs create mode 100644 contracts/fuzz/fuzz_targets/utils.rs diff --git a/.github/corpus/pricing/max_price b/.github/corpus/pricing/max_price new file mode 100644 index 0000000000000000000000000000000000000000..5f708fd364ef58f77131c3ab5d6b45819649d07b GIT binary patch literal 18 OcmZSh4*~Tsni&9*eGc>h literal 0 HcmV?d00001 diff --git a/.github/corpus/pricing/min_price b/.github/corpus/pricing/min_price new file mode 100644 index 0000000000000000000000000000000000000000..2d131d377107b1074a2aa214c8111511c6e4ac20 GIT binary patch literal 18 NcmZQzWPkuT4FCWw00aO4 literal 0 HcmV?d00001 diff --git a/.github/corpus/pricing/negative_price b/.github/corpus/pricing/negative_price new file mode 100644 index 0000000000000000000000000000000000000000..45031919443d48a83b36e6fe40f425481e05e427 GIT binary patch literal 18 OcmZRW^B)3WG!p=gZVxR0 literal 0 HcmV?d00001 diff --git a/.github/corpus/pricing/zero_price b/.github/corpus/pricing/zero_price new file mode 100644 index 0000000000000000000000000000000000000000..bd6ceca60b983958a9c273a1ddff67877716d62a GIT binary patch literal 18 KcmZQzKnDN-5&!`J literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b86b0fd8c7ce6094da1abfb294e22de2e4f03069 GIT binary patch literal 18 OcmZQzWPku>7y|$RMgRr? literal 0 HcmV?d00001 diff --git a/.github/corpus/state_machine/double_cancel b/.github/corpus/state_machine/double_cancel new file mode 100644 index 0000000000000000000000000000000000000000..5554f3837605957db79412659975a9b06fb2167b GIT binary patch literal 18 OcmZQ#WPkuB7y|$RVE_jJ literal 0 HcmV?d00001 diff --git a/.github/corpus/state_machine/resume_nonexistent b/.github/corpus/state_machine/resume_nonexistent new file mode 100644 index 0000000000000000000000000000000000000000..80eb3ee511716bf88b78c00efadf01825dd7ddc1 GIT binary patch literal 9 McmZR4&ddM?00&;M1& literal 0 HcmV?d00001 diff --git a/.github/corpus/subscription/cancel_immediate b/.github/corpus/subscription/cancel_immediate new file mode 100644 index 0000000000000000000000000000000000000000..1a502afbba0ec6e4d6e346ec4324593881bdc079 GIT binary patch literal 9 LcmZQ&WPktw06qW* literal 0 HcmV?d00001 diff --git a/.github/corpus/subscription/golden_path b/.github/corpus/subscription/golden_path new file mode 100644 index 0000000000000000000000000000000000000000..f5552b3f1d96c0ad226a0d3780a085fc9e4a474f GIT binary patch literal 27 PcmZQbVSoT8Msx-M3X%Y8 literal 0 HcmV?d00001 diff --git a/.github/corpus/subscription/multi_charge b/.github/corpus/subscription/multi_charge new file mode 100644 index 0000000000000000000000000000000000000000..7cd055c1da1a52ce8c5845abcf8183e9a46747ab GIT binary patch literal 27 OcmZQ#WPkuB3.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) +} From 900d2e99785d4d8c9f9e0fe55bd64dcd9ee124bb Mon Sep 17 00:00:00 2001 From: Kefas Kingsley Date: Wed, 27 May 2026 14:28:48 +0100 Subject: [PATCH 2/2] feat(ci): rewrite fuzz CI with cargo-fuzz matrix, corpus cache, crash triage - 4-target matrix (subscription, pricing, rate_limit, state_machine) running in parallel - nightly toolchain + cargo-fuzz installation - seed corpus copied from .github/corpus/ and cached between runs - 30-minute run per target with AddressSanitizer - crash artifacts uploaded on failure with 14-day retention - corpus uploaded weekly for coverage continuity - scheduled weekly run (Monday 06:00 UTC) - explicit error notification with local reproduction command --- .github/workflows/fuzz-test.yml | 112 ++++++++++++++++++++------------ 1 file changed, 72 insertions(+), 40 deletions(-) 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 }} "