diff --git a/src/campaigns/cancel.rs b/src/campaigns/cancel.rs index 9fda789..5c6d687 100644 --- a/src/campaigns/cancel.rs +++ b/src/campaigns/cancel.rs @@ -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_net, increment_cancelled_campaign_count, remove_voting_state, set_campaign, + set_revenue_pool, set_total_raised_net, }; pub(crate) fn cancel_campaign(env: &Env, campaign_id: u32) -> Result<(), Error> { @@ -51,6 +52,19 @@ 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 claimable amount from the + // platform-stats counter so unclaimed refunds no longer permanently + // inflate the statistic. `total_raised_global` (the token-migration + // escrow gate, #407) is deliberately left untouched — refunds stay + // escrowed in the current token until each contributor claims them. + let total_raised_net = get_total_raised_net(env); + set_total_raised_net( + env, + total_raised_net + .checked_sub(campaign.amount_raised) + .ok_or(Error::Overflow)?, + ); + env.events().publish( ("campaign_cancelled", campaign_id, campaign.creator.clone()), campaign.amount_raised, @@ -100,6 +114,19 @@ pub(crate) fn admin_cancel_campaign( decrement_active_campaign_count(env); increment_cancelled_campaign_count(env); + // Issue #455: remove the cancelled campaign's claimable amount from the + // platform-stats counter so unclaimed refunds no longer permanently + // inflate the statistic. `total_raised_global` (the token-migration + // escrow gate, #407) is deliberately left untouched — refunds stay + // escrowed in the current token until each contributor claims them. + let total_raised_net = get_total_raised_net(env); + set_total_raised_net( + env, + total_raised_net + .checked_sub(campaign.amount_raised) + .ok_or(Error::Overflow)?, + ); + env.events().publish( ("campaign_admin_cancelled", campaign_id, admin), (campaign.creator.clone(), reason), diff --git a/src/campaigns/withdraw.rs b/src/campaigns/withdraw.rs index e004b8c..8148bcd 100644 --- a/src/campaigns/withdraw.rs +++ b/src/campaigns/withdraw.rs @@ -7,10 +7,10 @@ use crate::lifecycle::{ }; use crate::storage::{ bump_instance_ttl, decrement_active_campaign_count, get_admin, get_campaign_reserve, - get_campaign_vesting, get_platform_fee, get_total_raised_global, + get_campaign_vesting, get_platform_fee, get_total_raised_global, get_total_raised_net, 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_total_raised_global, set_total_raised_net, + set_withdraw_release_delay_days, set_withdraw_reserve_percentage, }; use crate::types::CampaignReserve; @@ -100,6 +100,13 @@ pub(crate) fn withdraw_funds(env: &Env, campaign_id: u32) -> Result<(), Error> { .checked_sub(campaign.amount_raised - reserve_amount) .ok_or(Error::Overflow)?, ); + let total_raised_net = get_total_raised_net(env); + set_total_raised_net( + env, + total_raised_net + .checked_sub(campaign.amount_raised - reserve_amount) + .ok_or(Error::Overflow)?, + ); // Token transfers happen after all state updates (CEI pattern). let admin_addr = get_admin(env); @@ -164,6 +171,13 @@ pub(crate) fn withdraw_reserve(env: &Env, campaign_id: u32) -> Result<(), Error> .checked_sub(reserve.amount) .ok_or(Error::Overflow)?, ); + let total_raised_net = get_total_raised_net(env); + set_total_raised_net( + env, + total_raised_net + .checked_sub(reserve.amount) + .ok_or(Error::Overflow)?, + ); // Token transfer happens after all state updates (CEI pattern). let client = token_client(env); diff --git a/src/contributions.rs b/src/contributions.rs index a9c8065..7de66f4 100644 --- a/src/contributions.rs +++ b/src/contributions.rs @@ -7,9 +7,10 @@ use crate::lifecycle::{ use crate::storage::{ bump_instance_ttl, decrement_contributor_count, get_campaign_block_contribution_count, get_contribution, get_lifetime_contribution, get_personal_cap, get_total_raised_global, - increment_contributor_count, remove_contribution, remove_personal_cap, remove_revenue_claimed, - set_campaign, set_campaign_block_contribution_count, set_contribution, - set_lifetime_contribution, set_personal_cap, set_total_raised_global, AdminKey, + get_total_raised_net, increment_contributor_count, remove_contribution, remove_personal_cap, + remove_revenue_claimed, set_campaign, set_campaign_block_contribution_count, set_contribution, + set_lifetime_contribution, set_personal_cap, set_total_raised_global, set_total_raised_net, + AdminKey, }; use crate::types::Campaign; @@ -133,6 +134,14 @@ fn update_contribution_accounting( total_raised.checked_add(amount).ok_or(Error::Overflow)?, ); + let total_raised_net = get_total_raised_net(env); + set_total_raised_net( + env, + total_raised_net + .checked_add(amount) + .ok_or(Error::Overflow)?, + ); + Ok(()) } @@ -308,12 +317,29 @@ pub(crate) fn claim_refund(env: &Env, campaign_id: u32, contributor: Address) -> .ok_or(Error::Overflow)?; set_campaign(env, campaign_id, &campaign); + // `total_raised_global` is the token-migration escrow gate (#407): it must + // track every payout from the contract in the current token, so it is + // always decremented here — including for cancelled campaigns, whose + // refunds remain escrowed until claimed. let total_raised = get_total_raised_global(env); set_total_raised_global( env, total_raised.checked_sub(amount).ok_or(Error::Overflow)?, ); + // The platform-stats counter (#455) was already reduced by the full + // claimable amount at cancellation time, so only decrement it for + // expired/failed (non-cancelled) campaigns to avoid double subtraction. + if !campaign.is_cancelled { + let total_raised_net = get_total_raised_net(env); + set_total_raised_net( + env, + total_raised_net + .checked_sub(amount) + .ok_or(Error::Overflow)?, + ); + } + let client = token_client(env); client.transfer(&env.current_contract_address(), &contributor, &amount); diff --git a/src/queries.rs b/src/queries.rs index 99c1da2..3aa5bf5 100644 --- a/src/queries.rs +++ b/src/queries.rs @@ -4,7 +4,7 @@ use crate::storage::{ get_active_campaign_count, get_campaign, get_campaign_count, get_cancelled_campaign_count, get_category_campaign_bucket, get_category_campaign_count, get_contribution, get_contributor_count, get_creator_campaign_bucket, get_creator_campaign_count, - get_platform_fee, get_token, get_total_raised_global, get_verified_campaign_count, + get_platform_fee, get_token, get_total_raised_net, get_verified_campaign_count, CATEGORY_CAMPAIGNS_BUCKET_SIZE, CREATOR_CAMPAIGNS_BUCKET_SIZE, }; use crate::types::{Campaign, Category, CreatorStats, PlatformReport, PlatformStats}; @@ -257,7 +257,7 @@ pub(crate) fn get_platform_stats(env: &Env) -> PlatformStats { active_campaigns: get_active_campaign_count(env), verified_campaigns: get_verified_campaign_count(env), cancelled_campaigns: get_cancelled_campaign_count(env), - total_amount_raised: get_total_raised_global(env), + total_amount_raised: get_total_raised_net(env), stats_are_partial: false, scanned_up_to: total_campaigns, } @@ -268,7 +268,7 @@ pub(crate) fn get_platform_stats(env: &Env) -> PlatformStats { pub(crate) fn get_platform_report(env: &Env) -> PlatformReport { let total_campaigns = get_campaign_count(env); let active_campaigns = get_active_campaign_count(env); - let total_raised = get_total_raised_global(env); + let total_raised = get_total_raised_net(env); let platform_fee_bps = get_platform_fee(env); let is_paused = env .storage() diff --git a/src/storage.rs b/src/storage.rs index bd8c54c..5e369b2 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -130,6 +130,9 @@ pub enum ContributionKey { ContributorCount(u32), /// Total amount raised across all campaigns. TotalRaised, + /// Platform-stats display total: like `TotalRaised`, but a cancelled + /// campaign's claimable amount is removed at cancellation time (#455). + TotalRaisedNet, /// Per-campaign contributions per block for anomaly detection, keyed by campaign ID. BlockCampaignContributionCount(u32), } @@ -714,6 +717,38 @@ pub fn set_total_raised_global(env: &Env, amount: i128) { .set(&ContributionKey::TotalRaised, &amount); } +/// Returns the platform-stats display total raised. It mirrors every movement +/// of `total_raised_global` (contributions, refunds, withdrawals, reserve +/// releases) and additionally removes a cancelled campaign's full claimable +/// amount at cancellation time (#455). This counter feeds read-only platform +/// stats; it is NOT the token-migration escrow gate — that remains +/// `total_raised_global` (#407), so refunds still escrowed in the current +/// token keep blocking `accept_token_update` until each contributor claims +/// them. +/// +/// Not written by `init()` on purpose: an absent key reads as `0` (so fresh +/// contracts report an empty platform total), the first write happens on the +/// first contribution, and skipping the eager write keeps the testutils event +/// snapshot stable for tests with very large event logs. +/// +/// Deployment note: a contract upgraded to a build with this counter has no +/// backfilled value — pre-upgrade activity is absent until a migration +/// populates it (e.g. from `total_raised_global` minus cancelled campaigns' +/// claimable amounts). Coordinate with the #604/#718 work before deploying. +pub fn get_total_raised_net(env: &Env) -> i128 { + env.storage() + .instance() + .get(&ContributionKey::TotalRaisedNet) + .unwrap_or(0) +} + +/// Stores the platform-stats display total raised. +pub fn set_total_raised_net(env: &Env, amount: i128) { + env.storage() + .instance() + .set(&ContributionKey::TotalRaisedNet, &amount); +} + // ── Creator campaigns (bucketed) ────────────────────────────────────────────── /// Maximum number of campaign IDs stored in a single bucket for a creator. diff --git a/src/tests/test_queries.rs b/src/tests/test_queries.rs index 30e2e5f..fd219a2 100644 --- a/src/tests/test_queries.rs +++ b/src/tests/test_queries.rs @@ -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] @@ -580,6 +582,245 @@ fn test_get_creator_stats_zero_campaigns() { assert_eq!(stats.total_contributors, 0); } +// ── Issue #455 regression tests: platform-stats total on cancellation ────────── + +#[test] +fn test_cancel_campaign_removes_claimable_amount_from_platform_stats() { + 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 stats test"), + String::from_str(&env, "Verify platform stats after cancel"), + 2000, + 30, + Category::Learner, + false, + 0, + 0i128, + )); + client.verify_campaign(&id); + client.contribute(&id, &contributor1, &1000); + + assert_eq!(client.get_platform_stats().total_amount_raised, 1000); + + client.cancel_campaign(&id); + + // Issue #455: cancellation must subtract the full claimable amount + // (campaign.amount_raised) from the platform-stats counter. + assert_eq!(client.get_platform_stats().total_amount_raised, 0); +} + +#[test] +fn test_unclaimed_refund_does_not_inflate_platform_stats() { + 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 the + // platform-stats total, even though no contributor has claimed a refund. + assert_eq!(client.get_platform_stats().total_amount_raised, 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_platform_stats().total_amount_raised, 1000); + + // Cancel: subtracts full 1000 → platform-stats total = 0 + client.cancel_campaign(&id); + assert_eq!(client.get_platform_stats().total_amount_raised, 0); + + // Claim first refund: should NOT decrement the platform-stats counter again + client.claim_refund(&id, &contributor1); + assert_eq!( + client.get_platform_stats().total_amount_raised, + 0, + "platform-stats total must not go negative after first refund" + ); + + // Claim second refund: should NOT decrement the platform-stats counter again + client.claim_refund(&id, &contributor2); + assert_eq!( + client.get_platform_stats().total_amount_raised, + 0, + "platform-stats total 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_platform_stats().total_amount_raised, 300); + + // Cancel A: platform-stats total should drop by 100 → 200 + client.cancel_campaign(&c_a); + assert_eq!( + client.get_platform_stats().total_amount_raised, + 200, + "After cancelling A, only B's 200 should remain" + ); + + // Cancel B: platform-stats total should drop by 200 → 0 + client.cancel_campaign(&c_b); + assert_eq!( + client.get_platform_stats().total_amount_raised, + 0, + "After cancelling both, the platform-stats total 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_platform_stats().total_amount_raised, 0); + + // Cancelling a campaign with amount_raised == 0 must not underflow. + client.cancel_campaign(&id); + assert_eq!(client.get_platform_stats().total_amount_raised, 0); +} + +/// Pins the two-counter invariant: the platform-stats counter diverges from +/// the escrow counter only by the sum of cancelled campaigns' claimable +/// amounts, and they converge again once those refunds are claimed. +#[test] +fn test_platform_stats_align_with_escrow_after_refund_settlement() { + let (env, _admin, creator, contributor1, contributor2, _token, token_admin, client) = + setup_env(); + + token_admin.mint(&contributor1, &5000); + token_admin.mint(&contributor2, &5000); + + // c1 stays active; c2 gets cancelled. + let c1 = client.create_campaign(&make_params( + creator.clone(), + String::from_str(&env, "Active"), + String::from_str(&env, "Stays active"), + 2000, + 30, + Category::Learner, + false, + 0, + 0i128, + )); + let c2 = client.create_campaign(&make_params( + creator.clone(), + String::from_str(&env, "Cancelled"), + String::from_str(&env, "Gets cancelled"), + 2000, + 30, + Category::Learner, + false, + 0, + 0i128, + )); + + client.verify_campaign(&c1); + client.verify_campaign(&c2); + client.contribute(&c1, &contributor1, &500); + client.contribute(&c2, &contributor2, &300); + + assert_eq!(client.get_platform_stats().total_amount_raised, 800); + assert_eq!(client.get_total_raised_global(), 800); + + // Cancel c2: the stats counter drops by c2's 300, but the escrow counter + // stays put because the refund is still owed in the current token. + client.cancel_campaign(&c2); + assert_eq!(client.get_platform_stats().total_amount_raised, 500); + assert_eq!(client.get_total_raised_global(), 800); + + // Once the refund is claimed, escrow drops to match the stats counter. + client.claim_refund(&c2, &contributor2); + assert_eq!(client.get_platform_stats().total_amount_raised, 500); + assert_eq!(client.get_total_raised_global(), 500); +} + #[test] fn test_get_platform_stats_after_initialization() { let env = Env::default();