From 800d39a1ca983b6e11b39af2f6865a2e87f94cb5 Mon Sep 17 00:00:00 2001 From: Banx17 Date: Thu, 2 Jul 2026 05:28:27 +0100 Subject: [PATCH 1/3] Fix #556: Automatically revoke verification upon description update --- src/campaigns/update.rs | 9 ++++++++- src/storage.rs | 7 +++++++ src/tests/test_campaign_update.rs | 4 ++++ 3 files changed, 19 insertions(+), 1 deletion(-) 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/storage.rs b/src/storage.rs index aa20a39..03cd2da 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -898,6 +898,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/test_campaign_update.rs b/src/tests/test_campaign_update.rs index 14f05ac..4c8a7c6 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] From 27d8cfa4ed9d8b8223cbfda9853126c46e24199a Mon Sep 17 00:00:00 2001 From: Banx17 Date: Thu, 2 Jul 2026 05:38:17 +0100 Subject: [PATCH 2/3] Fix CI: Resolve compilation errors on main --- src/admin.rs | 25 ++++++++++++++----------- src/campaigns/create.rs | 10 +++++----- src/campaigns/withdraw.rs | 10 +++++----- src/lifecycle.rs | 5 ++++- src/tests/helpers.rs | 2 ++ src/tests/test_benchmark.rs | 4 ++++ src/tests/test_campaign_update.rs | 6 ++++++ src/tests/test_cancel_after_goal_met.rs | 6 ++++++ src/tests/test_cancel_revenue_orphan.rs | 10 ++++++++++ src/tests/test_contribute_caps.rs | 2 ++ src/tests/test_issues.rs | 14 ++++++++++++-- src/tests/test_lifecycle_events.rs | 8 ++++++++ src/tests/test_refund_edge.rs | 2 ++ src/tests/test_revenue.rs | 2 ++ src/tests/test_revenue_deposit.rs | 2 ++ src/tests/test_vesting.rs | 8 ++++++++ src/tests/test_voting_verify.rs | 4 ++++ src/types.rs | 11 +++++++++-- 18 files changed, 105 insertions(+), 26 deletions(-) diff --git a/src/admin.rs b/src/admin.rs index 6590537..f10bfa9 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -4,15 +4,16 @@ use crate::errors::Error; use crate::lifecycle::{assert_admin, get_campaign_or_error, require_active_campaign}; use crate::storage::{ self, bump_instance_ttl, get_active_campaign_count, get_admin, get_approval_threshold_bps, - get_campaign_milestones, get_emergency_pause_signers, get_max_campaign_funding_goal, get_min_campaign_funding_goal, get_min_votes_quorum, - get_pending_admin, get_pending_token, get_pending_token_release, get_platform_fee, get_token, - get_total_raised_global, get_version, is_initialized, remove_has_voted, remove_pending_admin, - remove_pending_token, remove_voting_state, set_admin, set_approval_threshold_bps, - set_campaign_count, set_campaign_milestones, set_creation_disabled, set_emergency_pause_signers, set_initialized, set_max_campaign_funding_goal, - set_min_campaign_funding_goal, set_min_votes_quorum, set_min_voting_balance, set_pending_admin, - set_pending_token, set_pending_token_release, set_platform_fee, set_token, - set_total_raised_global, set_version, set_campaign_milestone_count, set_withdraw_release_delay_days, - set_withdraw_reserve_percentage, DataKey, + get_campaign_milestones, get_emergency_pause_signers, get_max_campaign_funding_goal, + get_min_campaign_funding_goal, get_min_votes_quorum, get_pending_admin, get_pending_token, + get_pending_token_release, get_platform_fee, get_token, get_total_raised_global, get_version, + is_initialized, remove_has_voted, remove_pending_admin, remove_pending_token, + remove_voting_state, set_admin, set_approval_threshold_bps, set_campaign_count, + set_campaign_milestone_count, set_campaign_milestones, set_creation_disabled, set_initialized, + set_max_campaign_funding_goal, set_min_campaign_funding_goal, set_min_votes_quorum, + set_min_voting_balance, set_pending_admin, set_pending_token, set_pending_token_release, + set_platform_fee, set_token, set_total_raised_global, set_version, + set_withdraw_release_delay_days, set_withdraw_reserve_percentage, DataKey, }; use crate::voting; @@ -110,7 +111,7 @@ pub(crate) fn emergency_pause(env: &Env, caller: Address) -> Result<(), Error> { if signers.is_empty() { return Err(Error::NotAuthorized); } - let is_authorized = signers.iter().any(|s| s == &caller); + let is_authorized = signers.iter().any(|s| s == caller); if !is_authorized { return Err(Error::NotAuthorized); } @@ -535,9 +536,11 @@ pub(crate) fn verify_milestone( let mut milestones = get_campaign_milestones(env, campaign_id); let mut found = false; - for m in milestones.iter_mut() { + for i in 0..milestones.len() { + let mut m = milestones.get(i).unwrap(); if m.id == milestone_id { m.verified = true; + milestones.set(i, m); found = true; break; } diff --git a/src/campaigns/create.rs b/src/campaigns/create.rs index 32f2441..8ec6c4f 100644 --- a/src/campaigns/create.rs +++ b/src/campaigns/create.rs @@ -6,10 +6,10 @@ use crate::storage::{ bump_instance_ttl, get_campaign_count, get_category_campaign_bucket, get_category_campaign_count, get_category_duration_cap, get_creation_disabled, get_creator_campaign_bucket, get_creator_campaign_count, get_max_campaign_funding_goal, - get_min_campaign_funding_goal, get_token, set_campaign, set_campaign_count, set_campaign_start_time, - set_category_campaign_bucket, set_category_campaign_count, set_creator_campaign_bucket, - set_creator_campaign_count, set_revenue_pool, CATEGORY_CAMPAIGNS_BUCKET_SIZE, - CREATOR_CAMPAIGNS_BUCKET_SIZE, + get_min_campaign_funding_goal, get_token, set_campaign, set_campaign_count, + set_campaign_start_time, set_category_campaign_bucket, set_category_campaign_count, + set_creator_campaign_bucket, set_creator_campaign_count, set_revenue_pool, + CATEGORY_CAMPAIGNS_BUCKET_SIZE, CREATOR_CAMPAIGNS_BUCKET_SIZE, }; use crate::types::{Campaign, Category, CreateCampaignParams, MaybePendingCreator}; @@ -82,7 +82,7 @@ pub(crate) fn create_campaign(env: &Env, params: CreateCampaignParams) -> Result let deadline = calculate_deadline(env.ledger().timestamp(), duration_days)?; - let campaign_token = if let Some(t) = token { + let campaign_token = if let crate::types::MaybeToken::Some(t) = token { env.try_invoke_contract::( &t, &soroban_sdk::Symbol::new(env, "decimals"), diff --git a/src/campaigns/withdraw.rs b/src/campaigns/withdraw.rs index 6ca89d5..e11c43d 100644 --- a/src/campaigns/withdraw.rs +++ b/src/campaigns/withdraw.rs @@ -2,14 +2,14 @@ use soroban_sdk::Env; use crate::errors::Error; use crate::lifecycle::{ - get_campaign_or_error, get_creator_campaign, require_not_paused, token_client, - token_client_for_address, + get_campaign_or_error, get_creator_campaign, require_not_paused, token_client_for_address, }; use crate::storage::{ bump_instance_ttl, decrement_active_campaign_count, get_admin, get_campaign_milestones, - get_campaign_reserve, get_platform_fee, get_total_raised_global, get_withdraw_release_delay_days, - get_withdraw_reserve_percentage, set_campaign, set_campaign_reserve, set_total_raised_global, - set_withdraw_release_delay_days, set_withdraw_reserve_percentage, + get_campaign_reserve, get_platform_fee, get_total_raised_global, + get_withdraw_release_delay_days, get_withdraw_reserve_percentage, set_campaign, + set_campaign_reserve, set_total_raised_global, set_withdraw_release_delay_days, + set_withdraw_reserve_percentage, }; use crate::types::CampaignReserve; diff --git a/src/lifecycle.rs b/src/lifecycle.rs index cf2cf10..57dd711 100644 --- a/src/lifecycle.rs +++ b/src/lifecycle.rs @@ -79,7 +79,10 @@ pub(crate) fn token_client(env: &Env) -> token::Client<'_> { token::Client::new(env, &get_token(env)) } -pub(crate) fn token_client_for_address(env: &Env, token_addr: &Address) -> token::Client<'_> { +pub(crate) fn token_client_for_address<'a>( + env: &'a Env, + token_addr: &Address, +) -> token::Client<'a> { token::Client::new(env, token_addr) } 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 4c8a7c6..b6f99f1 100644 --- a/src/tests/test_campaign_update.rs +++ b/src/tests/test_campaign_update.rs @@ -492,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); @@ -512,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); @@ -540,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..8f32bcd 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); 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 e60eda1..58c029e 100644 --- a/src/types.rs +++ b/src/types.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{contracttype, Address, String, Vec}; +use soroban_sdk::{contracttype, Address, String}; /// Represents an optional pending campaign creator for ownership transfers. /// Mirrors `Option
` — used instead of the standard `Option` because @@ -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(_)) @@ -152,7 +159,7 @@ pub struct CreateCampaignParams { /// Per-user contribution cap in tokens. `0` means no cap. pub max_contribution_per_user: i128, /// Token address for this campaign. If None, uses global default. - pub token: Option
, + pub token: MaybeToken, /// Whether this campaign uses milestone-based withdrawals. pub uses_milestones: bool, } From 5aea5921e69a5157a3e4cb2eda64e2d5d0315ee3 Mon Sep 17 00:00:00 2001 From: Banx17 Date: Thu, 2 Jul 2026 11:06:58 +0100 Subject: [PATCH 3/3] feat(#563): Allow proportional withdrawals based on milestones --- src/campaigns/withdraw.rs | 57 ++++++++++++++++++++----------- src/storage.rs | 22 ++++++++++++ src/tests/test_issues.rs | 71 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 19 deletions(-) diff --git a/src/campaigns/withdraw.rs b/src/campaigns/withdraw.rs index e11c43d..eb2e3d7 100644 --- a/src/campaigns/withdraw.rs +++ b/src/campaigns/withdraw.rs @@ -6,10 +6,10 @@ use crate::lifecycle::{ }; use crate::storage::{ bump_instance_ttl, decrement_active_campaign_count, get_admin, get_campaign_milestones, - get_campaign_reserve, get_platform_fee, get_total_raised_global, + get_campaign_reserve, get_campaign_withdrawn_amount, get_platform_fee, get_total_raised_global, get_withdraw_release_delay_days, get_withdraw_reserve_percentage, set_campaign, - set_campaign_reserve, set_total_raised_global, set_withdraw_release_delay_days, - set_withdraw_reserve_percentage, + set_campaign_reserve, set_campaign_withdrawn_amount, set_total_raised_global, + set_withdraw_release_delay_days, set_withdraw_reserve_percentage, }; use crate::types::CampaignReserve; @@ -35,25 +35,35 @@ pub(crate) fn withdraw_funds(env: &Env, campaign_id: u32) -> Result<(), Error> { return Err(Error::FundingGoalNotReached); } + let mut withdrawable_gross = campaign.amount_raised; + if campaign.uses_milestones { let milestones = get_campaign_milestones(env, campaign_id); - let all_verified = milestones.iter().all(|m| m.verified); - if !all_verified { - return Err(Error::ValidationFailed); - } + let total_verified_target: i128 = milestones + .iter() + .filter(|m| m.verified) + .map(|m| m.target_amount) + .sum(); + withdrawable_gross = withdrawable_gross.min(total_verified_target); + } + + let processed = get_campaign_withdrawn_amount(env, campaign_id); + if withdrawable_gross <= processed { + return Err(Error::NoFundsToWithdraw); } + let to_process = withdrawable_gross - processed; + bump_instance_ttl(env); let platform_fee = campaign .fee_override .unwrap_or_else(|| get_platform_fee(env)); - let fee_amount = campaign - .amount_raised + let fee_amount = to_process .checked_mul(platform_fee as i128) .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 @@ -73,10 +83,15 @@ pub(crate) fn withdraw_funds(env: &Env, campaign_id: u32) -> Result<(), Error> { &creator_amount, ); - campaign.funds_withdrawn = true; - campaign.is_active = false; - set_campaign(env, campaign_id, &campaign); - decrement_active_campaign_count(env); + let new_processed = processed + to_process; + set_campaign_withdrawn_amount(env, campaign_id, new_processed); + + if new_processed >= campaign.amount_raised { + campaign.funds_withdrawn = true; + campaign.is_active = false; + set_campaign(env, campaign_id, &campaign); + decrement_active_campaign_count(env); + } if reserve_amount > 0 { let delay_days = get_withdraw_release_delay_days(env); @@ -86,11 +101,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); } @@ -98,7 +117,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 03cd2da..bf44b40 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)`. @@ -967,3 +969,23 @@ pub fn set_campaign_milestone_count(env: &Env, campaign_id: u32, count: u32) { .persistent() .set(&DataKey::CampaignMilestoneCount(campaign_id), &count); } + +pub fn get_campaign_withdrawn_amount(env: &Env, campaign_id: u32) -> i128 { + let key = DataKey::CampaignWithdrawnAmount(campaign_id); + if let Some(val) = env.storage().persistent().get(&key) { + env.storage() + .persistent() + .extend_ttl(&key, BUMP_THRESHOLD, BUMP_AMOUNT); + val + } else { + 0 + } +} + +pub fn set_campaign_withdrawn_amount(env: &Env, campaign_id: u32, amount: i128) { + let key = DataKey::CampaignWithdrawnAmount(campaign_id); + env.storage().persistent().set(&key, &amount); + env.storage() + .persistent() + .extend_ttl(&key, BUMP_THRESHOLD, BUMP_AMOUNT); +} diff --git a/src/tests/test_issues.rs b/src/tests/test_issues.rs index 8f32bcd..7235625 100644 --- a/src/tests/test_issues.rs +++ b/src/tests/test_issues.rs @@ -600,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 + ); +}