diff --git a/CHANGELOG.md b/CHANGELOG.md index 87f32293..468dc739 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +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. ### Removed - Removed the dead `BlockContributionCount` storage key variant and its unused `get_block_contribution_count` / `set_block_contribution_count` helpers. Only the per-campaign `BlockCampaignContributionCount` is actually used by the anomaly-detection burst guard (#435). 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 be2c281c..1803f075 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,41 @@ 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()` | ## Bookmarks (Out-of-Lifecycle Wallet Action) Bookmarks (`save_campaign`, `remove_saved_campaign`, `get_saved_campaigns`) are wallet-level operations that exist independently of campaign lifecycle state. A wallet can bookmark a campaign at **any** point in the campaign's lifecycle: @@ -138,16 +136,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 6d793df8..a1e61665 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -5,12 +5,15 @@ 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_refund_total, get_pending_token, get_pending_token_release, + get_platform_fee, get_token, get_total_raised_global, get_version, is_initialized, get_pending_admin, get_pending_token, get_pending_token_release, get_platform_fee, get_token, get_token_update_delay_secs, 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_platform_fee, set_token, set_token_update_delay_secs, set_total_raised_global, set_version, set_withdraw_release_delay_days, set_withdraw_reserve_percentage, AdminKey, }; @@ -366,7 +369,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 9fda789d..965a51ea 100644 --- a/src/campaigns/cancel.rs +++ b/src/campaigns/cancel.rs @@ -7,8 +7,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_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, }; pub(crate) fn cancel_campaign(env: &Env, campaign_id: u32) -> Result<(), Error> { @@ -51,6 +52,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, @@ -105,5 +122,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/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(); diff --git a/src/contributions.rs b/src/contributions.rs index 11af7a59..ab68445a 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; @@ -308,11 +309,26 @@ 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)?, + ); + } 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); client.transfer(&env.current_contract_address(), &contributor, &amount); @@ -346,3 +362,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 573ed967..1a4e7d32 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, } /// Builds an exhaustive `match self { Error::V => stringify!(V), ... }` from @@ -118,6 +120,54 @@ impl Error { /// payloads and debug logs can show a human-readable name instead of the /// bare discriminant number. pub fn name(&self) -> &'static str { + match self { + Error::NotAuthorized => "NotAuthorized", + Error::CampaignNotFound => "CampaignNotFound", + Error::CampaignNotActive => "CampaignNotActive", + Error::FundingGoalMustBePositive => "FundingGoalMustBePositive", + Error::InvalidDuration => "InvalidDuration", + Error::InvalidRevenueShare => "InvalidRevenueShare", + Error::RevenueShareOnlyForStartup => "RevenueShareOnlyForStartup", + Error::DeadlinePassed => "DeadlinePassed", + Error::ContributionMustBePositive => "ContributionMustBePositive", + Error::DeadlineNotPassed => "DeadlineNotPassed", + Error::FundsAlreadyWithdrawn => "FundsAlreadyWithdrawn", + Error::FundingGoalNotReached => "FundingGoalNotReached", + Error::NoFundsToWithdraw => "NoFundsToWithdraw", + Error::CampaignAlreadyVerified => "CampaignAlreadyVerified", + Error::ValidationFailed => "ValidationFailed", + Error::AlreadyVoted => "AlreadyVoted", + Error::NotTokenHolder => "NotTokenHolder", + Error::VotingQuorumNotMet => "VotingQuorumNotMet", + Error::VotingThresholdNotMet => "VotingThresholdNotMet", + Error::AlreadyInitialized => "AlreadyInitialized", + Error::NotPendingOwner => "NotPendingOwner", + Error::NoTransferPending => "NoTransferPending", + Error::InvalidNewOwner => "InvalidNewOwner", + Error::ContractPaused => "ContractPaused", + Error::ContributionCapExceeded => "ContributionCapExceeded", + Error::CampaignNotVerified => "CampaignNotVerified", + Error::AmountRaisedIsZero => "AmountRaisedIsZero", + Error::RevenueSharingNotEnabled => "RevenueSharingNotEnabled", + Error::CancellationNotAllowed => "CancellationNotAllowed", + Error::Overflow => "Overflow", + Error::InvalidTokenContract => "InvalidTokenContract", + Error::CreationDisabled => "CreationDisabled", + Error::FundingGoalTooLow => "FundingGoalTooLow", + Error::AdminVerificationConflict => "AdminVerificationConflict", + Error::CommunityVerificationConflict => "CommunityVerificationConflict", + Error::DeadlineAlreadyExtended => "DeadlineAlreadyExtended", + Error::ExtensionTooLong => "ExtensionTooLong", + Error::FundingGoalTooHigh => "FundingGoalTooHigh", + Error::InvalidPlatformFee => "InvalidPlatformFee", + Error::TransferAlreadyPending => "TransferAlreadyPending", + Error::InvalidVestingDelay => "InvalidVestingDelay", + Error::GoalMetCancellationNotAllowed => "GoalMetCancellationNotAllowed", + Error::InvalidStateTransition => "InvalidStateTransition", + Error::CampaignAlreadyBookmarked => "CampaignAlreadyBookmarked", + Error::CampaignNotBookmarked => "CampaignNotBookmarked", + Error::PersonalCapNotFound => "PersonalCapNotFound", + } error_names!( self, [ @@ -291,6 +341,10 @@ mod tests { Error::InvalidStateTransition.to_string(), "InvalidStateTransition" ); + assert_eq!( + Error::PersonalCapNotFound.to_string(), + "PersonalCapNotFound" + ); assert_eq!( Error::CampaignAlreadyBookmarked.to_string(), "CampaignAlreadyBookmarked" diff --git a/src/lib.rs b/src/lib.rs index 3cf86a74..e6152369 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -470,6 +470,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 { diff --git a/src/storage.rs b/src/storage.rs index bd8c54c2..73b21e80 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, /// Admin-configured delay (seconds) before a proposed token update can be /// accepted, overriding the compiled-in `TOKEN_UPDATE_DELAY_SECS` default (#650). TokenUpdateDelaySecs, @@ -714,6 +716,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. diff --git a/src/tests/test_campaign_update.rs b/src/tests/test_campaign_update.rs index f7d50333..e68cd0c2 100644 --- a/src/tests/test_campaign_update.rs +++ b/src/tests/test_campaign_update.rs @@ -62,6 +62,7 @@ fn test_update_campaign_emits_title_and_description() { // #349: campaign_metadata_updated emits (old_title, old_desc, new_title, new_desc). let events = env.events().all(); let last_event = events.last().unwrap(); + // Event payload: (old_title, old_description, title, event_description) let payload: (String, String, String, String) = soroban_sdk::FromVal::from_val(&env, &last_event.2); @@ -99,6 +100,7 @@ fn test_update_campaign_event_tracks_latest_description() { // The second call's "old" is V2, "new" is V3. let events = env.events().all(); let last_event = events.last().unwrap(); + // 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")); 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/test_queries.rs b/src/tests/test_queries.rs index 30e2e5fd..d267356a 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]