diff --git a/src/campaign_donation.cairo b/src/campaign_donation.cairo index c72ab67..8e9ff4f 100644 --- a/src/campaign_donation.cairo +++ b/src/campaign_donation.cairo @@ -63,6 +63,10 @@ pub mod CampaignDonation { /// Protocol fee percentage using 10000 basis points (e.g. 250 = 2.5%). Default is 0. protocol_fee_percent: u256, protocol_fee_address: ContractAddress, + unique_donors_count: Map, // Number of unique donors per campaign + campaign_donors: Map< + (u256, ContractAddress), bool, + > // Track if an address has donated to a campaign } @@ -286,6 +290,43 @@ pub mod CampaignDonation { let campaign: Campaigns = self.campaigns.read(campaign_id); campaign } + + fn get_campaign_progress(self: @ContractState, campaign_id: u256) -> u8 { + // Validate campaign exists + assert(campaign_id > 0, CAMPAIGN_NOT_FOUND); + assert(campaign_id <= self.campaign_counts.read(), CAMPAIGN_NOT_FOUND); + + let campaign: Campaigns = self.campaigns.read(campaign_id); + if campaign.target_amount == 0 { + return 0_u8; + } + + let progress = (campaign.current_balance * 100) / campaign.target_amount; + + // // Cap at 100% for overfunded campaigns + if progress > 100_u256 { + return 100_u8; + } + + // Convert the calculated progress to u8 using try_into(). + // Since we already capped at 100, this should always succeed + let progress_u8: u8 = match progress.try_into() { + Option::Some(val) => val, + Option::None => 100_u8 // Fallback to 100% if conversion fails + }; + + progress_u8 + } + + + fn get_campaign_donor_count(self: @ContractState, campaign_id: u256) -> u32 { + // Validate campaign exists + assert(campaign_id > 0, CAMPAIGN_NOT_FOUND); + assert(campaign_id <= self.campaign_counts.read(), CAMPAIGN_NOT_FOUND); + + // Simply return the stored count of unique donors + self.unique_donors_count.read(campaign_id) + } fn set_donation_nft_address( ref self: ContractState, donation_nft_address: ContractAddress, ) { @@ -552,6 +593,15 @@ pub mod CampaignDonation { // Save donation reference for the donor self.donor_donations.entry(donor).push((campaign_id, donation_id)); + // Update unique donor count if this is the first donation from this donor to this + // campaign + let has_donated_before = self.campaign_donors.read((campaign_id, donor)); + if !has_donated_before { + self.campaign_donors.write((campaign_id, donor), true); + let current_unique_donors = self.unique_donors_count.read(campaign_id); + self.unique_donors_count.write(campaign_id, current_unique_donors + 1); + } + // 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); diff --git a/src/interfaces/ICampaignDonation.cairo b/src/interfaces/ICampaignDonation.cairo index 6a293b6..179f4e7 100644 --- a/src/interfaces/ICampaignDonation.cairo +++ b/src/interfaces/ICampaignDonation.cairo @@ -107,6 +107,7 @@ pub trait ICampaignDonation { /// # Returns /// * `Array` - An array of all donations made to the campaign fn get_campaign_donations(self: @TContractState, campaign_id: u256) -> Array; + /// Sets the address of the donation NFT contract /// /// # Arguments @@ -122,6 +123,7 @@ pub trait ICampaignDonation { /// # Returns /// * `u256` - The token ID of the minted NFT fn mint_donation_nft(ref self: TContractState, campaign_id: u256, donation_id: u256) -> u256; + // ************************************************************************* // USER EXPERIENCE ENHANCEMENTS // ************************************************************************* @@ -161,10 +163,12 @@ pub trait ICampaignDonation { /// /// # Returns /// * `Array<(u256, Donations)>` - Array of tuples (campaign_id, donation) + fn get_donations_by_donor( self: @TContractState, donor: ContractAddress, ) -> Array<(u256, Donations)>; + /// Gets the total amount donated by a specific address /// /// # Arguments @@ -172,8 +176,10 @@ pub trait ICampaignDonation { /// /// # Returns /// * `u256` - Total amount donated across all campaigns + fn get_total_donated_by_donor(self: @TContractState, donor: ContractAddress) -> u256; + /// Checks if a donor has contributed to a specific campaign /// /// # Arguments @@ -182,6 +188,7 @@ pub trait ICampaignDonation { /// /// # Returns /// * `bool` - True if the donor has contributed, false otherwise + fn has_donated_to_campaign( self: @TContractState, campaign_id: u256, donor: ContractAddress, ) -> bool; @@ -239,31 +246,33 @@ pub trait ICampaignDonation { /// @return The address where protocol fees are sent fn get_protocol_fee_address(self: @TContractState) -> ContractAddress; + /// @notice Sets a new protocol fee collection address /// @param new_fee_address The new address to collect protocol fees fn set_protocol_fee_address(ref self: TContractState, new_fee_address: ContractAddress); // ************************************************************************* -// ANALYTICS & INSIGHTS -// ************************************************************************* + // ANALYTICS & INSIGHTS + // ************************************************************************* /// Gets the progress percentage of a campaign -/// -/// # Arguments -/// * `campaign_id` - The campaign ID -/// -/// # Returns -/// * `u8` - Progress percentage (0-100) -// fn get_campaign_progress(self: @TContractState, campaign_id: u256) -> u8; + /// + /// # Arguments + /// * `campaign_id` - The campaign ID + /// + /// # Returns + /// * `u8` - Progress percentage (0-100) + + fn get_campaign_progress(self: @TContractState, campaign_id: u256) -> u8; /// Gets the number of unique donors for a campaign -/// -/// # Arguments -/// * `campaign_id` - The campaign ID -/// -/// # Returns -/// * `u32` - Number of unique donors -// fn get_campaign_donor_count(self: @TContractState, campaign_id: u256) -> u32; + /// + /// # Arguments + /// * `campaign_id` - The campaign ID + /// + /// # Returns + /// * `u32` - Number of unique donors + fn get_campaign_donor_count(self: @TContractState, campaign_id: u256) -> u32; /// Gets campaigns close to reaching their goal /// /// # Arguments diff --git a/tests/test_campaign_donation.cairo b/tests/test_campaign_donation.cairo index 5b1e9ba..60f7f9f 100644 --- a/tests/test_campaign_donation.cairo +++ b/tests/test_campaign_donation.cairo @@ -593,6 +593,106 @@ fn test_withdraw_funds_from_campaign_successful() { assert(owner_balance_after - owner_balance_before == 800, 'Withdrawal error') } +#[test] +fn test_get_campaign_progress() { + let (token_address, sender, campaign_donation, _erc721, _, _) = setup(); + let target_amount = 1000_u256; + let donation_token = token_address; + + // Create multiple campaigns + start_cheat_caller_address(campaign_donation.contract_address, sender); + let campaign_id_1 = campaign_donation + .create_campaign('Campaign1', target_amount, donation_token); + let campaign_id_2 = campaign_donation + .create_campaign('Campaign2', target_amount, donation_token); + let campaign_id_3 = campaign_donation + .create_campaign('Campaign3', target_amount, donation_token); + stop_cheat_caller_address(campaign_donation.contract_address); + + let token_dispatcher = IERC20Dispatcher { contract_address: token_address }; + + // Setup token approvals + start_cheat_caller_address(token_address, sender); + token_dispatcher.approve(campaign_donation.contract_address, 10000); + stop_cheat_caller_address(token_address); + + // Make donations to campaigns + start_cheat_caller_address(campaign_donation.contract_address, sender); + let _donation_id_1 = campaign_donation.donate_to_campaign(campaign_id_1, 100); // 10% + let _donation_id_2 = campaign_donation.donate_to_campaign(campaign_id_1, 200); // +20% = 30% + let _donation_id_3 = campaign_donation.donate_to_campaign(campaign_id_2, 500); // 50% + let _donation_id_4 = campaign_donation.donate_to_campaign(campaign_id_3, 1000); // 100% + stop_cheat_caller_address(campaign_donation.contract_address); + + // Test cases + // Partially funded: 300/1000 = 30% + let progress_1 = campaign_donation.get_campaign_progress(campaign_id_1); + assert(progress_1 == 30, 'partially funded'); + + // Partially funded: 500/1000 = 50% + let progress_2 = campaign_donation.get_campaign_progress(campaign_id_2); + assert(progress_2 == 50, 'partially funded'); + + // Fully/overfunded: 1000/1000 = 100% + let progress_3 = campaign_donation.get_campaign_progress(campaign_id_3); + assert(progress_3 == 100, 'fully/overfunded'); +} + +#[test] +fn test_campaign_progress_precision() { + let (token_address, sender, campaign_donation, _erc721, _, _) = setup(); + let target_amount = 1000_u256; + let donation_token = token_address; + + // Create test campaign + start_cheat_caller_address(campaign_donation.contract_address, sender); + let campaign_id = campaign_donation + .create_campaign('PrecisionTest', target_amount, donation_token); + stop_cheat_caller_address(campaign_donation.contract_address); + + let token_dispatcher = IERC20Dispatcher { contract_address: token_address }; + + // Setup token approval + start_cheat_caller_address(token_address, sender); + token_dispatcher.approve(campaign_donation.contract_address, 10000); + stop_cheat_caller_address(token_address); + + // Test various precise percentages + start_cheat_caller_address(campaign_donation.contract_address, sender); + + // Test 51% + campaign_donation.donate_to_campaign(campaign_id, 512); + let progress = campaign_donation.get_campaign_progress(campaign_id); + assert(progress == 51, 'incorrect 51%'); + + // Test 79% + campaign_donation.donate_to_campaign(campaign_id, 284); // 512 + 284 = 796 + let progress = campaign_donation.get_campaign_progress(campaign_id); + assert(progress == 79, 'incorrect 79%'); + + // Test 83% + campaign_donation.donate_to_campaign(campaign_id, 40); // 796 + 40 = 836 + let progress = campaign_donation.get_campaign_progress(campaign_id); + assert(progress == 83, 'incorrect 83%'); + + // Test 93% + campaign_donation.donate_to_campaign(campaign_id, 94); // 836 + 94 = 930 + let progress = campaign_donation.get_campaign_progress(campaign_id); + assert(progress == 93, 'incorrect 93%'); + + // Test 99% + campaign_donation.donate_to_campaign(campaign_id, 60); // 930 + 60 = 990 + let progress = campaign_donation.get_campaign_progress(campaign_id); + assert(progress == 99, 'incorrect 99%'); + + // Test edge case: 99.9% (should round down to 99%) + campaign_donation.donate_to_campaign(campaign_id, 9); // 990 + 9 = 999 + let progress = campaign_donation.get_campaign_progress(campaign_id); + assert(progress == 99, 'incorrect 99.9%'); + + stop_cheat_caller_address(campaign_donation.contract_address); +} + #[test] fn test_update_campaign_target_successful() { let (token_address, sender, campaign_donation, _erc721, _, _) = setup(); @@ -703,9 +803,162 @@ fn test_cancel_campaign_already_closed() { // Try to cancel again campaign_donation.cancel_campaign(campaign_id); + stop_cheat_caller_address(campaign_donation.contract_address); } +#[test] +fn test_unique_donor_count() { + let (token_address, sender, campaign_donation, _erc721, _, _) = setup(); + let target_amount = 1000_u256; + let another_user: ContractAddress = contract_address_const::<'another_user'>(); + let third_user: ContractAddress = contract_address_const::<'third_user'>(); + + // Create a campaign + start_cheat_caller_address(campaign_donation.contract_address, sender); + let donation_token = token_address; + let campaign_id = campaign_donation.create_campaign('DonorTest', target_amount, donation_token); + stop_cheat_caller_address(campaign_donation.contract_address); + + let token_dispatcher = IERC20Dispatcher { contract_address: token_address }; + + // Setup token approvals and balances for all users + start_cheat_caller_address(token_address, sender); + token_dispatcher.approve(campaign_donation.contract_address, 10000); + token_dispatcher.transfer(another_user, 10000); + token_dispatcher.transfer(third_user, 10000); + stop_cheat_caller_address(token_address); + + start_cheat_caller_address(token_address, another_user); + token_dispatcher.approve(campaign_donation.contract_address, 10000); + stop_cheat_caller_address(token_address); + + start_cheat_caller_address(token_address, third_user); + token_dispatcher.approve(campaign_donation.contract_address, 10000); + stop_cheat_caller_address(token_address); + + // Test initial state + let initial_count = campaign_donation.get_campaign_donor_count(campaign_id); + assert(initial_count == 0, 'Initial count should be 0'); + + // First donation from sender + start_cheat_caller_address(campaign_donation.contract_address, sender); + campaign_donation.donate_to_campaign(campaign_id, 100); + stop_cheat_caller_address(campaign_donation.contract_address); + + let count_after_first = campaign_donation.get_campaign_donor_count(campaign_id); + assert(count_after_first == 1, 'Count should be 1'); + + // Second donation from another_user + start_cheat_caller_address(campaign_donation.contract_address, another_user); + campaign_donation.donate_to_campaign(campaign_id, 200); + stop_cheat_caller_address(campaign_donation.contract_address); + + let count_after_second = campaign_donation.get_campaign_donor_count(campaign_id); + assert(count_after_second == 2, 'Count should be 2'); + + // Third donation from third_user + start_cheat_caller_address(campaign_donation.contract_address, third_user); + campaign_donation.donate_to_campaign(campaign_id, 150); + stop_cheat_caller_address(campaign_donation.contract_address); + + let count_after_third = campaign_donation.get_campaign_donor_count(campaign_id); + assert(count_after_third == 3, 'Count should be 3'); +} + +#[test] +fn test_repeat_donor_count() { + let (token_address, sender, campaign_donation, _erc721, _, _) = setup(); + let target_amount = 1000_u256; + + // Create a campaign + start_cheat_caller_address(campaign_donation.contract_address, sender); + let donation_token = token_address; + let campaign_id = campaign_donation + .create_campaign('RepeatDonorTest', target_amount, donation_token); + stop_cheat_caller_address(campaign_donation.contract_address); + + let token_dispatcher = IERC20Dispatcher { contract_address: token_address }; + + // Setup token approval + start_cheat_caller_address(token_address, sender); + token_dispatcher.approve(campaign_donation.contract_address, 10000); + stop_cheat_caller_address(token_address); + + // First donation + start_cheat_caller_address(campaign_donation.contract_address, sender); + campaign_donation.donate_to_campaign(campaign_id, 100); + stop_cheat_caller_address(campaign_donation.contract_address); + + let count_after_first = campaign_donation.get_campaign_donor_count(campaign_id); + assert(count_after_first == 1, 'Count should be 1'); + + // Second donation from same user + start_cheat_caller_address(campaign_donation.contract_address, sender); + campaign_donation.donate_to_campaign(campaign_id, 200); + stop_cheat_caller_address(campaign_donation.contract_address); + + let count_after_repeat = campaign_donation.get_campaign_donor_count(campaign_id); + assert(count_after_repeat == 1, 'Count should still be 1'); +} + +#[test] +fn test_multiple_campaigns_donor_count() { + let (token_address, sender, campaign_donation, _erc721, _, _) = setup(); + let target_amount = 1000_u256; + let another_user: ContractAddress = contract_address_const::<'another_user'>(); + + // Create two campaigns + start_cheat_caller_address(campaign_donation.contract_address, sender); + let donation_token = token_address; + let campaign_id_1 = campaign_donation + .create_campaign('Campaign1', target_amount, donation_token); + let campaign_id_2 = campaign_donation + .create_campaign('Campaign2', target_amount, donation_token); + stop_cheat_caller_address(campaign_donation.contract_address); + + let token_dispatcher = IERC20Dispatcher { contract_address: token_address }; + + // Setup token approvals and balances + start_cheat_caller_address(token_address, sender); + token_dispatcher.approve(campaign_donation.contract_address, 10000); + token_dispatcher.transfer(another_user, 10000); + stop_cheat_caller_address(token_address); + + start_cheat_caller_address(token_address, another_user); + token_dispatcher.approve(campaign_donation.contract_address, 10000); + stop_cheat_caller_address(token_address); + + // Sender donates to campaign 1 + start_cheat_caller_address(campaign_donation.contract_address, sender); + campaign_donation.donate_to_campaign(campaign_id_1, 100); + stop_cheat_caller_address(campaign_donation.contract_address); + + // Another user donates to campaign 2 + start_cheat_caller_address(campaign_donation.contract_address, another_user); + campaign_donation.donate_to_campaign(campaign_id_2, 200); + stop_cheat_caller_address(campaign_donation.contract_address); + + // Verify counts for both campaigns + let count_campaign_1 = campaign_donation.get_campaign_donor_count(campaign_id_1); + let count_campaign_2 = campaign_donation.get_campaign_donor_count(campaign_id_2); + + assert(count_campaign_1 == 1, 'Campaign 1 count should be 1'); + assert(count_campaign_2 == 1, 'Campaign 2 count should be 1'); + + // Same user donates to both campaigns + start_cheat_caller_address(campaign_donation.contract_address, sender); + campaign_donation.donate_to_campaign(campaign_id_2, 150); + stop_cheat_caller_address(campaign_donation.contract_address); + + // Verify updated counts + let new_count_campaign_1 = campaign_donation.get_campaign_donor_count(campaign_id_1); + let new_count_campaign_2 = campaign_donation.get_campaign_donor_count(campaign_id_2); + + assert(new_count_campaign_1 == 1, 'should still be 1'); + assert(new_count_campaign_2 == 2, 'should be 2'); +} + #[test] fn test_claim_refund_successful() { let (token_address, sender, campaign_donation, _erc721, _, _) = setup(); @@ -1284,4 +1537,3 @@ fn test_has_donated_to_campaign_single_donation() { let has_donated = campaign_donation.has_donated_to_campaign(campaign_id, sender); assert(has_donated, 'Donation exists'); } -