diff --git a/campaign/src/lib.rs b/campaign/src/lib.rs index ea92d8d..6edf275 100644 --- a/campaign/src/lib.rs +++ b/campaign/src/lib.rs @@ -3,14 +3,244 @@ pub mod storage; pub mod types; -use soroban_sdk::{contract, contractimpl, Env}; +use soroban_sdk::{contract, contractimpl, Env, Vec}; +use types::{CampaignData, CampaignStatus, Error, MilestoneData, MilestoneStatus, StellarAsset, CampaignEvent}; +use storage::{get_campaign, set_campaign, set_milestone}; #[contract] pub struct CampaignContract; #[contractimpl] impl CampaignContract { + /// Initialize a new campaign with strict validation on all inputs. + /// + /// Requires: Creator authorization via `creator.require_auth()` + /// 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::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 + pub fn initialize( + env: Env, + creator: soroban_sdk::Address, + goal_amount: i128, + end_time: u64, + accepted_assets: Vec, + milestones: Vec, + ) -> 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, + raised_amount: 0, + end_time, + status: CampaignStatus::Active, + accepted_assets: accepted_assets.clone(), + milestone_count, + }; + + set_campaign(&env, &campaign); + + // Store each milestone + for (index, milestone) in milestones.iter().enumerate() { + 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); + + Ok(()) + } + pub fn hello(env: Env) -> soroban_sdk::Symbol { soroban_sdk::Symbol::new(&env, "campaign") } } + +/// Helper function to validate Stellar assets +/// Ensures each asset has a non-empty asset_code +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); + } + } + 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(); + + if prev.target_amount >= current.target_amount { + panic_with_error(env, Error::InvalidMilestones); + } + } + + // 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); + } + } else { + panic_with_error(env, Error::InvalidMilestones); + } + + Ok(()) +} + +/// Helper function to panic with a descriptive error message +fn panic_with_error(env: &Env, error: Error) -> ! { + let error_name = match error { + Error::InvalidGoalAmount => "InvalidGoalAmount", + Error::InvalidEndTime => "InvalidEndTime", + Error::InvalidAssets => "InvalidAssets", + Error::InvalidAssetCode => "InvalidAssetCode", + Error::InvalidMilestones => "InvalidMilestones", + Error::MilestoneMismatch => "MilestoneMismatch", + Error::InvalidMilestoneCount => "InvalidMilestoneCount", + Error::AlreadyInitialized => "AlreadyInitialized", + Error::UnauthorizedCreator => "UnauthorizedCreator", + Error::InvalidCampaignTransition => "InvalidCampaignTransition", + Error::InvalidMilestoneTransition => "InvalidMilestoneTransition", + Error::CampaignNotActive => "CampaignNotActive", + Error::CampaignEnded => "CampaignEnded", + Error::GoalNotReached => "GoalNotReached", + }; + env.panic_with_error(soroban_sdk::Symbol::new(env, error_name)) +} + +/// Validates campaign status transitions and panics if invalid +/// +/// Valid transitions: +/// Active -> GoalReached (when goal reached) +/// Active -> Ended (when deadline passes) +/// GoalReached -> Ended (when deadline passes) +/// Active/GoalReached/Ended -> Cancelled (by creator) +pub fn validate_campaign_transition( + env: &Env, + current_status: &CampaignStatus, + 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 +/// +/// Valid transitions: +/// Locked -> Unlocked (when target_amount reached) +/// Unlocked -> Released (when explicitly released) +/// Locked -> Released (direct transition allowed) +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 6e4f0de..52c3c80 100644 --- a/campaign/src/types.rs +++ b/campaign/src/types.rs @@ -1,27 +1,95 @@ use soroban_sdk::{contracttype, Address, BytesN, Vec}; +// ── Error enum ────────────────────────────────────────────────────────────── + +/// All error types for validation and state transitions +#[contracttype] +#[derive(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 + + // ── 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 +} + // ── Supporting enums ───────────────────────────────────────────────────────── /// Issue #167 – campaign lifecycle status +/// State transitions: +/// Active -> GoalReached (goal reached) +/// Active -> Ended (deadline passed) +/// GoalReached -> Ended (deadline passed) +/// Active/GoalReached/Ended -> Cancelled (by creator) #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub enum CampaignStatus { - Active, - Successful, - Failed, - Cancelled, + Active, // Campaign accepting donations + GoalReached, // Goal amount reached, still accepting donations until deadline + Ended, // Deadline passed or campaign concluded + Cancelled, // Campaign cancelled by creator } /// Issue #168 – milestone release status +/// State transitions: +/// Locked -> Unlocked (when target_amount reached) +/// Unlocked -> Released (when explicitly released by admin) +/// Locked/Unlocked -> Released (milestone marked as released) #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub enum MilestoneStatus { - Pending, - Released, - Cancelled, + Locked, // Milestone condition not yet met + Unlocked, // Target amount reached, awaiting release + Released, // Funds released to beneficiary +} + +// ── Contract events ────────────────────────────────────────────────────────── + +/// Campaign lifecycle events +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum CampaignEvent { + Initialized { + creator: Address, + goal_amount: i128, + end_time: u64, + asset_count: u32, + milestone_count: u32, + }, +} + +/// Reusable struct for Stellar asset representation +/// Enables consistent multi-asset support across the contract +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StellarAsset { + /// Asset code (e.g., "XLM", "USDC", "EUR") + pub asset_code: soroban_sdk::String, + /// Issuer address; None for native XLM + pub issuer: Option
, +} + +impl StellarAsset { + /// Helper function to check if this asset is native XLM + pub fn is_xlm(&self) -> bool { + self.issuer.is_none() + } } /// Accepted asset descriptor (native XLM or a Stellar asset) +/// Deprecated: Use StellarAsset instead #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub enum AssetInfo { @@ -53,7 +121,7 @@ pub struct CampaignData { pub raised_amount: i128, pub end_time: u64, pub status: CampaignStatus, - pub accepted_assets: Vec, + pub accepted_assets: Vec, pub milestone_count: u32, }