From 167432dc36b3a44d2fa2b3bcd891af976dc06ab6 Mon Sep 17 00:00:00 2001 From: DARCSZN Date: Tue, 23 Jun 2026 13:48:04 +0100 Subject: [PATCH] feat(#fuzz): add fuzz targets for set_metadata and mint_tokens Adds fuzz_set_metadata and fuzz_mint_tokens targets to cover the two previously unfuzzed functions that handle user-supplied strings and amounts. Registers both in fuzz/Cargo.toml, wires them into the fuzz-testing.yml CI workflow (60 s each), and updates the README. --- .github/workflows/fuzz-testing.yml | 12 ++- contracts/token-factory/fuzz/Cargo.toml | 12 +++ contracts/token-factory/fuzz/README.md | 41 ++++++++- .../fuzz/fuzz_targets/fuzz_mint_tokens.rs | 86 +++++++++++++++++++ .../fuzz/fuzz_targets/fuzz_set_metadata.rs | 60 +++++++++++++ 5 files changed, 209 insertions(+), 2 deletions(-) create mode 100644 contracts/token-factory/fuzz/fuzz_targets/fuzz_mint_tokens.rs create mode 100644 contracts/token-factory/fuzz/fuzz_targets/fuzz_set_metadata.rs diff --git a/.github/workflows/fuzz-testing.yml b/.github/workflows/fuzz-testing.yml index 0ad2efdd..ce8b5dfc 100644 --- a/.github/workflows/fuzz-testing.yml +++ b/.github/workflows/fuzz-testing.yml @@ -44,7 +44,17 @@ jobs: working-directory: contracts/token-factory/fuzz run: cargo +nightly run --release --bin fuzz_burn -- -max_len=1000 -timeout=60 -max_total_time=60 continue-on-error: true - + + - name: Run set_metadata fuzz target + working-directory: contracts/token-factory/fuzz + run: cargo +nightly run --release --bin fuzz_set_metadata -- -max_len=10000 -timeout=60 -max_total_time=60 + continue-on-error: true + + - name: Run mint_tokens fuzz target + working-directory: contracts/token-factory/fuzz + run: cargo +nightly run --release --bin fuzz_mint_tokens -- -max_len=1000 -timeout=60 -max_total_time=60 + continue-on-error: true + - name: Upload fuzz artifacts on failure if: failure() uses: actions/upload-artifact@v7 diff --git a/contracts/token-factory/fuzz/Cargo.toml b/contracts/token-factory/fuzz/Cargo.toml index 76256dd9..e0c50913 100644 --- a/contracts/token-factory/fuzz/Cargo.toml +++ b/contracts/token-factory/fuzz/Cargo.toml @@ -26,6 +26,18 @@ path = "fuzz_targets/fuzz_burn.rs" test = false doc = false +[[bin]] +name = "fuzz_set_metadata" +path = "fuzz_targets/fuzz_set_metadata.rs" +test = false +doc = false + +[[bin]] +name = "fuzz_mint_tokens" +path = "fuzz_targets/fuzz_mint_tokens.rs" +test = false +doc = false + [profile.release] opt-level = 3 diff --git a/contracts/token-factory/fuzz/README.md b/contracts/token-factory/fuzz/README.md index 5595d6cc..727ab68f 100644 --- a/contracts/token-factory/fuzz/README.md +++ b/contracts/token-factory/fuzz/README.md @@ -4,11 +4,13 @@ This directory contains fuzz testing targets for the token factory contract usin ## Overview -Fuzz testing generates random inputs to discover edge cases and potential crashes in arithmetic and validation logic. The fuzz targets focus on three critical areas: +Fuzz testing generates random inputs to discover edge cases and potential crashes in arithmetic and validation logic. The fuzz targets focus on five critical areas: 1. **create_token**: UTF-8 string validation, name/symbol/decimals parsing, fee arithmetic with random inputs 2. **fee_arithmetic**: Integer overflow checking in fee calculations, saturation arithmetic, boundary conditions 3. **burn**: Burn amount arithmetic, balance invariants, total supply calculations with random amounts +4. **set_metadata**: Metadata URI string handling, fee comparison arithmetic, duplicate-set guard +5. **mint_tokens**: Amount validation, max-supply cap overflow (`checked_add`), fee distribution arithmetic ## Setup @@ -102,6 +104,43 @@ cargo +nightly run --release --bin fuzz_create_token -- -max_len=10000 -timeout= **File**: `fuzz_targets/fuzz_burn.rs` +### fuzz_set_metadata + +**Focus**: Metadata URI string handling and fee arithmetic for `set_metadata` + +**Tests**: +- Arbitrary byte sequences converted to UTF-8 metadata URIs (no length limit in contract) +- Fee sufficiency guard: `fee_payment >= metadata_fee` +- Fee remainder arithmetic after payment +- Duplicate-set guard simulation (idempotency path skips arithmetic) +- Saturating fee-accumulation operations matching `distribute_fee` + +**Success Criteria**: +- No panics on any valid UTF-8 URI or fee combination +- Non-UTF-8 inputs handled without panic +- All arithmetic invariants maintained + +**File**: `fuzz_targets/fuzz_set_metadata.rs` + +### fuzz_mint_tokens + +**Focus**: Amount validation, max-supply cap enforcement, and fee distribution for `mint_tokens` + +**Tests**: +- Non-positive amount rejection (`amount <= 0`) +- Fee sufficiency guard: `fee_payment >= base_fee` +- `checked_add` overflow on `current_supply + amount` (maps to `ArithmeticOverflow` error) +- Max-supply cap enforcement: `new_total > cap` (maps to `MaxSupplyExceeded` error) +- Supply monotonicity: minting always increases total supply +- Fee split distribution arithmetic (two-recipient model) + +**Success Criteria**: +- No panics on any `i128` amount or supply combination +- Overflow paths detected via `checked_add`, not via panic +- Supply invariants (`new_total <= cap`) maintained on success path + +**File**: `fuzz_targets/fuzz_mint_tokens.rs` + ## CI Integration Fuzz tests are automatically run by GitHub Actions: diff --git a/contracts/token-factory/fuzz/fuzz_targets/fuzz_mint_tokens.rs b/contracts/token-factory/fuzz/fuzz_targets/fuzz_mint_tokens.rs new file mode 100644 index 00000000..51dff8e9 --- /dev/null +++ b/contracts/token-factory/fuzz/fuzz_targets/fuzz_mint_tokens.rs @@ -0,0 +1,86 @@ +#![no_main] + +use arbitrary::Arbitrary; +use libfuzzer_sys::fuzz_target; + +#[derive(Arbitrary, Debug, Clone)] +struct FuzzMintTokensInput { + amount: i128, + fee_payment: i128, + base_fee: i128, + // Simulates token_info.max_supply + max_supply: Option, + // Simulates already-minted supply tracked in storage + current_supply: i128, +} + +fuzz_target!(|input: FuzzMintTokensInput| { + let base_fee = input.base_fee.saturating_abs(); + let fee_payment = input.fee_payment; + let amount = input.amount; + + // --- Guard: amount must be > 0 (mirrors `if amount <= 0` in mint_tokens) --- + if amount <= 0 { + // Contract returns InvalidParameters; no further arithmetic is performed. + return; + } + + // --- Guard: fee must cover base_fee --- + if fee_payment < base_fee { + // Contract returns InsufficientFee. + return; + } + + // After both guards pass, fee remainder must be non-negative. + let fee_remainder = fee_payment.saturating_sub(base_fee); + assert!(fee_remainder >= 0); + + // --- Max-supply cap arithmetic (mirrors the checked_add in mint_tokens) --- + if let Some(cap) = input.max_supply { + // Only positive caps are meaningful; non-positive caps are invalid params + // in create_token/batch, so we can skip them here. + if cap <= 0 { + return; + } + + let current = input.current_supply.max(0); // storage always holds >= 0 + + // This is the exact expression from lib.rs line 551: + // let new_total = current.checked_add(amount).ok_or(Error::ArithmeticOverflow)?; + match current.checked_add(amount) { + None => { + // Overflow path — contract returns ArithmeticOverflow, no panic. + return; + } + Some(new_total) => { + if new_total > cap { + // Contract returns MaxSupplyExceeded. + return; + } + // Successful path: new_total is within the cap. + assert!(new_total > current, "minting a positive amount must increase supply"); + assert!(new_total <= cap, "supply must never exceed cap"); + } + } + } else { + // No cap: unlimited mint — only the amount guard applies. + // Verify the mint amount itself doesn't silently wrap when accumulated. + let accumulated = amount.saturating_add(amount); + assert!(accumulated >= amount, "saturating_add must not decrease value for positive inputs"); + } + + // --- Fee distribution arithmetic (mirrors distribute_fee) --- + // Verify that splitting the fee across multiple recipients cannot panic. + // Model: two recipients each receiving 50 % (5_000 bps out of 10_000). + let share_a = fee_payment + .saturating_mul(5_000) + / 10_000; + let share_b = fee_payment + .saturating_mul(5_000) + / 10_000; + let distributed = share_a.saturating_add(share_b); + let _remainder = fee_payment.saturating_sub(distributed); + + assert!(share_a >= 0 || fee_payment < 0); + assert!(share_b >= 0 || fee_payment < 0); +}); diff --git a/contracts/token-factory/fuzz/fuzz_targets/fuzz_set_metadata.rs b/contracts/token-factory/fuzz/fuzz_targets/fuzz_set_metadata.rs new file mode 100644 index 00000000..7de9db60 --- /dev/null +++ b/contracts/token-factory/fuzz/fuzz_targets/fuzz_set_metadata.rs @@ -0,0 +1,60 @@ +#![no_main] + +use arbitrary::Arbitrary; +use libfuzzer_sys::fuzz_target; + +#[derive(Arbitrary, Debug, Clone)] +struct FuzzSetMetadataInput { + // Random bytes for metadata URI - no length restriction in the contract + uri_bytes: Vec, + fee_payment: i128, + metadata_fee: i128, + // Whether a duplicate set attempt follows the first + attempt_duplicate: bool, +} + +fuzz_target!(|input: FuzzSetMetadataInput| { + // Normalize the metadata_fee so it is non-negative (contract stores positive fees) + let metadata_fee = input.metadata_fee.saturating_abs(); + let fee_payment = input.fee_payment; + + // --- Fee comparison logic (mirrors set_metadata guard) --- + let fee_sufficient = fee_payment >= metadata_fee; + + if fee_sufficient { + // Payment at or above the required fee must never underflow when subtracted + let remainder = fee_payment.saturating_sub(metadata_fee); + assert!(remainder >= 0); + } + + // --- Metadata URI string validation --- + // The contract accepts any soroban_sdk::String; we model user-supplied bytes here. + let uri_str = match String::from_utf8(input.uri_bytes.clone()) { + Ok(s) => s, + // Non-UTF-8 bytes would be rejected at the SDK boundary — treat as invalid + Err(_) => return, + }; + + // Empty URIs are technically accepted by the contract (no length guard in set_metadata). + // Verify that working with the string does not panic regardless of content. + let _uri_len = uri_str.len(); + let _is_empty = uri_str.is_empty(); + + // Simulate the "already set" idempotency guard: a second call for the same + // token must return MetadataAlreadySet without touching the fee. + if input.attempt_duplicate { + // After a successful first call the storage slot is occupied. + // The fee path is never reached, so no arithmetic is performed. + // Verify the fee values themselves are still well-formed. + assert!(metadata_fee >= 0); + } + + // --- Overflow-safe fee accumulation (mirrors distribute_fee arithmetic) --- + // Ensure multiplying the fee by a small operation count cannot overflow. + let ops: i128 = 3; // set_metadata is a single operation, but guard the pattern + let _scaled = metadata_fee.saturating_mul(ops); + let _total = fee_payment.saturating_add(metadata_fee); + + // Invariant: saturating operations always return a value + assert!(metadata_fee.saturating_add(i128::MAX) >= 0 || metadata_fee < 0); +});