diff --git a/campaign/Cargo.toml b/campaign/Cargo.toml index 3fe7aa3..e613562 100644 --- a/campaign/Cargo.toml +++ b/campaign/Cargo.toml @@ -9,6 +9,7 @@ common = { path = "../common" } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } +common = { path = "../common", features = ["testutils"] } [lib] crate-type = ["cdylib"] diff --git a/campaign/src/lib.rs b/campaign/src/lib.rs index dae99aa..2695e78 100644 --- a/campaign/src/lib.rs +++ b/campaign/src/lib.rs @@ -1,18 +1,20 @@ #![no_std] use common::{ - check_contract_not_frozen, check_contract_not_locked, check_creator_auth, check_not_already_initialized, is_asset_accepted, validate_add, validate_div, validate_mul, - validate_sub, AssetInfo, CampaignStatus, ErrorCode, MilestoneStatus, + validate_sub, AssetInfo, CampaignStatus, MilestoneStatus, }; use soroban_sdk::{ - contract, contractimpl, contracttype, symbol_short, Address, BytesN, Env, Symbol, Vec, + contract, contracterror, contractimpl, contracttype, symbol_short, Address, BytesN, Env, + Symbol, Vec, }; pub mod storage; const MAX_MILESTONES: u32 = 5; const REFUND_WINDOW: u64 = 30 * 24 * 60 * 60; // 30 days in seconds +const MAX_DEADLINE_EXTENSION: u64 = 90 * 24 * 60 * 60; // 90 days in seconds +const SECONDS_PER_DAY: i64 = 24 * 60 * 60; #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq)] @@ -21,94 +23,97 @@ pub enum Error { /// elevated permissions (e.g., admin-only actions, creator-only actions). /// Recoverable: This is a bad input issue - only the authorized caller can retry. Unauthorized = 1, - + /// Thrown when initialize() is called more than once on a contract. /// Terminal: This indicates a re-initialization attack or incorrect usage. AlreadyInitialized = 2, - + /// Thrown when a campaign is interacted with before it has been initialized. /// Recoverable: The caller must initialize the contract first before performing other operations. NotInitialized = 21, - + /// Thrown when the goal amount provided during initialization is <= 0. /// Recoverable: Fix the goal amount to a positive value and retry initialization. InvalidGoalAmount = 3, - + /// Thrown when the end time provided during initialization is in the past. /// Recoverable: Fix the end time to a future timestamp and retry initialization. InvalidEndTime = 4, - + /// Thrown when no accepted assets are provided during initialization. /// Recoverable: Provide at least one accepted asset and retry initialization. NoAcceptedAssets = 5, - + /// Thrown when milestones provided during initialization are invalid (wrong count, non-increasing amounts, last milestone != goal). /// Recoverable: Fix the milestones to meet the validation criteria and retry initialization. InvalidMilestones = 6, - + /// Thrown when an invalid amount (<=0) is provided for an operation. /// Recoverable: Provide a valid positive amount and retry. InvalidAmount = 7, - + /// Thrown when an asset that is not in the campaign's accepted assets list is used in a donation. /// Recoverable: Use an accepted asset or add the asset to the campaign's accepted assets list. AssetNotAccepted = 8, - + /// Thrown when an operation is attempted on a campaign that is not in an active state. /// Recoverable: Verify the campaign's current status before attempting the operation. CampaignNotActive = 9, - + /// Thrown when an operation is attempted on a campaign that has passed its end time. /// Recoverable: Cannot be retried - campaign has concluded. CampaignEnded = 10, - + /// Thrown when attempting to cancel a campaign that has remaining funds in the contract. /// Recoverable: Withdraw or distribute all funds before attempting to cancel. CannotCancelWithFunds = 22, - + /// Thrown when a refund is attempted after the refund window (30 days after campaign end) has closed. /// Recoverable: Cannot be retried - refund window has expired. RefundWindowClosed = 19, - + /// Thrown when a milestone with the specified index does not exist on the campaign. /// Recoverable: Verify the milestone index exists before attempting the operation. MilestoneNotFound = 13, - + /// Thrown when attempting to release a milestone that is still in Locked status. /// Recoverable: Wait for the milestone to be unlocked (when enough funds are raised) before attempting to release. MilestoneNotUnlocked = 17, - + /// Thrown when attempting to release a milestone out of order - a previous milestone has not been released. /// Recoverable: Release milestones in sequential order. PreviousMilestoneNotReleased = 15, - + /// Thrown when a donation is less than the campaign's minimum donation amount. /// Recoverable: Increase the donation amount to meet the minimum and retry. DonationTooSmall = 14, - + /// Thrown when an arithmetic operation would cause an overflow or underflow. /// Terminal: This indicates a critical bug in the contract's accounting logic. Overflow = 18, - + /// Thrown when a reentrant call is detected on the contract. /// Terminal: This indicates a reentrancy attack or incorrect usage of reentrant calls. Reentrant = 23, - + /// Thrown when an operation is attempted on a frozen contract. /// Recoverable: Cannot be retried - contract is frozen and cannot accept modifications. ContractFrozen = 24, - - /// Thrown when there is insufficient balance in the contract to perform an operation (e.g., withdrawal, transfer). + + /// Thrown when insufficient balance in the contract to perform an operation (e.g., withdrawal, transfer). /// Recoverable: Ensure the contract has enough funds before attempting the operation. InsufficientContractBalance = 25, - + + /// Thrown when a requested deadline extension would push the campaign end time + /// more than 90 days past the original end time. + /// Recoverable: Provide a new_end_time within the 90-day extension limit. + DeadlineExceedsLimit = 26, + // Legacy errors maintained for backward compatibility CampaignCancelled = 11, DonationFailed = 12, MilestoneAlreadyReleased = 16, NoRefundAvailable = 20, - ArithmeticOverflow = 18, - NotAcceptedAsset = 8, } #[contracttype] @@ -126,6 +131,7 @@ pub enum DataKey { XlmTokenAddress, MinDonationAmount, CampaignEndTime, + OriginalEndTime, } #[contracttype] @@ -259,6 +265,10 @@ impl CampaignContract { // Store min donation amount storage::set_min_donation_amount(&env, &min_donation_amount); + // Record the original end time so deadline extensions are always capped + // relative to the initial deadline, even across repeated extensions. + storage::set_original_end_time(&env, &end_time); + let campaign_data = CampaignData { creator: creator.clone(), goal_amount, @@ -368,7 +378,7 @@ impl CampaignContract { for (i, asset_info) in campaign_data.accepted_assets.iter().enumerate() { let asset_raised = storage::get_raised_per_asset(&env, asset_info.clone()).unwrap_or(0); if asset_raised > 0 { - let per_asset_release = if i == campaign_data.accepted_assets.len() - 1 { + let per_asset_release = if i == (campaign_data.accepted_assets.len() - 1) as usize { // Last asset, release the remainder match validate_sub(release_amount, total_released_this_milestone) { Ok(amt) => amt, @@ -403,7 +413,7 @@ impl CampaignContract { } }; - let token_address = get_token_address(&env, &asset_info); + let token_address = get_token_address(&env, &asset_info)?; let token_client = soroban_sdk::token::TokenClient::new(&env, &token_address); token_client.transfer( &env.current_contract_address(), @@ -417,10 +427,7 @@ impl CampaignContract { asset_info.clone(), recipient.clone(), ), - ( - per_asset_release, - env.ledger().timestamp(), - ), + (per_asset_release, env.ledger().timestamp()), ); } } @@ -641,6 +648,8 @@ impl CampaignContract { /// Cancels the campaign and starts the refund window. /// Only callable by the campaign creator. + /// Only permitted while no funds have been raised (`raised_amount == 0`); + /// otherwise fails with `CannotCancelWithFunds`. /// Sets the campaign status to Cancelled and records the end time for refund window calculation. pub fn cancel_campaign(env: Env) -> Result<(), Error> { let mut campaign_data = get_campaign_data(&env)?; @@ -659,9 +668,8 @@ impl CampaignContract { return Err(Error::CampaignNotActive); } - // Prevent cancellation if there are still unreleased funds in the contract - let remaining_funds = validate_sub(campaign_data.raised_amount, campaign_data.released_amount)?; - if remaining_funds > 0 { + // Cancellation is only permitted while nothing has been raised + if campaign_data.raised_amount != 0 { return Err(Error::CannotCancelWithFunds); } @@ -674,13 +682,82 @@ impl CampaignContract { storage::set_campaign_end_time(&env, current_time); env.events().publish( - (symbol_short!("cancelled"),), + (Symbol::new(&env, "campaign_cancelled"),), (campaign_data.creator.clone(), current_time), ); Ok(()) } + /// Extends the campaign deadline to `new_end_time`. + /// Only callable by the campaign creator while the campaign is Active or GoalReached. + /// The new end time must be strictly later than the current end time and may + /// not push the deadline more than 90 days past the ORIGINAL end time, + /// even across repeated extensions. + pub fn extend_deadline(env: Env, new_end_time: u64) -> Result<(), Error> { + let mut campaign_data = get_campaign_data(&env)?; + campaign_data.creator.require_auth(); + + // Check that contract is not frozen + if storage::is_frozen(&env) { + return Err(Error::ContractFrozen); + } + + // Only allow extensions while the campaign is Active or GoalReached + if !matches!( + campaign_data.status, + CampaignStatus::Active | CampaignStatus::GoalReached + ) { + return Err(Error::CampaignNotActive); + } + + // The new deadline must be later than the current one + if new_end_time <= campaign_data.end_time { + return Err(Error::InvalidEndTime); + } + + // Cap at 90 days past the original end time, even across repeated extensions + let original_end_time = + storage::get_original_end_time(&env).unwrap_or(campaign_data.end_time); + let max_allowed = match original_end_time.checked_add(MAX_DEADLINE_EXTENSION) { + Some(time) => time, + None => return Err(Error::Overflow), + }; + if new_end_time > max_allowed { + return Err(Error::DeadlineExceedsLimit); + } + + campaign_data.end_time = new_end_time; + storage::set_campaign_data(&env, &campaign_data); + + env.events().publish( + (Symbol::new(&env, "deadline_extended"),), + (campaign_data.creator.clone(), new_end_time), + ); + + Ok(()) + } + + /// Returns the current campaign status together with the number of days + /// remaining until the campaign's end time. + /// `days_remaining` is computed from the current ledger timestamp and is + /// negative once the deadline has passed. + pub fn get_campaign_status(env: Env) -> Result<(CampaignStatus, i64), Error> { + let campaign_data = get_campaign_data(&env)?; + let seconds_remaining = campaign_data.end_time as i64 - env.ledger().timestamp() as i64; + let mut days_remaining = seconds_remaining / SECONDS_PER_DAY; + if seconds_remaining % SECONDS_PER_DAY != 0 { + // Round away from zero so any time left counts as a full day and + // any time past the deadline reports as negative. + if seconds_remaining > 0 { + days_remaining += 1; + } else { + days_remaining -= 1; + } + } + Ok((campaign_data.status, days_remaining)) + } + /// Returns the total refundable amount for a donor. /// This is the sum of all per-asset contributions that haven't been refunded yet. pub fn get_refundable_amount(env: Env, donor: Address) -> i128 { @@ -726,6 +803,69 @@ impl CampaignContract { Ok(()) } + /// Ends the campaign early at the creator's discretion. + /// Only callable by the campaign creator while the campaign is Active or + /// GoalReached; fails if the campaign is already Ended or Cancelled. + /// Ending does not prevent the final milestone from being released: + /// `release_milestone` validates milestone state, not campaign status. + /// Does not start the refund window (ending is not a failure mode), so the + /// stored end time is left untouched. + pub fn end_campaign(env: Env) -> Result<(), Error> { + let mut campaign_data = get_campaign_data(&env)?; + campaign_data.creator.require_auth(); + + // Check that contract is not frozen + if storage::is_frozen(&env) { + return Err(Error::ContractFrozen); + } + + // Only allow ending from Active or GoalReached status + if !matches!( + campaign_data.status, + CampaignStatus::Active | CampaignStatus::GoalReached + ) { + return Err(Error::CampaignNotActive); + } + + // Update campaign status + campaign_data.status = CampaignStatus::Ended; + storage::set_campaign_data(&env, &campaign_data); + + env.events().publish( + (Symbol::new(&env, "campaign_ended"),), + (campaign_data.creator.clone(), env.ledger().timestamp()), + ); + + Ok(()) + } + + /// Permissionlessly transitions an expired-but-still-Active campaign to + /// Ended and emits the `campaign_ended` event. + /// + /// Anyone may call this: deadline enforcement must not depend on the + /// creator's cooperation. Without a permissionless transition, a creator + /// could simply refuse to act and keep a dead campaign nominally Active, + /// blocking downstream flows that key off the status. This call is + /// idempotent - calling it on a non-expired or already-ended campaign is a + /// no-op returning Ok. + pub fn update_status(env: Env) -> Result<(), Error> { + let mut campaign_data = get_campaign_data(&env)?; + + if env.ledger().timestamp() > campaign_data.end_time + && campaign_data.status == CampaignStatus::Active + { + campaign_data.status = CampaignStatus::Ended; + storage::set_campaign_data(&env, &campaign_data); + + env.events().publish( + (Symbol::new(&env, "campaign_ended"),), + (campaign_data.creator.clone(), env.ledger().timestamp()), + ); + } + + Ok(()) + } + pub fn donate(env: Env, donor: Address, amount: i128, asset: AssetInfo) -> Result<(), Error> { donor.require_auth(); @@ -751,6 +891,17 @@ impl CampaignContract { let mut data = get_campaign_data(&env)?; if env.ledger().timestamp() > data.end_time { + // A donation attempt against an expired, still-Active campaign + // triggers the same transition as `update_status` so the status + // never stays Active past the deadline. + if data.status == CampaignStatus::Active { + data.status = CampaignStatus::Ended; + storage::set_campaign_data(&env, &data); + env.events().publish( + (Symbol::new(&env, "campaign_ended"),), + (data.creator.clone(), env.ledger().timestamp()), + ); + } return Err(Error::CampaignEnded); } @@ -769,7 +920,8 @@ impl CampaignContract { let token_client = soroban_sdk::token::TokenClient::new(&env, &token_address); token_client.transfer(&donor, &env.current_contract_address(), &amount); - data.raised_amount = validate_add(data.raised_amount, amount)?; + data.raised_amount = + validate_add(data.raised_amount, amount).map_err(|_| Error::Overflow)?; let mut goal_just_reached = false; if data.raised_amount >= data.goal_amount && data.status != CampaignStatus::GoalReached { @@ -806,17 +958,26 @@ impl CampaignContract { last_donation_time: 0, }); - donor_record.total_donated = validate_add(donor_record.total_donated, amount)?; + donor_record.total_donated = + validate_add(donor_record.total_donated, amount).map_err(|_| Error::Overflow)?; donor_record.last_donation_time = env.ledger().timestamp(); + // soroban_sdk::Vec has no in-place mutation, so rebuild the per-asset + // breakdown, accumulating into the matching entry when it exists. let mut found = false; - for item in donor_record.per_asset.iter_mut() { + let mut updated_per_asset: Vec = Vec::new(&env); + for item in donor_record.per_asset.iter() { if item.asset == asset { - item.amount = validate_add(item.amount, amount)?; + updated_per_asset.push_back(PerAssetBreakdown { + asset: item.asset.clone(), + amount: validate_add(item.amount, amount).map_err(|_| Error::Overflow)?, + }); found = true; - break; + } else { + updated_per_asset.push_back(item.clone()); } } + donor_record.per_asset = updated_per_asset; if !found { donor_record.per_asset.push_back(PerAssetBreakdown { @@ -831,7 +992,7 @@ impl CampaignContract { storage::set_raised_per_asset( &env, asset.clone(), - validate_add(total_asset_raised, amount)?, + validate_add(total_asset_raised, amount).map_err(|_| Error::Overflow)?, ); env.events().publish( @@ -843,4 +1004,4 @@ impl CampaignContract { } } -mod test; \ No newline at end of file +mod test; diff --git a/campaign/src/storage.rs b/campaign/src/storage.rs index c23a955..91e0d3a 100644 --- a/campaign/src/storage.rs +++ b/campaign/src/storage.rs @@ -12,8 +12,8 @@ use soroban_sdk::{Address, Env}; -use crate::{CampaignData, ContractStatus, DataKey, DonorRecord, MilestoneData}; -use common::AssetInfo; +use crate::{CampaignData, DataKey, DonorRecord, MilestoneData}; +use common::{AssetInfo, CampaignStatus}; pub fn has_campaign_data(env: &Env) -> bool { env.storage().persistent().has(&DataKey::CampaignData) @@ -59,13 +59,13 @@ pub fn get_admin(env: &Env) -> Option
{ env.storage().persistent().get(&DataKey::Admin) } -pub fn set_contract_status(env: &Env, status: ContractStatus) { +pub fn set_contract_status(env: &Env, status: CampaignStatus) { env.storage() .persistent() .set(&DataKey::ContractStatus, &status); } -pub fn get_contract_status(env: &Env) -> Option { +pub fn get_contract_status(env: &Env) -> Option { env.storage().persistent().get(&DataKey::ContractStatus) } @@ -171,4 +171,14 @@ pub fn set_campaign_end_time(env: &Env, timestamp: u64) { pub fn get_campaign_end_time(env: &Env) -> Option { env.storage().persistent().get(&DataKey::CampaignEndTime) -} \ No newline at end of file +} + +pub fn set_original_end_time(env: &Env, timestamp: &u64) { + env.storage() + .persistent() + .set(&DataKey::OriginalEndTime, timestamp); +} + +pub fn get_original_end_time(env: &Env) -> Option { + env.storage().persistent().get(&DataKey::OriginalEndTime) +} diff --git a/campaign/src/test.rs b/campaign/src/test.rs index 2534e63..5cee05a 100644 --- a/campaign/src/test.rs +++ b/campaign/src/test.rs @@ -1,12 +1,10 @@ use crate::{ - Campaign, CampaignClient, CampaignData, CampaignStatus, Error, MilestoneData, MilestoneInput, - MilestoneStatus, -}; -use common::{AssetInfo, ErrorCode}; -use soroban_sdk::{ - testutils::{Address as _, Events as _, Ledger as _}, - Address, BytesN, Env, IntoVal, Symbol, + CampaignContract, CampaignContractClient, CampaignData, CampaignStatus, Error, MilestoneData, + MilestoneInput, MilestoneStatus, }; +use common::AssetInfo; +use soroban_sdk::testutils::{Address as _, Events as _, Ledger as _}; +use soroban_sdk::{Address, BytesN, Env, IntoVal as _, Symbol}; fn desc_hash(env: &Env, bytes: [u8; 32]) -> BytesN<32> { BytesN::from_array(env, &bytes) @@ -51,8 +49,8 @@ fn assert_milestone_data_equal(a: &MilestoneData, b: &MilestoneData) { #[test] fn test_initialize_success() { let env = Env::default(); - let contract_id = env.register_contract(None, Campaign); - let client = CampaignClient::new(&env, &contract_id); + let contract_id = env.register_contract(None, CampaignContract); + let client = CampaignContractClient::new(&env, &contract_id); let creator = Address::generate(&env); let goal_amount = 10_000; @@ -95,8 +93,8 @@ fn test_initialize_success() { #[test] fn test_donate_and_check_milestones() { let env = Env::default(); - let contract_id = env.register_contract(None, Campaign); - let client = CampaignClient::new(&env, &contract_id); + let contract_id = env.register_contract(None, CampaignContract); + let client = CampaignContractClient::new(&env, &contract_id); let creator = Address::generate(&env); let goal_amount = 10_000; @@ -132,26 +130,27 @@ fn test_donate_and_check_milestones() { let campaign_data = client.get_campaign_info(); assert_eq!(campaign_data.raised_amount, donation_amount); - let milestone1 = client.get_milestone(&0).unwrap(); + let milestone1 = client.get_milestone(&0); assert_eq!(milestone1.status, MilestoneStatus::Unlocked); - let milestone2 = client.get_milestone(&1).unwrap(); + let milestone2 = client.get_milestone(&1); assert_eq!(milestone2.status, MilestoneStatus::Locked); let events = env.events().all(); let donation_event = events.last().unwrap(); + assert_eq!(donation_event.0, contract_id); + assert_eq!( + donation_event.1, + soroban_sdk::vec![&env, Symbol::new(&env, "donation").into_val(&env)] + ); + let payload: (Address, i128, AssetInfo, i128) = donation_event.2.into_val(&env); assert_eq!( - donation_event, + payload, ( - contract_id.clone(), - (Symbol::new(&env, "donation"),).into_val(&env), - ( - donor.clone(), - donation_amount, - AssetInfo::Native, - donation_amount - ) - .into_val(&env) + donor.clone(), + donation_amount, + AssetInfo::Native, + donation_amount ) ); } @@ -159,8 +158,8 @@ fn test_donate_and_check_milestones() { #[test] fn test_invalid_milestone_order() { let env = Env::default(); - let contract_id = env.register_contract(None, Campaign); - let client = CampaignClient::new(&env, &contract_id); + let contract_id = env.register_contract(None, CampaignContract); + let client = CampaignContractClient::new(&env, &contract_id); let creator = Address::generate(&env); let goal_amount = 10_000; @@ -193,8 +192,8 @@ fn test_invalid_milestone_order() { #[test] fn test_donation_below_minimum() { let env = Env::default(); - let contract_id = env.register_contract(None, Campaign); - let client = CampaignClient::new(&env, &contract_id); + let contract_id = env.register_contract(None, CampaignContract); + let client = CampaignContractClient::new(&env, &contract_id); let creator = Address::generate(&env); let goal_amount = 10_000; @@ -227,8 +226,8 @@ fn test_donation_below_minimum() { #[test] fn test_get_nonexistent_milestone() { let env = Env::default(); - let contract_id = env.register_contract(None, Campaign); - let client = CampaignClient::new(&env, &contract_id); + let contract_id = env.register_contract(None, CampaignContract); + let client = CampaignContractClient::new(&env, &contract_id); let creator = Address::generate(&env); let goal_amount = 10_000; @@ -259,8 +258,8 @@ fn test_get_nonexistent_milestone() { #[test] fn test_initialize_by_unauthorized_caller() { let env = Env::default(); - let contract_id = env.register_contract(None, Campaign); - let client = CampaignClient::new(&env, &contract_id); + let contract_id = env.register_contract(None, CampaignContract); + let client = CampaignContractClient::new(&env, &contract_id); let creator = Address::generate(&env); let unauthorized_caller = Address::generate(&env); @@ -276,7 +275,7 @@ fn test_initialize_by_unauthorized_caller() { ]; let min_donation = 100; - let result = CampaignClient::new(&env, &contract_id).try_initialize( + let result = CampaignContractClient::new(&env, &contract_id).try_initialize( &unauthorized_caller, &goal_amount, &end_time, @@ -290,8 +289,8 @@ fn test_initialize_by_unauthorized_caller() { #[test] fn test_re_initialization_prevented() { let env = Env::default(); - let contract_id = env.register_contract(None, Campaign); - let client = CampaignClient::new(&env, &contract_id); + let contract_id = env.register_contract(None, CampaignContract); + let client = CampaignContractClient::new(&env, &contract_id); let creator = Address::generate(&env); let goal_amount = 10_000; @@ -331,8 +330,8 @@ fn test_re_initialization_prevented() { #[test] fn test_release_milestone_by_non_creator() { let env = Env::default(); - let contract_id = env.register_contract(None, Campaign); - let client = CampaignClient::new(&env, &contract_id); + let contract_id = env.register_contract(None, CampaignContract); + let client = CampaignContractClient::new(&env, &contract_id); let creator = Address::generate(&env); let non_creator = Address::generate(&env); @@ -364,19 +363,16 @@ fn test_release_milestone_by_non_creator() { let donor = Address::generate(&env); client.donate(&donor, &6_000, &AssetInfo::Native); - let result = CampaignClient::new(&env, &contract_id).try_release_milestone( - &non_creator, - &0, - &non_creator, - ); + let result = + CampaignContractClient::new(&env, &contract_id).try_release_milestone(&0, &non_creator); assert!(result.is_err()); } #[test] fn test_release_milestones_in_order_enforced() { let env = Env::default(); - let contract_id = env.register_contract(None, Campaign); - let client = CampaignClient::new(&env, &contract_id); + let contract_id = env.register_contract(None, CampaignContract); + let client = CampaignContractClient::new(&env, &contract_id); let creator = Address::generate(&env); let goal_amount = 15_000; @@ -411,27 +407,27 @@ fn test_release_milestones_in_order_enforced() { let donor = Address::generate(&env); client.donate(&donor, &16_000, &AssetInfo::Native); - let result = client.try_release_milestone(&creator, &2, &creator); + let result = client.try_release_milestone(&2, &creator); assert_eq!(result, Err(Ok(Error::PreviousMilestoneNotReleased))); - let result = client.try_release_milestone(&creator, &1, &creator); + let result = client.try_release_milestone(&1, &creator); assert_eq!(result, Err(Ok(Error::PreviousMilestoneNotReleased))); - let result = client.try_release_milestone(&creator, &0, &creator); + let result = client.try_release_milestone(&0, &creator); assert!(result.is_ok()); - let result = client.try_release_milestone(&creator, &1, &creator); + let result = client.try_release_milestone(&1, &creator); assert!(result.is_ok()); - let result = client.try_release_milestone(&creator, &2, &creator); + let result = client.try_release_milestone(&2, &creator); assert!(result.is_ok()); } #[test] fn test_cannot_release_milestone_twice() { let env = Env::default(); - let contract_id = env.register_contract(None, Campaign); - let client = CampaignClient::new(&env, &contract_id); + let contract_id = env.register_contract(None, CampaignContract); + let client = CampaignContractClient::new(&env, &contract_id); let creator = Address::generate(&env); let goal_amount = 10_000; @@ -458,18 +454,18 @@ fn test_cannot_release_milestone_twice() { let donor = Address::generate(&env); client.donate(&donor, &10_000, &AssetInfo::Native); - let result = client.try_release_milestone(&creator, &0, &creator); + let result = client.try_release_milestone(&0, &creator); assert!(result.is_ok()); - let result = client.try_release_milestone(&creator, &0, &creator); + let result = client.try_release_milestone(&0, &creator); assert_eq!(result, Err(Ok(Error::MilestoneAlreadyReleased))); } #[test] fn test_cannot_release_locked_milestone() { let env = Env::default(); - let contract_id = env.register_contract(None, Campaign); - let client = CampaignClient::new(&env, &contract_id); + let contract_id = env.register_contract(None, CampaignContract); + let client = CampaignContractClient::new(&env, &contract_id); let creator = Address::generate(&env); let goal_amount = 10_000; @@ -493,15 +489,15 @@ fn test_cannot_release_locked_milestone() { &min_donation, ); - let result = client.try_release_milestone(&creator, &0, &creator); + let result = client.try_release_milestone(&0, &creator); assert_eq!(result, Err(Ok(Error::MilestoneNotUnlocked))); } #[test] fn test_donate_freezes_state_validation() { let env = Env::default(); - let contract_id = env.register_contract(None, Campaign); - let client = CampaignClient::new(&env, &contract_id); + let contract_id = env.register_contract(None, CampaignContract); + let client = CampaignContractClient::new(&env, &contract_id); let creator = Address::generate(&env); let goal_amount = 10_000; @@ -537,8 +533,8 @@ fn test_donate_freezes_state_validation() { #[test] fn test_unauthorized_donor_cannot_donate() { let env = Env::default(); - let contract_id = env.register_contract(None, Campaign); - let client = CampaignClient::new(&env, &contract_id); + let contract_id = env.register_contract(None, CampaignContract); + let client = CampaignContractClient::new(&env, &contract_id); let creator = Address::generate(&env); let goal_amount = 10_000; @@ -564,7 +560,7 @@ fn test_unauthorized_donor_cannot_donate() { let unauthorized_donor = Address::generate(&env); - let result = CampaignClient::new(&env, &contract_id).try_donate( + let result = CampaignContractClient::new(&env, &contract_id).try_donate( &unauthorized_donor, &5_000, &AssetInfo::Native, @@ -575,8 +571,8 @@ fn test_unauthorized_donor_cannot_donate() { #[test] fn test_donation_validates_campaign_status() { let env = Env::default(); - let contract_id = env.register_contract(None, Campaign); - let client = CampaignClient::new(&env, &contract_id); + let contract_id = env.register_contract(None, CampaignContract); + let client = CampaignContractClient::new(&env, &contract_id); let creator = Address::generate(&env); let goal_amount = 10_000; @@ -614,20 +610,56 @@ fn test_donation_validates_campaign_status() { /// target_amount (since `released_amount` starts at 0). #[test] fn test_release_amount_single_milestone() { + let env = Env::default(); + let contract_id = env.register_contract(None, CampaignContract); + let client = CampaignContractClient::new(&env, &contract_id); + + let creator = Address::generate(&env); + let goal_amount = 10_000; + let end_time = env.ledger().timestamp() + 1_000; + let accepted_assets = soroban_sdk::vec![&env, AssetInfo::Native]; + let milestones = soroban_sdk::vec![ + &env, + MilestoneInput { + target_amount: 10_000, + description_hash: desc_hash(&env, [0; 32]), + }, + ]; + let min_donation = 100; + + client.initialize( + &creator, + &goal_amount, + &end_time, + &accepted_assets, + &milestones, + &min_donation, + ); + + let donor = Address::generate(&env); + client.donate(&donor, &10_000, &AssetInfo::Native); + + let result = client.try_release_milestone(&0, &creator); + assert!(result.is_ok()); + + let campaign_data = client.get_campaign_info(); + assert_eq!(campaign_data.released_amount, 10_000); +} + // ─── Refund Tests ─────────────────────────────────────────────────────────────── #[test] fn test_multi_asset_refund_exact_calculation() { let env = Env::default(); - let contract_id = env.register_contract(None, Campaign); - let client = CampaignClient::new(&env, &contract_id); + let contract_id = env.register_contract(None, CampaignContract); + let client = CampaignContractClient::new(&env, &contract_id); - let creator = Address::random(&env); + let creator = Address::generate(&env); let goal_amount = 10_000; let end_time = env.ledger().timestamp() + 1000; // Setup multi-asset campaign - let token_address = Address::random(&env); + let token_address = Address::generate(&env); let accepted_assets = soroban_sdk::vec![ &env, AssetInfo::Native, @@ -654,7 +686,7 @@ fn test_multi_asset_refund_exact_calculation() { // Set XLM token address for Native asset client.set_xlm_token(&token_address); - let donor = Address::random(&env); + let donor = Address::generate(&env); // Donate with multiple assets client.donate(&donor, &3_000, &AssetInfo::Native); @@ -687,10 +719,10 @@ fn test_multi_asset_refund_exact_calculation() { #[test] fn test_refund_window_boundary_at_expiration() { let env = Env::default(); - let contract_id = env.register_contract(None, Campaign); - let client = CampaignClient::new(&env, &contract_id); + let contract_id = env.register_contract(None, CampaignContract); + let client = CampaignContractClient::new(&env, &contract_id); - let creator = Address::random(&env); + let creator = Address::generate(&env); let goal_amount = 10_000; let end_time = env.ledger().timestamp() + 1000; let accepted_assets = soroban_sdk::vec![&env, AssetInfo::Native]; @@ -698,7 +730,7 @@ fn test_refund_window_boundary_at_expiration() { &env, MilestoneInput { target_amount: 10_000, - description_hash: [0; 32].into(), + description_hash: desc_hash(&env, [0; 32]), }, ]; let min_donation = 100; @@ -712,10 +744,10 @@ fn test_refund_window_boundary_at_expiration() { &min_donation, ); - let token_address = Address::random(&env); + let token_address = Address::generate(&env); client.set_xlm_token(&token_address); - let donor = Address::random(&env); + let donor = Address::generate(&env); client.donate(&donor, &5_000, &AssetInfo::Native); // Cancel campaign @@ -740,10 +772,10 @@ fn test_refund_window_boundary_at_expiration() { #[test] fn test_refund_window_closed_after_boundary() { let env = Env::default(); - let contract_id = env.register_contract(None, Campaign); - let client = CampaignClient::new(&env, &contract_id); + let contract_id = env.register_contract(None, CampaignContract); + let client = CampaignContractClient::new(&env, &contract_id); - let creator = Address::random(&env); + let creator = Address::generate(&env); let goal_amount = 10_000; let end_time = env.ledger().timestamp() + 1000; let accepted_assets = soroban_sdk::vec![&env, AssetInfo::Native]; @@ -751,7 +783,7 @@ fn test_refund_window_closed_after_boundary() { &env, MilestoneInput { target_amount: 10_000, - description_hash: [0; 32].into(), + description_hash: desc_hash(&env, [0; 32]), }, ]; let min_donation = 100; @@ -765,10 +797,10 @@ fn test_refund_window_closed_after_boundary() { &min_donation, ); - let token_address = Address::random(&env); + let token_address = Address::generate(&env); client.set_xlm_token(&token_address); - let donor = Address::random(&env); + let donor = Address::generate(&env); client.donate(&donor, &5_000, &AssetInfo::Native); // Cancel campaign @@ -790,8 +822,8 @@ fn test_refund_window_closed_after_boundary() { #[test] fn test_refund_only_in_cancelled_or_failed_status() { let env = Env::default(); - let contract_id = env.register_contract(None, Campaign); - let client = CampaignClient::new(&env, &contract_id); + let contract_id = env.register_contract(None, CampaignContract); + let client = CampaignContractClient::new(&env, &contract_id); let creator = Address::generate(&env); let goal_amount = 10_000; @@ -815,10 +847,10 @@ fn test_refund_only_in_cancelled_or_failed_status() { &min_donation, ); - let token_address = Address::random(&env); + let token_address = Address::generate(&env); client.set_xlm_token(&token_address); - let donor = Address::random(&env); + let donor = Address::generate(&env); client.donate(&donor, &5_000, &AssetInfo::Native); // Try to refund while campaign is still active - should fail @@ -830,10 +862,10 @@ fn test_refund_only_in_cancelled_or_failed_status() { #[test] fn test_fail_campaign_starts_refund_window() { let env = Env::default(); - let contract_id = env.register_contract(None, Campaign); - let client = CampaignClient::new(&env, &contract_id); + let contract_id = env.register_contract(None, CampaignContract); + let client = CampaignContractClient::new(&env, &contract_id); - let creator = Address::random(&env); + let creator = Address::generate(&env); let goal_amount = 10_000; let end_time = env.ledger().timestamp() + 1000; let accepted_assets = soroban_sdk::vec![&env, AssetInfo::Native]; @@ -841,7 +873,7 @@ fn test_fail_campaign_starts_refund_window() { &env, MilestoneInput { target_amount: 10_000, - description_hash: [0; 32].into(), + description_hash: desc_hash(&env, [0; 32]), }, ]; let min_donation = 100; @@ -855,10 +887,10 @@ fn test_fail_campaign_starts_refund_window() { &min_donation, ); - let token_address = Address::random(&env); + let token_address = Address::generate(&env); client.set_xlm_token(&token_address); - let donor = Address::random(&env); + let donor = Address::generate(&env); client.donate(&donor, &5_000, &AssetInfo::Native); // Fail campaign @@ -879,8 +911,8 @@ fn test_fail_campaign_starts_refund_window() { #[test] fn test_refund_requires_donor_authorization() { let env = Env::default(); - let contract_id = env.register_contract(None, Campaign); - let client = CampaignClient::new(&env, &contract_id); + let contract_id = env.register_contract(None, CampaignContract); + let client = CampaignContractClient::new(&env, &contract_id); let creator = Address::generate(&env); let goal_amount = 10_000; @@ -907,18 +939,18 @@ fn test_refund_requires_donor_authorization() { &min_donation, ); - let token_address = Address::random(&env); + let token_address = Address::generate(&env); client.set_xlm_token(&token_address); - let donor = Address::random(&env); + let donor = Address::generate(&env); client.donate(&donor, &5_000, &AssetInfo::Native); // Cancel campaign client.cancel_campaign(); // Try to refund without donor authorization - let unauthorized_caller = Address::random(&env); - let result = CampaignClient::new(&env, &contract_id).try_refund(&donor); + let unauthorized_caller = Address::generate(&env); + let result = CampaignContractClient::new(&env, &contract_id).try_refund(&donor); // Authorization should fail at require_auth() assert!(result.is_err()); } @@ -926,10 +958,10 @@ fn test_refund_requires_donor_authorization() { #[test] fn test_get_refund_window_remaining() { let env = Env::default(); - let contract_id = env.register_contract(None, Campaign); - let client = CampaignClient::new(&env, &contract_id); + let contract_id = env.register_contract(None, CampaignContract); + let client = CampaignContractClient::new(&env, &contract_id); - let creator = Address::random(&env); + let creator = Address::generate(&env); let goal_amount = 10_000; let end_time = env.ledger().timestamp() + 1000; let accepted_assets = soroban_sdk::vec![&env, AssetInfo::Native]; @@ -937,7 +969,7 @@ fn test_get_refund_window_remaining() { &env, MilestoneInput { target_amount: 10_000, - description_hash: [0; 32].into(), + description_hash: desc_hash(&env, [0; 32]), }, ]; let min_donation = 100; @@ -979,10 +1011,10 @@ fn test_get_refund_window_remaining() { #[test] fn test_get_refundable_amount() { let env = Env::default(); - let contract_id = env.register_contract(None, Campaign); - let client = CampaignClient::new(&env, &contract_id); + let contract_id = env.register_contract(None, CampaignContract); + let client = CampaignContractClient::new(&env, &contract_id); - let creator = Address::random(&env); + let creator = Address::generate(&env); let goal_amount = 10_000; let end_time = env.ledger().timestamp() + 1000; let accepted_assets = soroban_sdk::vec![&env, AssetInfo::Native]; @@ -990,7 +1022,7 @@ fn test_get_refundable_amount() { &env, MilestoneInput { target_amount: 10_000, - description_hash: [0; 32].into(), + description_hash: desc_hash(&env, [0; 32]), }, ]; let min_donation = 100; @@ -1004,10 +1036,10 @@ fn test_get_refundable_amount() { &min_donation, ); - let token_address = Address::random(&env); + let token_address = Address::generate(&env); client.set_xlm_token(&token_address); - let donor = Address::random(&env); + let donor = Address::generate(&env); // Initially no refundable amount assert_eq!(client.get_refundable_amount(&donor), 0); @@ -1021,3 +1053,335 @@ fn test_get_refundable_amount() { client.refund(&donor); assert_eq!(client.get_refundable_amount(&donor), 0); } + +#[test] +fn test_cancel_campaign_with_one_stroop_raised_fails() { + let env = Env::default(); + let contract_id = env.register_contract(None, CampaignContract); + let client = CampaignContractClient::new(&env, &contract_id); + + let creator = Address::generate(&env); + let goal_amount = 10_000; + let end_time = env.ledger().timestamp() + 1000; + let accepted_assets = soroban_sdk::vec![&env, AssetInfo::Native]; + let milestones = soroban_sdk::vec![ + &env, + MilestoneInput { + target_amount: 10_000, + description_hash: desc_hash(&env, [0; 32]), + }, + ]; + // Minimum donation of 1 stroop so a single-stroop donation is accepted + let min_donation = 1; + + client.initialize( + &creator, + &goal_amount, + &end_time, + &accepted_assets, + &milestones, + &min_donation, + ); + + let token_address = Address::generate(&env); + client.set_xlm_token(&token_address); + + let donor = Address::generate(&env); + client.donate(&donor, &1, &AssetInfo::Native); + + // Cancel must fail once any funds have been raised (even a single stroop) + let result = client.try_cancel_campaign(); + assert_eq!(result, Err(Ok(Error::CannotCancelWithFunds))); + + // Status remains Active + let campaign_data = client.get_campaign_info(); + assert_eq!(campaign_data.status, CampaignStatus::Active); +} + +#[test] +fn test_extend_deadline_beyond_90_day_cap_fails() { + let env = Env::default(); + let contract_id = env.register_contract(None, CampaignContract); + let client = CampaignContractClient::new(&env, &contract_id); + + let creator = Address::generate(&env); + let goal_amount = 10_000; + let day: u64 = 24 * 60 * 60; + let original_end_time = env.ledger().timestamp() + 1000; + let accepted_assets = soroban_sdk::vec![&env, AssetInfo::Native]; + let milestones = soroban_sdk::vec![ + &env, + MilestoneInput { + target_amount: 10_000, + description_hash: desc_hash(&env, [0; 32]), + }, + ]; + let min_donation = 100; + + client.initialize( + &creator, + &goal_amount, + &original_end_time, + &accepted_assets, + &milestones, + &min_donation, + ); + + // Extension exactly to the cap (original + 90 days) is allowed + let capped_end_time = original_end_time + 90 * day; + let result = client.try_extend_deadline(&capped_end_time); + assert!(result.is_ok(), "extension to the 90-day cap failed"); + assert_eq!(client.get_campaign_info().end_time, capped_end_time); + + // Extension one second past the cap (original + 90 days) must fail + let result = client.try_extend_deadline(&(capped_end_time + 1)); + assert_eq!(result, Err(Ok(Error::DeadlineExceedsLimit))); + + // End time is unchanged after the rejected extension + assert_eq!(client.get_campaign_info().end_time, capped_end_time); +} + +#[test] +fn test_extend_deadline_chained_twice() { + let env = Env::default(); + let contract_id = env.register_contract(None, CampaignContract); + let client = CampaignContractClient::new(&env, &contract_id); + + let creator = Address::generate(&env); + let goal_amount = 10_000; + let day: u64 = 24 * 60 * 60; + let original_end_time = env.ledger().timestamp() + 1000; + let accepted_assets = soroban_sdk::vec![&env, AssetInfo::Native]; + let milestones = soroban_sdk::vec![ + &env, + MilestoneInput { + target_amount: 10_000, + description_hash: desc_hash(&env, [0; 32]), + }, + ]; + let min_donation = 100; + + client.initialize( + &creator, + &goal_amount, + &original_end_time, + &accepted_assets, + &milestones, + &min_donation, + ); + + // First extension: original + 30 days + let first_extension = original_end_time + 30 * day; + client.extend_deadline(&first_extension); + assert_eq!(client.get_campaign_info().end_time, first_extension); + + // Second chained extension: still measured against the ORIGINAL deadline. + // original + 90 days is allowed; anything past it is rejected even though + // it is later than the current end time. + let second_extension = original_end_time + 89 * day; + client.extend_deadline(&second_extension); + assert_eq!(client.get_campaign_info().end_time, second_extension); + + // The cap applies relative to the original end time, not the latest one + let result = client.try_extend_deadline(&(original_end_time + 91 * day)); + assert_eq!(result, Err(Ok(Error::DeadlineExceedsLimit))); +} + +#[test] +fn test_get_campaign_status_days_remaining() { + let env = Env::default(); + let contract_id = env.register_contract(None, CampaignContract); + let client = CampaignContractClient::new(&env, &contract_id); + + let creator = Address::generate(&env); + let goal_amount = 10_000; + let day: u64 = 24 * 60 * 60; + let start = env.ledger().timestamp(); + let end_time = start + 5 * day; + let accepted_assets = soroban_sdk::vec![&env, AssetInfo::Native]; + let milestones = soroban_sdk::vec![ + &env, + MilestoneInput { + target_amount: 10_000, + description_hash: desc_hash(&env, [0; 32]), + }, + ]; + let min_donation = 100; + + client.initialize( + &creator, + &goal_amount, + &end_time, + &accepted_assets, + &milestones, + &min_donation, + ); + + // 5 full days remain + let (status, days_remaining) = client.get_campaign_status(); + assert_eq!(status, CampaignStatus::Active); + assert_eq!(days_remaining, 5); + + // Advance to 12 hours before the deadline: partial day rounds up to 1 + env.ledger().with_mut(|l| l.timestamp = end_time - day / 2); + let (_, days_remaining) = client.get_campaign_status(); + assert_eq!(days_remaining, 1); + + // After the deadline days_remaining becomes negative + env.ledger().with_mut(|l| l.timestamp = end_time + 3 * day); + let (_, days_remaining) = client.get_campaign_status(); + assert_eq!(days_remaining, -3); +} + +#[test] +fn test_end_campaign_by_creator() { + let env = Env::default(); + let contract_id = env.register_contract(None, CampaignContract); + let client = CampaignContractClient::new(&env, &contract_id); + + let creator = Address::generate(&env); + let goal_amount = 10_000; + let end_time = env.ledger().timestamp() + 1000; + let accepted_assets = soroban_sdk::vec![&env, AssetInfo::Native]; + let milestones = soroban_sdk::vec![ + &env, + MilestoneInput { + target_amount: 10_000, + description_hash: desc_hash(&env, [0; 32]), + }, + ]; + let min_donation = 100; + + client.initialize( + &creator, + &goal_amount, + &end_time, + &accepted_assets, + &milestones, + &min_donation, + ); + + // Creator ends the campaign before the deadline + client.end_campaign(); + assert_eq!(client.get_campaign_info().status, CampaignStatus::Ended); + + // Ending again fails because the campaign is already Ended + let result = client.try_end_campaign(); + assert_eq!(result, Err(Ok(Error::CampaignNotActive))); +} + +#[test] +fn test_update_status_transitions_expired_active_campaign() { + let env = Env::default(); + let contract_id = env.register_contract(None, CampaignContract); + let client = CampaignContractClient::new(&env, &contract_id); + + let creator = Address::generate(&env); + let goal_amount = 10_000; + let end_time = env.ledger().timestamp() + 1000; + let accepted_assets = soroban_sdk::vec![&env, AssetInfo::Native]; + let milestones = soroban_sdk::vec![ + &env, + MilestoneInput { + target_amount: 10_000, + description_hash: desc_hash(&env, [0; 32]), + }, + ]; + let min_donation = 100; + + client.initialize( + &creator, + &goal_amount, + &end_time, + &accepted_assets, + &milestones, + &min_donation, + ); + + // Advance past the deadline: status stays Active until someone acts + env.ledger().with_mut(|l| l.timestamp = end_time + 1); + assert_eq!(client.get_campaign_info().status, CampaignStatus::Active); + + // A random non-creator account can trigger the transition + let anyone = Address::generate(&env); + let client_anyone = CampaignContractClient::new(&env, &contract_id); + let _ = anyone; + client_anyone.update_status(); + assert_eq!(client.get_campaign_info().status, CampaignStatus::Ended); + + // Idempotent: calling again on an already-Ended campaign is a no-op + client.update_status(); + assert_eq!(client.get_campaign_info().status, CampaignStatus::Ended); +} + +#[test] +fn test_update_status_before_deadline_is_noop() { + let env = Env::default(); + let contract_id = env.register_contract(None, CampaignContract); + let client = CampaignContractClient::new(&env, &contract_id); + + let creator = Address::generate(&env); + let goal_amount = 10_000; + let end_time = env.ledger().timestamp() + 1000; + let accepted_assets = soroban_sdk::vec![&env, AssetInfo::Native]; + let milestones = soroban_sdk::vec![ + &env, + MilestoneInput { + target_amount: 10_000, + description_hash: desc_hash(&env, [0; 32]), + }, + ]; + let min_donation = 100; + + client.initialize( + &creator, + &goal_amount, + &end_time, + &accepted_assets, + &milestones, + &min_donation, + ); + + // Before the deadline update_status must not change anything + client.update_status(); + assert_eq!(client.get_campaign_info().status, CampaignStatus::Active); +} + +#[test] +fn test_donation_after_deadline_marks_campaign_ended() { + let env = Env::default(); + let contract_id = env.register_contract(None, CampaignContract); + let client = CampaignContractClient::new(&env, &contract_id); + + let creator = Address::generate(&env); + let goal_amount = 10_000; + let end_time = env.ledger().timestamp() + 1000; + let accepted_assets = soroban_sdk::vec![&env, AssetInfo::Native]; + let milestones = soroban_sdk::vec![ + &env, + MilestoneInput { + target_amount: 10_000, + description_hash: desc_hash(&env, [0; 32]), + }, + ]; + let min_donation = 100; + + client.initialize( + &creator, + &goal_amount, + &end_time, + &accepted_assets, + &milestones, + &min_donation, + ); + + // Advance past the deadline + env.ledger().with_mut(|l| l.timestamp = end_time + 1); + assert_eq!(client.get_campaign_info().status, CampaignStatus::Active); + + // Donation attempt rejects and triggers the transition to Ended + let donor = Address::generate(&env); + let result = client.try_donate(&donor, &500, &AssetInfo::Native); + assert_eq!(result, Err(Ok(Error::CampaignEnded))); + assert_eq!(client.get_campaign_info().status, CampaignStatus::Ended); +} diff --git a/common/Cargo.toml b/common/Cargo.toml index 1893c80..a973695 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -5,3 +5,9 @@ edition = "2021" [dependencies] soroban-sdk = { workspace = true } + +[features] +# soroban-sdk-macros 20.x emits `#[cfg(feature = "testutils")]` into whichever +# crate invokes its macros. Enabling this feature gives the shared contracttypes +# their test-only helpers so dependent crates can use them in tests. +testutils = ["soroban-sdk/testutils"]