Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
12 changes: 11 additions & 1 deletion EVENT_PAYLOADS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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**
1 change: 1 addition & 0 deletions docs/AUTHORIZATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
89 changes: 44 additions & 45 deletions docs/CAMPAIGN_LIFECYCLE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Campaign Lifecycle

## States
## Overview

```
Created → Active → Verified (optional) → Withdrawn (goal met)
Expand All @@ -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:

Expand All @@ -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`.
Expand Down Expand Up @@ -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:
Expand All @@ -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.
- **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.
48 changes: 0 additions & 48 deletions frontend/src/components/MilestoneProgressBar.tsx

This file was deleted.

17 changes: 0 additions & 17 deletions frontend/src/types/campaign.ts

This file was deleted.

2 changes: 2 additions & 0 deletions fuzz/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ target
corpus
artifacts
coverage
# Generated by `cargo fuzz` builds; not part of the committed crate state.
Cargo.lock
8 changes: 7 additions & 1 deletion src/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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);
}

Expand Down
37 changes: 35 additions & 2 deletions src/campaigns/cancel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(())
}
3 changes: 1 addition & 2 deletions src/campaigns/transfer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,14 @@ 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,
MaybePendingCreator::None => return Err(Error::NoTransferPending),
};
pending.require_auth();

require_not_paused(env)?;

bump_instance_ttl(env);
let old_creator = campaign.creator.clone();

Expand Down
Loading
Loading