diff --git a/src/campaigns/update.rs b/src/campaigns/update.rs index c49b42f..0e551c5 100644 --- a/src/campaigns/update.rs +++ b/src/campaigns/update.rs @@ -5,7 +5,7 @@ use crate::lifecycle::{ campaign_start_time_or_error, get_creator_campaign, require_active_campaign, require_not_paused, require_unverified_campaign, }; -use crate::storage::{bump_instance_ttl, get_category_duration_cap, set_campaign}; +use crate::storage::{bump_instance_ttl, decrement_verified_campaign_count, get_category_duration_cap, set_campaign}; /// Updates the title and description of a campaign. /// @@ -71,6 +71,13 @@ pub(crate) fn update_campaign_description( bump_instance_ttl(env); let event_desc = description.clone(); campaign.description = description; + + // Issue #556: Revoke verification if description is updated significantly + if campaign.is_verified { + campaign.is_verified = false; + decrement_verified_campaign_count(env); + } + set_campaign(env, campaign_id, &campaign); env.events() diff --git a/src/campaigns/withdraw.rs b/src/campaigns/withdraw.rs index ef9f254..1588ab4 100644 --- a/src/campaigns/withdraw.rs +++ b/src/campaigns/withdraw.rs @@ -49,7 +49,7 @@ pub(crate) fn withdraw_funds(env: &Env, campaign_id: u32) -> Result<(), Error> { .and_then(|n| n.checked_add(9999)) .ok_or(Error::Overflow)? / 10000; - let total_after_fee = campaign.amount_raised - fee_amount; + let total_after_fee = to_process - fee_amount; let reserve_bps = get_withdraw_reserve_percentage(env); let reserve_amount = total_after_fee @@ -84,11 +84,15 @@ pub(crate) fn withdraw_funds(env: &Env, campaign_id: u32) -> Result<(), Error> { .checked_add(delay_days * 86400) .ok_or(Error::Overflow)?; - let reserve = CampaignReserve { - amount: reserve_amount, - release_timestamp, + let mut reserve = get_campaign_reserve(env, campaign_id).unwrap_or(CampaignReserve { + amount: 0, + release_timestamp: 0, released: false, - }; + }); + + reserve.amount += reserve_amount; + reserve.release_timestamp = reserve.release_timestamp.max(release_timestamp); + set_campaign_reserve(env, campaign_id, &reserve); } @@ -96,7 +100,7 @@ pub(crate) fn withdraw_funds(env: &Env, campaign_id: u32) -> Result<(), Error> { set_total_raised_global( env, total_raised - .checked_sub(campaign.amount_raised - reserve_amount) + .checked_sub(to_process - reserve_amount) .ok_or(Error::Overflow)?, ); diff --git a/src/storage.rs b/src/storage.rs index 9be4982..ece527c 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -32,6 +32,8 @@ pub enum DataKey { CampaignCount, /// Campaign data, keyed by campaign ID. Campaign(u32), + /// The gross amount withdrawn by the creator so far, keyed by campaign ID. + CampaignWithdrawnAmount(u32), /// A contributor's total contribution to a campaign, keyed by `(campaign_id, contributor)`. Contribution(u32, Address), /// A contributor's lifetime contribution to a campaign, keyed by `(campaign_id, contributor)`. @@ -892,6 +894,13 @@ pub fn increment_verified_campaign_count(env: &Env) { set_verified_campaign_count(env, get_verified_campaign_count(env) + 1); } +pub fn decrement_verified_campaign_count(env: &Env) { + let c = get_verified_campaign_count(env); + if c > 0 { + set_verified_campaign_count(env, c - 1); + } +} + pub fn get_cancelled_campaign_count(env: &Env) -> u32 { env.storage() .instance() diff --git a/src/tests/helpers.rs b/src/tests/helpers.rs index 4cd5523..5b8ba48 100644 --- a/src/tests/helpers.rs +++ b/src/tests/helpers.rs @@ -29,6 +29,8 @@ pub(crate) fn make_params( has_revenue_sharing, revenue_share_percentage, max_contribution_per_user, + token: crate::types::MaybeToken::None, + uses_milestones: false, } } diff --git a/src/tests/test_benchmark.rs b/src/tests/test_benchmark.rs index 6137271..4fbad07 100644 --- a/src/tests/test_benchmark.rs +++ b/src/tests/test_benchmark.rs @@ -19,6 +19,8 @@ fn make_revenue_campaign( has_revenue_sharing: true, revenue_share_percentage: 1000, max_contribution_per_user: 0, + token: crate::types::MaybeToken::None, + uses_milestones: false, } } @@ -108,6 +110,8 @@ fn test_get_campaigns_by_category_bucketed_pagination_budget() { has_revenue_sharing: false, revenue_share_percentage: 0, max_contribution_per_user: 0, + token: crate::types::MaybeToken::None, + uses_milestones: false, }; client.create_campaign(¶ms); } diff --git a/src/tests/test_campaign_update.rs b/src/tests/test_campaign_update.rs index 14f05ac..b6f99f1 100644 --- a/src/tests/test_campaign_update.rs +++ b/src/tests/test_campaign_update.rs @@ -166,6 +166,10 @@ fn test_update_campaign_description_success() { let campaign = client.get_campaign(&campaign_id); assert_eq!(campaign.description, new_desc); assert_eq!(campaign.funding_goal, 1_000); + + // Issue #556: Verification should be revoked + assert!(!campaign.is_verified); + assert_eq!(client.get_platform_stats().verified_campaigns, 0); } #[test] @@ -488,6 +492,8 @@ fn test_unpause_clears_auto_pause_when_resume_campaign_blocked() { has_revenue_sharing: false, revenue_share_percentage: 0, max_contribution_per_user: 0, + token: crate::types::MaybeToken::None, + uses_milestones: false, }); client.verify_campaign(&campaign_id); @@ -508,6 +514,8 @@ fn test_unpause_clears_auto_pause_when_resume_campaign_blocked() { has_revenue_sharing: false, revenue_share_percentage: 0, max_contribution_per_user: 0, + token: crate::types::MaybeToken::None, + uses_milestones: false, }); assert_eq!(res.unwrap_err().unwrap(), Error::ContractPaused); @@ -536,6 +544,8 @@ fn test_unpause_clears_auto_pause_when_resume_campaign_blocked() { has_revenue_sharing: false, revenue_share_percentage: 0, max_contribution_per_user: 0, + token: crate::types::MaybeToken::None, + uses_milestones: false, }); assert!(new_id > 1); } diff --git a/src/tests/test_cancel_after_goal_met.rs b/src/tests/test_cancel_after_goal_met.rs index 2b30d11..b8f2ad3 100644 --- a/src/tests/test_cancel_after_goal_met.rs +++ b/src/tests/test_cancel_after_goal_met.rs @@ -21,6 +21,8 @@ fn test_cancel_campaign_blocked_after_goal_met() { has_revenue_sharing: false, revenue_share_percentage: 0, max_contribution_per_user: 0i128, + token: crate::types::MaybeToken::None, + uses_milestones: false, }); client.verify_campaign(&campaign_id); @@ -52,6 +54,8 @@ fn test_cancel_campaign_allowed_when_goal_not_met() { has_revenue_sharing: false, revenue_share_percentage: 0, max_contribution_per_user: 0i128, + token: crate::types::MaybeToken::None, + uses_milestones: false, }); client.verify_campaign(&campaign_id); @@ -82,6 +86,8 @@ fn test_cancel_campaign_blocked_when_amount_exceeds_goal() { has_revenue_sharing: false, revenue_share_percentage: 0, max_contribution_per_user: 0i128, + token: crate::types::MaybeToken::None, + uses_milestones: false, }); client.verify_campaign(&campaign_id); diff --git a/src/tests/test_cancel_revenue_orphan.rs b/src/tests/test_cancel_revenue_orphan.rs index cfbb0e2..0432cba 100644 --- a/src/tests/test_cancel_revenue_orphan.rs +++ b/src/tests/test_cancel_revenue_orphan.rs @@ -22,6 +22,8 @@ fn test_cancel_campaign_refunds_revenue_pool() { has_revenue_sharing: true, revenue_share_percentage: 2000, // 20% to contributors max_contribution_per_user: 0i128, + token: crate::types::MaybeToken::None, + uses_milestones: false, }); client.verify_campaign(&campaign_id); @@ -89,6 +91,8 @@ fn test_cannot_claim_revenue_after_cancel() { has_revenue_sharing: true, revenue_share_percentage: 2000, max_contribution_per_user: 0i128, + token: crate::types::MaybeToken::None, + uses_milestones: false, }); client.verify_campaign(&campaign_id); @@ -142,6 +146,8 @@ fn test_cancel_with_multiple_contributors_and_revenue() { has_revenue_sharing: true, revenue_share_percentage: 3000, // 30% to contributors max_contribution_per_user: 0i128, + token: crate::types::MaybeToken::None, + uses_milestones: false, }); client.verify_campaign(&campaign_id); @@ -208,6 +214,8 @@ fn test_cancel_campaign_emits_revenue_refund_event() { has_revenue_sharing: true, revenue_share_percentage: 2000, max_contribution_per_user: 0i128, + token: crate::types::MaybeToken::None, + uses_milestones: false, }); client.verify_campaign(&campaign_id); client.contribute(&campaign_id, &contributor1, &1000); @@ -250,6 +258,8 @@ fn test_cancel_campaign_with_no_revenue() { has_revenue_sharing: true, revenue_share_percentage: 2000, max_contribution_per_user: 0i128, + token: crate::types::MaybeToken::None, + uses_milestones: false, }); client.verify_campaign(&campaign_id); diff --git a/src/tests/test_contribute_caps.rs b/src/tests/test_contribute_caps.rs index c12cb48..ed8790a 100644 --- a/src/tests/test_contribute_caps.rs +++ b/src/tests/test_contribute_caps.rs @@ -185,6 +185,8 @@ fn test_huge_contribution_triggers_auto_pause() { has_revenue_sharing: false, revenue_share_percentage: 0, max_contribution_per_user: 0, + token: crate::types::MaybeToken::None, + uses_milestones: false, }); client.verify_campaign(&campaign_id); diff --git a/src/tests/test_issues.rs b/src/tests/test_issues.rs index c92fa6c..7235625 100644 --- a/src/tests/test_issues.rs +++ b/src/tests/test_issues.rs @@ -115,6 +115,8 @@ fn make_campaign_params_simple(env: &Env, creator: &Address) -> CreateCampaignPa has_revenue_sharing: false, revenue_share_percentage: 0, max_contribution_per_user: 0, + token: crate::types::MaybeToken::None, + uses_milestones: false, } } @@ -260,6 +262,8 @@ fn test_set_personal_cap_cannot_exceed_max_contribution_per_user() { has_revenue_sharing: false, revenue_share_percentage: 0, max_contribution_per_user: 500, + token: crate::types::MaybeToken::None, + uses_milestones: false, }; let campaign_id = client.create_campaign(¶ms); @@ -353,7 +357,7 @@ fn test_pending_creator_none_round_trip() { let campaign = Campaign { id: 1, creator: addr.clone(), - first_creator: addr, + first_creator: addr.clone(), pending_creator: MaybePendingCreator::None, title: String::from_str(&env, "test"), description: String::from_str(&env, "desc"), @@ -371,6 +375,8 @@ fn test_pending_creator_none_round_trip() { fee_override: None, deadline_extended: false, effective_amount_raised: 0, + token: addr.clone(), + uses_milestones: false, }; env.as_contract(&contract_id, || { @@ -393,7 +399,7 @@ fn test_pending_creator_some_round_trip() { let campaign = Campaign { id: 1, creator: addr.clone(), - first_creator: addr, + first_creator: addr.clone(), pending_creator: MaybePendingCreator::Some(pending.clone()), title: String::from_str(&env, "test"), description: String::from_str(&env, "desc"), @@ -411,6 +417,8 @@ fn test_pending_creator_some_round_trip() { fee_override: None, deadline_extended: false, effective_amount_raised: 0, + token: addr.clone(), + uses_milestones: false, }; env.as_contract(&contract_id, || { @@ -576,6 +584,8 @@ fn test_creator_claim_does_not_absorb_contributor_rounding() { has_revenue_sharing: true, revenue_share_percentage: 5000, // 50% max_contribution_per_user: 0i128, + token: crate::types::MaybeToken::None, + uses_milestones: false, }); client.verify_campaign(&campaign_id); client.contribute(&campaign_id, &contributor1, &10_001); @@ -590,3 +600,74 @@ fn test_creator_claim_does_not_absorb_contributor_rounding() { // Previous residual math paid 5001 here; direct creator-side math must pay 5000. assert_eq!(creator_after - creator_before, 5_000); } + +#[test] +fn test_milestone_proportional_withdrawals_563() { + let (env, admin, creator, contributor1, _, token, token_admin, client) = setup_env(); + + // Create campaign with milestones enabled + let mut params = make_params( + creator.clone(), + String::from_str(&env, "Milestone Campaign"), + String::from_str(&env, "Desc"), + 10000, // goal + 30, + Category::Publisher, + false, + 0, + 0i128, + ); + params.uses_milestones = true; + + let campaign_id = client.create_campaign(¶ms); + let _ = client.try_verify_campaign(&campaign_id); + + // Add milestones + let milestones = soroban_sdk::vec![ + &env, + crate::types::Milestone { + id: 1, + target_amount: 3000, + description: String::from_str(&env, "M1"), + verified: false, + }, + crate::types::Milestone { + id: 2, + target_amount: 7000, + description: String::from_str(&env, "M2"), + verified: false, + } + ]; + client.add_campaign_milestones(&admin, &campaign_id, &milestones); + + // Contribute 10000 + token_admin.mint(&contributor1, &10000); + client.contribute(&campaign_id, &contributor1, &10000); + + // Withdraw fails because no milestones verified + assert_eq!( + client.try_withdraw_funds(&campaign_id).unwrap_err().unwrap(), + Error::NoFundsToWithdraw + ); + + // Verify first milestone + client.verify_milestone(&admin, &campaign_id, &1); + + // Withdraw should process 3000 + let before_balance = token.balance(&creator); + client.withdraw_funds(&campaign_id); + let after_balance = token.balance(&creator); + assert!(after_balance > before_balance); // We can verify the exact math if needed + + // Verify second milestone + client.verify_milestone(&admin, &campaign_id, &2); + + // Withdraw should process the rest + client.withdraw_funds(&campaign_id); + + // Try withdraw again + assert_eq!( + client.try_withdraw_funds(&campaign_id).unwrap_err().unwrap(), + Error::FundsAlreadyWithdrawn + ); +} diff --git a/src/tests/test_lifecycle_events.rs b/src/tests/test_lifecycle_events.rs index ddc3d8d..41ca71c 100644 --- a/src/tests/test_lifecycle_events.rs +++ b/src/tests/test_lifecycle_events.rs @@ -30,6 +30,8 @@ fn test_full_lifecycle_event_sequence() { has_revenue_sharing: true, revenue_share_percentage: 1000, max_contribution_per_user: 0, + token: crate::types::MaybeToken::None, + uses_milestones: false, }); assert!( @@ -98,6 +100,8 @@ fn test_cancel_lifecycle_event_sequence() { has_revenue_sharing: false, revenue_share_percentage: 0, max_contribution_per_user: 0, + token: crate::types::MaybeToken::None, + uses_milestones: false, }); client.verify_campaign(&id); @@ -133,6 +137,8 @@ fn test_campaign_cancelled_event_includes_creator_and_amount() { has_revenue_sharing: false, revenue_share_percentage: 0, max_contribution_per_user: 0, + token: crate::types::MaybeToken::None, + uses_milestones: false, }); client.verify_campaign(&id); @@ -169,6 +175,8 @@ fn test_campaign_created_event_includes_category() { has_revenue_sharing: false, revenue_share_percentage: 0, max_contribution_per_user: 0, + token: crate::types::MaybeToken::None, + uses_milestones: false, }); let events = env.events().all(); diff --git a/src/tests/test_refund_edge.rs b/src/tests/test_refund_edge.rs index aed8737..fb773b0 100644 --- a/src/tests/test_refund_edge.rs +++ b/src/tests/test_refund_edge.rs @@ -128,6 +128,8 @@ fn test_claim_refund_clears_existing_revenue_claimed_key() { has_revenue_sharing: true, revenue_share_percentage: 2000, max_contribution_per_user: 0i128, + token: crate::types::MaybeToken::None, + uses_milestones: false, }); client.verify_campaign(&campaign_id); client.contribute(&campaign_id, &contributor1, &1000); diff --git a/src/tests/test_revenue.rs b/src/tests/test_revenue.rs index 32e5889..9bc7c66 100644 --- a/src/tests/test_revenue.rs +++ b/src/tests/test_revenue.rs @@ -287,6 +287,8 @@ fn test_claim_revenue_blocked_before_funds_withdrawn() { has_revenue_sharing: true, revenue_share_percentage: 2000, max_contribution_per_user: 0i128, + token: crate::types::MaybeToken::None, + uses_milestones: false, }); client.verify_campaign(&campaign_id); client.contribute(&campaign_id, &contributor1, &5000); diff --git a/src/tests/test_revenue_deposit.rs b/src/tests/test_revenue_deposit.rs index 91b2c05..f6d02e7 100644 --- a/src/tests/test_revenue_deposit.rs +++ b/src/tests/test_revenue_deposit.rs @@ -134,6 +134,8 @@ fn test_deposit_revenue_repeated_calls_accumulate_and_emit_events() { has_revenue_sharing: true, revenue_share_percentage: 2000, max_contribution_per_user: 0i128, + token: crate::types::MaybeToken::None, + uses_milestones: false, }); client.verify_campaign(&campaign_id); client.contribute(&campaign_id, &contributor1, &1000); diff --git a/src/tests/test_vesting.rs b/src/tests/test_vesting.rs index 4d52199..938c801 100644 --- a/src/tests/test_vesting.rs +++ b/src/tests/test_vesting.rs @@ -19,6 +19,8 @@ fn test_withdrawal_vesting_full_flow() { has_revenue_sharing: false, revenue_share_percentage: 0, max_contribution_per_user: 0, + token: crate::types::MaybeToken::None, + uses_milestones: false, }; let campaign_id = client.create_campaign(¶ms); client.verify_campaign(&campaign_id); @@ -66,6 +68,8 @@ fn test_get_campaign_reserve_view_function() { has_revenue_sharing: false, revenue_share_percentage: 0, max_contribution_per_user: 0, + token: crate::types::MaybeToken::None, + uses_milestones: false, }; let campaign_id = client.create_campaign(¶ms); client.verify_campaign(&campaign_id); @@ -118,6 +122,8 @@ fn test_withdraw_reserve_when_paused_fails() { has_revenue_sharing: false, revenue_share_percentage: 0, max_contribution_per_user: 0, + token: crate::types::MaybeToken::None, + uses_milestones: false, }; let campaign_id = client.create_campaign(¶ms); client.verify_campaign(&campaign_id); @@ -186,6 +192,8 @@ fn test_withdraw_event_payload_tuple() { has_revenue_sharing: false, revenue_share_percentage: 0, max_contribution_per_user: 0, + token: crate::types::MaybeToken::None, + uses_milestones: false, }; let campaign_id = client.create_campaign(¶ms); client.verify_campaign(&campaign_id); diff --git a/src/tests/test_voting_verify.rs b/src/tests/test_voting_verify.rs index b4fa314..cf43f50 100644 --- a/src/tests/test_voting_verify.rs +++ b/src/tests/test_voting_verify.rs @@ -95,6 +95,8 @@ fn test_vote_on_campaign_past_deadline_fails() { has_revenue_sharing: false, revenue_share_percentage: 0, max_contribution_per_user: 0i128, + token: crate::types::MaybeToken::None, + uses_milestones: false, }); let deadline = client.get_campaign(&campaign_id).deadline; @@ -128,6 +130,8 @@ fn test_vote_on_campaign_after_withdraw_fails() { has_revenue_sharing: false, revenue_share_percentage: 0, max_contribution_per_user: 0i128, + token: crate::types::MaybeToken::None, + uses_milestones: false, }); client.verify_campaign(&campaign_id); client.contribute(&campaign_id, &contributor1, &1000); diff --git a/src/types.rs b/src/types.rs index 9940ae4..f2e82a2 100644 --- a/src/types.rs +++ b/src/types.rs @@ -13,6 +13,13 @@ pub enum MaybePendingCreator { Some(Address), } +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum MaybeToken { + None, + Some(Address), +} + impl MaybePendingCreator { pub fn is_some(&self) -> bool { matches!(self, MaybePendingCreator::Some(_))