Skip to content
Open
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
136 changes: 89 additions & 47 deletions .github/workflows/fuzz-test.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions contracts/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ members = [
"fraud",
"types",
]
exclude = ["fuzz"]

[profile.release]
opt-level = "z"
Expand Down
37 changes: 37 additions & 0 deletions contracts/fuzz/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
create-plan subscribe charge refund pause resume cancel
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
price:1 price:100 price:1000000 interval:monthly yearly
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
create_plan rate_limit immediate_retry delayed_retry
131 changes: 131 additions & 0 deletions contracts/fuzz/fuzz_targets/common.rs
Original file line number Diff line number Diff line change
@@ -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<T>(f: impl FnOnce() -> T) -> Option<T> {
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;
}
}
Loading