From 3aaa0ac286f4acbab64158bb26046d06dd583852 Mon Sep 17 00:00:00 2001 From: Promise Olubudo <254755326+baedboibidex-cmyk@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:44:28 +0000 Subject: [PATCH 1/7] fix: decrement total_raised_global in cancel_campaign and admin_cancel_campaign Track unclaimed refunds across cancelled campaigns via PendingRefundTotal, so accept_token_update can gate on both zero raised and zero pending refunds. - Add PendingRefundTotal to AdminKey storage - Subtract campaign.amount_raised from total_raised_global at cancel time - Increment pending_refund_total at cancel time - Decrement pending_refund_total in claim_refund for cancelled campaigns - Update accept_token_update gate to also check pending_refund_total == 0 - Update claim_refund to skip total_raised_global decrement for cancelled campaigns (already decremented at cancel) Co-authored-by: Promise Olubudo <254755326+baedboibidex-cmyk@users.noreply.github.com> --- src/campaigns/cancel.rs | 37 +++++++++++++++++++++++++++++++++++-- src/contributions.rs | 16 +++++++++++----- src/storage.rs | 17 +++++++++++++++++ 3 files changed, 63 insertions(+), 7 deletions(-) diff --git a/src/campaigns/cancel.rs b/src/campaigns/cancel.rs index b25ce750..fee5317c 100644 --- a/src/campaigns/cancel.rs +++ b/src/campaigns/cancel.rs @@ -6,8 +6,9 @@ use crate::lifecycle::{ require_not_paused, transition, CampaignState, }; 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, + bump_instance_ttl, decrement_active_campaign_count, get_pending_refund_total, get_revenue_pool, + get_total_raised_global, get_token, increment_cancelled_campaign_count, remove_voting_state, + set_campaign, set_pending_refund_total, set_revenue_pool, set_total_raised_global, }; pub(crate) fn cancel_campaign(env: &Env, campaign_id: u32) -> Result<(), Error> { @@ -49,6 +50,22 @@ pub(crate) fn cancel_campaign(env: &Env, campaign_id: u32) -> Result<(), Error> decrement_active_campaign_count(env); increment_cancelled_campaign_count(env); + let total_raised = get_total_raised_global(env); + set_total_raised_global( + env, + total_raised + .checked_sub(campaign.amount_raised) + .ok_or(Error::Overflow)?, + ); + + let pending_refund = get_pending_refund_total(env); + set_pending_refund_total( + env, + pending_refund + .checked_add(campaign.amount_raised) + .ok_or(Error::Overflow)?, + ); + env.events().publish( ("campaign_cancelled", campaign_id, campaign.creator.clone()), campaign.amount_raised, @@ -102,5 +119,21 @@ pub(crate) fn admin_cancel_campaign( (campaign.creator.clone(), reason), ); + let total_raised = get_total_raised_global(env); + set_total_raised_global( + env, + total_raised + .checked_sub(campaign.amount_raised) + .ok_or(Error::Overflow)?, + ); + + let pending_refund = get_pending_refund_total(env); + set_pending_refund_total( + env, + pending_refund + .checked_add(campaign.amount_raised) + .ok_or(Error::Overflow)?, + ); + Ok(()) } diff --git a/src/contributions.rs b/src/contributions.rs index c436cc52..0edfd6fa 100644 --- a/src/contributions.rs +++ b/src/contributions.rs @@ -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)?, - ); + // For cancelled campaigns, total_raised_global was decremented upfront + // in cancel_campaign / admin_cancel_campaign; decrementing per-claimant + // here would double-count. For failed_due_to_goal campaigns, the + // per-claimant decrement is still needed. + 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); diff --git a/src/storage.rs b/src/storage.rs index d2e22e1e..d76b47c9 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -76,6 +76,8 @@ pub enum AdminKey { WithdrawReleaseDelayDays, /// Percentage of funds held in reserve (basis points). WithdrawReservePercentage, + /// Total unclaimed refund amount across all cancelled campaigns. + PendingRefundTotal, } /// Keys for campaign records, indexes, and aggregate campaign counters. @@ -711,6 +713,21 @@ pub fn set_total_raised_global(env: &Env, amount: i128) { .set(&ContributionKey::TotalRaised, &amount); } +/// Returns the total unclaimed refund amount across cancelled campaigns. +pub fn get_pending_refund_total(env: &Env) -> i128 { + env.storage() + .instance() + .get(&AdminKey::PendingRefundTotal) + .unwrap_or(0) +} + +/// Stores the total unclaimed refund amount across cancelled campaigns. +pub fn set_pending_refund_total(env: &Env, amount: i128) { + env.storage() + .instance() + .set(&AdminKey::PendingRefundTotal, &amount); +} + // ── Creator campaigns (bucketed) ────────────────────────────────────────────── /// Maximum number of campaign IDs stored in a single bucket for a creator. From 99d05fcdf7a4311ccf6e9497e62043a85691086f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=97=A9=F0=9D=97=9C=F0=9D=97=96=F0=9D=97=A7=E2=AD=95?= =?UTF-8?q?=EF=B8=8F=F0=9D=97=A5=20=E2=9B=93=EF=B8=8F=20=F0=9D=97=96?= =?UTF-8?q?=E2=AD=95=EF=B8=8F=F0=9D=97=A5=F0=9D=97=A1=F0=9D=97=98?= =?UTF-8?q?=F0=9D=97=A5=F0=9D=97=A6=F0=9D=97=A7=E2=AD=95=EF=B8=8F?= =?UTF-8?q?=F0=9D=97=A1=F0=9D=97=98?= <155533416+victor-134@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:36:19 +0000 Subject: [PATCH 2/7] fix: move require_not_paused before require_auth in accept_campaign_transfer (#453) Prevents users from wasting a Freighter signature when the contract is paused. The paused check now happens before pending.require_auth(). --- src/campaigns/transfer.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/campaigns/transfer.rs b/src/campaigns/transfer.rs index 054f75c9..47844325 100644 --- a/src/campaigns/transfer.rs +++ b/src/campaigns/transfer.rs @@ -51,6 +51,7 @@ pub(crate) fn initiate_campaign_transfer( pub(crate) fn accept_campaign_transfer(env: &Env, campaign_id: u32) -> Result<(), Error> { let mut campaign = get_campaign_or_error(env, campaign_id)?; require_active_campaign(&campaign)?; + require_not_paused(env)?; let pending = match campaign.pending_creator.clone() { MaybePendingCreator::Some(addr) => addr, @@ -58,8 +59,6 @@ pub(crate) fn accept_campaign_transfer(env: &Env, campaign_id: u32) -> Result<() }; pending.require_auth(); - require_not_paused(env)?; - bump_instance_ttl(env); let old_creator = campaign.creator.clone(); From ccd829d91130da54e62553027be45613fcffd85d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=97=A9=F0=9D=97=9C=F0=9D=97=96=F0=9D=97=A7=E2=AD=95?= =?UTF-8?q?=EF=B8=8F=F0=9D=97=A5=20=E2=9B=93=EF=B8=8F=20=F0=9D=97=96?= =?UTF-8?q?=E2=AD=95=EF=B8=8F=F0=9D=97=A5=F0=9D=97=A1=F0=9D=97=98?= =?UTF-8?q?=F0=9D=97=A5=F0=9D=97=A6=F0=9D=97=A7=E2=AD=95=EF=B8=8F?= =?UTF-8?q?=F0=9D=97=A1=F0=9D=97=98?= <155533416+victor-134@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:42:07 +0000 Subject: [PATCH 3/7] fix: update test event assertions for 4-tuple campaign_metadata_updated payload The update_campaign function now emits (old_title, old_description, title, event_description) as a 4-tuple, but the tests were still unpacking as a 2-tuple (String, String), causing HostError(UnexpectedSize). Updated test_update_campaign_emits_title_and_description and test_update_campaign_event_tracks_latest_description to unpack the 4-tuple and assert against the correct indices (2 and 3 for new values). --- src/tests/test_campaign_update.rs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/tests/test_campaign_update.rs b/src/tests/test_campaign_update.rs index 15180645..8d42c424 100644 --- a/src/tests/test_campaign_update.rs +++ b/src/tests/test_campaign_update.rs @@ -59,10 +59,12 @@ fn test_update_campaign_emits_title_and_description() { let events = env.events().all(); let last_event = events.last().unwrap(); - let payload: (String, String) = soroban_sdk::FromVal::from_val(&env, &last_event.2); + // Event payload: (old_title, old_description, title, event_description) + let payload: (String, String, String, String) = + soroban_sdk::FromVal::from_val(&env, &last_event.2); - assert_eq!(payload.0, new_title); - assert_eq!(payload.1, new_desc); + assert_eq!(payload.2, new_title); + assert_eq!(payload.3, new_desc); } #[test] @@ -94,9 +96,11 @@ fn test_update_campaign_event_tracks_latest_description() { let events = env.events().all(); let last_event = events.last().unwrap(); - let payload: (String, String) = soroban_sdk::FromVal::from_val(&env, &last_event.2); - assert_eq!(payload.0, String::from_str(&env, "Title V3")); - assert_eq!(payload.1, String::from_str(&env, "Description V3")); + // Event payload: (old_title, old_description, title, event_description) + let payload: (String, String, String, String) = + soroban_sdk::FromVal::from_val(&env, &last_event.2); + assert_eq!(payload.2, String::from_str(&env, "Title V3")); + assert_eq!(payload.3, String::from_str(&env, "Description V3")); } #[test] From f9ab233206f997479298b8466f9f6e7c036f205a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=97=A9=F0=9D=97=9C=F0=9D=97=96=F0=9D=97=A7=E2=AD=95?= =?UTF-8?q?=EF=B8=8F=F0=9D=97=A5=20=E2=9B=93=EF=B8=8F=20=F0=9D=97=96?= =?UTF-8?q?=E2=AD=95=EF=B8=8F=F0=9D=97=A5=F0=9D=97=A1=F0=9D=97=98?= =?UTF-8?q?=F0=9D=97=A5=F0=9D=97=A6=F0=9D=97=A7=E2=AD=95=EF=B8=8F?= =?UTF-8?q?=F0=9D=97=A1=F0=9D=97=98?= <155533416+victor-134@users.noreply.github.com> Date: Fri, 31 Jul 2026 08:14:23 +0000 Subject: [PATCH 4/7] fix: revert broken ProofOfHeartContract merges and restore remove_personal_cap (#503) Reverts the broken duplicate contract and stray files shipped by two bad merges, and properly re-implements the feature they attempted: - Remove the stray `#[contract] ProofOfHeartContract` duplicate contract (list_active_campaigns with tag_filter, category max-goal caps) from lib.rs/admin.rs, leaving the real ProofOfHeart contract as the only one. - Delete orphaned files from #616/#618 that referenced the removed contract or were stray: src/proof_of_heart/, src/tests/voting_tests.rs, src/events.ts, src/types.ts, src/campaigns.rs, src/clients.rs, src/test.rs, and the frontend/ directory (no build setup; README references a separate frontend repo). - Re-implement issue #503 properly on the real contract: public remove_personal_cap(campaign_id, contributor) entrypoint that removes a contributor's personal contribution cap (requires contributor auth and an active campaign), new Error::PersonalCapNotFound (46), and a personal_cap_removed event. - Update docs: AUTHORIZATION.md, EVENT_PAYLOADS.md (count 49), CHANGELOG.md, and merge the duplicated sections of CAMPAIGN_LIFECYCLE.md into one coherent document (DataKey -> AdminKey renames). - Add tests covering restore/remove flow, event shape, not-found and inactive-campaign errors, and contributor auth recording. All 405 tests pass; cargo check, fmt, clippy clean. --- CHANGELOG.md | 5 + EVENT_PAYLOADS.md | 12 +- docs/AUTHORIZATION.md | 1 + docs/CAMPAIGN_LIFECYCLE.md | 90 +++++------ .../src/components/MilestoneProgressBar.tsx | 48 ------ frontend/src/types/campaign.ts | 17 -- fuzz/.gitignore | 2 + src/admin.rs | 35 ----- src/campaigns.rs | 7 - src/clients.rs | 28 ---- src/contributions.rs | 30 ++++ src/errors.rs | 7 + src/events.ts | 32 ---- src/lib.rs | 41 ++--- src/proof_of_heart/src/admin.rs | 36 ----- src/proof_of_heart/src/voting.rs | 33 ---- src/test.rs | 23 --- src/tests/test_cap_interactions.rs | 146 +++++++++++++++++- src/tests/voting_tests.rs | 22 --- src/types.ts | 20 --- 20 files changed, 257 insertions(+), 378 deletions(-) delete mode 100644 frontend/src/components/MilestoneProgressBar.tsx delete mode 100644 frontend/src/types/campaign.ts delete mode 100644 src/campaigns.rs delete mode 100644 src/clients.rs delete mode 100644 src/events.ts delete mode 100644 src/proof_of_heart/src/admin.rs delete mode 100644 src/proof_of_heart/src/voting.rs delete mode 100644 src/test.rs delete mode 100644 src/tests/voting_tests.rs delete mode 100644 src/types.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 29a66f00..7ae7a0cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Added + +- `remove_personal_cap(campaign_id, contributor)` public entrypoint exposing removal of a contributor's personal contribution cap, restoring the campaign-wide `max_contribution_per_user` as the only bound. Emits `personal_cap_removed` and returns `PersonalCapNotFound` when no cap is set (#503). + ### Fixed +- Reverted two accidental merges that shipped a broken duplicate `ProofOfHeartContract` contract (a stray `list_active_campaigns(tag_filter)`, category max-goal cap functions, and a `remove_personal_cap` impl referencing non-existent storage keys), plus orphaned TypeScript SDK files and stray frontend React files with no build setup. The real `ProofOfHeart` contract is now the only contract in the crate. - `cancel_campaign` now rejects with `GoalMetCancellationNotAllowed` when `amount_raised >= funding_goal` and funds have not yet been withdrawn, preventing rug-pull-adjacent behaviour where a creator could cancel after reaching the goal and force all contributors to self-serve refunds (#164). - `update_campaign_description` now blocks edits once `amount_raised > 0`, preventing bait-and-switch after contributions (#166). diff --git a/EVENT_PAYLOADS.md b/EVENT_PAYLOADS.md index b12f0dd3..766ee4cd 100644 --- a/EVENT_PAYLOADS.md +++ b/EVENT_PAYLOADS.md @@ -354,6 +354,16 @@ Every `publish(...)` call in the contract, with its topics, data shape, and the --- +### `personal_cap_removed` + +| Field | Value | +|---------|------------------------------------------------------------| +| Topics | `("personal_cap_removed", campaign_id: u32, contributor: Address)` | +| Data | `()` | +| Source | `lib.rs` — `remove_personal_cap()` | + +--- + ### `admin_transfer_initiated` | Field | Value | @@ -484,4 +494,4 @@ Every `publish(...)` call in the contract, with its topics, data shape, and the --- -> **Total: 48 documented `publish()` call sites** +> **Total: 49 documented `publish()` call sites** diff --git a/docs/AUTHORIZATION.md b/docs/AUTHORIZATION.md index 3503c186..4df84c07 100644 --- a/docs/AUTHORIZATION.md +++ b/docs/AUTHORIZATION.md @@ -49,6 +49,7 @@ This contract uses Soroban `Address::require_auth()` checks to ensure only the c | `resume_campaign(campaign_id, caller)` | `caller` (must be `campaign.creator` or stored `admin`) | | `purge_voting_state(campaign_id, voters, finalize_aggregate)` | stored `admin` (from `get_admin`) | | `set_personal_cap(campaign_id, contributor, amount)` | `contributor` | +| `remove_personal_cap(campaign_id, contributor)` | `contributor` | | `extend_campaign_deadline(campaign_id, additional_days)` | `campaign.creator` | | `withdraw_reserve(campaign_id)` | `campaign.creator` | | `set_vesting_params(admin, delay_days, reserve_bps)` | stored `admin` (and `admin` must match stored admin) | diff --git a/docs/CAMPAIGN_LIFECYCLE.md b/docs/CAMPAIGN_LIFECYCLE.md index 1455d6f5..cb353880 100644 --- a/docs/CAMPAIGN_LIFECYCLE.md +++ b/docs/CAMPAIGN_LIFECYCLE.md @@ -1,6 +1,6 @@ # Campaign Lifecycle -## States +## Overview ``` Created → Active → Verified (optional) → Withdrawn (goal met) @@ -16,42 +16,7 @@ A campaign progresses through the following states: 5. **Cancelled** — Creator calls `cancel_campaign()`. Campaign becomes inactive; contributors can `claim_refund()`. 6. **Expired** — Deadline passes without meeting the goal. Contributors can `claim_refund()`. -## Pause Mechanism - -The contract has two independent pause flags: - -### Manual Pause (`DataKey::Paused`) - -- Set by admin via `pause()`. -- Cleared by admin via `unpause()`. -- Emits `contract_paused` / `contract_unpaused`. - -### Auto-Pause (`DataKey::AutoPaused`) - -Automatically set on either of two anomaly triggers during `contribute()`: - -- **Huge contribution** — A single contribution exceeds 200% of the campaign's `funding_goal` (`amount * 10000 > funding_goal * 20000`). Emits `("auto_paused",)` with `("huge_contribution", amount)`. -- **Burst** — More than 10 contributions to the same campaign in a single ledger (block). Emits `("auto_paused",)` with `("burst", block_count)`. - -In both cases the contribution is rejected (`ContractPaused` error) and the storage write is rolled back, so `AutoPaused` never persists in production — the flag is always cleared on the next successful call. This caveat is important for indexers. - -- Blocks all state-changing operations (same as manual pause). -- Cleared by: - - **`unpause()`** — Admin can always clear the auto-pause flag, even if the triggering campaign is no longer active. - - **`resume_campaign(campaign_id)`** — Admin clears the flag, but only if the referenced campaign is still active (not cancelled/expired). - -### Why two flags? - -Using separate flags provides a clearer audit trail — indexers can distinguish between an admin-initiated pause and an automatic safety pause. The admin can always recover the contract via `unpause()`, even when `resume_campaign()` is blocked (e.g., the triggering campaign was cancelled). - -### Recovery Scenarios - -| Scenario | Recovery | -|----------|----------| -| Burst contribution triggers auto-pause; campaign is still active | `resume_campaign(campaign_id)` or `unpause()` | -| Burst contribution triggers auto-pause; campaign was cancelled | `unpause()` only (`resume_campaign` fails with `CampaignNotActive`) | -| Admin pauses manually | `unpause()` | -# Campaign Lifecycle (State Machine) +## State Machine Campaigns are represented by a `Campaign` struct with these key state flags: @@ -65,8 +30,6 @@ Additional derived conditions used by the contract: - **Funded**: `amount_raised >= funding_goal` - **Expired/Failed**: `ledger.timestamp() > deadline && amount_raised < funding_goal` -## States - ### 1) Active (unverified) - Set on `create_campaign`: `is_active = true`, `is_cancelled = false`, `funds_withdrawn = false`, `is_verified = false`. @@ -112,6 +75,42 @@ Additional derived conditions used by the contract: - If the deadline passes and the campaign did not reach its goal (`Expired/Failed` derived condition), contributors can claim refunds via `claim_refund`. - The contract does not currently toggle `is_active` automatically when a deadline passes; "expired" is computed at call time using the ledger timestamp. +## Pause Mechanism + +The contract has two independent pause flags: + +### Manual Pause (`AdminKey::Paused`) + +- Set by admin via `pause()`. +- Cleared by admin via `unpause()`. +- Emits `contract_paused` / `contract_unpaused`. + +### Auto-Pause (`AdminKey::AutoPaused`) + +Automatically set on either of two anomaly triggers during `contribute()`: + +- **Huge contribution** — A single contribution exceeds 200% of the campaign's `funding_goal` (`amount * 10000 > funding_goal * 20000`). Emits `("auto_paused",)` with `("huge_contribution", amount)`. +- **Burst** — More than 10 contributions to the same campaign in a single ledger (block). Emits `("auto_paused",)` with `("burst", block_count)`. + +In both cases the contribution is rejected (`ContractPaused` error) and the storage write is rolled back, so `AutoPaused` never persists in production — the flag is always cleared on the next successful call. This caveat is important for indexers. + +- Blocks all state-changing operations (same as manual pause). +- Cleared by: + - **`unpause()`** — Admin can always clear the auto-pause flag, even if the triggering campaign is no longer active. + - **`resume_campaign(campaign_id)`** — Admin clears the flag, but only if the referenced campaign is still active (not cancelled/expired). + +### Why two flags? + +Using separate flags provides a clearer audit trail — indexers can distinguish between an admin-initiated pause and an automatic safety pause. The admin can always recover the contract via `unpause()`, even when `resume_campaign()` is blocked (e.g., the triggering campaign was cancelled). + +### Recovery Scenarios + +| Scenario | Recovery | +|----------|----------| +| Burst contribution triggers auto-pause; campaign is still active | `resume_campaign(campaign_id)` or `unpause()` | +| Burst contribution triggers auto-pause; campaign was cancelled | `unpause()` only (`resume_campaign` fails with `CampaignNotActive`) | +| Admin pauses manually | `unpause()` | + ## Token Migration Policy (issue #407) The contract supports a two-step token migration via `propose_token_update` (7-day delay) followed by `accept_token_update`. To prevent stranding escrowed campaign balances in the old token: @@ -127,16 +126,17 @@ The contract supports a two-step token migration via `propose_token_update` (7-d > **Known limitation:** undistributed revenue-sharing pools (`deposit_revenue`) are not yet tracked by `total_raised_global`. A withdrawn revenue-sharing campaign with an unclaimed pool could still leave funds in the old token across a migration. Tracking revenue pools in the migration guard is tracked as a follow-up. +## Deadline Calculation Policy -# Campaign Lifecycle & Deadline Calculation Policy +### Time & Duration Mechanics -## 1. Time & Duration Mechanics -Smart contracts on Soroban rely on ledger timestamps, which represent strict UTC Unix timestamps in seconds. +Smart contracts on Soroban rely on ledger timestamps, which represent strict UTC Unix timestamps in seconds. ### Deadline Computation Rule + When a campaign is created via `create_campaign`, the expiration deadline is calculated deterministically using elapsed seconds: $$\text{deadline} = \text{env.ledger().timestamp()} + (\text{duration\_days} \times 86400)$$ -* **Strict Elapsed Time**: A "30-day" campaign represents exactly $30 \times 86400 = 2,592,000$ seconds of ledger time elapsed. -* **No Calendar Drift**: Because Stellar ledgers do not account for local timezones, leap seconds, or Daylight Saving Time (DST) shifts, expiration times are immutable and mathematically precise relative to block progression. -* **Frontend Expectation**: Frontend clients should display countdown timers based on absolute Unix timestamp deltas rather than local calendar day increments to prevent user confusion. \ No newline at end of file +- **Strict Elapsed Time**: A "30-day" campaign represents exactly $30 \times 86400 = 2{,}592{,}000$ seconds of ledger time elapsed. +- **No Calendar Drift**: Because Stellar ledgers do not account for local timezones, leap seconds, or Daylight Saving Time (DST) shifts, expiration times are immutable and mathematically precise relative to block progression. +- **Frontend Expectation**: Frontend clients should display countdown timers based on absolute Unix timestamp deltas rather than local calendar day increments to prevent user confusion. diff --git a/frontend/src/components/MilestoneProgressBar.tsx b/frontend/src/components/MilestoneProgressBar.tsx deleted file mode 100644 index 94f8c58c..00000000 --- a/frontend/src/components/MilestoneProgressBar.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import React from 'react'; -import { CampaignMilestone } from '@/types/campaign'; - -interface MilestoneProgressBarProps { - milestones: CampaignMilestone[]; -} - -export const MilestoneProgressBar: React.FC = ({ milestones }) => { - if (!milestones || milestones.length === 0) { - return null; - } - - return ( -
-

Campaign Milestones

-
- {milestones.map((milestone, index) => { - const progressPercentage = Math.min( - Math.round((milestone.currentAmount / milestone.targetAmount) * 100), - 100 - ); - - return ( -
-
- - {index + 1}. {milestone.title} - - - {milestone.currentAmount} / {milestone.targetAmount} XLM ({progressPercentage}%) - -
-
-
-
-

{milestone.description}

-
- ); - })} -
-
- ); -}; \ No newline at end of file diff --git a/frontend/src/types/campaign.ts b/frontend/src/types/campaign.ts deleted file mode 100644 index 2c96b456..00000000 --- a/frontend/src/types/campaign.ts +++ /dev/null @@ -1,17 +0,0 @@ -export interface CampaignMilestone { - id: string; - title: string; - description: string; - targetAmount: number; - currentAmount: number; - isCompleted: boolean; - dueDate?: string; -} - -export interface CampaignWithMilestones { - id: string; - title: string; - goalAmount: number; - totalRaised: number; - milestones: CampaignMilestone[]; -} \ No newline at end of file diff --git a/fuzz/.gitignore b/fuzz/.gitignore index 1a45eee7..1f90e60b 100644 --- a/fuzz/.gitignore +++ b/fuzz/.gitignore @@ -2,3 +2,5 @@ target corpus artifacts coverage +# Generated by `cargo fuzz` builds; not part of the committed crate state. +Cargo.lock diff --git a/src/admin.rs b/src/admin.rs index e6e6c3f8..0256f571 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -509,38 +509,3 @@ pub(crate) fn resume_campaign(env: &Env, campaign_id: u32, caller: Address) -> R Ok(()) } - -use soroban_sdk::{contractimpl, Address, Env, String}; -use crate::errors::Error; - -#[contractimpl] -impl ProofOfHeartContract { - /// Sets or updates the maximum funding goal cap for a specific campaign category. - pub fn set_category_max_goal_cap( - env: Env, - admin: Address, - category: String, - max_goal: i128, - ) -> Result<(), Error> { - admin.require_auth(); - - // Verify admin permissions (assumes admin check helper exists) - Self::verify_admin(&env, &admin)?; - - let cap_key = DataKey::CategoryMaxGoalCap(category.clone()); - env.storage().persistent().set(&cap_key, &max_goal); - - env.events().publish( - (Symbol::new(&env, "category_cap_updated"), category), - max_goal, - ); - - Ok(()) - } - - /// Retrieves the maximum funding goal cap for a given category, if defined. - pub fn get_category_max_goal_cap(env: Env, category: String) -> Option { - let cap_key = DataKey::CategoryMaxGoalCap(category); - env.storage().persistent().get(&cap_key) - } -} \ No newline at end of file diff --git a/src/campaigns.rs b/src/campaigns.rs deleted file mode 100644 index 980cafb1..00000000 --- a/src/campaigns.rs +++ /dev/null @@ -1,7 +0,0 @@ -// Inside create_campaign function or validation module -let category_cap_key = DataKey::CategoryMaxGoalCap(campaign_category.clone()); -if let Some(max_cap) = env.storage().persistent().get::(&category_cap_key) { - if funding_goal > max_cap { - return Err(Error::FundingGoalExceedsCategoryCap); - } -} \ No newline at end of file diff --git a/src/clients.rs b/src/clients.rs deleted file mode 100644 index f01fda56..00000000 --- a/src/clients.rs +++ /dev/null @@ -1,28 +0,0 @@ -import { Campaign, PlatformStats } from './types'; - -export class CampaignClient { - constructor(private readonly rpcUrl: string, private readonly contractId: string) {} - - async getCampaign(campaignId: string): Promise { - // Query Stellar RPC / Soroban contract read-only methods - const response = await fetch(this.rpcUrl, { - method: 'POST', - body: JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'get_campaign', - params: { contractId: this.contractId, campaignId }, - }), - }); - const data = await response.json(); - return data.result; - } - - async getPlatformStats(): Promise { - return { - totalCampaigns: 42, - totalVolumeXlm: '150000', - activeContributors: 1280, - }; - } -} \ No newline at end of file diff --git a/src/contributions.rs b/src/contributions.rs index 1a8ec0fc..564e2cb7 100644 --- a/src/contributions.rs +++ b/src/contributions.rs @@ -322,3 +322,33 @@ pub(crate) fn set_personal_cap_fn( ); Ok(()) } + +/// Removes the contributor's personal contribution cap for a campaign (#503). +/// Mirrors `set_personal_cap_fn`'s guards: the caller must authorize and the +/// campaign must still be active. Removing a cap that is not set is an error +/// rather than a silent no-op, so indexers can rely on `personal_cap_removed` +/// meaning a cap actually existed. +/// +/// # Errors +/// * `CampaignNotFound` - No campaign with the given ID. +/// * `CampaignNotActive` - The campaign is cancelled, withdrawn, or otherwise inactive. +/// * `PersonalCapNotFound` - The contributor has no personal cap set on this campaign. +pub(crate) fn remove_personal_cap_fn( + env: &Env, + campaign_id: u32, + contributor: Address, +) -> Result<(), Error> { + contributor.require_auth(); + let campaign = get_campaign_or_error(env, campaign_id)?; + require_active_campaign(&campaign)?; + if get_personal_cap(env, campaign_id, &contributor).is_none() { + return Err(Error::PersonalCapNotFound); + } + bump_instance_ttl(env); + remove_personal_cap(env, campaign_id, &contributor); + env.events().publish( + ("personal_cap_removed", campaign_id, contributor.clone()), + (), + ); + Ok(()) +} diff --git a/src/errors.rs b/src/errors.rs index 9b0cefa2..a2c5356b 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -95,6 +95,8 @@ pub enum Error { CampaignAlreadyBookmarked = 44, /// The campaign is not in the wallet's saved/bookmarked list. CampaignNotBookmarked = 45, + /// The contributor has no personal cap set on this campaign. + PersonalCapNotFound = 46, } impl Error { @@ -148,6 +150,7 @@ impl Error { Error::InvalidStateTransition => "InvalidStateTransition", Error::CampaignAlreadyBookmarked => "CampaignAlreadyBookmarked", Error::CampaignNotBookmarked => "CampaignNotBookmarked", + Error::PersonalCapNotFound => "PersonalCapNotFound", } } } @@ -174,5 +177,9 @@ mod tests { Error::GoalMetCancellationNotAllowed.to_string(), "GoalMetCancellationNotAllowed" ); + assert_eq!( + Error::PersonalCapNotFound.to_string(), + "PersonalCapNotFound" + ); } } diff --git a/src/events.ts b/src/events.ts deleted file mode 100644 index 350ee8a7..00000000 --- a/src/events.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { xdr } from '@stellar/stellar-sdk'; - -export type ContractEventUnion = - | { type: 'CampaignCreated'; campaignId: string; creator: string; goal: string } - | { type: 'VoteCast'; campaignId: string; voter: string; approve: boolean } - | { type: 'PersonalCapRemoved'; campaignId: string; contributor: string }; - -/** - * Parses a raw base64 encoded Soroban contract event XDR into a typed event object. - */ -export function parseContractEvent(eventXdrBase64: string): ContractEventUnion { - const event = xdr.ContractEvent.fromXDR(eventXdrBase64, 'base64'); - const topics = event.body().v0().topics(); - const data = event.body().v0().data(); - - const eventSymbol = topics[0]?.sym().toString() || ''; - - switch (eventSymbol) { - long: { - // Decode based on contract topic structure - const campaignId = topics[1]?.u64()?.toString() || '0'; - return { - type: 'CampaignCreated', - campaignId, - creator: 'G...', - goal: '0', - }; - } - default: - throw new Error(`Unrecognized contract event symbol: ${eventSymbol}`); - } -} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index d3c21240..feb17bc0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -457,6 +457,17 @@ impl ProofOfHeart { contributions::set_personal_cap_fn(&env, campaign_id, contributor, amount) } + /// Removes the contributor's personal contribution cap for a campaign, + /// restoring the campaign-wide `max_contribution_per_user` as the only + /// bound on their contributions (#503). Requires `contributor`'s auth. + pub fn remove_personal_cap( + env: Env, + campaign_id: u32, + contributor: Address, + ) -> Result<(), Error> { + contributions::remove_personal_cap_fn(&env, campaign_id, contributor) + } + // ── Read-only queries ───────────────────────────────────────────────────── pub fn get_campaign(env: Env, campaign_id: u32) -> Result { @@ -647,33 +658,3 @@ impl ProofOfHeart { #[cfg(test)] mod tests; - -use soroban_sdk::{contract, contractimpl, Env, String, Vec}; - -#[contract] -pub struct ProofOfHeartContract; - -#[contractimpl] -impl ProofOfHeartContract { - /// Lists active campaigns, optionally filtered by a specific tag string. - pub fn list_active_campaigns(env: Env, tag_filter: Option) -> Vec { - let all_campaigns: Vec = env - .storage() - .instance() - .get(&DataKey::Campaigns) - .unwrap_or(Vec::new(&env)); - - match tag_filter { - Some(filter_tag) => { - let mut filtered = Vec::new(&env); - for campaign in all_campaigns.iter() { - if campaign.tags.contains(&filter_tag) { - filtered.push_back(campaign); - } - } - filtered - } - None => all_campaigns, - } - } -} \ No newline at end of file diff --git a/src/proof_of_heart/src/admin.rs b/src/proof_of_heart/src/admin.rs deleted file mode 100644 index 8b7202d1..00000000 --- a/src/proof_of_heart/src/admin.rs +++ /dev/null @@ -1,36 +0,0 @@ -// contracts/proof_of_heart/src/storage.rs (or admin.rs) - -use soroban_sdk::{contractimpl, Address, Env}; -use crate::errors::Error; - -#[contractimpl] -impl ProofOfHeartContract { - /// Removes a personal contribution cap for a contributor on a specific campaign. - /// Requires authorization from the contributor. - pub fn remove_personal_cap( - env: Env, - campaign_id: u32, - contributor: Address, - ) -> Result<(), Error> { - // Ensure the contributor authorizes the removal of their personal cap - contributor.require_auth(); - - let storage_key = DataKey::PersonalCap(campaign_id, contributor.clone()); - - // Check if cap exists before attempting removal - if !env.storage().persistent().has(&storage_key) { - return Err(Error::CapNotFound); - } - - // Remove the personal cap from persistent storage - env.storage().persistent().remove(&storage_key); - - // Emit event for indexers and off-chain listeners - env.events().publish( - (Symbol::new(&env, "personal_cap_removed"), campaign_id), - contributor, - ); - - Ok(()) - } -} \ No newline at end of file diff --git a/src/proof_of_heart/src/voting.rs b/src/proof_of_heart/src/voting.rs deleted file mode 100644 index 395c190e..00000000 --- a/src/proof_of_heart/src/voting.rs +++ /dev/null @@ -1,33 +0,0 @@ -// contracts/proof_of_heart/src/voting.rs - -pub fn cast_vote(env: Env, voter: Address, campaign_id: u64, approve: bool) -> Result<(), Error> { - voter.require_auth(); - - let vote_key = DataKey::Vote(campaign_id, voter.clone()); - - // Check if a vote already exists for this voter on this campaign - if let Some(existing_vote) = env.storage().persistent().get::(&vote_key) { - // If the vote direction is identical, treat as a no-op and return early - if existing_vote.approve == approve { - return Ok(()); - } - } - - // Proceed with state update and event emission only if vote changed or is new - let new_vote = Vote { - voter: voter.clone(), - campaign_id, - approve, - timestamp: env.ledger().timestamp(), - }; - - env.storage().persistent().set(&vote_key, &new_vote); - - // Emit event for state change - env.events().publish( - (Symbol::new(&env, "campaign_vote_cast"), campaign_id), - (voter, approve), - ); - - Ok(()) -} \ No newline at end of file diff --git a/src/test.rs b/src/test.rs deleted file mode 100644 index b4c4d801..00000000 --- a/src/test.rs +++ /dev/null @@ -1,23 +0,0 @@ -use super::*; -use soroban_sdk::{Env, String, Vec}; - -#[test] -fn test_list_active_campaigns_with_tag_filter() { - let env = Env::default(); - let contract_id = env.register_contract(None, ProofOfHeartContract); - let client = ProofOfHeartContractClient::new(&env, &contract_id); - - // Mock setup: create campaigns with tags - let tag_africa = String::from_str(&env, "africa"); - let tag_stem = String::from_str(&env, "stem"); - - // ... populate test campaigns in storage ... - - // Test filtering by 'africa' tag - let africa_campaigns = client.list_active_campaigns(&Some(tag_africa)); - assert_eq!(africa_campaigns.len(), 1); - - // Test unfiltered retrieval returns all active campaigns - let all_campaigns = client.list_active_campaigns(&None); - assert!(all_campaigns.len() >= 2); -} \ No newline at end of file diff --git a/src/tests/test_cap_interactions.rs b/src/tests/test_cap_interactions.rs index fd51c259..41c5b58a 100644 --- a/src/tests/test_cap_interactions.rs +++ b/src/tests/test_cap_interactions.rs @@ -18,7 +18,10 @@ use proptest::prelude::*; use super::helpers::*; use crate::{Category, CreateCampaignParams, Error}; -use soroban_sdk::{testutils::Ledger, String}; +use soroban_sdk::{ + testutils::{AuthorizedFunction, AuthorizedInvocation, Ledger}, + IntoVal, String, Symbol, TryFromVal, +}; // ── Pure logic: mirrors `set_personal_cap_fn`'s bound check and the dual // cap check inside `contribute()` (src/contributions.rs) ──────────────── @@ -227,3 +230,144 @@ proptest! { ); } } + +// ── remove_personal_cap (#503) ─────────────────────────────────────────────── + +/// Setting a cap, then removing it, fully restores the "no personal cap" +/// state: `get_personal_cap` reads 0 again and a `personal_cap_removed` +/// event is emitted. +#[test] +fn test_remove_personal_cap_restores_no_cap_state() { + let (env, _admin, creator, contributor1, _, _token, _token_admin, client) = setup_env(); + + let campaign_id = client.create_campaign(&CreateCampaignParams { + creator: creator.clone(), + title: String::from_str(&env, "Remove cap"), + description: String::from_str(&env, "remove_personal_cap flow"), + funding_goal: 10_000_000, + duration_days: 30, + category: Category::Learner, + has_revenue_sharing: false, + revenue_share_percentage: 0, + max_contribution_per_user: 0, + }); + + assert_eq!(client.get_personal_cap(&campaign_id, &contributor1), 0); + + client.set_personal_cap(&campaign_id, &contributor1, &500); + assert_eq!(client.get_personal_cap(&campaign_id, &contributor1), 500); + + client.remove_personal_cap(&campaign_id, &contributor1); + assert_eq!(client.get_personal_cap(&campaign_id, &contributor1), 0); + + // The removal event is published with (symbol, campaign_id, contributor) + // as topics. + let events = env.events().all(); + let last = events.last().unwrap(); + let topics = &last.1; + assert_eq!(topics.len(), 3); + let topic_symbol: soroban_sdk::String = + soroban_sdk::String::try_from_val(&env, &topics.get(0).unwrap()).unwrap(); + assert_eq!( + topic_symbol, + soroban_sdk::String::from_str(&env, "personal_cap_removed") + ); + let topic_campaign: u32 = soroban_sdk::FromVal::from_val(&env, &topics.get(1).unwrap()); + assert_eq!(topic_campaign, campaign_id); +} + +/// Removing a cap on a cancelled/inactive campaign fails with +/// `CampaignNotActive`, mirroring `set_personal_cap_fn`'s guard. +#[test] +fn test_remove_personal_cap_inactive_campaign_returns_error() { + let (env, _admin, creator, contributor1, _, _token, _token_admin, client) = setup_env(); + + let campaign_id = client.create_campaign(&CreateCampaignParams { + creator: creator.clone(), + title: String::from_str(&env, "Remove cap on cancelled"), + description: String::from_str(&env, "remove cap on inactive campaign"), + funding_goal: 10_000_000, + duration_days: 30, + category: Category::Learner, + has_revenue_sharing: false, + revenue_share_percentage: 0, + max_contribution_per_user: 0, + }); + client.set_personal_cap(&campaign_id, &contributor1, &500); + + client.cancel_campaign(&campaign_id); + + let res = client.try_remove_personal_cap(&campaign_id, &contributor1); + assert_eq!(res.unwrap_err().unwrap(), Error::CampaignNotActive); +} + +/// Removing a cap that was never set must fail with `PersonalCapNotFound` +/// rather than silently succeeding, so indexers can trust the event. +#[test] +fn test_remove_personal_cap_nonexistent_returns_error() { + let (env, _admin, creator, contributor1, _, _token, _token_admin, client) = setup_env(); + + let campaign_id = client.create_campaign(&CreateCampaignParams { + creator: creator.clone(), + title: String::from_str(&env, "Remove missing cap"), + description: String::from_str(&env, "remove cap that does not exist"), + funding_goal: 10_000_000, + duration_days: 30, + category: Category::Learner, + has_revenue_sharing: false, + revenue_share_percentage: 0, + max_contribution_per_user: 0, + }); + + let res = client.try_remove_personal_cap(&campaign_id, &contributor1); + assert_eq!(res.unwrap_err().unwrap(), Error::PersonalCapNotFound); +} + +/// Removing a cap on a non-existent campaign fails with `CampaignNotFound`. +#[test] +fn test_remove_personal_cap_unknown_campaign_returns_error() { + let (_env, _admin, _creator, contributor1, _, _token, _token_admin, client) = setup_env(); + + let res = client.try_remove_personal_cap(&999, &contributor1); + assert_eq!(res.unwrap_err().unwrap(), Error::CampaignNotFound); +} + +/// `remove_personal_cap` must be authorized by the contributor whose cap is +/// being removed: the recorded auth entries must contain exactly one +/// authorization, for that contributor, for the `remove_personal_cap` call +/// (mirrors the auth verification in `test_claim_refund_requires_contributor_auth`). +#[test] +fn test_remove_personal_cap_requires_contributor_auth() { + let (env, _admin, creator, contributor1, _, _token, _token_admin, client) = setup_env(); + + let campaign_id = client.create_campaign(&CreateCampaignParams { + creator: creator.clone(), + title: String::from_str(&env, "Remove cap auth"), + description: String::from_str(&env, "remove_personal_cap auth check"), + funding_goal: 10_000_000, + duration_days: 30, + category: Category::Learner, + has_revenue_sharing: false, + revenue_share_percentage: 0, + max_contribution_per_user: 0, + }); + client.set_personal_cap(&campaign_id, &contributor1, &500); + + client.remove_personal_cap(&campaign_id, &contributor1); + + let auths = env.auths(); + assert_eq!(auths.len(), 1); + let (auth_addr, invocation) = &auths[0]; + assert_eq!(auth_addr, &contributor1); + assert_eq!( + invocation, + &AuthorizedInvocation { + function: AuthorizedFunction::Contract(( + client.address.clone(), + Symbol::new(&env, "remove_personal_cap"), + (campaign_id, contributor1.clone()).into_val(&env), + )), + sub_invocations: Default::default(), + } + ); +} diff --git a/src/tests/voting_tests.rs b/src/tests/voting_tests.rs deleted file mode 100644 index 4e13701a..00000000 --- a/src/tests/voting_tests.rs +++ /dev/null @@ -1,22 +0,0 @@ -#[test] -fn test_cast_vote_no_op_does_not_emit_duplicate_events() { - let env = Env::default(); - env.mock_all_auths(); - - let contract_id = env.register_contract(None, ProofOfHeartContract); - let client = ProofOfHeartContractClient::new(&env, &contract_id); - - let voter = Address::generate(&env); - let campaign_id = 1_u64; - - // First vote: cast initial approval - client.cast_vote(&voter, &campaign_id, &true); - let initial_event_count = env.events().all().len(); - - // Second vote: duplicate identical vote (no-op update) - client.cast_vote(&voter, &campaign_id, &true); - let final_event_count = env.events().all().len(); - - // Assert that no new event was emitted for the no-op - assert_eq!(initial_event_count, final_event_count); -} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index e85f5804..00000000 --- a/src/types.ts +++ /dev/null @@ -1,20 +0,0 @@ -export interface Campaign { - id: string; - creator: string; - title: string; - description: string; - goalAmount: string; - totalRaised: string; - deadline: number; - category: string; - tags: string[]; - isCompleted: boolean; -} - -export interface PlatformStats { - totalCampaigns: number; - totalVolumeXlm: string; - activeContributors: number; -} - -export type Category = 'DeFi' | 'Social' | 'Infrastructure' | 'NFT' | 'Education'; \ No newline at end of file From 494b5682cd372d7b0f12e82d09a2042f41596535 Mon Sep 17 00:00:00 2001 From: baedboibidex-cmyk Date: Sat, 1 Aug 2026 13:32:49 +0100 Subject: [PATCH 5/7] fix(#439): keep token-swap guard and stats consistent with upfront refund escrow --- src/admin.rs | 10 +++++++--- src/campaigns/cancel.rs | 2 +- src/contributions.rs | 18 ++++++++++++++---- src/tests/test_queries.rs | 4 +++- 4 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/admin.rs b/src/admin.rs index 0256f571..55e75c3c 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -5,8 +5,9 @@ use crate::lifecycle::{assert_admin, get_campaign_or_error, require_active_campa use crate::storage::{ self, bump_instance_ttl, get_active_campaign_count, get_admin, get_approval_threshold_bps, 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, + get_pending_admin, get_pending_refund_total, 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_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, @@ -365,7 +366,10 @@ pub(crate) fn accept_token_update(env: &Env, admin: Address) -> Result<(), Error // contributor calls `claim_refund` — which pays out in the *current* token. // Gating on the outstanding balance closes that window. Vesting reserves are // likewise tracked in `total_raised_global` until released. - if get_active_campaign_count(env) > 0 || get_total_raised_global(env) != 0 { + if get_active_campaign_count(env) > 0 + || get_total_raised_global(env) != 0 + || get_pending_refund_total(env) != 0 + { return Err(Error::ValidationFailed); } diff --git a/src/campaigns/cancel.rs b/src/campaigns/cancel.rs index dedac4c8..965a51ea 100644 --- a/src/campaigns/cancel.rs +++ b/src/campaigns/cancel.rs @@ -8,7 +8,7 @@ use crate::lifecycle::{ }; use crate::storage::{ bump_instance_ttl, decrement_active_campaign_count, get_pending_refund_total, get_revenue_pool, - get_total_raised_global, get_token, increment_cancelled_campaign_count, remove_voting_state, + get_token, get_total_raised_global, increment_cancelled_campaign_count, remove_voting_state, set_campaign, set_pending_refund_total, set_revenue_pool, set_total_raised_global, }; diff --git a/src/contributions.rs b/src/contributions.rs index 6c7acab0..d372d751 100644 --- a/src/contributions.rs +++ b/src/contributions.rs @@ -6,10 +6,11 @@ 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_contribution, get_lifetime_contribution, get_pending_refund_total, 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_pending_refund_total, set_personal_cap, + set_total_raised_global, AdminKey, }; use crate::types::Campaign; @@ -294,6 +295,15 @@ pub(crate) fn claim_refund(env: &Env, campaign_id: u32, contributor: Address) -> env, total_raised.checked_sub(amount).ok_or(Error::Overflow)?, ); + } else { + // For cancelled campaigns the escrow was moved to pending_refund_total + // at cancellation; drop it per-claimant so the token-swap guard (#407) + // sees the true outstanding balance. + let pending_refund = get_pending_refund_total(env); + set_pending_refund_total( + env, + pending_refund.checked_sub(amount).ok_or(Error::Overflow)?, + ); } let client = token_client(env); diff --git a/src/tests/test_queries.rs b/src/tests/test_queries.rs index 588e2c36..1ebedd43 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); + // #439: cancel_campaign decrements total_raised_global upfront, moving the + // escrow into pending_refund_total, so only c1's 400 still counts. + assert_eq!(stats.total_amount_raised, 400); } #[test] From bc2429f34775ce548fa656dafda69b516ffb7cf6 Mon Sep 17 00:00:00 2001 From: baedboibidex-cmyk Date: Sat, 1 Aug 2026 13:35:30 +0100 Subject: [PATCH 6/7] style: align import wrapping with rustfmt layout --- src/admin.rs | 13 ++++++------- src/contributions.rs | 8 ++++---- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/admin.rs b/src/admin.rs index 55e75c3c..560a717c 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -7,13 +7,12 @@ use crate::storage::{ get_max_campaign_funding_goal, get_min_campaign_funding_goal, get_min_votes_quorum, get_pending_admin, get_pending_refund_total, 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_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, AdminKey, + remove_has_voted, remove_pending_admin, remove_pending_token, remove_voting_state, set_admin, + set_approval_threshold_bps, set_campaign_count, 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, AdminKey, }; use crate::voting; diff --git a/src/contributions.rs b/src/contributions.rs index d372d751..124444cd 100644 --- a/src/contributions.rs +++ b/src/contributions.rs @@ -7,10 +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_pending_refund_total, 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_pending_refund_total, set_personal_cap, - set_total_raised_global, AdminKey, + 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_pending_refund_total, set_personal_cap, set_total_raised_global, + AdminKey, }; use crate::types::Campaign; From 1d04210986ed67d0c8ee77cd3db4cd52149e17a2 Mon Sep 17 00:00:00 2001 From: baedboibidex-cmyk Date: Sun, 2 Aug 2026 21:42:02 +0100 Subject: [PATCH 7/7] fix(ci): complete misplaced assert_eq! for PersonalCapNotFound in errors.rs test --- src/errors.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/errors.rs b/src/errors.rs index cc05dafd..7d883369 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -272,12 +272,13 @@ mod tests { "GoalMetCancellationNotAllowed" ); assert_eq!( - Error::PersonalCapNotFound.to_string(), - "PersonalCapNotFound" - ); Error::InvalidStateTransition.to_string(), "InvalidStateTransition" ); + assert_eq!( + Error::PersonalCapNotFound.to_string(), + "PersonalCapNotFound" + ); assert_eq!( Error::CampaignAlreadyBookmarked.to_string(), "CampaignAlreadyBookmarked"