diff --git a/src/base/errors.cairo b/src/base/errors.cairo index 2a62486..7a89c6e 100644 --- a/src/base/errors.cairo +++ b/src/base/errors.cairo @@ -138,5 +138,8 @@ pub mod Errors { // Throw Error when Fee percent exceeds 100% pub const PROTOCOL_FEE_PERCENTAGE_EXCEED: felt252 = 'Error: Fee percent exceeds 100%'; + + // Throw Error when Total amount must be > 0 + pub const TOTAL_DONATION_AMOUNT: felt252 = 'Error: Total amount must be > 0'; } diff --git a/src/base/types.cairo b/src/base/types.cairo index 89eba6c..b61b553 100644 --- a/src/base/types.cairo +++ b/src/base/types.cairo @@ -85,6 +85,24 @@ pub struct DistributionHistory { pub timestamp: u64, } +#[derive(Drop, Serde, Clone)] +pub struct DonationResult { + pub campaign_id: u256, + pub amount: u256, + pub success: bool, + pub donation_id: u256, +} + +#[derive(Drop, starknet::Event)] +pub struct BatchDonationProcessed { + #[key] + pub donor: ContractAddress, + pub total_campaigns: u32, + pub successful_donations: u32, + pub total_amount: u256, + pub results: Array, +} + #[derive(Copy, Drop, Serde, starknet::Store)] pub struct StreamMetrics { pub last_activity: u64, diff --git a/src/campaign_donation.cairo b/src/campaign_donation.cairo index c72ab67..83b4a3a 100644 --- a/src/campaign_donation.cairo +++ b/src/campaign_donation.cairo @@ -24,9 +24,11 @@ pub mod CampaignDonation { DOUBLE_WITHDRAWAL, INSUFFICIENT_ALLOWANCE, INSUFFICIENT_BALANCE, INVALID_DONATION_TOKEN, MORE_THAN_TARGET, NFT_NOT_CONFIGURED, OPERATION_OVERFLOW, PROTOCOL_FEE_ADDRESS_NOT_SET, PROTOCOL_FEE_PERCENTAGE_EXCEED, REFUND_ALREADY_CLAIMED, TARGET_NOT_REACHED, TARGET_REACHED, - WITHDRAWAL_FAILED, ZERO_ALLOWANCE, ZERO_AMOUNT, + TOTAL_DONATION_AMOUNT, WITHDRAWAL_FAILED, ZERO_ALLOWANCE, ZERO_AMOUNT, + }; + use crate::base::types::{ + Campaigns, DonationMetadata, DonationResult, Donations, }; - use crate::base::types::{Campaigns, DonationMetadata, Donations}; component!(path: UpgradeableComponent, storage: upgradeable, event: UpgradeableEvent); component!(path: OwnableComponent, storage: ownable, event: OwnableEvent); @@ -75,12 +77,23 @@ pub mod CampaignDonation { CampaignUpdated: CampaignUpdated, CampaignCancelled: CampaignCancelled, CampaignRefunded: CampaignRefunded, + BatchDonationProcessed: BatchDonationProcessed, #[flat] OwnableEvent: OwnableComponent::Event, #[flat] UpgradeableEvent: UpgradeableComponent::Event, } + #[derive(Drop, starknet::Event)] + pub struct BatchDonationProcessed { + #[key] + pub donor: ContractAddress, + pub total_campaigns: u32, + pub successful_donations: u32, + pub total_amount: u256, + pub results: Array, + } + #[derive(Drop, starknet::Event)] pub struct Campaign { @@ -444,6 +457,86 @@ pub mod CampaignDonation { ); } + fn batch_donate(ref self: ContractState, campaign_amounts: Array<(u256, u256)>) { + const MAX_BATCH_SIZE: u32 = 20; + + // Input validation + assert(campaign_amounts.len() > 0, 'Empty campaign array'); + assert(campaign_amounts.len() <= MAX_BATCH_SIZE, 'Batch size too large'); + + let donor = get_caller_address(); + let contract_address = get_contract_address(); + + // STEP 1: Gas-optimized validation and total calculation - O(n) complexity + let total_amount = self._validate_and_calculate_total_optimized(@campaign_amounts); + assert(total_amount > 0, TOTAL_DONATION_AMOUNT); + + // STEP 2: Token approval and balance checks + let donation_token = self.donation_token.read(); + let token_dispatcher = IERC20Dispatcher { contract_address: donation_token }; + + let donor_balance = token_dispatcher.balance_of(donor); + assert(donor_balance >= total_amount, 'Insufficient balance'); + + let allowance = token_dispatcher.allowance(donor, contract_address); + assert(allowance >= total_amount, 'Insufficient allowance'); + + // STEP 3: Single transfer for all donations (optimization) + let transfer_success = token_dispatcher + .transfer_from(donor, contract_address, total_amount); + assert(transfer_success, 'Transfer failed'); + + // STEP 4: Process all donations with result tracking + let mut results: Array = ArrayTrait::new(); + let mut successful_donations: u32 = 0; + let mut actual_total_amount: u256 = 0; + let mut i = 0; + + while i < campaign_amounts.len() { + let (campaign_id, requested_amount) = *campaign_amounts.at(i); + + // Process donation and get actual amount and donation ID + let (donation_id, actual_amount) = self + ._process_internal_donation_with_return(donor, campaign_id, requested_amount); + + // Track results (only add if donation actually happened) + if actual_amount > 0 { + results + .append( + DonationResult { + campaign_id, amount: actual_amount, success: true, donation_id, + }, + ); + successful_donations += 1; + actual_total_amount += actual_amount; + } + + i += 1; + } + + // CRITICAL FIX: Refund any excess if actual total is less than transferred amount + if actual_total_amount < total_amount { + let refund_amount = total_amount - actual_total_amount; + let refund_success = token_dispatcher.transfer(donor, refund_amount); + assert(refund_success, 'Refund failed'); + } + + // Emit batch event (use actual amount, not pre-calculated amount) + self + .emit( + Event::BatchDonationProcessed( + BatchDonationProcessed { + donor, + total_campaigns: campaign_amounts.len(), + successful_donations, + total_amount: actual_total_amount, // Use actual amount, not pre-calculated + results, + }, + ), + ); + } + + fn get_protocol_fee_percent(self: @ContractState) -> u256 { self.protocol_fee_percent.read() } @@ -466,6 +559,93 @@ pub mod CampaignDonation { #[generate_trait] impl InternalImpl of InternalTrait { + fn _validate_and_calculate_total_optimized( + self: @ContractState, campaign_amounts: @Array<(u256, u256)>, + ) -> u256 { + // STEP 1: Pre-calculate campaign batch totals in single pass O(n) + let mut campaign_totals: Array = ArrayTrait::new(); + let mut i = 0; + + while i < campaign_amounts.len() { + let (campaign_id, amount) = *campaign_amounts.at(i); + + // Validate donation amount > 0 + assert(amount > 0, 'Amount must be > 0'); + + // Find existing total for this campaign or create new entry + let mut found = false; + let mut found_index = 0; + let mut j = 0; + while j < campaign_totals.len() { + let existing_total = *campaign_totals.at(j); // FIXED: Now works with Copy trait + if existing_total.campaign_id == campaign_id { + found = true; + found_index = j; + break; + } + j += 1; + } + + if found { + // Update existing total (simplified approach - rebuild array) + let mut new_totals: Array = ArrayTrait::new(); + let mut k = 0; + while k < campaign_totals.len() { + let existing = *campaign_totals.at(k); + if k == found_index { + new_totals + .append( + CampaignBatchTotal { + campaign_id: existing.campaign_id, + total_amount: existing.total_amount + amount, + }, + ); + } else { + new_totals.append(existing); + } + k += 1; + } + campaign_totals = new_totals; + } else { + campaign_totals + .append(CampaignBatchTotal { campaign_id, total_amount: amount }); + } + + i += 1; + } + + // STEP 2: Validate campaigns and calculate effective total O(unique_campaigns) + let mut total: u256 = 0; + let mut k = 0; + + while k < campaign_totals.len() { + let campaign_total = *campaign_totals.at(k); // FIXED: Now works with Copy trait + + // Validate campaign exists and is active + let campaign = self.campaigns.read(campaign_total.campaign_id); + assert(!campaign.owner.is_zero(), 'Campaign does not exist'); + assert(!campaign.is_closed, 'Campaign is closed'); + assert(!campaign.is_goal_reached, 'Campaign goal reached'); + + // Calculate effective amount with auto-capping + let remaining = campaign.target_amount - campaign.current_balance; + let effective_amount = if campaign_total.total_amount > remaining { + remaining // Cap to remaining amount + } else { + campaign_total.total_amount // Use full amount + }; + + total += effective_amount; + + // Check for overflow + assert(total >= effective_amount, 'Total overflow'); + + k += 1; + } + + total + } + fn _create_campaign( ref self: ContractState, campaign_ref: felt252, @@ -595,49 +775,228 @@ pub mod CampaignDonation { withdrawn_amount } - fn calculate_protocol_fee(self: @ContractState, total_amount: @u256) -> u256 { - let fee_percent = self.protocol_fee_percent.read(); - let protocol_fee = (*total_amount * fee_percent) / 10000; - protocol_fee - } + fn _process_internal_donation_with_return( + ref self: ContractState, donor: ContractAddress, campaign_id: u256, amount: u256, + ) -> (u256, u256) { + let mut campaign = self.campaigns.read(campaign_id); + let timestamp = get_block_timestamp(); + // Calculate actual donation amount (don't exceed target) - AUTO-CAPPING + let remaining_amount = campaign.target_amount - campaign.current_balance; + let actual_amount = if amount > remaining_amount { + remaining_amount + } else { + amount + }; - fn get_asset_address(self: @ContractState, token_name: felt252) -> ContractAddress { - let mut token_address: ContractAddress = contract_address_const::<0>(); - if token_name == 'USDC' || token_name == 'usdc' { - token_address = - contract_address_const::< - 0x053c91253bc9682c04929ca02ed00b3e423f6710d2ee7e0d5ebb06f3ecf368a8, - >(); + // Skip if no amount to donate (campaign already fully funded) + if actual_amount == 0 { + return (0, 0); } - if token_name == 'STRK' || token_name == 'strk' { - token_address = - contract_address_const::< - 0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d, - >(); + + // Get next donation ID + let donation_id = self.donation_count.read() + 1; + + // Update campaign amount + campaign.current_balance = campaign.current_balance + actual_amount; + + // If goal reached, mark as closed + if campaign.current_balance >= campaign.target_amount { + campaign.is_goal_reached = true; + campaign.is_closed = true; } - if token_name == 'ETH' || token_name == 'eth' { - token_address = - contract_address_const::< - 0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7, - >(); + + self.campaigns.write(campaign_id, campaign); + + // Create donation record + let donation = Donations { donation_id, donor, campaign_id, amount: actual_amount }; + + // Properly append to the Vec using push + self.donations.entry(campaign_id).push(donation); + + self.donation_count.write(donation_id); + + // Update the per-campaign donation count + let campaign_donation_count = self.donation_counts.read(campaign_id); + self.donation_counts.write(campaign_id, campaign_donation_count + 1); + + // Emit donation event for each successful donation + self + .emit( + Event::Donation( + Donation { donor, campaign_id, amount: actual_amount, timestamp }, + ), + ); + + // Return both donation_id and actual_amount for tracking + (donation_id, actual_amount) + } + } + + + fn calculate_protocol_fee(self: @ContractState, total_amount: @u256) -> u256 { + let fee_percent = self.protocol_fee_percent.read(); + let protocol_fee = (*total_amount * fee_percent) / 10000; + protocol_fee + } + + + fn get_asset_address(self: @ContractState, token_name: felt252) -> ContractAddress { + let mut token_address: ContractAddress = contract_address_const::<0>(); + if token_name == 'USDC' || token_name == 'usdc' { + token_address = + contract_address_const::< + 0x053c91253bc9682c04929ca02ed00b3e423f6710d2ee7e0d5ebb06f3ecf368a8, + >(); + } + if token_name == 'STRK' || token_name == 'strk' { + token_address = + contract_address_const::< + 0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d, + >(); + } + if token_name == 'ETH' || token_name == 'eth' { + token_address = + contract_address_const::< + 0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7, + >(); + } + if token_name == 'USDT' || token_name == 'usdt' { + token_address = + contract_address_const::< + 0x068f5c6a61780768455de69077e07e89787839bf8166decfbf92b645209c0fb8, + >(); + } + + token_address + } + + fn _validate_and_calculate_total( + self: @ContractState, campaign_amounts: @Array<(u256, u256)>, + ) -> u256 { + let mut total: u256 = 0; + let mut i = 0; + + while i < campaign_amounts.len() { + let (campaign_id, amount) = *campaign_amounts.at(i); + + // Validate donation amount > 0 + assert(amount > 0, 'Amount must be > 0'); + + // Validate campaign exists + let campaign = self.campaigns.read(campaign_id); + assert(!campaign.owner.is_zero(), 'Campaign does not exist'); + + // Check campaign is active (not closed/goal reached) + assert(!campaign.is_closed, 'Campaign is closed'); + assert(!campaign.is_goal_reached, 'Campaign goal reached'); + + // Check for integer overflow in total calculation + let new_total = total + amount; + assert(new_total >= total, 'Amount overflow'); + total = new_total; + + i += 1; + } + + total + } + + /// GAS-OPTIMIZED: O(n) validation that handles mid-batch campaign completion + /// Pre-calculates campaign totals to avoid nested loops (was O(n²), now O(n)) + /// FIXED: Removed dictionary approach to avoid Copy trait issues + fn _validate_and_calculate_total_optimized( + self: @ContractState, campaign_amounts: @Array<(u256, u256)>, + ) -> u256 { + // STEP 1: Pre-calculate campaign batch totals in single pass O(n) + let mut campaign_totals: Array = ArrayTrait::new(); + let mut i = 0; + + while i < campaign_amounts.len() { + let (campaign_id, amount) = *campaign_amounts.at(i); + + // Validate donation amount > 0 + assert(amount > 0, 'Amount must be > 0'); + + // Find existing total for this campaign or create new entry + let mut found = false; + let mut found_index = 0; + let mut j = 0; + while j < campaign_totals.len() { + let existing_total = *campaign_totals.at(j); // FIXED: Now works with Copy trait + if existing_total.campaign_id == campaign_id { + found = true; + found_index = j; + break; + } + j += 1; } - if token_name == 'USDT' || token_name == 'usdt' { - token_address = - contract_address_const::< - 0x068f5c6a61780768455de69077e07e89787839bf8166decfbf92b645209c0fb8, - >(); + + if found { + // Update existing total (simplified approach - rebuild array) + let mut new_totals: Array = ArrayTrait::new(); + let mut k = 0; + while k < campaign_totals.len() { + let existing = *campaign_totals.at(k); + if k == found_index { + new_totals + .append( + CampaignBatchTotal { + campaign_id: existing.campaign_id, + total_amount: existing.total_amount + amount, + }, + ); + } else { + new_totals.append(existing); + } + k += 1; + } + campaign_totals = new_totals; + } else { + campaign_totals.append(CampaignBatchTotal { campaign_id, total_amount: amount }); } - token_address + i += 1; } - } - #[abi(embed_v0)] - impl UpgradeableImpl of IUpgradeable { - fn upgrade(ref self: ContractState, new_class_hash: ClassHash) { - self.ownable.assert_only_owner(); - self.upgradeable.upgrade(new_class_hash); + // STEP 2: Validate campaigns and calculate effective total O(unique_campaigns) + let mut total: u256 = 0; + let mut k = 0; + + while k < campaign_totals.len() { + let campaign_total = *campaign_totals.at(k); // FIXED: Now works with Copy trait + + // Validate campaign exists and is active + let campaign = self.campaigns.read(campaign_total.campaign_id); + assert(!campaign.owner.is_zero(), 'Campaign does not exist'); + assert(!campaign.is_closed, 'Campaign is closed'); + assert(!campaign.is_goal_reached, 'Campaign goal reached'); + + // Calculate effective amount with auto-capping + let remaining = campaign.target_amount - campaign.current_balance; + let effective_amount = if campaign_total.total_amount > remaining { + remaining // Cap to remaining amount + } else { + campaign_total.total_amount // Use full amount + }; + + total += effective_amount; + + // Check for overflow + assert(total >= effective_amount, 'Total overflow'); + + k += 1; } + + total } } + +#[abi(embed_v0)] +impl UpgradeableImpl of IUpgradeable { + fn upgrade(ref self: ContractState, new_class_hash: ClassHash) { + self.ownable.assert_only_owner(); + self.upgradeable.upgrade(new_class_hash); + } +} + diff --git a/src/interfaces/ICampaignDonation.cairo b/src/interfaces/ICampaignDonation.cairo index 6a293b6..ae771ec 100644 --- a/src/interfaces/ICampaignDonation.cairo +++ b/src/interfaces/ICampaignDonation.cairo @@ -223,6 +223,12 @@ pub trait ICampaignDonation { // / * Refund must not have been claimed already fn claim_refund(ref self: TContractState, campaign_id: u256); + + fn batch_donate( + ref self: TContractState, + campaign_amounts: Array<(u256, u256)> // Array of (campaign_id, amount) + ); + // ************************************************************************* // PROTOCOL FEES // *************************************************************************