From 644584afe325bc8e5f1cb5c55127d9578a5c6066 Mon Sep 17 00:00:00 2001 From: David Ojo Date: Fri, 27 Mar 2026 15:37:50 +0100 Subject: [PATCH 1/3] fix(security): implement balance check to prevent fee overdraw --- contract/contract/src/base/errors.rs | 6 +- contract/contract/src/base/events.rs | 73 +++- contract/contract/src/base/types.rs | 17 + contract/contract/src/crowdfunding.rs | 61 ++- .../contract/src/interfaces/crowdfunding.rs | 6 + contract/contract/test/all_events_test.rs | 221 +++++++++++ contract/contract/test/crowdfunding_test.rs | 4 +- contract/contract/test/issue_208_test.rs | 352 ++++++++++++++++++ contract/contract/test/mod.rs | 2 + .../test/withdraw_platform_fees_test.rs | 4 +- 10 files changed, 729 insertions(+), 17 deletions(-) create mode 100644 contract/contract/test/all_events_test.rs create mode 100644 contract/contract/test/issue_208_test.rs diff --git a/contract/contract/src/base/errors.rs b/contract/contract/src/base/errors.rs index d365f51..c9a0b5b 100644 --- a/contract/contract/src/base/errors.rs +++ b/contract/contract/src/base/errors.rs @@ -51,8 +51,10 @@ pub enum CrowdfundingError { PoolAlreadyClosed = 45, PoolNotDisbursedOrRefunded = 46, InvalidGoalUpdate = 47, - InsufficientFees = 48, - UserBlacklisted = 49, + /// Withdrawal exceeds the tracked platform-fee balance. + InsufficientPlatformFees = 48, + /// Withdrawal exceeds the tracked event-fee balance. + InsufficientEventFees = 49, CampaignCancelled = 50, } diff --git a/contract/contract/src/base/events.rs b/contract/contract/src/base/events.rs index 639d8cc..162f378 100644 --- a/contract/contract/src/base/events.rs +++ b/contract/contract/src/base/events.rs @@ -1,7 +1,49 @@ #![allow(deprecated)] -use soroban_sdk::{Address, BytesN, Env, String, Symbol}; +use soroban_sdk::{Address, BytesN, Env, String, Symbol, Vec}; -use crate::base::types::PoolState; +use crate::base::types::{EventRecord, PoolState, StorageKey}; + +// --------------------------------------------------------------------------- +// Global event tracker +// --------------------------------------------------------------------------- + +/// Increment the persistent event counter and append a record to `AllEvents`. +/// +/// Uses persistent storage so the log survives ledger TTL expiry. +/// Called by every public event emitter in this module. +fn record_event(env: &Env, name: &str) { + let count_key = StorageKey::AllEventsCount; + let list_key = StorageKey::AllEvents; + + // Increment counter (starts at 0 if not yet initialised) + let new_index: u64 = env + .storage() + .persistent() + .get::<_, u64>(&count_key) + .unwrap_or(0) + + 1; + + env.storage().persistent().set(&count_key, &new_index); + + // Append a lightweight record to the global list + let mut list: Vec = env + .storage() + .persistent() + .get::<_, Vec>(&list_key) + .unwrap_or_else(|| Vec::new(env)); + + list.push_back(EventRecord { + index: new_index, + name: String::from_str(env, name), + timestamp: env.ledger().timestamp(), + }); + + env.storage().persistent().set(&list_key, &list); +} + +// --------------------------------------------------------------------------- +// Event emitters +// --------------------------------------------------------------------------- pub fn campaign_created( env: &Env, @@ -13,11 +55,13 @@ pub fn campaign_created( ) { let topics = (Symbol::new(env, "campaign_created"), id, creator); env.events().publish(topics, (title, goal, deadline)); + record_event(env, "campaign_created"); } pub fn campaign_goal_updated(env: &Env, id: BytesN<32>, new_goal: i128) { let topics = (Symbol::new(env, "campaign_goal_updated"), id); env.events().publish(topics, new_goal); + record_event(env, "campaign_goal_updated"); } #[allow(clippy::too_many_arguments)] @@ -36,6 +80,7 @@ pub fn pool_created( topics, (name, description, target_amount, min_contribution, deadline), ); + record_event(env, "pool_created"); } pub fn event_created( @@ -49,46 +94,55 @@ pub fn event_created( let topics = (Symbol::new(env, "event_created"), pool_id, creator); env.events() .publish(topics, (name, target_amount, deadline)); + record_event(env, "event_created"); } pub fn pool_state_updated(env: &Env, pool_id: u64, new_state: PoolState) { let topics = (Symbol::new(env, "pool_state_updated"), pool_id); env.events().publish(topics, new_state); + record_event(env, "pool_state_updated"); } pub fn contract_paused(env: &Env, admin: Address, timestamp: u64) { let topics = (Symbol::new(env, "contract_paused"), admin); env.events().publish(topics, timestamp); + record_event(env, "contract_paused"); } pub fn contract_unpaused(env: &Env, admin: Address, timestamp: u64) { let topics = (Symbol::new(env, "contract_unpaused"), admin); env.events().publish(topics, timestamp); + record_event(env, "contract_unpaused"); } pub fn admin_renounced(env: &Env, admin: Address) { let topics = (Symbol::new(env, "admin_renounced"), admin); env.events().publish(topics, ()); + record_event(env, "admin_renounced"); } pub fn emergency_contact_updated(env: &Env, admin: Address, contact: Address) { let topics = (Symbol::new(env, "emergency_contact_updated"), admin); env.events().publish(topics, contact); + record_event(env, "emergency_contact_updated"); } pub fn donation_made(env: &Env, campaign_id: BytesN<32>, contributor: Address, amount: i128) { let topics = (Symbol::new(env, "donation_made"), campaign_id); env.events().publish(topics, (contributor, amount)); + record_event(env, "donation_made"); } pub fn campaign_cancelled(env: &Env, id: BytesN<32>) { let topics = (Symbol::new(env, "campaign_cancelled"), id); env.events().publish(topics, ()); + record_event(env, "campaign_cancelled"); } pub fn campaign_refunded(env: &Env, id: BytesN<32>, contributor: Address, amount: i128) { let topics = (Symbol::new(env, "campaign_refunded"), id, contributor); env.events().publish(topics, amount); + record_event(env, "campaign_refunded"); } pub fn contribution( @@ -103,6 +157,7 @@ pub fn contribution( let topics = (Symbol::new(env, "contribution"), pool_id, contributor); env.events() .publish(topics, (asset, amount, timestamp, is_private)); + record_event(env, "contribution"); } pub fn emergency_withdraw_requested( @@ -114,26 +169,31 @@ pub fn emergency_withdraw_requested( ) { let topics = (Symbol::new(env, "emergency_withdraw_requested"), admin); env.events().publish(topics, (token, amount, unlock_time)); + record_event(env, "emergency_withdraw_requested"); } pub fn emergency_withdraw_executed(env: &Env, admin: Address, token: Address, amount: i128) { let topics = (Symbol::new(env, "emergency_withdraw_executed"), admin); env.events().publish(topics, (token, amount)); + record_event(env, "emergency_withdraw_executed"); } pub fn crowdfunding_token_set(env: &Env, admin: Address, token: Address) { let topics = (Symbol::new(env, "crowdfunding_token_set"), admin); env.events().publish(topics, token); + record_event(env, "crowdfunding_token_set"); } pub fn creation_fee_set(env: &Env, admin: Address, fee: i128) { let topics = (Symbol::new(env, "creation_fee_set"), admin); env.events().publish(topics, fee); + record_event(env, "creation_fee_set"); } pub fn creation_fee_paid(env: &Env, creator: Address, amount: i128) { let topics = (Symbol::new(env, "creation_fee_paid"), creator); env.events().publish(topics, amount); + record_event(env, "creation_fee_paid"); } pub fn refund( @@ -146,41 +206,49 @@ pub fn refund( ) { let topics = (Symbol::new(env, "refund"), pool_id, contributor); env.events().publish(topics, (asset, amount, timestamp)); + record_event(env, "refund"); } pub fn pool_closed(env: &Env, pool_id: u64, closed_by: Address, timestamp: u64) { let topics = (Symbol::new(env, "pool_closed"), pool_id, closed_by); env.events().publish(topics, timestamp); + record_event(env, "pool_closed"); } pub fn platform_fees_withdrawn(env: &Env, to: Address, amount: i128) { let topics = (Symbol::new(env, "platform_fees_withdrawn"), to); env.events().publish(topics, amount); + record_event(env, "platform_fees_withdrawn"); } pub fn event_fees_withdrawn(env: &Env, admin: Address, to: Address, amount: i128) { let topics = (Symbol::new(env, "event_fees_withdrawn"), admin, to); env.events().publish(topics, amount); + record_event(env, "event_fees_withdrawn"); } pub fn address_blacklisted(env: &Env, admin: Address, address: Address) { let topics = (Symbol::new(env, "address_blacklisted"), admin); env.events().publish(topics, address); + record_event(env, "address_blacklisted"); } pub fn address_unblacklisted(env: &Env, admin: Address, address: Address) { let topics = (Symbol::new(env, "address_unblacklisted"), admin); env.events().publish(topics, address); + record_event(env, "address_unblacklisted"); } pub fn pool_metadata_updated(env: &Env, pool_id: u64, updater: Address, new_metadata_hash: String) { let topics = (Symbol::new(env, "pool_metadata_updated"), pool_id, updater); env.events().publish(topics, new_metadata_hash); + record_event(env, "pool_metadata_updated"); } pub fn platform_fee_bps_set(env: &Env, admin: Address, fee_bps: u32) { let topics = (Symbol::new(env, "platform_fee_bps_set"), admin); env.events().publish(topics, fee_bps); + record_event(env, "platform_fee_bps_set"); } pub fn ticket_sold( @@ -194,4 +262,5 @@ pub fn ticket_sold( let topics = (Symbol::new(env, "ticket_sold"), pool_id, buyer); env.events() .publish(topics, (price, event_amount, fee_amount)); + record_event(env, "ticket_sold"); } diff --git a/contract/contract/src/base/types.rs b/contract/contract/src/base/types.rs index 6a163f2..97d637a 100644 --- a/contract/contract/src/base/types.rs +++ b/contract/contract/src/base/types.rs @@ -1,5 +1,18 @@ use soroban_sdk::{contracttype, Address, BytesN, String, Vec}; +/// A lightweight record stored for every emitted contract event. +/// Used to populate the global `AllEvents` list. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EventRecord { + /// Sequential index (1-based) assigned at emission time. + pub index: u64, + /// Short name matching the event's topic Symbol (e.g. "pool_created"). + pub name: String, + /// Ledger timestamp at the moment the event was emitted. + pub timestamp: u64, +} + #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct CampaignDetails { @@ -290,6 +303,10 @@ pub enum StorageKey { EventPlatformFees(u64), // Track if someone bought a ticket UserTicket(u64, Address), + // Global event tracker: total number of events ever emitted + AllEventsCount, + // Global event tracker: append-only list of all emitted event records + AllEvents, } #[cfg(test)] diff --git a/contract/contract/src/crowdfunding.rs b/contract/contract/src/crowdfunding.rs index f67974a..72ddc7e 100644 --- a/contract/contract/src/crowdfunding.rs +++ b/contract/contract/src/crowdfunding.rs @@ -325,13 +325,21 @@ impl CrowdfundingTrait for CrowdfundingContract { .instance() .set(&event_pool_key, &(current_event + event_amount)); - // Credit platform fee pool + // Credit platform fee pool (per-pool ledger for auditability) let event_fee_key = StorageKey::EventPlatformFees(pool_id); let current_fees: i128 = env.storage().instance().get(&event_fee_key).unwrap_or(0); env.storage() .instance() .set(&event_fee_key, &(current_fees + fee_amount)); + // Aggregate into the global event-fee treasury so withdraw_event_fees + // always reflects the true withdrawable balance. + let treasury_key = StorageKey::EventFeeTreasury; + let treasury_balance: i128 = env.storage().instance().get(&treasury_key).unwrap_or(0); + env.storage() + .instance() + .set(&treasury_key, &(treasury_balance + fee_amount)); + // Track user ticket let user_ticket_key = StorageKey::UserTicket(pool_id, buyer.clone()); env.storage().instance().set(&user_ticket_key, &true); @@ -1719,14 +1727,16 @@ impl CrowdfundingTrait for CrowdfundingContract { } let platform_fees_key = StorageKey::PlatformFees; - let current_fees: i128 = env + let collected_fees: i128 = env .storage() .instance() .get(&platform_fees_key) .unwrap_or(0); - if amount > current_fees { - return Err(CrowdfundingError::InsufficientFees); + // Guard 1: amount must not exceed what the contract has tracked as + // collected platform fees — prevents draining pool-contribution funds. + if amount > collected_fees { + return Err(CrowdfundingError::InsufficientPlatformFees); } let token_key = StorageKey::CrowdfundingToken; @@ -1738,11 +1748,20 @@ impl CrowdfundingTrait for CrowdfundingContract { use soroban_sdk::token; let token_client = token::Client::new(&env, &token_address); + + // Guard 2: the on-chain token balance must cover the withdrawal. + // This catches any accounting drift between the fee counter and reality. + let contract_token_balance = token_client.balance(&env.current_contract_address()); + if amount > contract_token_balance { + return Err(CrowdfundingError::InsufficientPlatformFees); + } + token_client.transfer(&env.current_contract_address(), &to, &amount); + // Deduct from the tracked fee balance — pool funds are never touched. env.storage() .instance() - .set(&platform_fees_key, &(current_fees - amount)); + .set(&platform_fees_key, &(collected_fees - amount)); events::platform_fees_withdrawn(&env, to, amount); @@ -1772,10 +1791,12 @@ impl CrowdfundingTrait for CrowdfundingContract { } let event_fees_key = StorageKey::EventFeeTreasury; - let current_fees: i128 = env.storage().instance().get(&event_fees_key).unwrap_or(0); + let collected_event_fees: i128 = env.storage().instance().get(&event_fees_key).unwrap_or(0); - if amount > current_fees { - return Err(CrowdfundingError::InsufficientFees); + // Guard 1: amount must not exceed the tracked event-fee treasury — + // prevents draining pool-contribution or platform-fee funds. + if amount > collected_event_fees { + return Err(CrowdfundingError::InsufficientEventFees); } let token_key = StorageKey::CrowdfundingToken; @@ -1787,11 +1808,19 @@ impl CrowdfundingTrait for CrowdfundingContract { use soroban_sdk::token; let token_client = token::Client::new(&env, &token_address); + + // Guard 2: on-chain balance must cover the withdrawal. + let contract_token_balance = token_client.balance(&env.current_contract_address()); + if amount > contract_token_balance { + return Err(CrowdfundingError::InsufficientEventFees); + } + token_client.transfer(&env.current_contract_address(), &to, &amount); + // Deduct from the treasury — pool-contribution funds are never touched. env.storage() .instance() - .set(&event_fees_key, &(current_fees - amount)); + .set(&event_fees_key, &(collected_event_fees - amount)); events::event_fees_withdrawn(&env, admin, to, amount); @@ -1888,6 +1917,20 @@ impl CrowdfundingTrait for CrowdfundingContract { env.deployer().update_current_contract_wasm(new_wasm_hash); Ok(()) } + + fn get_all_events_count(env: Env) -> u64 { + env.storage() + .persistent() + .get::<_, u64>(&StorageKey::AllEventsCount) + .unwrap_or(0) + } + + fn get_all_events(env: Env) -> Vec { + env.storage() + .persistent() + .get::<_, Vec>(&StorageKey::AllEvents) + .unwrap_or_else(|| Vec::new(&env)) + } } impl CrowdfundingContract { diff --git a/contract/contract/src/interfaces/crowdfunding.rs b/contract/contract/src/interfaces/crowdfunding.rs index 5d21f8d..3ff5d59 100644 --- a/contract/contract/src/interfaces/crowdfunding.rs +++ b/contract/contract/src/interfaces/crowdfunding.rs @@ -217,4 +217,10 @@ pub trait CrowdfundingTrait { ) -> Result<(i128, i128), CrowdfundingError>; fn upgrade_contract(env: Env, new_wasm_hash: BytesN<32>) -> Result<(), CrowdfundingError>; + + /// Returns the total number of events ever emitted by this contract. + fn get_all_events_count(env: Env) -> u64; + + /// Returns the full list of emitted event records (index, name, timestamp). + fn get_all_events(env: Env) -> Vec; } diff --git a/contract/contract/test/all_events_test.rs b/contract/contract/test/all_events_test.rs new file mode 100644 index 0000000..f2812d3 --- /dev/null +++ b/contract/contract/test/all_events_test.rs @@ -0,0 +1,221 @@ +#![cfg(test)] + +use crate::{ + base::types::PoolConfig, + crowdfunding::{CrowdfundingContract, CrowdfundingContractClient}, +}; +use soroban_sdk::{testutils::Address as _, Address, Env, String}; + +// --------------------------------------------------------------------------- +// Shared setup +// --------------------------------------------------------------------------- + +fn setup(env: &Env) -> (CrowdfundingContractClient<'_>, Address, Address) { + env.mock_all_auths(); + let contract_id = env.register(CrowdfundingContract, ()); + let client = CrowdfundingContractClient::new(env, &contract_id); + + let admin = Address::generate(env); + let token_admin = Address::generate(env); + let token = env + .register_stellar_asset_contract_v2(token_admin) + .address(); + + client.initialize(&admin, &token, &0); + (client, admin, token) +} + +fn pool_config(env: &Env, token: &Address) -> PoolConfig { + PoolConfig { + name: String::from_str(env, "Test Pool"), + description: String::from_str(env, "A test pool"), + target_amount: 10_000, + min_contribution: 0, + is_private: false, + duration: 86_400, + created_at: env.ledger().timestamp(), + token_address: token.clone(), + } +} + +// --------------------------------------------------------------------------- +// Initial-state tests +// --------------------------------------------------------------------------- + +#[test] +fn test_initial_event_count_is_zero() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(CrowdfundingContract, ()); + let client = CrowdfundingContractClient::new(&env, &contract_id); + + assert_eq!(client.get_all_events_count(), 0); + assert_eq!(client.get_all_events().len(), 0); + + let admin = Address::generate(&env); + let token_admin = Address::generate(&env); + let token = env + .register_stellar_asset_contract_v2(token_admin) + .address(); + + client.initialize(&admin, &token, &0); + // initialize does not emit a tracked event + assert_eq!(client.get_all_events_count(), 0); +} + +#[test] +fn test_initial_events_list_is_empty() { + let env = Env::default(); + let (client, _, _) = setup(&env); + assert_eq!(client.get_all_events().len(), 0); +} + +// --------------------------------------------------------------------------- +// Increment tests +// --------------------------------------------------------------------------- + +#[test] +fn test_counter_increments_by_one_per_event() { + let env = Env::default(); + let (client, creator, token) = setup(&env); + + let before = client.get_all_events_count(); + client.create_pool(&creator, &pool_config(&env, &token)); + let after = client.get_all_events_count(); + + // create_pool emits pool_created + event_created → +2 + assert_eq!( + after - before, + 2, + "create_pool must emit exactly 2 tracked events (pool_created + event_created)" + ); +} + +#[test] +fn test_list_size_matches_counter() { + let env = Env::default(); + let (client, creator, token) = setup(&env); + + client.create_pool(&creator, &pool_config(&env, &token)); + + let count = client.get_all_events_count(); + let list_len = client.get_all_events().len() as u64; + + assert_eq!(count, list_len, "AllEventsCount must equal AllEvents.len()"); +} + +#[test] +fn test_single_event_increments_counter_by_one() { + let env = Env::default(); + let (client, _, _) = setup(&env); + + let before = client.get_all_events_count(); + client.pause(); + let after = client.get_all_events_count(); + + assert_eq!( + after - before, + 1, + "pause must increment counter by exactly 1" + ); +} + +#[test] +fn test_event_record_fields_are_correct() { + let env = Env::default(); + let (client, _, _) = setup(&env); + + let before_count = client.get_all_events_count(); + let ts = env.ledger().timestamp(); + + client.pause(); + + let records = client.get_all_events(); + let record = records.get(before_count as u32).unwrap(); + + assert_eq!(record.index, before_count + 1); + assert_eq!(record.name, String::from_str(&env, "contract_paused")); + assert_eq!(record.timestamp, ts); +} + +// --------------------------------------------------------------------------- +// Multiple consecutive events +// --------------------------------------------------------------------------- + +#[test] +fn test_counter_does_not_reset_across_multiple_events() { + let env = Env::default(); + let (client, creator, token) = setup(&env); + + client.pause(); + assert_eq!(client.get_all_events_count(), 1); + + client.unpause(); + assert_eq!(client.get_all_events_count(), 2); + + client.create_pool(&creator, &pool_config(&env, &token)); + assert_eq!(client.get_all_events_count(), 4); + + client.create_pool(&creator, &pool_config(&env, &token)); + assert_eq!(client.get_all_events_count(), 6); +} + +#[test] +fn test_list_grows_monotonically() { + let env = Env::default(); + let (client, creator, token) = setup(&env); + + let mut prev_len = client.get_all_events().len(); + + for _ in 0..3 { + client.create_pool(&creator, &pool_config(&env, &token)); + let new_len = client.get_all_events().len(); + assert!( + new_len > prev_len, + "AllEvents list must grow with each create_pool call" + ); + prev_len = new_len; + } +} + +#[test] +fn test_event_indices_are_sequential() { + let env = Env::default(); + let (client, _, _) = setup(&env); + + client.pause(); + client.unpause(); + client.pause(); + + let records = client.get_all_events(); + for (i, record) in records.iter().enumerate() { + assert_eq!( + record.index, + (i as u64) + 1, + "event index must be sequential and 1-based" + ); + } +} + +#[test] +fn test_counter_and_list_stay_in_sync_after_many_events() { + let env = Env::default(); + let (client, creator, token) = setup(&env); + + client.pause(); + client.unpause(); + client.create_pool(&creator, &pool_config(&env, &token)); + client.pause(); + client.unpause(); + client.create_pool(&creator, &pool_config(&env, &token)); + + let count = client.get_all_events_count(); + let list_len = client.get_all_events().len() as u64; + + assert_eq!( + count, list_len, + "counter and list length must stay in sync after many events" + ); + // 2×pause + 2×unpause + 2×create_pool(2 events each) = 8 + assert_eq!(count, 8); +} diff --git a/contract/contract/test/crowdfunding_test.rs b/contract/contract/test/crowdfunding_test.rs index a1300af..e2b3e6c 100644 --- a/contract/contract/test/crowdfunding_test.rs +++ b/contract/contract/test/crowdfunding_test.rs @@ -3416,7 +3416,7 @@ fn test_withdraw_platform_fees_insufficient_fees() { let (client, admin, _) = setup_test(&env); let res = client.try_withdraw_platform_fees(&admin, &100); - assert_eq!(res, Err(Ok(CrowdfundingError::InsufficientFees))); + assert_eq!(res, Err(Ok(CrowdfundingError::InsufficientPlatformFees))); } #[test] @@ -3506,7 +3506,7 @@ fn test_withdraw_event_fees_insufficient_fees() { let to = Address::generate(&env); let result = client.try_withdraw_event_fees(&admin, &to, &100); - assert_eq!(result, Err(Ok(CrowdfundingError::InsufficientFees))); + assert_eq!(result, Err(Ok(CrowdfundingError::InsufficientEventFees))); } #[test] diff --git a/contract/contract/test/issue_208_test.rs b/contract/contract/test/issue_208_test.rs new file mode 100644 index 0000000..be24be6 --- /dev/null +++ b/contract/contract/test/issue_208_test.rs @@ -0,0 +1,352 @@ +#![cfg(test)] + +//! Issue #208 — Prevent withdrawing more fees than exist. +//! +//! Covers: +//! - Failure: withdraw 101 when only 100 platform fees collected → InsufficientPlatformFees +//! - Failure: withdraw 101 when only 100 event fees collected → InsufficientEventFees +//! - Success: withdraw exactly 100 → state decrements correctly +//! - Isolation: pool-contribution funds are never touched by a fee withdrawal + +use crate::{ + base::{ + errors::CrowdfundingError, + types::{PoolConfig, StorageKey}, + }, + crowdfunding::{CrowdfundingContract, CrowdfundingContractClient}, +}; +use soroban_sdk::{ + testutils::Address as _, + token::{Client as TokenClient, StellarAssetClient}, + Address, Env, String, +}; + +// --------------------------------------------------------------------------- +// Shared setup +// --------------------------------------------------------------------------- + +struct Fixture<'a> { + env: Env, + client: CrowdfundingContractClient<'a>, + admin: Address, + token: Address, +} + +fn setup() -> Fixture<'static> { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(CrowdfundingContract, ()); + let client = CrowdfundingContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let token_admin = Address::generate(&env); + let token = env + .register_stellar_asset_contract_v2(token_admin) + .address(); + + client.initialize(&admin, &token, &0); + + // SAFETY: the Env outlives the test function; the 'static bound is + // satisfied because Env::default() is owned and moved into the struct. + let fixture = Fixture { + env, + client: unsafe { core::mem::transmute(client) }, + admin, + token, + }; + fixture +} + +/// Seed exactly `fee_amount` units into the `PlatformFees` counter and mint +/// the same amount to the contract address so the token balance matches. +fn seed_platform_fees(f: &Fixture<'_>, fee_amount: i128) { + StellarAssetClient::new(&f.env, &f.token).mint(&f.client.address, &fee_amount); + f.env.as_contract(&f.client.address, || { + f.env + .storage() + .instance() + .set(&StorageKey::PlatformFees, &fee_amount); + }); +} + +/// Seed exactly `fee_amount` units into the `EventFeeTreasury` counter and +/// mint the same amount to the contract address. +fn seed_event_fees(f: &Fixture<'_>, fee_amount: i128) { + StellarAssetClient::new(&f.env, &f.token).mint(&f.client.address, &fee_amount); + f.env.as_contract(&f.client.address, || { + f.env + .storage() + .instance() + .set(&StorageKey::EventFeeTreasury, &fee_amount); + }); +} + +/// Read the current `PlatformFees` counter from contract storage. +fn read_platform_fees(f: &Fixture<'_>) -> i128 { + f.env.as_contract(&f.client.address, || { + f.env + .storage() + .instance() + .get::<_, i128>(&StorageKey::PlatformFees) + .unwrap_or(0) + }) +} + +/// Read the current `EventFeeTreasury` counter from contract storage. +fn read_event_fees(f: &Fixture<'_>) -> i128 { + f.env.as_contract(&f.client.address, || { + f.env + .storage() + .instance() + .get::<_, i128>(&StorageKey::EventFeeTreasury) + .unwrap_or(0) + }) +} + +// --------------------------------------------------------------------------- +// Failure cases +// --------------------------------------------------------------------------- + +/// Withdraw 101 when only 100 platform fees exist → InsufficientPlatformFees. +#[test] +fn test_platform_fee_withdrawal_exceeds_balance_fails() { + let f = setup(); + seed_platform_fees(&f, 100); + + let receiver = Address::generate(&f.env); + let result = f.client.try_withdraw_platform_fees(&receiver, &101); + + assert_eq!( + result, + Err(Ok(CrowdfundingError::InsufficientPlatformFees)), + "withdrawing 101 when only 100 collected must return InsufficientPlatformFees" + ); +} + +/// Withdraw 101 when only 100 event fees exist → InsufficientEventFees. +#[test] +fn test_event_fee_withdrawal_exceeds_balance_fails() { + let f = setup(); + seed_event_fees(&f, 100); + + let receiver = Address::generate(&f.env); + let result = f.client.try_withdraw_event_fees(&f.admin, &receiver, &101); + + assert_eq!( + result, + Err(Ok(CrowdfundingError::InsufficientEventFees)), + "withdrawing 101 when only 100 collected must return InsufficientEventFees" + ); +} + +/// Withdraw exactly the full balance → must also fail with one unit over. +#[test] +fn test_platform_fee_withdrawal_one_over_exact_balance_fails() { + let f = setup(); + seed_platform_fees(&f, 100); + + // Drain the full balance first + let receiver = Address::generate(&f.env); + f.client.withdraw_platform_fees(&receiver, &100); + + // Now the counter is 0 — even 1 unit must fail + let result = f.client.try_withdraw_platform_fees(&receiver, &1); + assert_eq!( + result, + Err(Ok(CrowdfundingError::InsufficientPlatformFees)), + "any withdrawal after full drain must fail" + ); +} + +// --------------------------------------------------------------------------- +// Success cases +// --------------------------------------------------------------------------- + +/// Withdraw exactly 100 when 100 platform fees exist → succeeds, counter → 0. +#[test] +fn test_platform_fee_withdrawal_exact_amount_succeeds() { + let f = setup(); + seed_platform_fees(&f, 100); + + let receiver = Address::generate(&f.env); + let token_client = TokenClient::new(&f.env, &f.token); + + let receiver_before = token_client.balance(&receiver); + f.client.withdraw_platform_fees(&receiver, &100); + let receiver_after = token_client.balance(&receiver); + + assert_eq!( + receiver_after - receiver_before, + 100, + "receiver must gain exactly 100 tokens" + ); + assert_eq!( + read_platform_fees(&f), + 0, + "PlatformFees counter must be 0 after full withdrawal" + ); +} + +/// Withdraw a partial amount → counter decrements by exactly that amount. +#[test] +fn test_platform_fee_partial_withdrawal_decrements_counter() { + let f = setup(); + seed_platform_fees(&f, 100); + + let receiver = Address::generate(&f.env); + f.client.withdraw_platform_fees(&receiver, &60); + + assert_eq!( + read_platform_fees(&f), + 40, + "PlatformFees counter must reflect the remaining 40" + ); +} + +/// Withdraw exactly 100 event fees → succeeds, treasury counter → 0. +#[test] +fn test_event_fee_withdrawal_exact_amount_succeeds() { + let f = setup(); + seed_event_fees(&f, 100); + + let receiver = Address::generate(&f.env); + let token_client = TokenClient::new(&f.env, &f.token); + + let receiver_before = token_client.balance(&receiver); + f.client.withdraw_event_fees(&f.admin, &receiver, &100); + let receiver_after = token_client.balance(&receiver); + + assert_eq!( + receiver_after - receiver_before, + 100, + "receiver must gain exactly 100 tokens" + ); + assert_eq!( + read_event_fees(&f), + 0, + "EventFeeTreasury counter must be 0 after full withdrawal" + ); +} + +// --------------------------------------------------------------------------- +// State isolation — pool funds must never be touched +// --------------------------------------------------------------------------- + +/// Seed 100 platform fees + 500 pool-contribution funds. +/// Withdraw all 100 fees → pool funds remain exactly 500. +#[test] +fn test_platform_fee_withdrawal_does_not_touch_pool_funds() { + let f = setup(); + + // Create a pool and contribute 500 to it so the contract holds real pool funds + let creator = Address::generate(&f.env); + let contributor = Address::generate(&f.env); + let pool_config = PoolConfig { + name: String::from_str(&f.env, "Isolation Pool"), + description: String::from_str(&f.env, "Testing fund isolation"), + target_amount: 10_000, + min_contribution: 0, + is_private: false, + duration: 86_400, + created_at: f.env.ledger().timestamp(), + token_address: f.token.clone(), + }; + let pool_id = f.client.create_pool(&creator, &pool_config); + + let contribution_amount = 500i128; + StellarAssetClient::new(&f.env, &f.token).mint(&contributor, &contribution_amount); + f.client.contribute( + &pool_id, + &contributor, + &f.token, + &contribution_amount, + &false, + ); + + // Separately seed 100 platform fees on top + seed_platform_fees(&f, 100); + + let token_client = TokenClient::new(&f.env, &f.token); + let contract_balance_before = token_client.balance(&f.client.address); + // Contract holds: 500 (pool) + 100 (fees) = 600 + assert_eq!(contract_balance_before, 600); + + // Withdraw all 100 platform fees + let receiver = Address::generate(&f.env); + f.client.withdraw_platform_fees(&receiver, &100); + + // Contract must still hold exactly the 500 pool-contribution funds + let contract_balance_after = token_client.balance(&f.client.address); + assert_eq!( + contract_balance_after, 500, + "pool-contribution funds must be untouched after fee withdrawal" + ); + + // PlatformFees counter must be 0 + assert_eq!(read_platform_fees(&f), 0); + + // Attempting to withdraw 1 more must fail — pool funds are off-limits + let result = f.client.try_withdraw_platform_fees(&receiver, &1); + assert_eq!( + result, + Err(Ok(CrowdfundingError::InsufficientPlatformFees)), + "cannot withdraw pool-contribution funds via withdraw_platform_fees" + ); +} + +/// End-to-end: fees collected via buy_ticket flow into EventFeeTreasury +/// and are withdrawable; pool funds remain isolated. +#[test] +fn test_event_fee_withdrawal_via_buy_ticket_flow() { + let f = setup(); + + // Set 10% platform fee + f.client.set_platform_fee_bps(&1_000); + + let creator = Address::generate(&f.env); + let pool_config = PoolConfig { + name: String::from_str(&f.env, "Ticket Pool"), + description: String::from_str(&f.env, "End-to-end event fee test"), + target_amount: 100_000, + min_contribution: 0, + is_private: false, + duration: 86_400, + created_at: f.env.ledger().timestamp(), + token_address: f.token.clone(), + }; + let pool_id = f.client.create_pool(&creator, &pool_config); + + // Buyer purchases a ticket for 1_000 → fee = 100, event = 900 + let buyer = Address::generate(&f.env); + StellarAssetClient::new(&f.env, &f.token).mint(&buyer, &1_000); + f.client.buy_ticket(&pool_id, &buyer, &f.token, &1_000); + + // EventFeeTreasury must now hold 100 + assert_eq!(read_event_fees(&f), 100); + + // Withdraw 101 → must fail + let receiver = Address::generate(&f.env); + assert_eq!( + f.client.try_withdraw_event_fees(&f.admin, &receiver, &101), + Err(Ok(CrowdfundingError::InsufficientEventFees)), + "cannot withdraw more than collected event fees" + ); + + // Withdraw exactly 100 → must succeed + f.client.withdraw_event_fees(&f.admin, &receiver, &100); + assert_eq!(read_event_fees(&f), 0); + + // EventPool (creator's share = 900) must be untouched + let event_pool_balance: i128 = f.env.as_contract(&f.client.address, || { + f.env + .storage() + .instance() + .get::<_, i128>(&StorageKey::EventPool(pool_id)) + .unwrap_or(0) + }); + assert_eq!( + event_pool_balance, 900, + "event pool (creator funds) must be untouched after fee withdrawal" + ); +} diff --git a/contract/contract/test/mod.rs b/contract/contract/test/mod.rs index 5985720..3560bcd 100644 --- a/contract/contract/test/mod.rs +++ b/contract/contract/test/mod.rs @@ -1,3 +1,4 @@ +mod all_events_test; // mod blacklist_test; // Features not yet implemented mod batch_claim_test; mod buy_ticket_test; @@ -7,6 +8,7 @@ mod create_event_test; mod create_pool; mod crowdfunding_test; mod get_pool_contributions_paginated_test; +mod issue_208_test; mod platform_fee_test; mod pool_remaining_time_test; mod renounce_admin_test; diff --git a/contract/contract/test/withdraw_platform_fees_test.rs b/contract/contract/test/withdraw_platform_fees_test.rs index 0863086..7ae8204 100644 --- a/contract/contract/test/withdraw_platform_fees_test.rs +++ b/contract/contract/test/withdraw_platform_fees_test.rs @@ -64,7 +64,7 @@ fn test_withdraw_platform_fees_end_to_end() { // Nothing left to withdraw. assert_eq!( client.try_withdraw_platform_fees(&receiver, &1), - Err(Ok(CrowdfundingError::InsufficientFees)) + Err(Ok(CrowdfundingError::InsufficientPlatformFees)) ); } @@ -117,7 +117,7 @@ fn test_withdraw_platform_fees_insufficient_fees() { let receiver = Address::generate(&env); assert_eq!( client.try_withdraw_platform_fees(&receiver, &1001), - Err(Ok(CrowdfundingError::InsufficientFees)) + Err(Ok(CrowdfundingError::InsufficientPlatformFees)) ); } From 47dbc20b722bbdf13954389539c9db6b28a3b397 Mon Sep 17 00:00:00 2001 From: David Ojo Date: Fri, 27 Mar 2026 16:11:13 +0100 Subject: [PATCH 2/3] fix(events): catch all emission points and fix sequential counter tests --- contract/contract/src/base/events.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/contract/contract/src/base/events.rs b/contract/contract/src/base/events.rs index 4140f2a..bce5bef 100644 --- a/contract/contract/src/base/events.rs +++ b/contract/contract/src/base/events.rs @@ -73,6 +73,7 @@ pub fn pool_created( ) { let topics = (Symbol::new(env, "pool_created"), pool_id, creator); env.events().publish(topics, details); + record_event(env, "pool_created"); } pub fn event_created( From 8f4b02fadea6e114e70beb099107463cb9401c8f Mon Sep 17 00:00:00 2001 From: David Ojo Date: Sat, 28 Mar 2026 18:02:30 +0100 Subject: [PATCH 3/3] test(coverage): add 22 tests covering untested branches and error paths --- contract/contract/test/coverage_test.rs | 581 ++++++++++++++++++++++++ contract/contract/test/mod.rs | 1 + 2 files changed, 582 insertions(+) create mode 100644 contract/contract/test/coverage_test.rs diff --git a/contract/contract/test/coverage_test.rs b/contract/contract/test/coverage_test.rs new file mode 100644 index 0000000..a5356dc --- /dev/null +++ b/contract/contract/test/coverage_test.rs @@ -0,0 +1,581 @@ +#![cfg(test)] + +//! Coverage gap tests — exercises branches identified as uncovered. +//! +//! Each test targets a specific untested code path in crowdfunding.rs or +//! base/types.rs. Tests are grouped by the function they cover. + +use crate::{ + base::{ + errors::CrowdfundingError, + types::{ + PoolConfig, PoolMetadata, PoolState, MAX_DESCRIPTION_LENGTH, MAX_HASH_LENGTH, + MAX_URL_LENGTH, + }, + }, + crowdfunding::{CrowdfundingContract, CrowdfundingContractClient}, +}; +use soroban_sdk::{ + testutils::Address as _, testutils::Ledger as _, token::StellarAssetClient, vec, Address, + BytesN, Env, String, Vec, +}; + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +fn setup(env: &Env) -> (CrowdfundingContractClient<'_>, Address, Address) { + env.mock_all_auths(); + let id = env.register(CrowdfundingContract, ()); + let client = CrowdfundingContractClient::new(env, &id); + let admin = Address::generate(env); + let token_admin = Address::generate(env); + let token = env + .register_stellar_asset_contract_v2(token_admin) + .address(); + client.initialize(&admin, &token, &0); + (client, admin, token) +} + +fn campaign_id(env: &Env, n: u8) -> BytesN<32> { + BytesN::from_array(env, &[n; 32]) +} + +fn make_pool_metadata(env: &Env) -> PoolMetadata { + PoolMetadata { + description: String::from_str(env, "desc"), + external_url: String::from_str(env, "https://example.com"), + image_hash: String::from_str(env, "abc123"), + } +} + +// --------------------------------------------------------------------------- +// create_pool — InvalidToken branch +// --------------------------------------------------------------------------- + +/// create_pool with a token that doesn't match the platform token → InvalidToken. +#[test] +fn test_create_pool_wrong_token_fails() { + let env = Env::default(); + let (client, creator, _platform_token) = setup(&env); + + let other_admin = Address::generate(&env); + let wrong_token = env + .register_stellar_asset_contract_v2(other_admin) + .address(); + + let config = PoolConfig { + name: String::from_str(&env, "Bad Token Pool"), + description: String::from_str(&env, "desc"), + target_amount: 1_000, + min_contribution: 0, + is_private: false, + duration: 86_400, + created_at: env.ledger().timestamp(), + token_address: wrong_token, + }; + + assert_eq!( + client.try_create_pool(&creator, &config), + Err(Ok(CrowdfundingError::InvalidToken)) + ); +} + +// --------------------------------------------------------------------------- +// save_pool — InvalidMetadata branch +// --------------------------------------------------------------------------- + +/// save_pool with a description that exceeds MAX_DESCRIPTION_LENGTH → InvalidMetadata. +#[test] +fn test_save_pool_description_too_long_fails() { + let env = Env::default(); + let (client, creator, _) = setup(&env); + + let long_desc = "x".repeat((MAX_DESCRIPTION_LENGTH + 1) as usize); + let metadata = PoolMetadata { + description: String::from_str(&env, &long_desc), + external_url: String::from_str(&env, ""), + image_hash: String::from_str(&env, ""), + }; + + assert_eq!( + client.try_save_pool( + &String::from_str(&env, "Pool"), + &metadata, + &creator, + &1_000, + &(env.ledger().timestamp() + 86_400), + &None, + &None, + ), + Err(Ok(CrowdfundingError::InvalidMetadata)) + ); +} + +/// save_pool with an external_url that exceeds MAX_URL_LENGTH → InvalidMetadata. +#[test] +fn test_save_pool_url_too_long_fails() { + let env = Env::default(); + let (client, creator, _) = setup(&env); + + let long_url = "u".repeat((MAX_URL_LENGTH + 1) as usize); + let metadata = PoolMetadata { + description: String::from_str(&env, "ok"), + external_url: String::from_str(&env, &long_url), + image_hash: String::from_str(&env, ""), + }; + + assert_eq!( + client.try_save_pool( + &String::from_str(&env, "Pool"), + &metadata, + &creator, + &1_000, + &(env.ledger().timestamp() + 86_400), + &None, + &None, + ), + Err(Ok(CrowdfundingError::InvalidMetadata)) + ); +} + +/// save_pool with an image_hash that exceeds MAX_HASH_LENGTH → InvalidMetadata. +#[test] +fn test_save_pool_hash_too_long_fails() { + let env = Env::default(); + let (client, creator, _) = setup(&env); + + let long_hash = "h".repeat((MAX_HASH_LENGTH + 1) as usize); + let metadata = PoolMetadata { + description: String::from_str(&env, "ok"), + external_url: String::from_str(&env, ""), + image_hash: String::from_str(&env, &long_hash), + }; + + assert_eq!( + client.try_save_pool( + &String::from_str(&env, "Pool"), + &metadata, + &creator, + &1_000, + &(env.ledger().timestamp() + 86_400), + &None, + &None, + ), + Err(Ok(CrowdfundingError::InvalidMetadata)) + ); +} + +// --------------------------------------------------------------------------- +// save_pool — InvalidMultiSigConfig branches +// --------------------------------------------------------------------------- + +/// save_pool with req_sigs == 0 → InvalidMultiSigConfig. +#[test] +fn test_save_pool_multisig_zero_required_sigs_fails() { + let env = Env::default(); + let (client, creator, _) = setup(&env); + + let signer = Address::generate(&env); + let signers: Vec
= vec![&env, signer]; + + assert_eq!( + client.try_save_pool( + &String::from_str(&env, "Pool"), + &make_pool_metadata(&env), + &creator, + &1_000, + &(env.ledger().timestamp() + 86_400), + &Some(0u32), // req_sigs = 0 → invalid + &Some(signers), + ), + Err(Ok(CrowdfundingError::InvalidMultiSigConfig)) + ); +} + +/// save_pool with req_sigs > signer count → InvalidMultiSigConfig. +#[test] +fn test_save_pool_multisig_req_exceeds_signers_fails() { + let env = Env::default(); + let (client, creator, _) = setup(&env); + + let signer = Address::generate(&env); + let signers: Vec
= vec![&env, signer]; + + assert_eq!( + client.try_save_pool( + &String::from_str(&env, "Pool"), + &make_pool_metadata(&env), + &creator, + &1_000, + &(env.ledger().timestamp() + 86_400), + &Some(5u32), // 5 required but only 1 signer + &Some(signers), + ), + Err(Ok(CrowdfundingError::InvalidMultiSigConfig)) + ); +} + +/// save_pool with only one of (required_signatures, signers) provided → InvalidMultiSigConfig. +#[test] +fn test_save_pool_multisig_partial_config_fails() { + let env = Env::default(); + let (client, creator, _) = setup(&env); + + // signers provided but no required_signatures + let signer = Address::generate(&env); + let signers: Vec
= vec![&env, signer]; + + assert_eq!( + client.try_save_pool( + &String::from_str(&env, "Pool"), + &make_pool_metadata(&env), + &creator, + &1_000, + &(env.ledger().timestamp() + 86_400), + &None::, + &Some(signers), + ), + Err(Ok(CrowdfundingError::InvalidMultiSigConfig)) + ); +} + +/// save_pool with valid multisig config → succeeds and stores config. +#[test] +fn test_save_pool_valid_multisig_succeeds() { + let env = Env::default(); + let (client, creator, _) = setup(&env); + + let s1 = Address::generate(&env); + let s2 = Address::generate(&env); + let signers: Vec
= vec![&env, s1, s2]; + + let pool_id = client.save_pool( + &String::from_str(&env, "MultiSig Pool"), + &make_pool_metadata(&env), + &creator, + &5_000, + &(env.ledger().timestamp() + 86_400), + &Some(2u32), + &Some(signers), + ); + assert!(pool_id > 0); +} + +// --------------------------------------------------------------------------- +// update_pool_state — Completed and Cancelled terminal state branches +// --------------------------------------------------------------------------- + +/// Transitioning from Completed state → InvalidPoolState. +#[test] +fn test_update_pool_state_from_completed_fails() { + let env = Env::default(); + let (client, creator, _) = setup(&env); + + let pool_id = client.save_pool( + &String::from_str(&env, "Pool"), + &make_pool_metadata(&env), + &creator, + &1_000, + &(env.ledger().timestamp() + 86_400), + &None, + &None, + ); + + // Drive to Completed + client.update_pool_state(&pool_id, &PoolState::Completed); + + // Any further transition must fail + assert_eq!( + client.try_update_pool_state(&pool_id, &PoolState::Active), + Err(Ok(CrowdfundingError::InvalidPoolState)) + ); +} + +/// Transitioning from Cancelled state → InvalidPoolState. +#[test] +fn test_update_pool_state_from_cancelled_fails() { + let env = Env::default(); + let (client, creator, _) = setup(&env); + + let pool_id = client.save_pool( + &String::from_str(&env, "Pool"), + &make_pool_metadata(&env), + &creator, + &1_000, + &(env.ledger().timestamp() + 86_400), + &None, + &None, + ); + + client.update_pool_state(&pool_id, &PoolState::Cancelled); + + assert_eq!( + client.try_update_pool_state(&pool_id, &PoolState::Active), + Err(Ok(CrowdfundingError::InvalidPoolState)) + ); +} + +/// Paused → Active transition succeeds. +#[test] +fn test_update_pool_state_paused_to_active_succeeds() { + let env = Env::default(); + let (client, creator, _) = setup(&env); + + let pool_id = client.save_pool( + &String::from_str(&env, "Pool"), + &make_pool_metadata(&env), + &creator, + &1_000, + &(env.ledger().timestamp() + 86_400), + &None, + &None, + ); + + client.update_pool_state(&pool_id, &PoolState::Paused); + // Resume — should succeed + client.update_pool_state(&pool_id, &PoolState::Active); +} + +/// Active → Completed transition succeeds. +#[test] +fn test_update_pool_state_active_to_completed_succeeds() { + let env = Env::default(); + let (client, creator, _) = setup(&env); + + let pool_id = client.save_pool( + &String::from_str(&env, "Pool"), + &make_pool_metadata(&env), + &creator, + &1_000, + &(env.ledger().timestamp() + 86_400), + &None, + &None, + ); + + client.update_pool_state(&pool_id, &PoolState::Completed); +} + +// --------------------------------------------------------------------------- +// extend_campaign_deadline — CampaignAlreadyFunded and max-duration branches +// --------------------------------------------------------------------------- + +/// Extending deadline when campaign is already fully funded → CampaignAlreadyFunded. +#[test] +fn test_extend_deadline_already_funded_fails() { + let env = Env::default(); + let (client, _, token) = setup(&env); + + env.ledger().with_mut(|l| l.timestamp = 1_000); + + let creator = Address::generate(&env); + let donor = Address::generate(&env); + let goal = 500i128; + let deadline = 10_000u64; + let id = campaign_id(&env, 1); + + StellarAssetClient::new(&env, &token).mint(&donor, &goal); + client.create_campaign( + &id, + &String::from_str(&env, "Funded"), + &creator, + &goal, + &deadline, + &token, + ); + client.donate(&id, &donor, &token, &goal); + + // Campaign is now fully funded — extending deadline must fail + assert_eq!( + client.try_extend_campaign_deadline(&id, &(deadline + 1)), + Err(Ok(CrowdfundingError::CampaignAlreadyFunded)) + ); +} + +/// Extending deadline beyond the 90-day max from current time → InvalidDeadline. +#[test] +fn test_extend_deadline_exceeds_max_duration_fails() { + let env = Env::default(); + let (client, _, token) = setup(&env); + + env.ledger().with_mut(|l| l.timestamp = 1_000); + + let creator = Address::generate(&env); + let id = campaign_id(&env, 2); + let deadline = 50_000u64; + + client.create_campaign( + &id, + &String::from_str(&env, "Long"), + &creator, + &100_000, + &deadline, + &token, + ); + + // 91 days from now exceeds the 90-day cap + let ninety_one_days: u64 = 91 * 24 * 60 * 60; + let too_far = env.ledger().timestamp() + ninety_one_days; + + assert_eq!( + client.try_extend_campaign_deadline(&id, &too_far), + Err(Ok(CrowdfundingError::InvalidDeadline)) + ); +} + +// --------------------------------------------------------------------------- +// get_pool_metadata — non-existent pool returns empty strings +// --------------------------------------------------------------------------- + +/// get_pool_metadata for a pool that was never created returns three empty strings. +#[test] +fn test_get_pool_metadata_missing_pool_returns_empty() { + let env = Env::default(); + let (client, _, _) = setup(&env); + + let (desc, url, hash) = client.get_pool_metadata(&999u64); + assert_eq!(desc, String::from_str(&env, "")); + assert_eq!(url, String::from_str(&env, "")); + assert_eq!(hash, String::from_str(&env, "")); +} + +// --------------------------------------------------------------------------- +// is_cause_verified — returns false for unverified address +// --------------------------------------------------------------------------- + +/// is_cause_verified returns false for an address that was never verified. +#[test] +fn test_is_cause_verified_returns_false_for_unknown() { + let env = Env::default(); + let (client, _, _) = setup(&env); + + let random = Address::generate(&env); + assert!(!client.is_cause_verified(&random)); +} + +// --------------------------------------------------------------------------- +// PoolConfig::validate — panic paths +// --------------------------------------------------------------------------- + +/// PoolConfig with empty name panics. +#[test] +#[should_panic(expected = "pool name must not be empty")] +fn test_pool_config_empty_name_panics() { + let env = Env::default(); + let token = Address::generate(&env); + PoolConfig { + name: String::from_str(&env, ""), + description: String::from_str(&env, "desc"), + target_amount: 1_000, + min_contribution: 0, + is_private: false, + duration: 86_400, + created_at: 0, + token_address: token, + } + .validate(); +} + +/// PoolConfig with zero target_amount panics. +#[test] +#[should_panic(expected = "target_amount must be > 0")] +fn test_pool_config_zero_target_panics() { + let env = Env::default(); + let token = Address::generate(&env); + PoolConfig { + name: String::from_str(&env, "Pool"), + description: String::from_str(&env, "desc"), + target_amount: 0, + min_contribution: 0, + is_private: false, + duration: 86_400, + created_at: 0, + token_address: token, + } + .validate(); +} + +/// PoolConfig with min_contribution > target_amount panics. +#[test] +#[should_panic(expected = "min_contribution must be <= target_amount")] +fn test_pool_config_min_contribution_exceeds_target_panics() { + let env = Env::default(); + let token = Address::generate(&env); + PoolConfig { + name: String::from_str(&env, "Pool"), + description: String::from_str(&env, "desc"), + target_amount: 100, + min_contribution: 101, + is_private: false, + duration: 86_400, + created_at: 0, + token_address: token, + } + .validate(); +} + +/// PoolConfig with zero duration panics. +#[test] +#[should_panic(expected = "duration must be > 0")] +fn test_pool_config_zero_duration_panics() { + let env = Env::default(); + let token = Address::generate(&env); + PoolConfig { + name: String::from_str(&env, "Pool"), + description: String::from_str(&env, "desc"), + target_amount: 1_000, + min_contribution: 0, + is_private: false, + duration: 0, + created_at: 0, + token_address: token, + } + .validate(); +} + +// --------------------------------------------------------------------------- +// Guard 2 in withdraw_platform_fees — token balance below fee counter +// --------------------------------------------------------------------------- + +/// If the contract's token balance is lower than the tracked fee counter +/// (accounting drift), the withdrawal must still fail with InsufficientPlatformFees. +#[test] +fn test_withdraw_platform_fees_token_balance_guard() { + let env = Env::default(); + let (client, _, token) = setup(&env); + + // Seed the fee counter to 200 but only mint 100 tokens to the contract + StellarAssetClient::new(&env, &token).mint(&client.address, &100); + env.as_contract(&client.address, || { + env.storage() + .instance() + .set(&crate::base::types::StorageKey::PlatformFees, &200i128); + }); + + let receiver = Address::generate(&env); + // Requesting 150 — counter says ok (150 < 200) but balance says no (150 > 100) + assert_eq!( + client.try_withdraw_platform_fees(&receiver, &150), + Err(Ok(CrowdfundingError::InsufficientPlatformFees)) + ); +} + +/// Same guard for withdraw_event_fees. +#[test] +fn test_withdraw_event_fees_token_balance_guard() { + let env = Env::default(); + let (client, admin, token) = setup(&env); + + StellarAssetClient::new(&env, &token).mint(&client.address, &100); + env.as_contract(&client.address, || { + env.storage() + .instance() + .set(&crate::base::types::StorageKey::EventFeeTreasury, &200i128); + }); + + let receiver = Address::generate(&env); + assert_eq!( + client.try_withdraw_event_fees(&admin, &receiver, &150), + Err(Ok(CrowdfundingError::InsufficientEventFees)) + ); +} diff --git a/contract/contract/test/mod.rs b/contract/contract/test/mod.rs index 3560bcd..0c14088 100644 --- a/contract/contract/test/mod.rs +++ b/contract/contract/test/mod.rs @@ -4,6 +4,7 @@ mod batch_claim_test; mod buy_ticket_test; mod close_pool_test; mod close_private_pool_test; +mod coverage_test; mod create_event_test; mod create_pool; mod crowdfunding_test;