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
25 changes: 24 additions & 1 deletion src/campaigns/cancel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ use crate::lifecycle::{
};
use crate::storage::{
bump_instance_ttl, decrement_active_campaign_count, get_revenue_pool, get_token,
increment_cancelled_campaign_count, remove_voting_state, set_campaign, set_revenue_pool,
get_total_raised_global, increment_cancelled_campaign_count, remove_voting_state, set_campaign,
set_revenue_pool, set_total_raised_global,
};

pub(crate) fn cancel_campaign(env: &Env, campaign_id: u32) -> Result<(), Error> {
Expand Down Expand Up @@ -51,6 +52,17 @@ pub(crate) fn cancel_campaign(env: &Env, campaign_id: u32) -> Result<(), Error>
decrement_active_campaign_count(env);
increment_cancelled_campaign_count(env);

// Issue #455: remove the cancelled campaign's total claimable amount from
// the platform-wide total, so unclaimed refunds no longer permanently
// inflate the statistic or block token migration.
let total_raised = get_total_raised_global(env);
set_total_raised_global(
env,
total_raised
.checked_sub(campaign.amount_raised)
.ok_or(Error::Overflow)?,
);

env.events().publish(
("campaign_cancelled", campaign_id, campaign.creator.clone()),
campaign.amount_raised,
Expand Down Expand Up @@ -100,6 +112,17 @@ pub(crate) fn admin_cancel_campaign(
decrement_active_campaign_count(env);
increment_cancelled_campaign_count(env);

// Issue #455: remove the cancelled campaign's total claimable amount from
// the platform-wide total, so unclaimed refunds no longer permanently
// inflate the statistic or block token migration.
let total_raised = get_total_raised_global(env);
set_total_raised_global(
env,
total_raised
.checked_sub(campaign.amount_raised)
.ok_or(Error::Overflow)?,
);

env.events().publish(
("campaign_admin_cancelled", campaign_id, admin),
(campaign.creator.clone(), reason),
Expand Down
16 changes: 11 additions & 5 deletions src/contributions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -284,11 +284,17 @@ pub(crate) fn claim_refund(env: &Env, campaign_id: u32, contributor: Address) ->
.ok_or(Error::Overflow)?;
set_campaign(env, campaign_id, &campaign);

let total_raised = get_total_raised_global(env);
set_total_raised_global(
env,
total_raised.checked_sub(amount).ok_or(Error::Overflow)?,
);
// Issue #455: when a campaign is cancelled, the platform-wide total was
// already reduced by the full claimable amount at cancellation time.
// Only decrement here for expired/failed (non-cancelled) campaigns to
// avoid double subtraction.
if !campaign.is_cancelled {
let total_raised = get_total_raised_global(env);
set_total_raised_global(
env,
total_raised.checked_sub(amount).ok_or(Error::Overflow)?,
);
}

let client = token_client(env);
client.transfer(&env.current_contract_address(), &contributor, &amount);
Expand Down
26 changes: 12 additions & 14 deletions src/tests/test_admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -324,11 +324,12 @@ fn test_token_swap_succeeds_after_campaign_cancelled() {
assert_eq!(client.get_token(), new_token_address);
}

// ── Issue #407 follow-up: cancelling a campaign drops the active-campaign count
// to zero, but contributor refunds remain escrowed in the old token until
// claimed. The swap must stay blocked until those funds actually leave. ──────
// ── Issue #455: cancelling a campaign drops the campaign's refundable amount
// from total_raised_global at cancellation time (not at claim_refund time),
// so token migration is no longer blocked by unclaimed refunds from a
// cancelled campaign. ────────────────────────────────────────────────────────
#[test]
fn test_token_swap_blocked_with_unrefunded_cancelled_campaign() {
fn test_token_swap_succeeds_after_cancelled_campaign_with_unclaimed_refund() {
let (env, admin, creator, contributor1, _, _, token_admin, client) = setup_env();

token_admin.mint(&contributor1, &2000);
Expand All @@ -347,8 +348,10 @@ fn test_token_swap_blocked_with_unrefunded_cancelled_campaign() {
client.verify_campaign(&campaign_id);
client.contribute(&campaign_id, &contributor1, &500);

// Cancel: ActiveCampaignCount → 0, but the 500 is still escrowed in the
// old token pending claim_refund.
// Cancel: the campaign's 500 is removed from total_raised_global at
// cancellation time (issue #455), so no outstanding balance remains
// to block token migration — even though the contributor hasn't yet
// claimed their refund.
client.cancel_campaign(&campaign_id);

let new_token_address = env.register_stellar_asset_contract(admin.clone());
Expand All @@ -357,15 +360,10 @@ fn test_token_swap_blocked_with_unrefunded_cancelled_campaign() {
l.timestamp += TOKEN_UPDATE_DELAY_SECS + 1;
});

// Must still be blocked: outstanding balance remains in the old token.
// No longer blocked: total_raised_global was already reduced at
// cancellation time.
let res = client.try_accept_token_update(&admin);
assert_eq!(res.unwrap_err().unwrap(), Error::ValidationFailed);

// Once the contributor claims their refund, no old-token escrow remains and
// the swap can proceed.
client.claim_refund(&campaign_id, &contributor1);
let res2 = client.try_accept_token_update(&admin);
assert!(res2.is_ok());
assert!(res.is_ok());
assert_eq!(client.get_token(), new_token_address);
}

Expand Down
191 changes: 190 additions & 1 deletion src/tests/test_queries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,9 @@ fn test_get_platform_stats_returns_aggregates() {
assert_eq!(stats.active_campaigns, 1);
assert_eq!(stats.verified_campaigns, 2);
assert_eq!(stats.cancelled_campaigns, 1);
assert_eq!(stats.total_amount_raised, 700);
// Issue #455: cancelled campaign's raised amount is subtracted from
// the platform-wide total at cancellation time, so only c1's 400 remains.
assert_eq!(stats.total_amount_raised, 400);
}

#[test]
Expand Down Expand Up @@ -196,6 +198,9 @@ fn test_get_creator_stats_returns_aggregates() {
let stats = client.get_creator_stats(&creator);
assert_eq!(stats.total_campaigns, 2);
assert_eq!(stats.active_campaigns, 1);
// Note: get_creator_stats sums campaign.amount_raised directly, not
// total_raised_global, so it still reports the full 700 even after
// cancellation (issue #455 only affects the global counter).
assert_eq!(stats.total_raised, 700);
assert_eq!(stats.total_contributors, 3);
}
Expand Down Expand Up @@ -580,6 +585,190 @@ fn test_get_creator_stats_zero_campaigns() {
assert_eq!(stats.total_contributors, 0);
}

// ── Issue #455 regression tests: total_raised_global on cancellation ───────────

#[test]
fn test_cancel_campaign_removes_claimable_amount_from_global_total() {
let (env, _admin, creator, contributor1, _, _token, token_admin, client) = setup_env();

token_admin.mint(&contributor1, &5000);

let id = client.create_campaign(&make_params(
creator.clone(),
String::from_str(&env, "Cancel global test"),
String::from_str(&env, "Verify global total after cancel"),
2000,
30,
Category::Learner,
false,
0,
0i128,
));
client.verify_campaign(&id);
client.contribute(&id, &contributor1, &1000);

assert_eq!(client.get_total_raised_global(), 1000);

client.cancel_campaign(&id);

// Issue #455: cancellation must subtract the full claimable amount
// (campaign.amount_raised) from total_raised_global.
assert_eq!(client.get_total_raised_global(), 0);
}

#[test]
fn test_unclaimed_refund_does_not_remain_globally_raised() {
let (env, _admin, creator, contributor1, _, _token, token_admin, client) = setup_env();

token_admin.mint(&contributor1, &5000);

let id = client.create_campaign(&make_params(
creator.clone(),
String::from_str(&env, "Unclaimed refund"),
String::from_str(&env, "Reproduce #455 directly"),
2000,
30,
Category::Learner,
false,
0,
0i128,
));
client.verify_campaign(&id);
client.contribute(&id, &contributor1, &500);

// Cancel but do NOT claim refund
client.cancel_campaign(&id);

// The cancelled campaign's 500 must no longer be counted in
// total_raised_global, even though no contributor has claimed a refund.
assert_eq!(client.get_total_raised_global(), 0);
}

#[test]
fn test_refund_claim_after_cancel_does_not_double_decrement() {
let (env, _admin, creator, contributor1, contributor2, _token, token_admin, client) =
setup_env();

token_admin.mint(&contributor1, &5000);
token_admin.mint(&contributor2, &5000);

let id = client.create_campaign(&make_params(
creator.clone(),
String::from_str(&env, "No double decrement"),
String::from_str(&env, "Verify #455 prevents double subtraction"),
2000,
30,
Category::Learner,
false,
0,
0i128,
));
client.verify_campaign(&id);
client.contribute(&id, &contributor1, &600);
client.contribute(&id, &contributor2, &400);

assert_eq!(client.get_total_raised_global(), 1000);

// Cancel: subtracts full 1000 → total_raised_global = 0
client.cancel_campaign(&id);
assert_eq!(client.get_total_raised_global(), 0);

// Claim first refund: should NOT decrement total_raised_global again
client.claim_refund(&id, &contributor1);
assert_eq!(
client.get_total_raised_global(),
0,
"total_raised_global must not go negative after first refund"
);

// Claim second refund: should NOT decrement total_raised_global again
client.claim_refund(&id, &contributor2);
assert_eq!(
client.get_total_raised_global(),
0,
"total_raised_global must not go negative after second refund"
);
}

#[test]
fn test_multiple_campaigns_cancel_accounting() {
let (env, _admin, creator, contributor1, contributor2, _token, token_admin, client) =
setup_env();

token_admin.mint(&contributor1, &5000);
token_admin.mint(&contributor2, &5000);

let c_a = client.create_campaign(&make_params(
creator.clone(),
String::from_str(&env, "Campaign A"),
String::from_str(&env, "A"),
5000,
30,
Category::Learner,
false,
0,
0i128,
));
let c_b = client.create_campaign(&make_params(
creator.clone(),
String::from_str(&env, "Campaign B"),
String::from_str(&env, "B"),
5000,
30,
Category::Learner,
false,
0,
0i128,
));

client.verify_campaign(&c_a);
client.verify_campaign(&c_b);
client.contribute(&c_a, &contributor1, &100);
client.contribute(&c_b, &contributor2, &200);

assert_eq!(client.get_total_raised_global(), 300);

// Cancel A: total_raised_global should drop by 100 → 200
client.cancel_campaign(&c_a);
assert_eq!(
client.get_total_raised_global(),
200,
"After cancelling A, only B's 200 should remain"
);

// Cancel B: total_raised_global should drop by 200 → 0
client.cancel_campaign(&c_b);
assert_eq!(
client.get_total_raised_global(),
0,
"After cancelling both, total_raised_global should be 0"
);
}

#[test]
fn test_zero_value_campaign_cancel_no_underflow() {
let (env, _admin, creator, _, _, _, _, client) = setup_env();

// Create a campaign with no contributions (amount_raised = 0).
let id = client.create_campaign(&make_params(
creator.clone(),
String::from_str(&env, "Zero Value"),
String::from_str(&env, "Verify no underflow on cancel"),
1000,
30,
Category::Learner,
false,
0,
0i128,
));

assert_eq!(client.get_total_raised_global(), 0);

// Cancelling a campaign with amount_raised == 0 must not underflow.
client.cancel_campaign(&id);
assert_eq!(client.get_total_raised_global(), 0);
}

#[test]
fn test_get_platform_stats_after_initialization() {
let env = Env::default();
Expand Down
Loading