diff --git a/campaign/src/lib.rs b/campaign/src/lib.rs index c1ba257..8612c3d 100644 --- a/campaign/src/lib.rs +++ b/campaign/src/lib.rs @@ -20,18 +20,15 @@ impl CampaignContract { /// Can only be called once per contract instance /// /// # Panics - /// - `Error::UnauthorizedCreator` if caller is not the creator or lacks authorization - /// - `Error::AlreadyInitialized` if campaign already exists - /// - `Error::InvalidGoalAmount` if goal_amount <= 0 - /// - `Error::InvalidEndTime` if end_time <= current ledger timestamp - /// - `Error::InvalidAssets` if accepted_assets is empty - /// - `Error::InvalidAssetCode` if any asset_code is empty or invalid + /// - `Error::UnauthorizedCreator` if caller is not the creator + /// - `Error::AlreadyInitialized` if campaign already exists + /// - `Error::InvalidGoalAmount` if goal_amount <= 0 + /// - `Error::InvalidEndTime` if end_time <= current ledger timestamp + /// - `Error::InvalidAssets` if accepted_assets is empty + /// - `Error::InvalidAssetCode` if any asset_code is empty /// - `Error::InvalidMilestoneCount` if milestone count is not 1-5 - /// - `Error::InvalidMilestones` if milestones are not sorted ascending by target_amount - /// - `Error::MilestoneMismatch` if last milestone.target_amount != goal_amount - /// - /// # Events - /// Emits `campaign_initialized` event with campaign details + /// - `Error::InvalidMilestones` if milestones are not sorted ascending + /// - `Error::MilestoneMismatch` if last milestone.target_amount != goal_amount pub fn initialize( env: Env, creator: soroban_sdk::Address, @@ -41,43 +38,34 @@ impl CampaignContract { milestones: Vec, min_donation_amount: i128, ) -> Result<(), Error> { - // Authorization check: creator must authorize this call creator.require_auth(); - // Check if already initialized - can only initialize once if get_campaign(&env).is_some() { panic_with_error(&env, Error::AlreadyInitialized); } - // Validation 1: goal_amount > 0 if goal_amount <= 0 { panic_with_error(&env, Error::InvalidGoalAmount); } - // Validation 2: end_time > current ledger timestamp let current_timestamp = env.ledger().timestamp(); if end_time <= current_timestamp { panic_with_error(&env, Error::InvalidEndTime); } - // Validation 3: accepted_assets non-empty if accepted_assets.is_empty() { panic_with_error(&env, Error::InvalidAssets); } - // Validation 3b: validate each asset code validate_assets(&env, &accepted_assets)?; - // Validation 4: milestone count must be 1-5 let milestone_count = milestones.len() as u32; if milestone_count == 0 || milestone_count > types::MAX_MILESTONES { panic_with_error(&env, Error::InvalidMilestoneCount); } - // Validation 5 & 6: milestones sorted ascending and last == goal_amount validate_milestones(&env, &milestones, goal_amount)?; - // All validations passed, store campaign data let campaign = CampaignData { creator: creator.clone(), goal_amount, @@ -91,20 +79,20 @@ impl CampaignContract { set_campaign(&env, &campaign); - // Store each milestone for (index, milestone) in milestones.iter().enumerate() { - set_milestone(&env, index as u32, milestone); + set_milestone(&env, index as u32, &milestone); } - // Emit campaign_initialized event - let event = CampaignEvent::Initialized { - creator, - goal_amount, - end_time, - asset_count: accepted_assets.len() as u32, - milestone_count, - }; - env.events().publish(("campaign", "initialized"), event); + env.events().publish( + ("campaign", "initialized"), + CampaignInitializedEvent { + creator, + goal_amount, + end_time, + asset_count: accepted_assets.len() as u32, + milestone_count, + }, + ); Ok(()) } @@ -140,11 +128,54 @@ impl CampaignContract { } } -/// Helper function to validate Stellar assets -/// Ensures each asset has a non-empty asset_code +/// Issue #175 – assert the current invoker is the campaign creator. +/// +/// Reads the creator address from campaign storage and calls `require_auth()`. +/// Panics with `Error::UnauthorizedCreator` if the campaign is not initialized; +/// Soroban's auth framework panics if the invoker is not the creator. +fn require_creator(env: &Env) { + let campaign = + get_campaign(env).unwrap_or_else(|| panic_with_error(env, Error::UnauthorizedCreator)); + campaign.creator.require_auth(); +} + +/// Validates that `asset` is in the campaign's accepted list and returns the +/// token contract address needed to construct a `token::Client`. +/// +/// - `AssetInfo::Stellar(addr)` → `addr` must match an accepted asset's issuer. +/// - `AssetInfo::Native` (XLM) → finds the XLM entry by asset_code and uses its issuer. +fn get_token_address_for_asset( + env: &Env, + asset: &AssetInfo, + campaign: &CampaignData, +) -> Address { + match asset { + AssetInfo::Stellar(addr) => { + let accepted = campaign + .accepted_assets + .iter() + .any(|a| a.issuer == Some(addr.clone())); + if !accepted { + panic_with_error(env, Error::AssetNotAccepted); + } + addr.clone() + } + AssetInfo::Native => { + // Find the XLM entry in accepted_assets by asset_code == "XLM". + // Its issuer must hold the wrapped native token contract address. + let xlm_code = soroban_sdk::String::from_str(env, "XLM"); + campaign + .accepted_assets + .iter() + .find(|a| a.asset_code == xlm_code) + .and_then(|a| a.issuer.clone()) + .unwrap_or_else(|| panic_with_error(env, Error::AssetNotAccepted)) + } + } +} + fn validate_assets(env: &Env, assets: &Vec) -> Result<(), Error> { for asset in assets.iter() { - // asset_code must be non-empty if asset.asset_code.len() == 0 { panic_with_error(env, Error::InvalidAssetCode); } @@ -152,13 +183,11 @@ fn validate_assets(env: &Env, assets: &Vec) -> Result<(), Error> { Ok(()) } -/// Helper function to validate milestone conditions fn validate_milestones( env: &Env, milestones: &Vec, goal_amount: i128, ) -> Result<(), Error> { - // Check if milestones are sorted ascending by target_amount for i in 1..milestones.len() { let prev = &milestones.get(i - 1).unwrap(); let current = &milestones.get(i).unwrap(); @@ -168,7 +197,6 @@ fn validate_milestones( } } - // Check if last milestone.target_amount == goal_amount if let Some(last_milestone) = milestones.last() { if last_milestone.target_amount != goal_amount { panic_with_error(env, Error::MilestoneMismatch); @@ -180,7 +208,8 @@ fn validate_milestones( Ok(()) } -/// Helper function to panic with a descriptive error message +/// Panics the contract execution with the given error code. +/// With `contracterror`, `Error` implements `Into` directly. fn panic_with_error(env: &Env, error: Error) -> ! { let error_name = match error { Error::InvalidGoalAmount => "InvalidGoalAmount", @@ -202,12 +231,12 @@ fn panic_with_error(env: &Env, error: Error) -> ! { env.panic_with_error(soroban_sdk::Symbol::new(env, error_name)) } -/// Validates campaign status transitions and panics if invalid -/// +/// Validates campaign status transitions; panics if invalid. +/// /// Valid transitions: -/// Active -> GoalReached (when goal reached) -/// Active -> Ended (when deadline passes) -/// GoalReached -> Ended (when deadline passes) +/// Active -> GoalReached (goal reached) +/// Active -> Ended (deadline passes) +/// GoalReached -> Ended (deadline passes) /// Active/GoalReached/Ended -> Cancelled (by creator) pub fn validate_campaign_transition( env: &Env, @@ -215,63 +244,44 @@ pub fn validate_campaign_transition( next_status: &CampaignStatus, ) -> Result<(), Error> { match (current_status, next_status) { - // Active can transition to GoalReached, Ended, or Cancelled (CampaignStatus::Active, CampaignStatus::GoalReached) => Ok(()), (CampaignStatus::Active, CampaignStatus::Ended) => Ok(()), (CampaignStatus::Active, CampaignStatus::Cancelled) => Ok(()), - - // GoalReached can transition to Ended or Cancelled (CampaignStatus::GoalReached, CampaignStatus::Ended) => Ok(()), (CampaignStatus::GoalReached, CampaignStatus::Cancelled) => Ok(()), - - // Ended can only transition to Cancelled (CampaignStatus::Ended, CampaignStatus::Cancelled) => Ok(()), - - // Cancelled is terminal (CampaignStatus::Cancelled, _) => { panic_with_error(env, Error::InvalidCampaignTransition); } - - // All other transitions are invalid _ => { panic_with_error(env, Error::InvalidCampaignTransition); } } } -/// Validates milestone status transitions and panics if invalid -/// +/// Validates milestone status transitions; panics if invalid. +/// /// Valid transitions: -/// Locked -> Unlocked (when target_amount reached) -/// Unlocked -> Released (when explicitly released) -/// Locked -> Released (direct transition allowed) +/// Locked -> Unlocked (target_amount reached) +/// Unlocked -> Released (explicitly released) +/// Locked -> Released (direct release) pub fn validate_milestone_transition( env: &Env, current_status: &MilestoneStatus, next_status: &MilestoneStatus, ) -> Result<(), Error> { match (current_status, next_status) { - // Locked can transition to Unlocked or Released (MilestoneStatus::Locked, MilestoneStatus::Unlocked) => Ok(()), (MilestoneStatus::Locked, MilestoneStatus::Released) => Ok(()), - - // Unlocked can transition to Released (MilestoneStatus::Unlocked, MilestoneStatus::Released) => Ok(()), - - // Released is terminal (MilestoneStatus::Released, _) => { panic_with_error(env, Error::InvalidMilestoneTransition); } - - // Prevent Unlocked -> Locked (going backwards) (MilestoneStatus::Unlocked, MilestoneStatus::Locked) => { panic_with_error(env, Error::InvalidMilestoneTransition); } - - // All other transitions are invalid _ => { panic_with_error(env, Error::InvalidMilestoneTransition); } } } - diff --git a/campaign/src/types.rs b/campaign/src/types.rs index 646b3e3..902ec90 100644 --- a/campaign/src/types.rs +++ b/campaign/src/types.rs @@ -1,30 +1,34 @@ -use soroban_sdk::{contracttype, Address, BytesN, Vec}; +use soroban_sdk::{contracttype, contracterror, Address, BytesN, Vec}; // ── Error enum ────────────────────────────────────────────────────────────── -/// All error types for validation and state transitions -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] +/// All error types for validation and state transitions. +/// Uses `contracterror` so variants map to u32 codes and `env.panic_with_error` works. +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum Error { // ── Initialization validation errors ── - InvalidGoalAmount, // goal_amount must be > 0 - InvalidEndTime, // end_time must be > current ledger timestamp - InvalidAssets, // accepted_assets must be non-empty - InvalidAssetCode, // asset_code must be non-empty and valid - InvalidMilestones, // milestones must be sorted ascending and last must equal goal - MilestoneMismatch, // last milestone.target_amount != goal_amount - InvalidMilestoneCount, // milestone count must be 1-5 - AlreadyInitialized, // campaign already initialized - UnauthorizedCreator, // caller is not the creator or lacks authorization - + InvalidGoalAmount = 1, // goal_amount must be > 0 + InvalidEndTime = 2, // end_time must be > current ledger timestamp + InvalidAssets = 3, // accepted_assets must be non-empty + InvalidAssetCode = 4, // asset_code must be non-empty and valid + InvalidMilestones = 5, // milestones must be sorted ascending and last must equal goal + MilestoneMismatch = 6, // last milestone.target_amount != goal_amount + InvalidMilestoneCount = 7, // milestone count must be 1-5 + AlreadyInitialized = 8, // campaign already initialized + UnauthorizedCreator = 9, // caller is not the creator or lacks authorization + // ── State transition errors ── - InvalidCampaignTransition, // campaign status transition not allowed - InvalidMilestoneTransition,// milestone status transition not allowed - CampaignNotActive, // campaign must be Active to accept donations - CampaignEnded, // campaign end_time has passed - GoalNotReached, // cannot transition to GoalReached before reaching goal - /// Issue #192 – donation amount is below the campaign minimum - DonationTooSmall, + InvalidCampaignTransition = 10, // campaign status transition not allowed + InvalidMilestoneTransition = 11, // milestone status transition not allowed + CampaignNotActive = 12, // campaign must be Active to accept donations + CampaignEnded = 13, // campaign end_time has passed + GoalNotReached = 14, // cannot transition to GoalReached before reaching goal + + // ── Runtime errors ── + NotInitialized = 15, // campaign has not been initialized yet + AssetNotAccepted = 16, // donated asset is not in campaign's accepted_assets + InvalidDonationAmount = 17, // donation amount must be > 0 } // ── Supporting enums ───────────────────────────────────────────────────────── @@ -59,17 +63,16 @@ pub enum MilestoneStatus { // ── Contract events ────────────────────────────────────────────────────────── -/// Campaign lifecycle events +/// Emitted by `initialize`. Stored as a `contracttype` struct so it can be +/// passed as event data via `env.events().publish(...)`. #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub enum CampaignEvent { - Initialized { - creator: Address, - goal_amount: i128, - end_time: u64, - asset_count: u32, - milestone_count: u32, - }, +pub struct CampaignInitializedEvent { + pub creator: Address, + pub goal_amount: i128, + pub end_time: u64, + pub asset_count: u32, + pub milestone_count: u32, } /// Reusable struct for Stellar asset representation @@ -79,19 +82,22 @@ pub enum CampaignEvent { pub struct StellarAsset { /// Asset code (e.g., "XLM", "USDC", "EUR") pub asset_code: soroban_sdk::String, - /// Issuer address; None for native XLM + /// Issuer address; None for native XLM (display only — transfers need a contract address) pub issuer: Option
, } impl StellarAsset { - /// Helper function to check if this asset is native XLM + /// Returns true when this asset is native XLM (no issuer set). pub fn is_xlm(&self) -> bool { self.issuer.is_none() } } -/// Accepted asset descriptor (native XLM or a Stellar asset) -/// Deprecated: Use StellarAsset instead +/// Accepted asset descriptor (native XLM or a Stellar SEP-41 token). +/// Used in the `donate` function signature. +/// Native – identifies XLM; the XLM entry in `accepted_assets` must +/// carry the wrapped native contract address in its `issuer`. +/// Stellar(addr) – `addr` is the token contract address directly. #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub enum AssetInfo {