From 93b92872805c0167f19d46008439c1665cfdb928 Mon Sep 17 00:00:00 2001 From: Hakeem Kazeem Date: Mon, 22 Jun 2026 09:20:51 +0100 Subject: [PATCH 1/3] apply_donation & new_for methods in donate --- campaign/src/lib.rs | 118 ++++++++++++++++++++++-------------------- campaign/src/types.rs | 108 +++++++++++++++++++++----------------- 2 files changed, 122 insertions(+), 104 deletions(-) diff --git a/campaign/src/lib.rs b/campaign/src/lib.rs index d658f61..769cf62 100644 --- a/campaign/src/lib.rs +++ b/campaign/src/lib.rs @@ -15,9 +15,16 @@ pub mod storage; pub mod types; pub mod views; -use soroban_sdk::{contract, contractimpl, Address, Env, String, Vec, BytesN}; -use types::{CampaignData, CampaignInitializedEvent, CampaignStatus, CampaignStatusResponse, DonorRecord, Error, MilestoneData, MilestoneStatus, StellarAsset, AssetInfo}; -use storage::{get_campaign, set_campaign, get_milestone, set_milestone, get_donor, set_donor, storage_get_total_raised, storage_set_total_raised, increment_donor_asset_donation, get_donor_asset_donation, is_frozen, set_frozen, acquire_lock, release_lock}; +use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, String, Vec}; +use storage::{ + acquire_lock, get_campaign, get_donor, get_donor_asset_donation, get_milestone, + increment_donor_asset_donation, is_frozen, release_lock, set_campaign, set_donor, set_frozen, + set_milestone, storage_get_total_raised, storage_set_total_raised, +}; +use types::{ + AssetInfo, CampaignData, CampaignInitializedEvent, CampaignStatus, CampaignStatusResponse, + DonorRecord, Error, MilestoneData, MilestoneStatus, StellarAsset, +}; pub const VERSION: u32 = 1; @@ -139,8 +146,8 @@ impl CampaignContract { panic_with_error(&env, Error::ContractFrozen); } - let mut campaign: CampaignData = get_campaign(&env) - .unwrap_or_else(|| panic_with_error(&env, Error::NotInitialized)); + let mut campaign: CampaignData = + get_campaign(&env).unwrap_or_else(|| panic_with_error(&env, Error::NotInitialized)); // Issue #194 – status check: only Active or GoalReached campaigns accept donations match campaign.status { @@ -148,7 +155,9 @@ impl CampaignContract { _ => panic_with_error(&env, Error::CampaignNotActive), } - if amount <= 0 || (campaign.min_donation_amount > 0 && amount < campaign.min_donation_amount) { + if amount <= 0 + || (campaign.min_donation_amount > 0 && amount < campaign.min_donation_amount) + { panic_with_error(&env, Error::DonationTooSmall); } @@ -181,24 +190,15 @@ impl CampaignContract { let asset_address = get_token_address_for_asset(&env, &asset, &campaign); increment_donor_asset_donation(&env, &donor, &asset_address, amount); - // Update donor record - let mut donor_record = get_donor(&env, &donor).unwrap_or(DonorRecord { - donor: donor.clone(), - total_donated: 0, - asset: asset.clone(), - last_donation_time: 0, - last_donation_ledger: 0, - donation_count: 0, - refund_claimed: false, - }); - donor_record.total_donated = donor_record - .total_donated - .checked_add(amount) - .unwrap_or_else(|| panic_with_error(&env, Error::Overflow)); - donor_record.asset = asset.clone(); - donor_record.last_donation_time = env.ledger().timestamp(); - donor_record.last_donation_ledger = env.ledger().sequence(); - donor_record.donation_count = donor_record.donation_count.saturating_add(1); + let mut donor_record = + get_donor(&env, &donor).unwrap_or(DonorRecord::new_for(donor.clone(), asset.clone())); + donor_record.apply_donation( + &env, + amount, + env.ledger().timestamp(), + env.ledger().sequence(), + asset.clone(), + ); set_donor(&env, &donor, &donor_record); // Issue #195 – milestone unlock check @@ -210,14 +210,26 @@ impl CampaignContract { milestone.status = MilestoneStatus::Unlocked; set_milestone(&env, i, &milestone); // Emit milestone_unlocked event - event::milestone_unlocked(&env, i, milestone.target_amount, campaign.raised_amount); + event::milestone_unlocked( + &env, + i, + milestone.target_amount, + campaign.raised_amount, + ); } } } // Emit donation_received event let asset_code = resolve_asset_code(&env, &asset, &campaign); - event::donation_received(&env, &donor, amount, asset_code, campaign.raised_amount, env.ledger().timestamp()); + event::donation_received( + &env, + &donor, + amount, + asset_code, + campaign.raised_amount, + env.ledger().timestamp(), + ); // Issue #242 – Release reentrancy lock release_lock(&env); @@ -327,11 +339,11 @@ impl CampaignContract { panic_with_error(&env, Error::ContractFrozen); } - let campaign = get_campaign(&env) - .unwrap_or_else(|| panic_with_error(&env, Error::NotInitialized)); + let campaign = + get_campaign(&env).unwrap_or_else(|| panic_with_error(&env, Error::NotInitialized)); - let mut donor_record = get_donor(&env, &donor) - .unwrap_or_else(|| panic_with_error(&env, Error::NoDonorRecord)); + let mut donor_record = + get_donor(&env, &donor).unwrap_or_else(|| panic_with_error(&env, Error::NoDonorRecord)); // Eligibility Check 1: Campaign must be terminal if !campaign.status.is_terminal() { @@ -466,8 +478,8 @@ impl CampaignContract { pub fn release_milestone(env: Env, milestone_index: u32, recipient: Address) { // Issue #243 – Authorization: hoisted here so mock_all_auths() in tests // can intercept require_auth() within the contract invocation frame. - let campaign = get_campaign(&env) - .unwrap_or_else(|| panic_with_error(&env, Error::NotInitialized)); + let campaign = + get_campaign(&env).unwrap_or_else(|| panic_with_error(&env, Error::NotInitialized)); campaign.creator.require_auth(); release_milestone::release_milestone(&env, milestone_index, recipient); } @@ -480,8 +492,8 @@ impl CampaignContract { pub fn release_milestone_multi_asset(env: Env, milestone_index: u32, recipient: Address) { // Issue #243 – Authorization: hoisted here so mock_all_auths() in tests // can intercept require_auth() within the contract invocation frame. - let campaign = get_campaign(&env) - .unwrap_or_else(|| panic_with_error(&env, Error::NotInitialized)); + let campaign = + get_campaign(&env).unwrap_or_else(|| panic_with_error(&env, Error::NotInitialized)); campaign.creator.require_auth(); multi_asset_release::release_milestone_multi_asset(&env, milestone_index, recipient); } @@ -507,13 +519,14 @@ impl CampaignContract { /// - `Error::Unauthorized` if not called by the creator /// - `Error::NotInitialized` if campaign not yet initialized pub fn upgrade(env: Env, new_wasm_hash: BytesN<32>) { - let campaign = get_campaign(&env) - .unwrap_or_else(|| panic_with_error(&env, Error::NotInitialized)); + let campaign = + get_campaign(&env).unwrap_or_else(|| panic_with_error(&env, Error::NotInitialized)); campaign.creator.require_auth(); // Actually deploy the new WASM hash to the contract - env.deployer().update_current_contract_wasm(new_wasm_hash.clone()); + env.deployer() + .update_current_contract_wasm(new_wasm_hash.clone()); let timestamp = env.ledger().timestamp(); event::contract_upgraded(&env, &campaign.creator, new_wasm_hash, timestamp); @@ -528,8 +541,8 @@ impl CampaignContract { /// - `Error::Unauthorized` if not called by the creator /// - `Error::NotInitialized` if campaign not yet initialized pub fn freeze(env: Env) { - let campaign = get_campaign(&env) - .unwrap_or_else(|| panic_with_error(&env, Error::NotInitialized)); + let campaign = + get_campaign(&env).unwrap_or_else(|| panic_with_error(&env, Error::NotInitialized)); campaign.creator.require_auth(); @@ -547,8 +560,8 @@ impl CampaignContract { /// - `Error::Unauthorized` if not called by the creator /// - `Error::NotInitialized` if campaign not yet initialized pub fn unfreeze(env: Env) { - let campaign = get_campaign(&env) - .unwrap_or_else(|| panic_with_error(&env, Error::NotInitialized)); + let campaign = + get_campaign(&env).unwrap_or_else(|| panic_with_error(&env, Error::NotInitialized)); campaign.creator.require_auth(); @@ -565,18 +578,13 @@ impl CampaignContract { /// Panics with `Error::Unauthorized` 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::Unauthorized)); + let campaign = get_campaign(env).unwrap_or_else(|| panic_with_error(env, Error::Unauthorized)); 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`. -fn get_token_address_for_asset( - env: &Env, - asset: &AssetInfo, - campaign: &CampaignData, -) -> Address { +fn get_token_address_for_asset(env: &Env, asset: &AssetInfo, campaign: &CampaignData) -> Address { match asset { AssetInfo::Stellar(addr) => { let accepted = campaign @@ -640,14 +648,12 @@ fn validate_milestones( fn resolve_asset_code(env: &Env, asset: &AssetInfo, campaign: &CampaignData) -> String { match asset { AssetInfo::Native => String::from_str(env, "XLM"), - AssetInfo::Stellar(addr) => { - campaign - .accepted_assets - .iter() - .find(|a| a.issuer == Some(addr.clone())) - .map(|a| a.asset_code.clone()) - .unwrap_or_else(|| String::from_str(env, "UNKNOWN")) - } + AssetInfo::Stellar(addr) => campaign + .accepted_assets + .iter() + .find(|a| a.issuer == Some(addr.clone())) + .map(|a| a.asset_code.clone()) + .unwrap_or_else(|| String::from_str(env, "UNKNOWN")), } } diff --git a/campaign/src/types.rs b/campaign/src/types.rs index d6b010d..c973253 100644 --- a/campaign/src/types.rs +++ b/campaign/src/types.rs @@ -1,6 +1,8 @@ // src/types.rs -use soroban_sdk::{contracttype, contracterror, Address, BytesN, String, Vec}; +use soroban_sdk::{ + contracterror, contracttype, panic_with_error, Address, BytesN, Env, String, Vec, +}; // ─── Error enum ─────────────────────────────────────────────────────────────── @@ -14,103 +16,102 @@ use soroban_sdk::{contracttype, contracterror, Address, BytesN, String, Vec}; pub enum Error { // ── Requested contract error codes ──────────────────────────────────── /// `initialize` called on an already-initialised contract. - AlreadyInitialized = 1, + AlreadyInitialized = 1, /// Contract has not been initialised yet. - NotInitialized = 2, + NotInitialized = 2, /// Caller is not authorised to perform the operation. - Unauthorized = 3, + Unauthorized = 3, /// The campaign deadline has already passed. - CampaignEnded = 4, + CampaignEnded = 4, /// Operation requires the campaign to be `Active` or `GoalReached`. - CampaignNotActive = 5, + CampaignNotActive = 5, /// Donated asset is not in the campaign's accepted assets list. - AssetNotAccepted = 6, + AssetNotAccepted = 6, /// Donation amount is below the campaign's minimum threshold. - DonationTooSmall = 7, + DonationTooSmall = 7, /// Milestone index is out of range for this campaign. - MilestoneNotFound = 8, + MilestoneNotFound = 8, /// Milestone has not been unlocked yet and cannot be released. - MilestoneNotUnlocked = 9, + MilestoneNotUnlocked = 9, /// A previous milestone must be released before this one can be released. PreviousMilestoneNotReleased = 10, /// Cannot cancel the campaign while it still holds funds. - CannotCancelWithFunds = 11, + CannotCancelWithFunds = 11, /// Refunds are no longer permitted for this campaign. - RefundWindowClosed = 12, + RefundWindowClosed = 12, /// `goal_amount` must be strictly positive. - InvalidGoalAmount = 13, + InvalidGoalAmount = 13, /// `end_time` must be strictly greater than the current ledger timestamp. - InvalidEndTime = 14, + InvalidEndTime = 14, /// Milestones must be strictly ascending and the last must equal `goal_amount`. - InvalidMilestones = 15, + InvalidMilestones = 15, /// Contract does not hold enough funds to fulfil the requested transfer. InsufficientContractBalance = 16, /// A checked arithmetic operation overflowed. - Overflow = 17, + Overflow = 17, // ── Additional contract errors ───────────────────────────────────────── /// `accepted_assets` must be non-empty. - InvalidAssets = 18, + InvalidAssets = 18, /// `asset_code` must be non-empty and ≤ 12 characters (Stellar limit). - InvalidAssetCode = 19, + InvalidAssetCode = 19, /// Last milestone `target_amount` does not equal `goal_amount`. - MilestoneMismatch = 20, + MilestoneMismatch = 20, /// Milestone count must be in the range [1, MAX_MILESTONES]. - InvalidMilestoneCount = 21, + InvalidMilestoneCount = 21, /// The requested campaign status transition is not permitted. - InvalidCampaignTransition = 22, + InvalidCampaignTransition = 22, /// The requested milestone status transition is not permitted. - InvalidMilestoneTransition = 23, + InvalidMilestoneTransition = 23, /// Cannot transition to `GoalReached` — raised amount < goal. - GoalNotReached = 24, + GoalNotReached = 24, /// A storage read returned an unexpectedly invalid value. - InvalidStorageValue = 25, + InvalidStorageValue = 25, /// A storage write failed (entry too large, quota exceeded, etc.). - StorageWriteError = 26, + StorageWriteError = 26, // ── Asset / transfer ───────────────────────────────────────────────── 3x /// Recipient address is the contract itself — would lock funds permanently. - InvalidRecipient = 30, + InvalidRecipient = 30, /// The asset has no issuer address; transfers require a token contract address. - MissingIssuerAddress = 31, + MissingIssuerAddress = 31, /// Computed release amount is zero after proportional rounding. - ZeroReleaseAmount = 32, + ZeroReleaseAmount = 32, /// `released_amount` already equals `target_amount`; nothing left to release. - NothingToRelease = 33, + NothingToRelease = 33, /// `released_amount` would exceed `target_amount` after this operation. MilestoneReleasedExceedsTarget = 34, // ── Milestone ──────────────────────────────────────────────────────── 4x /// Milestone is already in the `Released` state. - MilestoneAlreadyReleased = 40, + MilestoneAlreadyReleased = 40, /// All milestones must be Released before the campaign can be concluded. - UnreleasedMilestonesExist = 41, + UnreleasedMilestonesExist = 41, // ── Refunds ────────────────────────────────────────────────────────── 5x /// Refunds are only permitted when the campaign is `Cancelled` or /// `Ended` without reaching the goal. - RefundNotPermitted = 50, + RefundNotPermitted = 50, /// No donor record found for the requesting address. - NoDonorRecord = 51, + NoDonorRecord = 51, /// Donor has already claimed a refund for this campaign. - RefundAlreadyClaimed = 52, + RefundAlreadyClaimed = 52, // RefundWindowClosed is defined above as RefundWindowClosed = 12 // ── Re-entrancy / concurrency ──────────────────────────────────────── 6x /// A re-entrant call was detected; operation aborted. - ReentrantCall = 60, + ReentrantCall = 60, // ── Amount validation ───────────────────────────────────────────────────────── 7x /// A generic negative or otherwise invalid amount was supplied. - InvalidAmount = 70, + InvalidAmount = 70, // ── Upgrade / freeze ─────────────────────────────────────────────────── 8x /// Contract is frozen; all mutating operations are blocked. - ContractFrozen = 80, + ContractFrozen = 80, } - // ─── Campaign lifecycle ─────────────────────────────────────────────────────── /// Campaign status with documented transition rules. @@ -163,11 +164,11 @@ impl CampaignStatus { pub fn can_transition_to(self, next: Self) -> bool { matches!( (self, next), - (Self::Active, Self::GoalReached) - | (Self::Active, Self::Ended) - | (Self::Active, Self::Cancelled) - | (Self::GoalReached, Self::Ended) - | (Self::GoalReached, Self::Cancelled) + (Self::Active, Self::GoalReached) + | (Self::Active, Self::Ended) + | (Self::Active, Self::Cancelled) + | (Self::GoalReached, Self::Ended) + | (Self::GoalReached, Self::Cancelled) ) } } @@ -448,11 +449,24 @@ impl DonorRecord { /// Apply a new donation to this record. Returns an error string (for /// debug builds) rather than panicking so the call site can choose how /// to surface it. - pub fn apply_donation(&mut self, amount: i128, time: u64, ledger: u32, asset: AssetInfo) { - self.total_donated = self.total_donated.saturating_add(amount); + pub fn apply_donation( + &mut self, + env: &Env, + amount: i128, + time: u64, + ledger: u32, + asset: AssetInfo, + ) { + self.total_donated = self + .total_donated + .checked_add(amount) + .unwrap_or_else(|| panic_with_error!(&env, Error::Overflow)); self.last_donation_time = time; self.last_donation_ledger = ledger; - self.donation_count = self.donation_count.saturating_add(1); + self.donation_count = self + .donation_count + .checked_add(1) + .unwrap_or_else(|| panic_with_error!(&env, Error::Overflow)); self.asset = asset; } } @@ -520,5 +534,3 @@ pub struct RefundProcessedEvent { pub asset: AssetInfo, pub ledger: u32, } - - From 7bd19973d5330d3e757010cdcdd54e2cf522a1f3 Mon Sep 17 00:00:00 2001 From: Hakeem Kazeem Date: Mon, 22 Jun 2026 09:38:58 +0100 Subject: [PATCH 2/3] cargo fmt and fix --- campaign/src/lib.rs | 6 +----- campaign/src/types.rs | 3 --- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/campaign/src/lib.rs b/campaign/src/lib.rs index bf00988..4504ef2 100644 --- a/campaign/src/lib.rs +++ b/campaign/src/lib.rs @@ -28,16 +28,12 @@ use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, String, Vec}; use storage::{ acquire_lock, get_campaign, get_donor, get_donor_asset_donation, get_milestone, increment_donor_asset_donation, is_frozen, release_lock, set_campaign, set_donor, set_frozen, - set_milestone, storage_get_total_raised, storage_set_total_raised, -}; -use types::{ - AssetInfo, CampaignData, CampaignInitializedEvent, CampaignStatus, CampaignStatusResponse, - DonorRecord, Error, MilestoneData, MilestoneStatus, StellarAsset, set_milestone, storage_get_donation_count, storage_get_release_count, storage_get_total_raised, storage_get_unique_donor_count, storage_increment_asset_raised, storage_increment_donation_count, storage_increment_unique_donor_count, storage_set_total_raised, }; + use types::{ AssetInfo, CampaignData, CampaignInitializedEvent, CampaignReport, CampaignStatus, CampaignStatusResponse, DashboardMetrics, DonorRecord, Error, MilestoneData, MilestoneStatus, diff --git a/campaign/src/types.rs b/campaign/src/types.rs index 9c8ad24..a98d4d4 100644 --- a/campaign/src/types.rs +++ b/campaign/src/types.rs @@ -3,7 +3,6 @@ use soroban_sdk::{ contracterror, contracttype, panic_with_error, Address, BytesN, Env, String, Vec, }; -use soroban_sdk::{contracterror, contracttype, Address, BytesN, Env, String, Vec}; // ─── Error enum ─────────────────────────────────────────────────────────────── @@ -470,14 +469,12 @@ impl DonorRecord { .total_donated .checked_add(amount) .unwrap_or_else(|| panic_with_error!(&env, Error::Overflow)); - .unwrap_or_else(|| env.panic_with_error(Error::Overflow)); self.last_donation_time = time; self.last_donation_ledger = ledger; self.donation_count = self .donation_count .checked_add(1) .unwrap_or_else(|| panic_with_error!(&env, Error::Overflow)); - .unwrap_or_else(|| env.panic_with_error(Error::Overflow)); self.asset = asset; } } From 9b205759ede86c8fc18cb68efd17b215f8f9639d Mon Sep 17 00:00:00 2001 From: Hakeem Kazeem Date: Mon, 22 Jun 2026 09:42:57 +0100 Subject: [PATCH 3/3] fix clippy --- campaign/src/lib.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/campaign/src/lib.rs b/campaign/src/lib.rs index 4504ef2..ad8079e 100644 --- a/campaign/src/lib.rs +++ b/campaign/src/lib.rs @@ -211,7 +211,7 @@ impl CampaignContract { storage_increment_asset_raised(&env, &asset_address, amount); increment_donor_asset_donation(&env, &donor, &asset_address, amount); - let mut donor_record = + let _donor_record = get_donor(&env, &donor).unwrap_or(DonorRecord::new_for(donor.clone(), asset.clone())); // Update donor record let existing_donor = get_donor(&env, &donor); @@ -398,7 +398,7 @@ impl CampaignContract { let campaign = get_campaign(&env).unwrap_or_else(|| panic_with_error(&env, Error::NotInitialized)); - let mut donor_record = + let _donor_record = get_donor(&env, &donor).unwrap_or_else(|| panic_with_error(&env, Error::NoDonorRecord)); let mut donor_record = @@ -626,6 +626,7 @@ impl CampaignContract { /// Reads the creator address from campaign storage and calls `require_auth()`. /// Panics with `Error::Unauthorized` if the campaign is not initialized; /// Soroban's auth framework panics if the invoker is not the creator. +#[allow(dead_code)] fn require_creator(env: &Env) { let campaign = get_campaign(env).unwrap_or_else(|| panic_with_error(env, Error::Unauthorized)); campaign.creator.require_auth();