Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/campaigns/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -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()
Expand Down
16 changes: 10 additions & 6 deletions src/campaigns/withdraw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -84,19 +84,23 @@ 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);
}

let total_raised = get_total_raised_global(env);
set_total_raised_global(
env,
total_raised
.checked_sub(campaign.amount_raised - reserve_amount)
.checked_sub(to_process - reserve_amount)
.ok_or(Error::Overflow)?,
);

Expand Down
9 changes: 9 additions & 0 deletions src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)`.
Expand Down Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions src/tests/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down
4 changes: 4 additions & 0 deletions src/tests/test_benchmark.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -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(&params);
}
Expand Down
10 changes: 10 additions & 0 deletions src/tests/test_campaign_update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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);

Expand All @@ -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);

Expand Down Expand Up @@ -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);
}
Expand Down
6 changes: 6 additions & 0 deletions src/tests/test_cancel_after_goal_met.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);

Expand Down
10 changes: 10 additions & 0 deletions src/tests/test_cancel_revenue_orphan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);

Expand Down
2 changes: 2 additions & 0 deletions src/tests/test_contribute_caps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
85 changes: 83 additions & 2 deletions src/tests/test_issues.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -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(&params);

Expand Down Expand Up @@ -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"),
Expand All @@ -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, || {
Expand All @@ -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"),
Expand All @@ -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, || {
Expand Down Expand Up @@ -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);
Expand All @@ -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(&params);
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
);
}
Loading
Loading