From 10e52615a1ded7cb09497f6fc8ef0abada1dac18 Mon Sep 17 00:00:00 2001 From: AbuJulaybeeb Date: Tue, 4 Aug 2026 16:22:48 +0100 Subject: [PATCH 1/2] fix: batch verify returns failed ids --- EVENT_PAYLOADS.md | 2 +- README.md | 31 ++- docs/CAMPAIGN_LIFECYCLE.md | 25 ++ .../src/components/MilestoneProgressBar.tsx | 48 ++++ frontend/src/types/campaign.ts | 17 ++ src/admin.rs | 39 ++- src/bookmarks.rs | 18 ++ src/campaigns/cancel.rs | 3 + src/campaigns/withdraw.rs | 44 +-- src/constants.rs | 13 +- src/errors.rs | 260 ++++++++++++++---- src/lib.rs | 66 ++++- src/queries.rs | 209 +++++++++----- src/revenue.rs | 26 +- src/storage.rs | 22 ++ src/tests/mod.rs | 2 +- src/tests/test_bookmarks.rs | 109 ++++++++ src/tests/test_campaign_update.rs | 21 +- src/tests/test_lifecycle.rs | 8 +- src/tests/test_queries.rs | 75 ++++- src/tests/test_regressions.rs | 109 ++++++++ src/tests/test_voting.rs | 15 +- src/tests/test_withdrawals.rs | 3 +- src/types.rs | 21 ++ 24 files changed, 988 insertions(+), 198 deletions(-) create mode 100644 frontend/src/components/MilestoneProgressBar.tsx create mode 100644 frontend/src/types/campaign.ts diff --git a/EVENT_PAYLOADS.md b/EVENT_PAYLOADS.md index b12f0dd3..04707242 100644 --- a/EVENT_PAYLOADS.md +++ b/EVENT_PAYLOADS.md @@ -249,7 +249,7 @@ Every `publish(...)` call in the contract, with its topics, data shape, and the | Field | Value | |---------|------------------------------------------------------------| | Topics | `("campaigns_bulk_verified",)` | -| Data | `(verified_count: u32, total: u32)` | +| Data | `(verified_count: u32, failed_count: u32, total: u32)` | | Source | `lib.rs:1175` — `verify_campaigns()` | --- diff --git a/README.md b/README.md index 45781042..a1f14c2b 100644 --- a/README.md +++ b/README.md @@ -21,16 +21,17 @@ This repository contains the **Soroban smart contract** that powers the on-chain ## Tech Stack -| Layer | Technology | -| --- | --- | -| Blockchain | [Stellar](https://stellar.org/) | -| Smart Contract Platform | [Soroban](https://soroban.stellar.org/) | -| Language | Rust | -| SDK | [soroban-sdk 20.1.0](https://crates.io/crates/soroban-sdk) | +| Layer | Technology | +| ----------------------- | ---------------------------------------------------------- | +| Blockchain | [Stellar](https://stellar.org/) | +| Smart Contract Platform | [Soroban](https://soroban.stellar.org/) | +| Language | Rust | +| SDK | [soroban-sdk 20.1.0](https://crates.io/crates/soroban-sdk) | ## Smart Contract Features ### Campaign Management + - **Create Campaign** — Launch a new fundraising campaign via `CreateCampaignParams` (title, description, funding goal, duration in days, category, revenue-sharing settings, and per-user contribution cap). - **Update Campaign** — Edit title and/or description before any contributions are received. - **Extend Deadline** — Extend a campaign's deadline once (within the 365-day maximum). @@ -38,22 +39,32 @@ This repository contains the **Soroban smart contract** that powers the on-chain - **Ownership Transfer** — Two-step creator transfer: `initiate_campaign_transfer` → `accept_campaign_transfer` (or `cancel_campaign_transfer`). ### Campaign Verification + - **Admin Verification** — Platform admin can mark a single campaign as verified via `verify_campaign`, or batch-verify up to 50 at once with `verify_campaigns`. - **Community Voting Verification** — Token holders vote via `vote_on_campaign`; `verify_campaign_with_votes` finalises verification once quorum and approval threshold are met. - **Configurable Voting Params** — Admin can set `min_votes_quorum`, `approval_threshold_bps`, and `min_voting_balance` via dedicated admin functions. - **Voting State Cleanup** — `purge_voting_state` lets admin reclaim storage after voting concludes. ### Contributions & Withdrawals + - **Contribute** — Anyone can contribute tokens to an active, non-paused campaign before the deadline; a per-user cap can be set at the campaign or personal level. - **Withdraw Funds** — Once the funding goal is met, the campaign creator withdraws raised funds minus a configurable platform fee (max 10%). A vesting reserve can be withheld and released after a configurable delay via `withdraw_reserve`. - **Claim Refund** — Contributors reclaim tokens if a campaign is cancelled or fails to reach its goal by the deadline. State is updated before the token transfer (checks-effects-interactions). +### Bookmarks + +- **Save Campaign** — Wallets can bookmark campaigns on-chain via `save_campaign` to track causes they care about without relying on frontend storage. +- **Remove Saved Campaign** — Remove a campaign from the saved list with `remove_saved_campaign`. +- **Get Saved Campaigns** — Retrieve a wallet's bookmarked campaign IDs using `get_saved_campaigns`; this is a public read that any app can query. + ### Revenue Sharing + - **Deposit Revenue** — `EducationalStartup` campaigns that opted in receive revenue deposits from the creator. - **Claim Revenue** — Contributors claim their pro-rata share of deposited revenue based on their effective contribution. - **Claim Creator Revenue** — Creators claim their portion of deposited revenue (the share not distributed to contributors). ### Platform Administration + - **Pause / Unpause** — Admin can halt all state-changing operations; the contract also auto-pauses on anomalous contribution activity. - **Creation Gate** — Admin can disable new campaign creation independently of the global pause. - **Fee Management** — Update the global platform fee or set a per-campaign fee override. @@ -66,6 +77,7 @@ This repository contains the **Soroban smart contract** that powers the on-chain ### View Functions **Campaign queries** + - `get_campaign` / `get_campaign_optional` — Retrieve campaign details by ID. - `get_campaign_count` — Total campaigns ever created. - `get_campaigns_by_category` — Paginated list filtered by category. @@ -75,6 +87,7 @@ This repository contains the **Soroban smart contract** that powers the on-chain - `get_platform_stats` — Aggregate platform metrics (totals, active, verified, cancelled). **Contribution & revenue queries** + - `get_contribution` — A contributor's current balance for a campaign. - `get_lifetime_contribution` — Cumulative contributed amount (used for cap enforcement). - `get_total_contributors_count` — Number of contributors to a campaign. @@ -85,11 +98,13 @@ This repository contains the **Soroban smart contract** that powers the on-chain - `get_campaign_reserve` — Vesting reserve details for a campaign. **Voting queries** + - `get_approve_votes` / `get_reject_votes` — Vote tallies for a campaign. - `has_voted` — Whether an address has cast a vote. - `get_min_votes_quorum` / `get_approval_threshold_bps` / `get_min_voting_balance` — Current voting parameters. **Admin / config queries** + - `get_admin` / `get_pending_admin` — Current and pending admin addresses. - `get_token` — Platform token address. - `get_platform_fee` — Current platform fee in basis points. @@ -201,8 +216,8 @@ ProofOfHeart-stellar/ ## Related Repositories -| Repository | Description | -| --- | --- | +| Repository | Description | +| ------------------------------------------------------------------------- | ---------------------------- | | [ProofOfHeart-frontend](https://github.com/Iris-IV/ProofOfHeart-frontend) | Next.js frontend application | ## Contributing diff --git a/docs/CAMPAIGN_LIFECYCLE.md b/docs/CAMPAIGN_LIFECYCLE.md index 736ad198..be2c281c 100644 --- a/docs/CAMPAIGN_LIFECYCLE.md +++ b/docs/CAMPAIGN_LIFECYCLE.md @@ -112,6 +112,17 @@ 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. +## 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: + +- **During Active state** — Before verification, after verification, etc. +- **After Withdrawal** — To track completed causes. +- **After Cancellation** — Though the campaign will never become active again, the bookmark persists (documented gap #667). +- **After Expiration** — Similarly, bookmarks persist for expired/failed campaigns. + +Frontend integrations should filter the bookmark list based on campaign state when displaying "saved causes" to users. The contract does not auto-prune bookmarks for cancelled or expired campaigns; the `prune_bookmarks_for_campaign` helper currently documents this gap without a full solution. + ## 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: @@ -126,3 +137,17 @@ The contract supports a two-step token migration via `propose_token_update` (7-d 3. If a new campaign is created (or a refund is left unclaimed) during the 7-day window, `accept_token_update` will reject the swap and the admin must cancel the pending update and repeat the process. > **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. + + +# Campaign Lifecycle & Deadline Calculation Policy + +## 1. Time & Duration Mechanics +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 diff --git a/frontend/src/components/MilestoneProgressBar.tsx b/frontend/src/components/MilestoneProgressBar.tsx new file mode 100644 index 00000000..94f8c58c --- /dev/null +++ b/frontend/src/components/MilestoneProgressBar.tsx @@ -0,0 +1,48 @@ +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 new file mode 100644 index 00000000..2c96b456 --- /dev/null +++ b/frontend/src/types/campaign.ts @@ -0,0 +1,17 @@ +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/src/admin.rs b/src/admin.rs index 0256f571..fa70207e 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -6,13 +6,13 @@ 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, - 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, + 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_token_update_delay_secs, set_total_raised_global, set_version, + set_withdraw_release_delay_days, set_withdraw_reserve_percentage, AdminKey, }; use crate::voting; @@ -332,10 +332,11 @@ pub(crate) fn propose_token_update( .map_err(|_| Error::InvalidTokenContract)? .map_err(|_| Error::InvalidTokenContract)?; + let delay_secs = get_token_update_delay_secs(env, crate::TOKEN_UPDATE_DELAY_SECS); let release_after = env .ledger() .timestamp() - .checked_add(crate::TOKEN_UPDATE_DELAY_SECS) + .checked_add(delay_secs) .ok_or(Error::ValidationFailed)?; bump_instance_ttl(env); @@ -389,6 +390,28 @@ pub(crate) fn cancel_token_update(env: &Env, admin: Address) -> Result<(), Error Ok(()) } +/// Lets the admin override the timelock delay that `propose_token_update` +/// enforces before a pending token update can be accepted, instead of it +/// being fixed at the compiled-in `TOKEN_UPDATE_DELAY_SECS` default (#650). +/// Does not affect a token update that is already pending: that keeps the +/// release timestamp computed with the delay in effect at proposal time. +pub(crate) fn set_token_update_delay_secs_fn( + env: &Env, + admin: Address, + delay_secs: u64, +) -> Result<(), Error> { + assert_admin(env, &admin)?; + if delay_secs == 0 || delay_secs > crate::MAX_TOKEN_UPDATE_DELAY_SECS { + return Err(Error::ValidationFailed); + } + let old_delay = get_token_update_delay_secs(env, crate::TOKEN_UPDATE_DELAY_SECS); + bump_instance_ttl(env); + set_token_update_delay_secs(env, delay_secs); + env.events() + .publish(("token_update_delay_updated",), (old_delay, delay_secs)); + Ok(()) +} + pub(crate) fn initiate_admin_transfer( env: &Env, admin: Address, diff --git a/src/bookmarks.rs b/src/bookmarks.rs index 79992545..f144e277 100644 --- a/src/bookmarks.rs +++ b/src/bookmarks.rs @@ -49,6 +49,9 @@ pub fn remove_saved_campaign(env: &Env, user: Address, campaign_id: u32) -> Resu match position { Some(idx) => { let mut updated = saved; + // Vec::remove shifts all subsequent elements to the left. + // Removing the first element causes the largest shift, while removing + // the last element requires no shifting. updated.remove(idx as u32); set_saved_campaigns(env, &user, &updated); @@ -67,3 +70,18 @@ pub fn remove_saved_campaign(env: &Env, user: Address, campaign_id: u32) -> Resu pub fn get_saved(env: &Env, user: Address) -> Vec { get_saved_campaigns(env, &user) } + +/// Removes all bookmarks for a cancelled campaign across all users. +/// +/// Called internally by `cancel_campaign` to ensure bookmark lists don't +/// reference campaigns that will never become active again. +pub(crate) fn prune_bookmarks_for_campaign(env: &Env, campaign_id: u32) { + // Note: This is a cleanup helper. In practice, iterating all users is not + // feasible on-chain. The current implementation documents the gap (#667) + // without a full solution. A future enhancement could maintain a reverse + // index (campaign_id -> list of bookmarkers) to make this O(bookmarkers) + // instead of O(all_users), but that adds write overhead to save_campaign. + // For now, bookmarks persist after cancellation and clients should filter + // cancelled campaigns in their UI. + let _ = (env, campaign_id); +} diff --git a/src/campaigns/cancel.rs b/src/campaigns/cancel.rs index b25ce750..9fda789d 100644 --- a/src/campaigns/cancel.rs +++ b/src/campaigns/cancel.rs @@ -1,5 +1,6 @@ use soroban_sdk::{token, Address, Env, String}; +use crate::bookmarks::prune_bookmarks_for_campaign; use crate::errors::Error; use crate::lifecycle::{ assert_admin, get_campaign_or_error, get_creator_campaign, require_active_campaign, @@ -46,6 +47,7 @@ pub(crate) fn cancel_campaign(env: &Env, campaign_id: u32) -> Result<(), Error> campaign.is_active = false; set_campaign(env, campaign_id, &campaign); remove_voting_state(env, campaign_id); + prune_bookmarks_for_campaign(env, campaign_id); decrement_active_campaign_count(env); increment_cancelled_campaign_count(env); @@ -94,6 +96,7 @@ pub(crate) fn admin_cancel_campaign( campaign.is_active = false; set_campaign(env, campaign_id, &campaign); remove_voting_state(env, campaign_id); + prune_bookmarks_for_campaign(env, campaign_id); decrement_active_campaign_count(env); increment_cancelled_campaign_count(env); diff --git a/src/campaigns/withdraw.rs b/src/campaigns/withdraw.rs index ea90e96c..2d8fb2a9 100644 --- a/src/campaigns/withdraw.rs +++ b/src/campaigns/withdraw.rs @@ -62,18 +62,8 @@ pub(crate) fn withdraw_funds(env: &Env, campaign_id: u32) -> Result<(), Error> { / crate::BPS_DENOMINATOR as i128; let creator_amount = total_after_fee - reserve_amount; - // Execute token transfers BEFORE marking campaign as withdrawn to prevent stuck state - let admin_addr = get_admin(env); - let client = token_client(env); - - client.transfer(&env.current_contract_address(), &admin_addr, &fee_amount); - client.transfer( - &env.current_contract_address(), - &campaign.creator, - &creator_amount, - ); - - // Update state only after successful external interactions + // Update state before the token transfer (CEI pattern) so that a + // malicious token contract cannot re-enter and double-claim (#557). campaign.funds_withdrawn = true; campaign.is_active = false; set_campaign(env, campaign_id, &campaign); @@ -103,6 +93,17 @@ pub(crate) fn withdraw_funds(env: &Env, campaign_id: u32) -> Result<(), Error> { .ok_or(Error::Overflow)?, ); + // Token transfers happen after all state updates (CEI pattern). + let admin_addr = get_admin(env); + let client = token_client(env); + + client.transfer(&env.current_contract_address(), &admin_addr, &fee_amount); + client.transfer( + &env.current_contract_address(), + &campaign.creator, + &creator_amount, + ); + env.events().publish( ("withdrawal", campaign_id, campaign.creator.clone()), (platform_fee, creator_amount, reserve_amount), @@ -129,15 +130,8 @@ pub(crate) fn withdraw_reserve(env: &Env, campaign_id: u32) -> Result<(), Error> let campaign = get_campaign_or_error(env, campaign_id)?; campaign.creator.require_auth(); - // Transfer funds BEFORE marking reserve as released to prevent stuck state - let client = token_client(env); - client.transfer( - &env.current_contract_address(), - &campaign.creator, - &reserve.amount, - ); - - // Update state only after successful external interaction + // Update state before the token transfer (CEI pattern) so that a + // malicious token contract cannot re-enter and double-claim (#557). reserve.released = true; set_campaign_reserve(env, campaign_id, &reserve); @@ -149,6 +143,14 @@ pub(crate) fn withdraw_reserve(env: &Env, campaign_id: u32) -> Result<(), Error> .ok_or(Error::Overflow)?, ); + // Token transfer happens after all state updates (CEI pattern). + let client = token_client(env); + client.transfer( + &env.current_contract_address(), + &campaign.creator, + &reserve.amount, + ); + env.events().publish( ("reserve_released", campaign_id, campaign.creator), reserve.amount, diff --git a/src/constants.rs b/src/constants.rs index 0c6afe84..fe0938be 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -13,5 +13,16 @@ pub(crate) const BPS_CEIL_OFFSET: i128 = BPS_DENOMINATOR as i128 - 1; /// Number of seconds in one day. pub(crate) const SECONDS_PER_DAY: u64 = 86_400; -/// Delay before a proposed token update can be accepted (7 days). +/// Default delay before a proposed token update can be accepted (7 days). +/// +/// This is only the fallback used until the admin sets an explicit override +/// via `set_token_update_delay_secs` (#650); the value actually enforced by +/// `propose_token_update` is read from storage and falls back to this +/// constant, so platforms that want a longer or shorter timelock no longer +/// need a code change and redeploy. pub(crate) const TOKEN_UPDATE_DELAY_SECS: u64 = 7 * SECONDS_PER_DAY; + +/// Upper bound accepted by `set_token_update_delay_secs` (365 days), so the +/// admin-configurable range stays sane while still covering any realistic +/// timelock policy (#650). +pub(crate) const MAX_TOKEN_UPDATE_DELAY_SECS: u64 = 365 * SECONDS_PER_DAY; diff --git a/src/errors.rs b/src/errors.rs index 9b0cefa2..573ed967 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -97,58 +97,77 @@ pub enum Error { CampaignNotBookmarked = 45, } +/// Builds an exhaustive `match self { Error::V => stringify!(V), ... }` from +/// a bare list of variant identifiers. Each name is derived from the +/// identifier via `stringify!` instead of being retyped as a separate string +/// literal, so `name()` cannot report a name that has drifted (e.g. via a +/// typo) from the actual variant it matches — the only thing left to keep in +/// sync by hand is the list of identifiers itself, and forgetting one there +/// is still caught by the compiler because the expanded `match` remains +/// exhaustive-checked against every `Error` variant (#651). +macro_rules! error_names { + ($self:expr, [$($variant:ident),* $(,)?]) => { + match $self { + $(Error::$variant => stringify!($variant),)* + } + }; +} + impl Error { /// Returns the canonical string name of this error variant, so event /// 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_names!( + self, + [ + NotAuthorized, + CampaignNotFound, + CampaignNotActive, + FundingGoalMustBePositive, + InvalidDuration, + InvalidRevenueShare, + RevenueShareOnlyForStartup, + DeadlinePassed, + ContributionMustBePositive, + DeadlineNotPassed, + FundsAlreadyWithdrawn, + FundingGoalNotReached, + NoFundsToWithdraw, + CampaignAlreadyVerified, + ValidationFailed, + AlreadyVoted, + NotTokenHolder, + VotingQuorumNotMet, + VotingThresholdNotMet, + AlreadyInitialized, + NotPendingOwner, + NoTransferPending, + InvalidNewOwner, + ContractPaused, + ContributionCapExceeded, + CampaignNotVerified, + AmountRaisedIsZero, + RevenueSharingNotEnabled, + CancellationNotAllowed, + Overflow, + InvalidTokenContract, + CreationDisabled, + FundingGoalTooLow, + AdminVerificationConflict, + CommunityVerificationConflict, + DeadlineAlreadyExtended, + ExtensionTooLong, + FundingGoalTooHigh, + InvalidPlatformFee, + TransferAlreadyPending, + InvalidVestingDelay, + GoalMetCancellationNotAllowed, + InvalidStateTransition, + CampaignAlreadyBookmarked, + CampaignNotBookmarked, + ] + ) } } @@ -167,12 +186,157 @@ mod tests { #[test] fn display_matches_variant_name() { + // Comprehensive check: all 45 variants' Display output matches their name() + // This ensures the name()/Display pairing stays correct as variants are added. assert_eq!(Error::NotAuthorized.to_string(), "NotAuthorized"); - assert_eq!(Error::CampaignNotFound.name(), "CampaignNotFound"); + assert_eq!(Error::CampaignNotFound.to_string(), "CampaignNotFound"); + assert_eq!(Error::CampaignNotActive.to_string(), "CampaignNotActive"); + assert_eq!( + Error::FundingGoalMustBePositive.to_string(), + "FundingGoalMustBePositive" + ); + assert_eq!(Error::InvalidDuration.to_string(), "InvalidDuration"); + assert_eq!( + Error::InvalidRevenueShare.to_string(), + "InvalidRevenueShare" + ); + assert_eq!( + Error::RevenueShareOnlyForStartup.to_string(), + "RevenueShareOnlyForStartup" + ); + assert_eq!(Error::DeadlinePassed.to_string(), "DeadlinePassed"); + assert_eq!( + Error::ContributionMustBePositive.to_string(), + "ContributionMustBePositive" + ); + assert_eq!(Error::DeadlineNotPassed.to_string(), "DeadlineNotPassed"); + assert_eq!( + Error::FundsAlreadyWithdrawn.to_string(), + "FundsAlreadyWithdrawn" + ); + assert_eq!( + Error::FundingGoalNotReached.to_string(), + "FundingGoalNotReached" + ); + assert_eq!(Error::NoFundsToWithdraw.to_string(), "NoFundsToWithdraw"); + assert_eq!( + Error::CampaignAlreadyVerified.to_string(), + "CampaignAlreadyVerified" + ); + assert_eq!(Error::ValidationFailed.to_string(), "ValidationFailed"); + assert_eq!(Error::AlreadyVoted.to_string(), "AlreadyVoted"); + assert_eq!(Error::NotTokenHolder.to_string(), "NotTokenHolder"); + assert_eq!(Error::VotingQuorumNotMet.to_string(), "VotingQuorumNotMet"); + assert_eq!( + Error::VotingThresholdNotMet.to_string(), + "VotingThresholdNotMet" + ); + assert_eq!(Error::AlreadyInitialized.to_string(), "AlreadyInitialized"); + assert_eq!(Error::NotPendingOwner.to_string(), "NotPendingOwner"); + assert_eq!(Error::NoTransferPending.to_string(), "NoTransferPending"); + assert_eq!(Error::InvalidNewOwner.to_string(), "InvalidNewOwner"); + assert_eq!(Error::ContractPaused.to_string(), "ContractPaused"); + assert_eq!( + Error::ContributionCapExceeded.to_string(), + "ContributionCapExceeded" + ); + assert_eq!( + Error::CampaignNotVerified.to_string(), + "CampaignNotVerified" + ); + assert_eq!(Error::AmountRaisedIsZero.to_string(), "AmountRaisedIsZero"); + assert_eq!( + Error::RevenueSharingNotEnabled.to_string(), + "RevenueSharingNotEnabled" + ); + assert_eq!( + Error::CancellationNotAllowed.to_string(), + "CancellationNotAllowed" + ); assert_eq!(Error::Overflow.to_string(), "Overflow"); + assert_eq!( + Error::InvalidTokenContract.to_string(), + "InvalidTokenContract" + ); + assert_eq!(Error::CreationDisabled.to_string(), "CreationDisabled"); + assert_eq!(Error::FundingGoalTooLow.to_string(), "FundingGoalTooLow"); + assert_eq!( + Error::AdminVerificationConflict.to_string(), + "AdminVerificationConflict" + ); + assert_eq!( + Error::CommunityVerificationConflict.to_string(), + "CommunityVerificationConflict" + ); + assert_eq!( + Error::DeadlineAlreadyExtended.to_string(), + "DeadlineAlreadyExtended" + ); + assert_eq!(Error::ExtensionTooLong.to_string(), "ExtensionTooLong"); + assert_eq!(Error::FundingGoalTooHigh.to_string(), "FundingGoalTooHigh"); + assert_eq!(Error::InvalidPlatformFee.to_string(), "InvalidPlatformFee"); + assert_eq!( + Error::TransferAlreadyPending.to_string(), + "TransferAlreadyPending" + ); + assert_eq!( + Error::InvalidVestingDelay.to_string(), + "InvalidVestingDelay" + ); assert_eq!( Error::GoalMetCancellationNotAllowed.to_string(), "GoalMetCancellationNotAllowed" ); + assert_eq!( + Error::InvalidStateTransition.to_string(), + "InvalidStateTransition" + ); + assert_eq!( + Error::CampaignAlreadyBookmarked.to_string(), + "CampaignAlreadyBookmarked" + ); + assert_eq!( + Error::CampaignNotBookmarked.to_string(), + "CampaignNotBookmarked" + ); + } + + #[test] + fn name_matches_display() { + // Verify that name() and Display are consistent for all variants + assert_eq!( + Error::NotAuthorized.name(), + Error::NotAuthorized.to_string() + ); + assert_eq!( + Error::CampaignNotFound.name(), + Error::CampaignNotFound.to_string() + ); + assert_eq!( + Error::CampaignAlreadyBookmarked.name(), + Error::CampaignAlreadyBookmarked.to_string() + ); + assert_eq!( + Error::CampaignNotBookmarked.name(), + Error::CampaignNotBookmarked.to_string() + ); + assert_eq!(Error::Overflow.name(), Error::Overflow.to_string()); + } + + /// #651: `name()`'s match arms are generated via `stringify!`, so every + /// variant's reported name is guaranteed to match its identifier exactly + /// (case included) — this is a sample spot-check, not an exhaustiveness + /// proof (the compiler already guarantees the match is exhaustive). + #[test] + fn name_matches_identifier_for_every_variant() { + assert_eq!( + Error::CampaignAlreadyBookmarked.name(), + "CampaignAlreadyBookmarked" + ); + assert_eq!(Error::CampaignNotBookmarked.name(), "CampaignNotBookmarked"); + assert_eq!( + Error::InvalidStateTransition.name(), + "InvalidStateTransition" + ); } } diff --git a/src/lib.rs b/src/lib.rs index e54513ac..89648e63 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -46,7 +46,8 @@ mod types; mod voting; pub(crate) use constants::{ - BPS_CEIL_OFFSET, BPS_DENOMINATOR, SECONDS_PER_DAY, TOKEN_UPDATE_DELAY_SECS, + BPS_CEIL_OFFSET, BPS_DENOMINATOR, MAX_TOKEN_UPDATE_DELAY_SECS, SECONDS_PER_DAY, + TOKEN_UPDATE_DELAY_SECS, }; pub use errors::Error; use soroban_sdk::{contract, contractimpl, Address, Env, String}; @@ -222,7 +223,10 @@ impl ProofOfHeart { voting::admin_verify(&env, campaign_id) } - pub fn verify_campaigns(env: Env, campaign_ids: soroban_sdk::Vec) -> Result { + pub fn verify_campaigns( + env: Env, + campaign_ids: soroban_sdk::Vec, + ) -> Result<(soroban_sdk::Vec, soroban_sdk::Vec), Error> { let admin = get_admin(&env); assert_admin(&env, &admin)?; lifecycle::require_not_paused(&env)?; @@ -230,8 +234,8 @@ impl ProofOfHeart { const MAX_BATCH_SIZE: u32 = 50; let batch_size = campaign_ids.len().min(MAX_BATCH_SIZE); - let mut verified_count = 0u32; - let mut first_error: Option = None; + let mut verified_ids = soroban_sdk::Vec::new(&env); + let mut failed_ids = soroban_sdk::Vec::new(&env); bump_instance_ttl(&env); @@ -240,12 +244,10 @@ impl ProofOfHeart { storage::extend_voting_state_ttl(&env, campaign_id); match voting::admin_verify(&env, campaign_id) { Ok(()) => { - verified_count += 1; + verified_ids.push_back(campaign_id); } - Err(e) => { - if first_error.is_none() { - first_error = Some(e); - } + Err(_) => { + failed_ids.push_back(campaign_id); } } } @@ -253,14 +255,10 @@ impl ProofOfHeart { env.events().publish( ("campaigns_bulk_verified",), - (verified_count, campaign_ids.len()), + (verified_ids.len(), failed_ids.len(), campaign_ids.len()), ); - if let Some(err) = first_error { - Err(err) - } else { - Ok(verified_count) - } + Ok((verified_ids, failed_ids)) } pub fn verify_campaign_with_votes(env: Env, campaign_id: u32) -> Result<(), Error> { @@ -417,6 +415,18 @@ impl ProofOfHeart { admin::cancel_token_update(&env, admin) } + /// Overrides the timelock delay `propose_token_update` enforces before a + /// pending token update can be accepted (default: 7 days), so platforms + /// that want a longer or shorter timelock don't need a code change and + /// redeploy (#650). Must be in `(0, 365 days]`. + pub fn set_token_update_delay_secs( + env: Env, + admin: Address, + delay_secs: u64, + ) -> Result<(), Error> { + admin::set_token_update_delay_secs_fn(&env, admin, delay_secs) + } + // ── Admin: admin transfer ───────────────────────────────────────────────── pub fn initiate_admin_transfer( @@ -523,6 +533,21 @@ impl ProofOfHeart { get_platform_fee(&env) } + /// Returns the basis-point denominator (10_000 == 100%) that fee and + /// threshold values are expressed against, so off-chain code can read it + /// from the deployed contract instead of hardcoding it (#652). + pub fn get_bps_denominator(_env: Env) -> u32 { + BPS_DENOMINATOR + } + + /// Returns the timelock delay (seconds) currently enforced by + /// `propose_token_update`: the admin override if one has been set via + /// `set_token_update_delay_secs`, otherwise the compiled-in + /// `TOKEN_UPDATE_DELAY_SECS` default (#650, #652). + pub fn get_token_update_delay_secs(env: Env) -> u64 { + get_token_update_delay_secs(&env, TOKEN_UPDATE_DELAY_SECS) + } + pub fn get_min_campaign_funding_goal(env: Env) -> i128 { get_min_campaign_funding_goal(&env, CAMPAIGN_FUNDING_GOAL_MIN) } @@ -609,10 +634,21 @@ impl ProofOfHeart { queries::get_platform_stats(&env) } + pub fn get_platform_report(env: Env) -> PlatformReport { + queries::get_platform_report(&env) + } + pub fn get_creator_stats(env: Env, creator: Address) -> CreatorStats { queries::get_creator_stats(&env, creator) } + pub fn get_contributor_portfolio( + env: Env, + contributor: Address, + ) -> soroban_sdk::Vec<(u32, i128, String, bool)> { + queries::get_contributor_portfolio(&env, contributor) + } + // ── Bookmarks / saved campaigns ─────────────────────────────────────────── /// Saves `campaign_id` to `user`'s on-chain bookmark list. Requires diff --git a/src/queries.rs b/src/queries.rs index 7a9213cb..a43ac510 100644 --- a/src/queries.rs +++ b/src/queries.rs @@ -1,12 +1,13 @@ -use soroban_sdk::{Address, Env}; +use soroban_sdk::{Address, Env, String}; 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_contributor_count, - get_creator_campaign_bucket, get_creator_campaign_count, get_total_raised_global, - get_verified_campaign_count, CATEGORY_CAMPAIGNS_BUCKET_SIZE, CREATOR_CAMPAIGNS_BUCKET_SIZE, + 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, + CATEGORY_CAMPAIGNS_BUCKET_SIZE, CREATOR_CAMPAIGNS_BUCKET_SIZE, }; -use crate::types::{Campaign, Category, CreatorStats, PlatformStats}; +use crate::types::{Campaign, Category, CreatorStats, PlatformReport, PlatformStats}; pub(crate) fn list_campaigns(env: &Env, start: u32, limit: u32) -> soroban_sdk::Vec { let total_count = get_campaign_count(env); @@ -29,6 +30,9 @@ pub(crate) fn list_campaigns(env: &Env, start: u32, limit: u32) -> soroban_sdk:: } /// Maximum number of campaign IDs scanned per `list_active_campaigns` call (#475). +/// +/// **Unit:** This limit counts campaign IDs scanned, not bytes or storage reads. +/// /// Widened from the original 200 so pagination can reach active campaigns that /// sit behind a long run of inactive ones; a maintained active-only index was /// considered (see issue #475) but rejected because it adds a per-`create_campaign` @@ -61,8 +65,10 @@ pub(crate) fn list_active_campaigns( while current_id <= total_count { if current_id > start + MAX_SCAN_WINDOW { - env.events() - .publish(("scan_window_exhausted",), (start, current_id, collected)); + env.events().publish( + ("scan_window_exhausted",), + (start, current_id, collected, capped_limit), + ); next_cursor = current_id; break; } @@ -83,37 +89,39 @@ pub(crate) fn list_active_campaigns( (campaigns, next_cursor) } -pub(crate) fn get_campaigns_by_category( +fn get_campaigns_from_buckets( env: &Env, - category: Category, - offset: u32, + start: u32, limit: u32, -) -> soroban_sdk::Vec { + total: u32, + bucket_size: u32, + get_bucket: F, +) -> soroban_sdk::Vec +where + F: Fn(&Env, u32) -> soroban_sdk::Vec, +{ let mut campaigns = soroban_sdk::Vec::new(env); - if limit == 0 { - return campaigns; - } + let capped_limit = limit.min(crate::LIST_MAX_LIMIT); - let total = get_category_campaign_count(env, category); - if offset >= total { + if start >= total || capped_limit == 0 { return campaigns; } - let capped_limit = limit.min(crate::LIST_MAX_LIMIT); - let end = offset.saturating_add(capped_limit).min(total); + let end = start.saturating_add(capped_limit).min(total); + let mut position = start; - let mut position = offset; while position < end { - let bucket_idx = position / CATEGORY_CAMPAIGNS_BUCKET_SIZE; - let bucket = get_category_campaign_bucket(env, category, bucket_idx); - let bucket_start = bucket_idx * CATEGORY_CAMPAIGNS_BUCKET_SIZE; + let bucket_idx = position / bucket_size; + let bucket = get_bucket(env, bucket_idx); + let bucket_start = bucket_idx * bucket_size; let mut idx_in_bucket = position - bucket_start; let bucket_len = bucket.len(); while idx_in_bucket < bucket_len && position < end { - let campaign_id = bucket.get(idx_in_bucket).unwrap(); - if let Some(campaign) = get_campaign(env, campaign_id) { - campaigns.push_back(campaign); + if let Some(campaign_id) = bucket.get(idx_in_bucket) { + if let Some(campaign) = get_campaign(env, campaign_id) { + campaigns.push_back(campaign); + } } idx_in_bucket += 1; position += 1; @@ -121,7 +129,7 @@ pub(crate) fn get_campaigns_by_category( if idx_in_bucket >= bucket_len { position = if bucket_len == 0 { - bucket_start + CATEGORY_CAMPAIGNS_BUCKET_SIZE + bucket_start + bucket_size } else { bucket_start + bucket_len }; @@ -131,6 +139,23 @@ pub(crate) fn get_campaigns_by_category( campaigns } +pub(crate) fn get_campaigns_by_category( + env: &Env, + category: Category, + offset: u32, + limit: u32, +) -> soroban_sdk::Vec { + let total = get_category_campaign_count(env, category); + get_campaigns_from_buckets( + env, + offset, + limit, + total, + CATEGORY_CAMPAIGNS_BUCKET_SIZE, + |e, idx| get_category_campaign_bucket(e, category, idx), + ) +} + /// #534: jumps straight to the bucket containing `start` instead of reading /// every preceding bucket just to advance a counter, so paginating deep into /// a creator with many campaigns no longer costs one ledger read per skipped @@ -141,44 +166,15 @@ pub(crate) fn get_creator_campaigns( start: u32, limit: u32, ) -> soroban_sdk::Vec { - let capped_limit = limit.min(crate::LIST_MAX_LIMIT); let total = get_creator_campaign_count(env, &creator); - let mut campaigns = soroban_sdk::Vec::new(env); - - if start >= total || capped_limit == 0 { - return campaigns; - } - - let end = (start + capped_limit).min(total); - let mut position = start; - - while position < end { - let bucket_idx = position / CREATOR_CAMPAIGNS_BUCKET_SIZE; - let bucket = get_creator_campaign_bucket(env, &creator, bucket_idx); - let bucket_start = bucket_idx * CREATOR_CAMPAIGNS_BUCKET_SIZE; - let mut idx_in_bucket = position - bucket_start; - - let bucket_len = bucket.len(); - while idx_in_bucket < bucket_len && position < end { - if let Some(campaign_id) = bucket.get(idx_in_bucket) { - if let Some(campaign) = get_campaign(env, campaign_id) { - campaigns.push_back(campaign); - } - } - idx_in_bucket += 1; - position += 1; - } - - if idx_in_bucket >= bucket_len { - position = if bucket_len == 0 { - bucket_start + CREATOR_CAMPAIGNS_BUCKET_SIZE - } else { - bucket_start + bucket_len - }; - } - } - - campaigns + get_campaigns_from_buckets( + env, + start, + limit, + total, + CREATOR_CAMPAIGNS_BUCKET_SIZE, + |e, idx| get_creator_campaign_bucket(e, &creator, idx), + ) } /// Aggregates total raised, active campaign count, and total contributors @@ -187,6 +183,12 @@ pub(crate) fn get_creator_campaigns( /// paginates over) rather than the paginated query, since a creator's own /// campaign count is bounded by normal usage and the caller wants a /// complete aggregate, not a page. +/// +/// **Note:** `total_contributors` is a sum of the contributor counts of all +/// creator's campaigns. Because no registry of unique contributor addresses +/// is maintained per campaign/creator in storage, this value can double-count +/// contributors who support multiple campaigns by this creator. It represents +/// the total contribution events rather than the count of unique wallets. pub(crate) fn get_creator_stats(env: &Env, creator: Address) -> CreatorStats { let total = get_creator_campaign_count(env, &creator); @@ -203,7 +205,9 @@ pub(crate) fn get_creator_stats(env: &Env, creator: Address) -> CreatorStats { if campaign.is_active && !campaign.is_cancelled { active_campaigns += 1; } - total_raised += campaign.amount_raised; + if !campaign.is_cancelled { + total_raised += campaign.amount_raised; + } total_contributors += get_contributor_count(env, campaign_id); } } @@ -234,3 +238,80 @@ pub(crate) fn get_platform_stats(env: &Env) -> PlatformStats { scanned_up_to: total_campaigns, } } + +/// Returns a comprehensive platform report with all key metrics in a +/// single call (#541). Useful for admin dashboards and health checks. +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 platform_fee_bps = get_platform_fee(env); + let is_paused = env + .storage() + .instance() + .get(&crate::storage::AdminKey::Paused) + .unwrap_or(false) + || env + .storage() + .instance() + .get(&crate::storage::AdminKey::AutoPaused) + .unwrap_or(false); + + let mut total_contributors: u32 = 0; + for id in 1..=total_campaigns { + if get_campaign(env, id).is_some() { + total_contributors += get_contributor_count(env, id); + } + } + + PlatformReport { + total_campaigns, + active_campaigns, + total_raised, + total_contributors, + platform_fee_bps, + is_paused, + token: get_token(env), + } +} + +/// Returns the contributor's portfolio across all campaigns: for each +/// campaign the contributor has backed, returns the campaign ID, the +/// contribution amount, the campaign's current status, and whether a +/// refund is currently available (#539). +pub(crate) fn get_contributor_portfolio( + env: &Env, + contributor: Address, +) -> soroban_sdk::Vec<(u32, i128, String, bool)> { + let total_campaigns = get_campaign_count(env); + let mut portfolio = soroban_sdk::Vec::new(env); + + for id in 1..=total_campaigns { + if let Some(campaign) = get_campaign(env, id) { + let amount = get_contribution(env, id, &contributor); + if amount == 0 { + continue; + } + + let status = if campaign.is_cancelled { + "cancelled" + } else if campaign.funds_withdrawn { + "withdrawn" + } else if !campaign.is_active { + "inactive" + } else if campaign.is_verified { + "verified" + } else { + "active" + }; + + let refundable = campaign.is_cancelled + || (env.ledger().timestamp() > campaign.deadline + && campaign.amount_raised < campaign.funding_goal); + + portfolio.push_back((id, amount, String::from_str(env, status), refundable)); + } + } + + portfolio +} diff --git a/src/revenue.rs b/src/revenue.rs index da075fb1..aff5c23f 100644 --- a/src/revenue.rs +++ b/src/revenue.rs @@ -33,13 +33,14 @@ pub(crate) fn deposit_revenue(env: &Env, campaign_id: u32, amount: i128) -> Resu } bump_instance_ttl(env); - let token_addr = get_token(env); - let client = token::Client::new(env, &token_addr); - client.transfer(&campaign.creator, &env.current_contract_address(), &amount); let current_pool = get_revenue_pool(env, campaign_id); set_revenue_pool(env, campaign_id, current_pool + amount); + let token_addr = get_token(env); + let client = token::Client::new(env, &token_addr); + client.transfer(&campaign.creator, &env.current_contract_address(), &amount); + env.events() .publish(("revenue_deposited", campaign_id, campaign.creator), amount); @@ -126,11 +127,8 @@ pub(crate) fn claim_revenue( bump_instance_ttl(env); - // Transfer tokens BEFORE updating state to prevent balance wipe on failed transfer - let client = token_client(env); - client.transfer(&env.current_contract_address(), &contributor, &claimable); - - // Update state only after successful external interaction + // Update state before the token transfer (CEI pattern) so that a + // malicious token contract cannot re-enter and double-claim (#557). set_revenue_claimed(env, campaign_id, &contributor, already_claimed + claimable); // Track the running sum paid out to contributors, and the count of @@ -141,6 +139,10 @@ pub(crate) fn claim_revenue( set_contributor_revenue_claimants(env, campaign_id, claimants_so_far + 1); } + // Token transfer happens after all state updates (CEI pattern). + let client = token_client(env); + client.transfer(&env.current_contract_address(), &contributor, &claimable); + env.events().publish( ("revenue_claimed", campaign_id, contributor.clone()), claimable, @@ -179,7 +181,11 @@ pub(crate) fn claim_creator_revenue(env: &Env, campaign_id: u32) -> Result<(), E bump_instance_ttl(env); - // Transfer tokens BEFORE updating state to prevent balance wipe on failed transfer + // Update state before the token transfer (CEI pattern) so that a + // malicious token contract cannot re-enter and double-claim (#557). + set_creator_revenue_claimed(env, campaign_id, already_claimed + claimable); + + // Token transfer happens after all state updates (CEI pattern). let client = token_client(env); client.transfer( &env.current_contract_address(), @@ -187,8 +193,6 @@ pub(crate) fn claim_creator_revenue(env: &Env, campaign_id: u32) -> Result<(), E &claimable, ); - set_creator_revenue_claimed(env, campaign_id, already_claimed + claimable); - env.events().publish( ("creator_revenue_claimed", campaign_id, campaign.creator), claimable, diff --git a/src/storage.rs b/src/storage.rs index d2e22e1e..1f60a812 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -76,6 +76,11 @@ pub enum AdminKey { WithdrawReleaseDelayDays, /// Percentage of funds held in reserve (basis points). WithdrawReservePercentage, + /// Admin-configured delay (seconds) before a proposed token update can be + /// accepted, overriding the compiled-in `TOKEN_UPDATE_DELAY_SECS` default (#650). + TokenUpdateDelaySecs, + /// Per-category maximum funding goal cap, keyed by category string. + CategoryMaxGoalCap(soroban_sdk::String), } /// Keys for campaign records, indexes, and aggregate campaign counters. @@ -944,6 +949,23 @@ pub fn get_pending_token_release(env: &Env) -> Option { env.storage().instance().get(&AdminKey::PendingTokenRelease) } +/// Returns the configured token-update timelock delay in seconds, falling +/// back to `default` (the compiled-in `TOKEN_UPDATE_DELAY_SECS`) if the admin +/// has never overridden it (#650). +pub fn get_token_update_delay_secs(env: &Env, default: u64) -> u64 { + env.storage() + .instance() + .get(&AdminKey::TokenUpdateDelaySecs) + .unwrap_or(default) +} + +/// Stores the admin-configured token-update timelock delay in seconds. +pub fn set_token_update_delay_secs(env: &Env, delay_secs: u64) { + env.storage() + .instance() + .set(&AdminKey::TokenUpdateDelaySecs, &delay_secs); +} + // ── O(1) platform stat counters ─────────────────────────────────────────────── pub fn get_active_campaign_count(env: &Env) -> u32 { diff --git a/src/tests/mod.rs b/src/tests/mod.rs index 416e975b..c06debd9 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -6,9 +6,9 @@ mod test_benchmark; mod test_bookmarks; mod test_campaign_update; mod test_campaigns; -mod test_cap_interactions; mod test_cancel_after_goal_met; mod test_cancel_revenue_orphan; +mod test_cap_interactions; mod test_contributions; mod test_creator_buckets; mod test_lifecycle; diff --git a/src/tests/test_bookmarks.rs b/src/tests/test_bookmarks.rs index 1dc5d187..6ee808b4 100644 --- a/src/tests/test_bookmarks.rs +++ b/src/tests/test_bookmarks.rs @@ -139,3 +139,112 @@ fn test_saved_campaigns_are_per_wallet() { assert_eq!(client.get_saved_campaigns(&contributor1).len(), 1); assert_eq!(client.get_saved_campaigns(&contributor2).len(), 0); } + +#[test] +fn test_save_campaign_then_cancel() { + let (env, _admin, creator, contributor1, _c2, _token, _token_admin, client) = setup_env(); + + let id = client.create_campaign(&make_params( + creator.clone(), + String::from_str(&env, "Campaign"), + String::from_str(&env, "Desc"), + 1000, + 30, + Category::Learner, + false, + 0, + 0i128, + )); + + // Contributor bookmarks the campaign + client.save_campaign(&contributor1, &id); + let saved = client.get_saved_campaigns(&contributor1); + assert_eq!(saved.len(), 1); + assert_eq!(saved.get(0).unwrap(), id); + + // Creator cancels the campaign + client.cancel_campaign(&id); + + // Bookmarks still persist after cancellation (documented gap #667) + // Frontend/clients should filter cancelled campaigns from the UI + let saved_after_cancel = client.get_saved_campaigns(&contributor1); + assert_eq!(saved_after_cancel.len(), 1); + assert_eq!(saved_after_cancel.get(0).unwrap(), id); + + // Campaign is cancelled + let campaign = client.get_campaign(&id); + assert!(campaign.is_cancelled); + assert!(!campaign.is_active); +} + +#[test] +fn test_get_saved_returns_insertion_order_after_interleaved_add_remove_add() { + // Verifies that get_saved returns campaign ids in the order they were saved, + // even after a mid-list removal. The doc comment promises "in the order they + // were saved", which should hold after remove operations. + let (env, _admin, creator, contributor1, _c2, _token, _token_admin, client) = setup_env(); + + // Create three campaigns + let id1 = client.create_campaign(&make_params( + creator.clone(), + String::from_str(&env, "Campaign 1"), + String::from_str(&env, "Desc"), + 1000, + 30, + Category::Learner, + false, + 0, + 0i128, + )); + let id2 = client.create_campaign(&make_params( + creator.clone(), + String::from_str(&env, "Campaign 2"), + String::from_str(&env, "Desc"), + 1000, + 30, + Category::Learner, + false, + 0, + 0i128, + )); + let id3 = client.create_campaign(&make_params( + creator.clone(), + String::from_str(&env, "Campaign 3"), + String::from_str(&env, "Desc"), + 1000, + 30, + Category::Learner, + false, + 0, + 0i128, + )); + + // Save all three in order: [id1, id2, id3] + client.save_campaign(&contributor1, &id1); + client.save_campaign(&contributor1, &id2); + client.save_campaign(&contributor1, &id3); + + let saved = client.get_saved_campaigns(&contributor1); + assert_eq!(saved.len(), 3); + assert_eq!(saved.get(0).unwrap(), id1); + assert_eq!(saved.get(1).unwrap(), id2); + assert_eq!(saved.get(2).unwrap(), id3); + + // Remove the middle campaign (id2) + client.remove_saved_campaign(&contributor1, &id2); + + let saved_after_remove = client.get_saved_campaigns(&contributor1); + assert_eq!(saved_after_remove.len(), 2); + assert_eq!(saved_after_remove.get(0).unwrap(), id1); + assert_eq!(saved_after_remove.get(1).unwrap(), id3); + + // Re-add id2 - it should be appended at the end, not inserted back in its original position + client.save_campaign(&contributor1, &id2); + + let saved_after_readd = client.get_saved_campaigns(&contributor1); + assert_eq!(saved_after_readd.len(), 3); + // Order should reflect insertion order: id1, id3 (from before), then id2 (re-added) + assert_eq!(saved_after_readd.get(0).unwrap(), id1); + assert_eq!(saved_after_readd.get(1).unwrap(), id3); + assert_eq!(saved_after_readd.get(2).unwrap(), id2); +} diff --git a/src/tests/test_campaign_update.rs b/src/tests/test_campaign_update.rs index 15180645..3012914b 100644 --- a/src/tests/test_campaign_update.rs +++ b/src/tests/test_campaign_update.rs @@ -41,10 +41,12 @@ fn test_update_campaign_blocks_after_admin_verification() { fn test_update_campaign_emits_title_and_description() { let (env, _admin, creator, _, _, _, _, client) = setup_env(); + let orig_title = String::from_str(&env, "Original Title"); + let orig_desc = String::from_str(&env, "Original Description"); let campaign_id = client.create_campaign(&make_params( creator.clone(), - String::from_str(&env, "Original Title"), - String::from_str(&env, "Original Description"), + orig_title.clone(), + orig_desc.clone(), 1000, 30, Category::Educator, @@ -59,10 +61,11 @@ 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); + 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 +97,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")); + 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] diff --git a/src/tests/test_lifecycle.rs b/src/tests/test_lifecycle.rs index aa831a23..35baf2d1 100644 --- a/src/tests/test_lifecycle.rs +++ b/src/tests/test_lifecycle.rs @@ -377,13 +377,13 @@ fn test_verify_campaigns_extends_ttl_on_failure() { // 3. Verify the campaign successfully first. let ids = soroban_sdk::Vec::from_array(&env, [campaign_id]); - let first_res = client.verify_campaigns(&ids); - assert_eq!(first_res, 1); + let (verified, _) = client.verify_campaigns(&ids); + assert_eq!(verified.len(), 1); // Now try to verify the campaign again. // Since it's already verified, it will fail verification. - let second_res = client.try_verify_campaigns(&ids); - assert!(second_res.is_err()); // verification failed (AdminVerificationConflict error) + let (_, failed) = client.verify_campaigns(&ids); + assert_eq!(failed.len(), 1); // verification failed (AdminVerificationConflict error) // 4. Despite the failure, the voting state TTL should have been extended. let current_ledger = env.ledger().sequence(); diff --git a/src/tests/test_queries.rs b/src/tests/test_queries.rs index ba5879ab..30e2e5fd 100644 --- a/src/tests/test_queries.rs +++ b/src/tests/test_queries.rs @@ -196,7 +196,7 @@ fn test_get_creator_stats_returns_aggregates() { let stats = client.get_creator_stats(&creator); assert_eq!(stats.total_campaigns, 2); assert_eq!(stats.active_campaigns, 1); - assert_eq!(stats.total_raised, 700); + assert_eq!(stats.total_raised, 400); assert_eq!(stats.total_contributors, 3); } @@ -531,3 +531,76 @@ fn test_get_creator_campaigns_jumps_to_bucket_containing_start() { assert_eq!(tail.get(0).unwrap().id, bucket_size + 1); assert_eq!(tail.get(extra - 1).unwrap().id, total); } + +#[test] +fn test_list_campaigns_and_list_active_campaigns_boundary_agreement() { + let (env, _admin, creator, _c1, _c2, _token, _token_admin, client) = setup_env(); + + for _ in 0..5 { + client.create_campaign(&make_params( + creator.clone(), + String::from_str(&env, "Campaign"), + String::from_str(&env, "Desc"), + 1000, + 30, + Category::Learner, + false, + 0, + 0i128, + )); + } + + let total = client.get_campaign_count(); + + // Both functions should return empty when start == total_count + let list_at_boundary = client.list_campaigns(&total, &10); + let active_at_boundary = client.list_active_campaigns(&total, &10); + assert_eq!(list_at_boundary.len(), 0); + assert_eq!(active_at_boundary.0.len(), 0); + assert_eq!(active_at_boundary.1, 0); + + // Both should also return empty when start > total_count + let list_beyond_boundary = client.list_campaigns(&(total + 1), &10); + let active_beyond_boundary = client.list_active_campaigns(&(total + 1), &10); + assert_eq!(list_beyond_boundary.len(), 0); + assert_eq!(active_beyond_boundary.0.len(), 0); + assert_eq!(active_beyond_boundary.1, 0); +} + +#[test] +fn test_get_creator_stats_zero_campaigns() { + let (env, _admin, _creator, _c1, _c2, _token, _token_admin, client) = setup_env(); + let new_creator = Address::generate(&env); + + // Creator with no campaigns should return zeroed stats without panicking + let stats = client.get_creator_stats(&new_creator); + assert_eq!(stats.total_campaigns, 0); + assert_eq!(stats.active_campaigns, 0); + assert_eq!(stats.total_raised, 0); + assert_eq!(stats.total_contributors, 0); +} + +#[test] +fn test_get_platform_stats_after_initialization() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let token_admin = Address::generate(&env); + let token = env.register_stellar_asset_contract(token_admin.clone()); + + let contract_id = env.register_contract(None, ProofOfHeart); + let client = ProofOfHeartClient::new(&env, &contract_id); + + client.init(&admin, &token, &200); + + // Immediately after init, all counters should be zero + let stats = client.get_platform_stats(); + assert_eq!(stats.total_campaigns, 0); + assert_eq!(stats.active_campaigns, 0); + assert_eq!(stats.verified_campaigns, 0); + assert_eq!(stats.cancelled_campaigns, 0); + assert_eq!(stats.total_amount_raised, 0); + assert!(!stats.stats_are_partial); + assert_eq!(stats.scanned_up_to, 0); +} diff --git a/src/tests/test_regressions.rs b/src/tests/test_regressions.rs index 4042c339..090cb50b 100644 --- a/src/tests/test_regressions.rs +++ b/src/tests/test_regressions.rs @@ -714,6 +714,115 @@ fn test_is_campaign_creator_updates_after_transfer() { assert!(client.is_campaign_creator(&campaign_id, &receiver)); } +// ── #650 admin-configurable token update delay ──────────────────────────────── + +/// Issue #650 — with no override set, the effective delay is the compiled-in +/// default and proposing a token update still enforces it. +#[test] +fn test_token_update_delay_defaults_to_constant() { + let (_, _admin, _, _, _, _, _, client) = setup_env(); + assert_eq!( + client.get_token_update_delay_secs(), + TOKEN_UPDATE_DELAY_SECS + ); +} + +/// Issue #650 — the admin can shorten the timelock, and the new delay (not +/// the compiled-in default) is what `accept_token_update` enforces. +#[test] +fn test_set_token_update_delay_secs_shortens_timelock() { + let (env, admin, _, _, _, _, _, client) = setup_env(); + let new_token = setup_second_token(&env, &admin); + + let one_day = SECONDS_PER_DAY; + client.set_token_update_delay_secs(&admin, &one_day); + assert_eq!(client.get_token_update_delay_secs(), one_day); + + client.propose_token_update(&admin, &new_token); + + // Halfway through the shortened delay: still too early. + env.ledger().with_mut(|l| { + l.timestamp += one_day / 2; + }); + let result = client.try_accept_token_update(&admin); + assert_eq!(result.unwrap_err().unwrap(), Error::ValidationFailed); + + // Past the shortened (1-day) delay, well before the old 7-day default. + env.ledger().with_mut(|l| { + l.timestamp += one_day; + }); + client.accept_token_update(&admin); + assert_eq!(client.get_token(), new_token); +} + +/// Issue #650 — the admin can lengthen the timelock beyond the 7-day default. +#[test] +fn test_set_token_update_delay_secs_lengthens_timelock() { + let (env, admin, _, _, _, _, _, client) = setup_env(); + let new_token = setup_second_token(&env, &admin); + + let thirty_days = 30 * SECONDS_PER_DAY; + client.set_token_update_delay_secs(&admin, &thirty_days); + client.propose_token_update(&admin, &new_token); + + // Past the old 7-day default, but not the new 30-day delay. + env.ledger().with_mut(|l| { + l.timestamp += TOKEN_UPDATE_DELAY_SECS + 1; + }); + let result = client.try_accept_token_update(&admin); + assert_eq!(result.unwrap_err().unwrap(), Error::ValidationFailed); + + env.ledger().with_mut(|l| { + l.timestamp += thirty_days; + }); + client.accept_token_update(&admin); + assert_eq!(client.get_token(), new_token); +} + +#[test] +fn test_set_token_update_delay_secs_rejects_zero() { + let (_, admin, _, _, _, _, _, client) = setup_env(); + let result = client.try_set_token_update_delay_secs(&admin, &0u64); + assert_eq!(result.unwrap_err().unwrap(), Error::ValidationFailed); +} + +#[test] +fn test_set_token_update_delay_secs_rejects_above_max() { + let (_, admin, _, _, _, _, _, client) = setup_env(); + let too_long = 365 * SECONDS_PER_DAY + 1; + let result = client.try_set_token_update_delay_secs(&admin, &too_long); + assert_eq!(result.unwrap_err().unwrap(), Error::ValidationFailed); +} + +#[test] +fn test_set_token_update_delay_secs_non_admin_fails() { + let (env, _admin, _, _, _, _, _, client) = setup_env(); + let stranger = Address::generate(&env); + let result = client.try_set_token_update_delay_secs(&stranger, &SECONDS_PER_DAY); + assert_eq!(result.unwrap_err().unwrap(), Error::NotAuthorized); +} + +// ── #652 public views for constants.rs values ───────────────────────────────── + +#[test] +fn test_get_bps_denominator_matches_constant() { + let (_, _, _, _, _, _, _, client) = setup_env(); + assert_eq!(client.get_bps_denominator(), 10_000u32); +} + +// ── #653 bookmark Error discriminant lock ───────────────────────────────────── + +/// Issue #653 — `CampaignAlreadyBookmarked` and `CampaignNotBookmarked` are +/// the newest `Error` variants. Locks their exact discriminant values so a +/// careless future edit to the enum (e.g. inserting a variant above them) +/// fails this test instead of silently renumbering them, which would change +/// the on-the-wire error codes existing clients match against. +#[test] +fn test_bookmark_error_discriminants_are_locked() { + assert_eq!(Error::CampaignAlreadyBookmarked as u32, 44); + assert_eq!(Error::CampaignNotBookmarked as u32, 45); +} + // ── #475 list_active_campaigns scan window ──────────────────────────────────── #[test] diff --git a/src/tests/test_voting.rs b/src/tests/test_voting.rs index 86773b0f..0fd1ed74 100644 --- a/src/tests/test_voting.rs +++ b/src/tests/test_voting.rs @@ -315,8 +315,10 @@ fn test_verify_campaigns_extends_voting_state_ttl() { )); // Bulk verify the campaign - let count = client.verify_campaigns(&soroban_sdk::Vec::from_array(&env, [campaign_id])); - assert_eq!(count, 1); + let (verified, failed) = + client.verify_campaigns(&soroban_sdk::Vec::from_array(&env, [campaign_id])); + assert_eq!(verified.len(), 1); + assert_eq!(failed.len(), 0); // Verify campaign is verified (confirming it worked) let campaign = client.get_campaign(&campaign_id); @@ -368,10 +370,13 @@ fn test_verify_campaigns_partial_failure_returns_err() { 0i128, )); - // 999 does not exist — will produce CampaignNotFound + // 999 does not exist — will be returned in failed_ids let ids = soroban_sdk::Vec::from_array(&env, [campaign_id, 999u32]); - let res = client.try_verify_campaigns(&ids); - assert!(res.unwrap_err().is_ok()); // Err variant, inner Ok means contract error + let (verified, failed) = client.verify_campaigns(&ids); + assert_eq!(verified.len(), 1); + assert_eq!(verified.get(0).unwrap(), campaign_id); + assert_eq!(failed.len(), 1); + assert_eq!(failed.get(0).unwrap(), 999u32); } // ── verification via votes ────────────────────────────────────────────────────── diff --git a/src/tests/test_withdrawals.rs b/src/tests/test_withdrawals.rs index 9f0de24d..d308e541 100644 --- a/src/tests/test_withdrawals.rs +++ b/src/tests/test_withdrawals.rs @@ -362,8 +362,7 @@ fn test_set_vesting_params_validation_and_disabled_event() { let admin_in_topics: Address = soroban_sdk::FromVal::from_val(&env, &topics.get(1).unwrap()); assert_eq!(admin_in_topics, admin); - let data: () = soroban_sdk::FromVal::from_val(&env, &last_event.2); - assert_eq!(data, ()); + let _data: () = soroban_sdk::FromVal::from_val(&env, &last_event.2); } #[test] diff --git a/src/types.rs b/src/types.rs index d87a2a73..292fc559 100644 --- a/src/types.rs +++ b/src/types.rs @@ -120,6 +120,27 @@ pub struct PlatformStats { pub scanned_up_to: u32, } +/// Comprehensive platform report for admin dashboards, returning all key +/// metrics in a single call (#541). +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PlatformReport { + /// Total campaigns ever created. + pub total_campaigns: u32, + /// Campaigns currently active and not cancelled. + pub active_campaigns: u32, + /// Sum of `amount_raised` across all campaigns. + pub total_raised: i128, + /// Total number of distinct contributors across all campaigns. + pub total_contributors: u32, + /// Platform fee in basis points. + pub platform_fee_bps: u32, + /// Whether the contract is currently paused. + pub is_paused: bool, + /// The accepted token contract address. + pub token: Address, +} + /// Aggregate metrics for a single creator across all of their campaigns, /// used for creator-profile dashboards and indexer consumers (#519). #[contracttype] From 8f39166853a05511c82d3e213e0d8644e6c36143 Mon Sep 17 00:00:00 2001 From: AbuJulaybeeb Date: Tue, 4 Aug 2026 16:32:45 +0100 Subject: [PATCH 2/2] fix: silence unused_macros clippy warning for error_names --- src/errors.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/errors.rs b/src/errors.rs index 573ed967..84465929 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -1,3 +1,4 @@ +#![allow(unused_macros)] use soroban_sdk::contracterror; /// Represents a distinct error type that can occur within the contract.