From a8de1e2c1a9b85d260ec90b076baaf156f70b3bc Mon Sep 17 00:00:00 2001 From: Tu Pham Date: Mon, 25 May 2026 17:37:54 +0700 Subject: [PATCH] test: add contract fuzz targets --- Cargo.toml | 3 + contracts/sorosave/Cargo.toml | 2 +- contracts/sorosave/fuzz/.gitignore | 5 ++ contracts/sorosave/fuzz/Cargo.toml | 34 ++++++++ contracts/sorosave/fuzz/README.md | 21 +++++ .../sorosave/fuzz/fuzz_targets/common.rs | 79 +++++++++++++++++++ .../sorosave/fuzz/fuzz_targets/contribute.rs | 41 ++++++++++ .../fuzz/fuzz_targets/create_group.rs | 22 ++++++ .../fuzz/fuzz_targets/distribute_payout.rs | 55 +++++++++++++ 9 files changed, 261 insertions(+), 1 deletion(-) create mode 100644 contracts/sorosave/fuzz/.gitignore create mode 100644 contracts/sorosave/fuzz/Cargo.toml create mode 100644 contracts/sorosave/fuzz/README.md create mode 100644 contracts/sorosave/fuzz/fuzz_targets/common.rs create mode 100644 contracts/sorosave/fuzz/fuzz_targets/contribute.rs create mode 100644 contracts/sorosave/fuzz/fuzz_targets/create_group.rs create mode 100644 contracts/sorosave/fuzz/fuzz_targets/distribute_payout.rs diff --git a/Cargo.toml b/Cargo.toml index 6aa5200..5c68252 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,9 @@ resolver = "2" members = [ "contracts/*", ] +exclude = [ + "contracts/sorosave/fuzz", +] [workspace.dependencies] soroban-sdk = "22.0.1" diff --git a/contracts/sorosave/Cargo.toml b/contracts/sorosave/Cargo.toml index 1715b7e..855b344 100644 --- a/contracts/sorosave/Cargo.toml +++ b/contracts/sorosave/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" publish = false [lib] -crate-type = ["cdylib"] +crate-type = ["cdylib", "rlib"] doctest = false [dependencies] diff --git a/contracts/sorosave/fuzz/.gitignore b/contracts/sorosave/fuzz/.gitignore new file mode 100644 index 0000000..ab0eaa1 --- /dev/null +++ b/contracts/sorosave/fuzz/.gitignore @@ -0,0 +1,5 @@ +target/ +corpus/ +artifacts/ +coverage/ +Cargo.lock diff --git a/contracts/sorosave/fuzz/Cargo.toml b/contracts/sorosave/fuzz/Cargo.toml new file mode 100644 index 0000000..a3c56b3 --- /dev/null +++ b/contracts/sorosave/fuzz/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "sorosave-fuzz" +version = "0.0.0" +edition = "2021" +publish = false + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +soroban-sdk = { version = "22.0.1", features = ["testutils"] } +sorosave = { path = ".." } + +[[bin]] +name = "create_group" +path = "fuzz_targets/create_group.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "contribute" +path = "fuzz_targets/contribute.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "distribute_payout" +path = "fuzz_targets/distribute_payout.rs" +test = false +doc = false +bench = false diff --git a/contracts/sorosave/fuzz/README.md b/contracts/sorosave/fuzz/README.md new file mode 100644 index 0000000..9446b24 --- /dev/null +++ b/contracts/sorosave/fuzz/README.md @@ -0,0 +1,21 @@ +# SoroSave Fuzz Targets + +This directory configures `cargo-fuzz` targets for the core SoroSave contract flows: + +- `create_group`: randomizes boundary values for group creation. +- `contribute`: randomizes member contribution order and repeated contribution attempts. +- `distribute_payout`: randomizes complete and incomplete payout attempts. + +Run from `contracts/sorosave`: + +```sh +cargo fuzz run create_group +cargo fuzz run contribute +cargo fuzz run distribute_payout +``` + +The fuzz package is excluded from the root workspace so normal `cargo test` and CI do not pull libFuzzer dependencies. + +## Current Findings + +No new contract issues are documented yet. These targets establish repeatable fuzz coverage for future edge-case discovery. diff --git a/contracts/sorosave/fuzz/fuzz_targets/common.rs b/contracts/sorosave/fuzz/fuzz_targets/common.rs new file mode 100644 index 0000000..b5fc7a8 --- /dev/null +++ b/contracts/sorosave/fuzz/fuzz_targets/common.rs @@ -0,0 +1,79 @@ +#![allow(dead_code)] + +use soroban_sdk::{testutils::Address as _, token::StellarAssetClient, Address, Env}; +use sorosave::{SoroSaveContract, SoroSaveContractClient}; + +pub struct Harness { + pub env: Env, + pub admin: Address, + pub client: SoroSaveContractClient<'static>, + pub token: Address, +} + +pub fn setup() -> Harness { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let contract_id = env.register(SoroSaveContract, (&admin,)); + let client = SoroSaveContractClient::new(&env, &contract_id); + + let token_admin = Address::generate(&env); + let token_id = env.register_stellar_asset_contract_v2(token_admin); + let token = token_id.address(); + let token_client = StellarAssetClient::new(&env, &token); + token_client.mint(&admin, &1_000_000_000_000); + + Harness { + env, + admin, + client, + token, + } +} + +pub fn read_i64(data: &[u8], offset: usize) -> i64 { + let mut bytes = [0_u8; 8]; + for (index, byte) in bytes.iter_mut().enumerate() { + *byte = data.get(offset + index).copied().unwrap_or_default(); + } + i64::from_le_bytes(bytes) +} + +pub fn read_u64(data: &[u8], offset: usize) -> u64 { + let mut bytes = [0_u8; 8]; + for (index, byte) in bytes.iter_mut().enumerate() { + *byte = data.get(offset + index).copied().unwrap_or_default(); + } + u64::from_le_bytes(bytes) +} + +pub fn positive_amount(data: &[u8], offset: usize) -> i128 { + (read_u64(data, offset) % 5_000_000 + 1) as i128 +} + +pub fn cycle_length(data: &[u8], offset: usize) -> u64 { + read_u64(data, offset) % 604_800 + 1 +} + +pub fn member_count(data: &[u8], offset: usize) -> usize { + data.get(offset) + .map(|value| (value % 5 + 2) as usize) + .unwrap_or(2) +} + +pub fn mint_members( + env: &Env, + token: &Address, + count: usize, + amount: i128, +) -> std::vec::Vec
{ + let token_client = StellarAssetClient::new(env, token); + let mut members = std::vec::Vec::with_capacity(count); + for _ in 0..count { + let member = Address::generate(env); + token_client.mint(&member, &amount); + members.push(member); + } + members +} diff --git a/contracts/sorosave/fuzz/fuzz_targets/contribute.rs b/contracts/sorosave/fuzz/fuzz_targets/contribute.rs new file mode 100644 index 0000000..09f94f1 --- /dev/null +++ b/contracts/sorosave/fuzz/fuzz_targets/contribute.rs @@ -0,0 +1,41 @@ +#![no_main] + +mod common; + +use libfuzzer_sys::fuzz_target; +use soroban_sdk::{Address, String}; + +fuzz_target!(|data: &[u8]| { + let harness = common::setup(); + let contribution_amount = common::positive_amount(data, 0); + let member_count = common::member_count(data, 8); + let members = common::mint_members( + &harness.env, + &harness.token, + member_count - 1, + contribution_amount * 4, + ); + + let group_id = harness.client.create_group( + &harness.admin, + &String::from_str(&harness.env, "Fuzz Contribute"), + &harness.token, + &contribution_amount, + &common::cycle_length(data, 9), + &(member_count as u32), + ); + + let mut actors = std::vec::Vec::
::with_capacity(member_count); + actors.push(harness.admin.clone()); + for member in members { + harness.client.join_group(&member, &group_id); + actors.push(member); + } + + harness.client.start_group(&harness.admin, &group_id); + + for byte in data.iter().skip(17).take(64) { + let actor = &actors[*byte as usize % actors.len()]; + let _ = harness.client.try_contribute(actor, &group_id); + } +}); diff --git a/contracts/sorosave/fuzz/fuzz_targets/create_group.rs b/contracts/sorosave/fuzz/fuzz_targets/create_group.rs new file mode 100644 index 0000000..a6f9db9 --- /dev/null +++ b/contracts/sorosave/fuzz/fuzz_targets/create_group.rs @@ -0,0 +1,22 @@ +#![no_main] + +mod common; + +use libfuzzer_sys::fuzz_target; +use soroban_sdk::String; + +fuzz_target!(|data: &[u8]| { + let harness = common::setup(); + let contribution_amount = (common::read_i64(data, 0) % 5_000_000) as i128; + let cycle_length = common::read_u64(data, 8); + let max_members = data.get(16).map(|value| (value % 8) as u32).unwrap_or(0); + + let _ = harness.client.try_create_group( + &harness.admin, + &String::from_str(&harness.env, "Fuzz Create"), + &harness.token, + &contribution_amount, + &cycle_length, + &max_members, + ); +}); diff --git a/contracts/sorosave/fuzz/fuzz_targets/distribute_payout.rs b/contracts/sorosave/fuzz/fuzz_targets/distribute_payout.rs new file mode 100644 index 0000000..f228532 --- /dev/null +++ b/contracts/sorosave/fuzz/fuzz_targets/distribute_payout.rs @@ -0,0 +1,55 @@ +#![no_main] + +mod common; + +use libfuzzer_sys::fuzz_target; +use soroban_sdk::{Address, String}; + +fuzz_target!(|data: &[u8]| { + let harness = common::setup(); + let contribution_amount = common::positive_amount(data, 0); + let member_count = common::member_count(data, 8); + let members = common::mint_members( + &harness.env, + &harness.token, + member_count - 1, + contribution_amount * 4, + ); + + let group_id = harness.client.create_group( + &harness.admin, + &String::from_str(&harness.env, "Fuzz Payout"), + &harness.token, + &contribution_amount, + &common::cycle_length(data, 9), + &(member_count as u32), + ); + + let mut actors = std::vec::Vec::
::with_capacity(member_count); + actors.push(harness.admin.clone()); + for member in members { + harness.client.join_group(&member, &group_id); + actors.push(member); + } + + harness.client.start_group(&harness.admin, &group_id); + + for (index, actor) in actors.iter().enumerate() { + let should_contribute = data + .get(17 + index) + .map(|byte| byte & 1 == 1) + .unwrap_or(true); + if should_contribute { + let _ = harness.client.try_contribute(actor, &group_id); + } + } + + for byte in data.iter().skip(17 + actors.len()).take(16) { + if byte & 1 == 0 { + let _ = harness.client.try_distribute_payout(&group_id); + } else { + let actor = &actors[*byte as usize % actors.len()]; + let _ = harness.client.try_contribute(actor, &group_id); + } + } +});