diff --git a/README.md b/README.md index e419963..32815f4 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,12 @@ - [**Changelog**](CHANGELOG.md) - Release notes and version history - [**Security Policy**](SECURITY.md) - Security guidelines and vulnerability reporting +## 📖 Technical Documentation + +- [**State Machine & Authorization Matrix**](docs/state-machine.md) - Complete reference for entrypoint security posture, authorization gates, freeze/reentrancy locks, and state transitions +- [**Event Schema**](docs/events.md) - All contract events, their topics, and data structures +- [**Deployment Guide**](docs/deployment.md) - Deployment workflow and network configuration + ## Contract Canonicalization Decision: **Option B (conservative)** — keep `campaign/` (`milestonex-campaign`) as the canonical crowdfunding contract for all new development, audits, deployments, and integrations. The `campaign/` implementation remains the authoritative contract for milestone flows, refunds, freeze/upgrade controls, reentrancy protection, typed errors, and dashboard analytics. diff --git a/campaign/src/contract.rs b/campaign/src/contract.rs index 189fbf7..c965ccc 100644 --- a/campaign/src/contract.rs +++ b/campaign/src/contract.rs @@ -9,15 +9,20 @@ use crate::types::{CampaignStatus, Error}; use crate::{validate_campaign_transition, MAX_DEADLINE_GAP_SECONDS}; use soroban_sdk::{panic_with_error, Env}; -/// Issue #212 – End the campaign early (before deadline). +/// Issue #212, #243 – End the campaign early (before deadline). +/// +/// **Authorization:** Creator must call `require_auth()`. +/// **Freeze Gate:** Panics with `Error::ContractFrozen` if contract is frozen. /// /// Transitions the campaign from `Active` or `GoalReached` to `Ended`. -/// Requires creator authorization. +/// +/// **See also:** `docs/state-machine.md` for campaign status transitions, authorization rules, +/// and complete entrypoint matrix. /// /// # Panics /// - `Error::NotInitialized` if campaign not initialized /// - `Error::Unauthorized` if caller is not the creator -/// - `Error::ContractFrozen` if contract is frozen (freeze invariant: all writes rejected) +/// - `Error::ContractFrozen` if contract is frozen /// - `Error::InvalidCampaignTransition` if campaign is already Ended or Cancelled pub fn end_campaign(env: &Env) { let mut campaign = @@ -40,15 +45,20 @@ pub fn end_campaign(env: &Env) { event::campaign_ended(env); } -/// Issue #214 – Cancel the campaign. +/// Issue #214, #243 – Cancel the campaign. +/// +/// **Authorization:** Creator must call `require_auth()`. +/// **Freeze Gate:** Panics with `Error::ContractFrozen` if contract is frozen. /// -/// Transitions the campaign from `Active`, `GoalReached`, or `Ended` to -/// `Cancelled`. Requires creator authorization. +/// Transitions the campaign from `Active`, `GoalReached`, or `Ended` to `Cancelled`. +/// All donors immediately become refund-eligible. +/// +/// **See also:** `docs/state-machine.md` for campaign status transitions and authorization matrix. /// /// # Panics /// - `Error::NotInitialized` if campaign not initialized /// - `Error::Unauthorized` if caller is not the creator -/// - `Error::ContractFrozen` if contract is frozen (freeze invariant: all writes rejected) +/// - `Error::ContractFrozen` if contract is frozen /// - `Error::InvalidCampaignTransition` if campaign is already Cancelled pub fn cancel_campaign(env: &Env) { let mut campaign = @@ -71,18 +81,20 @@ pub fn cancel_campaign(env: &Env) { event::campaign_cancelled(env, &campaign.creator); } -/// Issue #215 – Extend the campaign deadline. +/// Issue #215, #243 – Extend the campaign deadline. +/// +/// **Authorization:** Creator must call `require_auth()`. +/// **Freeze Gate:** Panics with `Error::ContractFrozen` if contract is frozen. /// /// Extends the campaign's `end_time` to a new future timestamp. -/// The new deadline cannot be more than ten years from the current ledger time; -/// this preserves the contract's time arithmetic invariants for status views, -/// refund windows, milestone release metadata, and campaign reports. -/// Requires creator authorization. +/// The new deadline cannot be more than ten years from current ledger time. +/// +/// **See also:** `docs/state-machine.md` for deadline validation rules and authorization matrix. /// /// # Panics /// - `Error::NotInitialized` if campaign not initialized /// - `Error::Unauthorized` if caller is not the creator -/// - `Error::ContractFrozen` if contract is frozen (freeze invariant: all writes rejected) +/// - `Error::ContractFrozen` if contract is frozen /// - `Error::InvalidEndTime` if `new_end_time <= current ledger timestamp` /// - `Error::InvalidEndTime` if `new_end_time` is more than ten years out /// - `Error::InvalidCampaignTransition` if campaign is not Active or GoalReached diff --git a/campaign/src/lib.rs b/campaign/src/lib.rs index b04f00a..3f4e18d 100644 --- a/campaign/src/lib.rs +++ b/campaign/src/lib.rs @@ -72,6 +72,12 @@ impl CampaignContract { /// - `Error::InvalidMilestoneCount` if milestone count is not 1-5 /// - `Error::InvalidMilestones` if milestones are not sorted ascending /// - `Error::MilestoneMismatch` if last milestone.target_amount != goal_amount + /// Issue #175, #194 – Initialize the campaign contract. + /// + /// **Authorization:** Creator must call `require_auth()`. + /// + /// **See also:** `docs/state-machine.md` for full authorization matrix, freeze/reentrancy gates, + /// and state transition rules for all entrypoints. pub fn initialize( env: Env, creator: soroban_sdk::Address, @@ -153,19 +159,18 @@ impl CampaignContract { Ok(()) } - /// Issue #194 – Donate to the campaign, enforcing campaign status. + /// Issue #194, #242, #243 – Donate to the campaign, enforcing campaign status. /// - /// Issue #242 – Reentrancy protection: acquires lock at entry, releases at exit. - /// Issue #243 – Authorization: `donor...()`. + /// **Authorization:** Donor must call `require_auth()`. + /// **Freeze Gate:** Panics with `Error::ContractFrozen` if contract is frozen. + /// **Reentrancy:** Acquires lock at entry, releases at exit. See `docs/state-machine.md`. /// /// Panics with `Error::CampaignNotActive` unless status is `Active` or `GoalReached`. /// Panics with `Error::CampaignEnded` if the current ledger timestamp is >= `end_time`, - /// regardless of whether status is still `Active` or `GoalReached`. This deadline - /// gate fires before any state mutation or storage TTL bump. + /// regardless of whether status is still `Active` or `GoalReached`. /// - /// Issue #195 – After updating raised_amount, loops over milestones and unlocks - /// any whose target_amount <= raised_amount and status == Locked. - /// Issue #198 – After donation, transitions to GoalReached if raised_amount >= goal_amount. + /// **See also:** `docs/state-machine.md` for full entrypoint authorization matrix, + /// freeze/reentrancy gates, and state transition diagrams. pub fn donate(env: Env, donor: Address, amount: i128, asset: AssetInfo) { // Issue #242 – Reentrancy protection: acquire lock acquire_lock(&env); @@ -392,7 +397,14 @@ impl CampaignContract { /// /// Issue #242 – Reentrancy protection: acquires lock at entry, releases at exit. /// Issue #243 – Authorization: `donor.require_auth()`. - /// Issue #244 – Balance verification: checks contract balance before each transfer. + /// Issue #211, #242, #243 – Claim a refund for donations. + /// + /// **Authorization:** Donor must call `require_auth()`. + /// **Freeze Gate:** Panics with `Error::ContractFrozen` if contract is frozen. + /// **Reentrancy:** Acquires lock at entry, releases at exit. + /// + /// **See also:** `docs/state-machine.md` for refund eligibility rules, freeze/reentrancy gates, + /// and complete authorization matrix. /// /// # Panics /// - `Error::NotInitialized` if campaign not initialized @@ -571,9 +583,15 @@ impl CampaignContract { /// Issue #246 – Upgrade the contract's WASM hash. /// + /// **Authorization:** Creator must call `require_auth()`. + /// **Freeze Gate:** Panics with `Error::ContractFrozen` if contract is frozen. + /// /// Only the admin (creator address stored at initialization) can call this. /// Emits `contract_upgraded` event on success. /// + /// **See also:** `docs/state-machine.md` for pre-upgrade migration checklist and + /// freeze/upgrade interaction rules. + /// /// # Panics /// - `Error::Unauthorized` if not called by the creator /// - `Error::NotInitialized` if campaign not yet initialized @@ -599,9 +617,14 @@ impl CampaignContract { /// Issue #246 – Freeze the contract, blocking all mutating operations. /// + /// **Authorization:** Creator must call `require_auth()`. + /// /// Only the admin (creator) can call this. /// While frozen, all write operations are rejected with `Error::ContractFrozen`. /// + /// **See also:** `docs/state-machine.md` for freeze module specification, freeze check pattern, + /// and complete freeze matrix showing which operations are blocked. + /// /// # Panics /// - `Error::Unauthorized` if not called by the creator /// - `Error::NotInitialized` if campaign not yet initialized @@ -619,8 +642,12 @@ impl CampaignContract { /// Issue #246 – Unfreeze the contract, re-enabling mutating operations. /// + /// **Authorization:** Creator must call `require_auth()`. + /// /// Only the admin (creator) can call this. /// + /// **See also:** `docs/state-machine.md` for freeze/unfreeze lifecycle and emergency procedures. + /// /// # Panics /// - `Error::Unauthorized` if not called by the creator /// - `Error::NotInitialized` if campaign not yet initialized diff --git a/campaign/src/multi_asset_release.rs b/campaign/src/multi_asset_release.rs index 57bc551..7c11ace 100644 --- a/campaign/src/multi_asset_release.rs +++ b/campaign/src/multi_asset_release.rs @@ -46,26 +46,29 @@ fn compute_asset_release( // ─── Main entrypoint ───────────────────────────────────────────────────────── -/// Issue #208 — Multi-asset milestone release +/// Issue #208, #242, #244 – Release milestone funds proportionally across all assets. /// -/// Releases milestone funds proportionally across every accepted asset. +/// **Authorization:** Creator must call `require_auth()` (via wrapper in `lib.rs`). +/// **Freeze Gate:** Checked before any mutation. +/// **Reentrancy:** Acquires lock at entry, releases at exit. +/// +/// Releases milestone funds proportionally across every accepted asset using the formula: +/// `per_asset_release = floor((asset_raised * milestone_release) / total_raised)` +/// +/// **See also:** `docs/state-machine.md` for milestone state transitions, multi-asset release logic, +/// and complete authorization matrix. /// /// **Precondition:** The caller (`#[contractimpl]` wrapper) MUST have already /// verified `creator.require_auth()` before calling this function. /// -/// Issue #242 – Reentrancy protection: acquires lock at entry, releases at exit. -/// Issue #244 – Balance verification: checks contract balance before each transfer. -/// /// Security properties: /// - Milestone must be in `Unlocked` state (exactly once). /// - Proportional math uses checked integer arithmetic — no overflows. /// - Status is written to storage BEFORE transfers (CEI pattern) so a /// re-entrant call on the same milestone index fails immediately. /// - Recipient must be non-zero (validated before any transfer). -/// - Per-asset actual balances are used, not stored estimates, so the -/// contract can never release more than it actually holds. -/// - Dust amounts below MIN_TRANSFER_AMOUNT are skipped rather than -/// causing the whole release to fail. +/// - Per-asset actual balances are used, not stored estimates. +/// - Dust amounts below MIN_TRANSFER_AMOUNT are skipped. pub fn release_milestone_multi_asset(env: &Env, milestone_index: u32, recipient: Address) { // Issue #242 – Reentrancy protection: acquire lock acquire_lock(env); diff --git a/campaign/src/release_milestone.rs b/campaign/src/release_milestone.rs index 47ca2b9..ab0050b 100644 --- a/campaign/src/release_milestone.rs +++ b/campaign/src/release_milestone.rs @@ -6,28 +6,20 @@ use crate::storage::{ use crate::types::{Error, MilestoneStatus}; use soroban_sdk::{panic_with_error, token, Address, Env}; -/// Issue #207 – `release_milestone` function +/// Issue #207, #242, #244 – Release funds for an unlocked milestone. /// -/// Releases funds for an unlocked milestone to the recipient. +/// **Authorization:** Creator must call `require_auth()` (via wrapper in `lib.rs`). +/// **Freeze Gate:** Panics with `Error::ContractFrozen` if contract is frozen. +/// **Reentrancy:** Acquires lock at entry, releases at exit. /// -/// **Precondition:** The caller (`#[contractimpl]` wrapper) MUST have already -/// verified `creator.require_auth()` before calling this function. -/// -/// Validates milestone status is `Unlocked`. -/// Prevents double release — `Released` milestones panic with `MilestoneAlreadyReleased`. -/// Prevents skipping milestones — previous milestone must be Released. -/// Transfers tokens from the campaign's primary (first) accepted asset to recipient. -/// Sets milestone status to `Released`. -/// Emits `milestone_released` event. -/// Respects the freeze flag — panics with `ContractFrozen` if frozen. +/// Releases funds for an unlocked milestone to the recipient using the campaign's +/// primary (first) accepted asset. /// -/// For campaigns accepting multiple assets, use `release_milestone_multi_asset` -/// instead, which distributes the release proportionally across all assets. +/// **See also:** `docs/state-machine.md` for milestone state transitions, release ordering, +/// and complete authorization matrix. /// -/// ## Security -/// -/// Issue #242 – Reentrancy protection: acquires lock at entry, releases at exit. -/// Issue #244 – Balance verification: checks contract balance before each transfer. +/// **Precondition:** The caller (`#[contractimpl]` wrapper) MUST have already +/// verified `creator.require_auth()` before calling this function. /// /// # Panics /// - `Error::NotInitialized` if campaign not initialized diff --git a/docs/state-machine.md b/docs/state-machine.md new file mode 100644 index 0000000..21fe699 --- /dev/null +++ b/docs/state-machine.md @@ -0,0 +1,439 @@ +# Contract State Machine & Entrypoint Authorization Matrix + +**Location:** `docs/state-machine.md` +**Companion Docs:** `docs/events.md`, `docs/deployment.md` +**Last Updated:** July 2026 +**Purpose:** Centralized reference for all contract state transitions, authorization rules, and mutation gates + +--- + +## Overview + +The MilestoneX campaign contract contains **11 mutating entrypoints** across three domains: + +1. **Campaign Lifecycle** (5 functions) +2. **Donor Operations** (2 functions) +3. **Milestone Release** (2 functions) +4. **Admin Controls** (2 functions) + +Each entrypoint enforces a consistent security posture: +- **Authorization gate** (creator or donor-specific `require_auth()`) +- **Freeze gate** (global contract freeze flag blocks all writes except freeze/unfreeze/initialize) +- **Status gate** (campaign state validation) +- **Reentrancy lock** (temporary storage lock for cross-contract calls) + +This document maps every entrypoint to these gates for rapid security review and contributor onboarding. + +--- + +## Entrypoint Authorization Matrix + +| Entrypoint | Auth | Freeze Gate | Status Gate(s) | Reentrancy Lock | Storage Mutations | Events | +|---|---|---|---|---|---|---| +| **initialize** | Creator `require_auth()` | ❌ None | Campaign not initialized, valid goal/deadline/assets/milestones | ❌ None | ✅ Campaign + Milestones (persistent) | `campaign_initialized` | +| **donate** | Donor `require_auth()` | ✅ Yes | Campaign `Active` OR `GoalReached` + timestamp < deadline + amount ≥ min_donation | ✅ Yes | ✅ Campaign, TotalRaised, AssetRaised, DonorData, DonorAssetDonation, DonationCount, UniqueDonorCount, Milestones (auto-unlock) | `donation_received`, `milestone_unlocked` (per unlocked), `campaign_goal_reached` (if goal reached) | +| **claim_refund** | Donor `require_auth()` | ✅ Yes | Campaign `Cancelled` OR `Ended` (unpaid goals) + within 30-day refund window + donor record exists + refund not already claimed | ✅ Yes | ✅ DonorData (mark refunded), token transfers per asset | `refund_claimed`, `asset_refund` (per asset) | +| **end_campaign** | Creator `require_auth()` | ✅ Yes | Campaign initialized + status ∈ {`Active`, `GoalReached`} | ❌ None | ✅ Campaign status → `Ended`, concluded_at_ledger | `campaign_ended` | +| **cancel_campaign** | Creator `require_auth()` | ✅ Yes | Campaign initialized + status ∈ {`Active`, `GoalReached`, `Ended`} | ❌ None | ✅ Campaign status → `Cancelled`, concluded_at_ledger | `campaign_cancelled` | +| **extend_deadline** | Creator `require_auth()` | ✅ Yes | Campaign initialized + status ∈ {`Active`, `GoalReached`} + new deadline > now + new deadline ≤ now + 10 years | ❌ None | ✅ Campaign end_time | `deadline_extended` | +| **release_milestone** | Creator `require_auth()` | ✅ Yes | Campaign initialized + milestone index valid + status `Unlocked` + prior milestones `Released` | ✅ Yes | ✅ Milestone status → `Released`, released_amount, released_at, released_to, ReleaseCount | `milestone_released` (per asset) | +| **release_milestone_multi_asset** | Creator `require_auth()` | ✅ Yes | Campaign initialized + milestone index valid + status `Unlocked` + recipient ≠ contract + total_raised > 0 | ✅ Yes | ✅ Milestone status → `Released`, AssetRaised (per-asset decrements), TotalRaised, ReleaseCount | `milestone_released` (per asset) | +| **freeze** | Creator `require_auth()` | ❌ None | Campaign initialized | ❌ None | ✅ Frozen flag → true | `contract_frozen` | +| **unfreeze** | Creator `require_auth()` | ❌ None | Campaign initialized | ❌ None | ✅ Frozen flag → false | `contract_unfrozen` | +| **upgrade** | Creator `require_auth()` | ✅ Yes | Campaign initialized | ❌ None | ❌ None (WASM update managed by Soroban runtime) | `contract_upgraded` | + +**Legend:** +- ✅ = Present / Required +- ❌ = Absent / Not applicable +- **Auth**: Authorization requirement (`require_auth()` caller) +- **Freeze Gate**: Blocked if `is_frozen(&env) == true`? +- **Status Gate**: Campaign state preconditions +- **Reentrancy Lock**: Temporary storage mutex to prevent cross-contract re-entrancy +- **Storage Mutations**: Which persistent or temporary storage keys are written +- **Events**: Events published to Stellar Horizon + +--- + +## Campaign Status Transitions + +``` + ┌────────────────────┐ + │ Initialize │ + │ (Creator auth) │ + └─────────┬──────────┘ + │ + ┌───────────▼──────────┐ + │ Active (initial) │ + │ Accepts donations │ + └───────────┬──────────┘ + │ + ┌───────────────┼──────────────────┐ + │ │ │ + │ │ (donate reaches │ + │ │ goal_amount) │ + ▼ ▼ │ + ┌──────────────┐ ┌──────────────┐ │ + │ Cancelled │ │ GoalReached │ │ + │ (Creator) │ │ (Auto-trnsn) │ │ + │ Refund open │ │ Accepts ∅ │ │ + │ 30 days │ │ donations │ │ + └──────────────┘ └──────┬───────┘ │ + │ │ + ┌───────────┴────────────┐ │ + │ (deadline passes, │ │ + │ or end_campaign) │ │ + ▼ ▼ ▼ + ┌──────────────┐ ┌──────────────┐ + │ Cancelled │─────────│ Ended │ + │ │ (admin) │ │ + │ Refund open │ │ Refund open │ + │ │ │ (30 days, if │ + │ │ │ goal unmet) │ + └──────────────┘ └──────────────┘ + +Terminal States: Ended, Cancelled +Accepting Donations: Active, GoalReached (until deadline) +Refund-Eligible: Cancelled (always), Ended (if goal not reached) +``` + +**Key Invariants:** +- `initialize` only succeeds before any prior initialization +- `cancel_campaign` always succeeds before terminal state is reached (can cancel from `Ended`) +- `end_campaign` only works while `Active` or `GoalReached` +- Deadline is enforced per-transaction: all mutations gate on `now < campaign.end_time` + +--- + +## Milestone Status Transitions + +``` +Locked ─────────────► Unlocked ────────────► Released + (auto-unlock (explicit, + on donation creator + reaching only) + target) +``` + +**Key Invariants:** +- Milestone auto-unlocks when `campaign.raised_amount >= milestone.target_amount` +- Manual release requires `release_milestone()` or `release_milestone_multi_asset()` +- Release order is enforced: previous milestone must be `Released` before current +- No skipping: attempting to release milestone N while milestone N-1 is `Unlocked` panics +- Milestone unlock is **idempotent**: re-reaching target does not re-emit `milestone_unlocked` + +--- + +## Freeze Module Specification + +**Location:** `campaign/src/storage.rs:355–375` + +### Purpose +Global admin safety mechanism to block all contract mutations during emergency or upgrade scenarios. + +### Data Storage +- **Key:** `DataKey::Frozen` (persistent, ~60-day TTL) +- **Type:** `bool` +- **Default:** `false` (contract not frozen until `freeze()` is called) + +### Freeze Check Pattern +```rust +// Every mutating entrypoint (except freeze/unfreeze/initialize) includes: +if is_frozen(&env) { + panic_with_error!(&env, Error::ContractFrozen); +} +``` + +This check fires **after** authorization but **before** any state mutations, ensuring: +1. Creator authorization is validated first (fail-fast on auth) +2. Freeze is checked second (fail-fast on global lock) +3. Business logic gates come last (status, reentrancy) + +### Freeze Invariants +- ✅ **Freeze is "sticky"** while active — all writes rejected until `unfreeze()` is called +- ✅ **Freeze/unfreeze are self-operations** — can be called while frozen (otherwise admin locked out) +- ✅ **Initialize is never frozen** — contract can be initialized after being frozen +- ✅ **Atomicity** — freeze state written to persistent storage; survives ledger sequence +- ✅ **TTL managed** — automatically refreshed to ~60 days on each access + +### Freeze Matrix +| Operation | Blocked When Frozen? | Rationale | +|---|---|---| +| initialize | ❌ No | Pre-initialization; contract state doesn't yet exist | +| donate | ✅ Yes | Mutation; prevent donations during emergency | +| claim_refund | ✅ Yes | Mutation; prevent refund claims during freeze | +| end_campaign | ✅ Yes | Mutation; campaign state locked | +| cancel_campaign | ✅ Yes | Mutation; campaign state locked | +| extend_deadline | ✅ Yes | Mutation; campaign metadata locked | +| release_milestone | ✅ Yes | Mutation; prevents unauthorized fund release | +| release_milestone_multi_asset | ✅ Yes | Mutation; prevents unauthorized fund release | +| freeze | ❌ No | Self-operation; must succeed to lock contract | +| unfreeze | ❌ No | Self-operation; must succeed to unlock contract | +| upgrade | ✅ Yes | Mutation (WASM update); prevent upgrade during freeze | + +--- + +## Reentrancy Protection + +**Location:** `campaign/src/storage.rs:300–313` + +### Purpose +Prevent re-entrant call chains during cross-contract token transfers. + +### Data Storage +- **Key:** `DataKey::ReentrancyLock` (temporary, ~7-day TTL) +- **Type:** `bool` (presence indicates lock held) +- **Behavior:** `acquire_lock()` panics with `Error::ReentrantCall` if already present; `release_lock()` removes the key + +### Protected Entrypoints +1. **donate** — Protect donation + milestone unlock + storage updates +2. **claim_refund** — Protect multi-asset refund transfers +3. **release_milestone** — Protect single-asset release +4. **release_milestone_multi_asset** — Protect multi-asset release + accounting updates + +### Patterns +```rust +// Standard acquire-release pattern: +pub fn donate(env: Env, donor: Address, amount: i128, asset: AssetInfo) { + acquire_lock(&env); // ← Panics if already locked + + // ... authorization, status gates ... + + // ... token transfers, storage mutations ... + + release_lock(&env); // ← Always called (explicit, not try-finally) +} +``` + +### Key Properties +- **Per-call mutex** — Lock is acquired at entry, released at exit +- **Temporary storage** — Auto-expires after ~7 days (contract lifetime is much longer, so not critical for cleanup) +- **Panic on conflict** — `Error::ReentrantCall` surfaced to caller +- **CEI pattern in multi-asset** — State written before transfers to prevent double-release via re-entrant call on same milestone + +--- + +## Event Emission Checklist + +All events are **immutable audit logs** published to Stellar Horizon. See `docs/events.md` for full event schema. + +### Entrypoint → Events + +| Entrypoint | Events Emitted | Conditions | +|---|---|---| +| initialize | `campaign_initialized` (1x) | Once per successful initialization | +| donate | `donation_received` (1x) | Every donation; `milestone_unlocked` (0..n) if milestones unlocked; `campaign_goal_reached` (1x) if goal reached on this call | +| claim_refund | `refund_claimed` (1x) + `asset_refund` (0..n) | 1x refund claimed per donor; 1x per asset refunded (multi-asset campaigns may emit multiple) | +| end_campaign | `campaign_ended` (1x) | Once per end call | +| cancel_campaign | `campaign_cancelled` (1x) | Once per cancel call | +| extend_deadline | `deadline_extended` (1x) | Once per extend call | +| release_milestone | `milestone_released` (1x) | Once per release; amount clamped to contract balance | +| release_milestone_multi_asset | `milestone_released` (0..n) | Per asset released (dust amounts < 1 base unit skipped) | +| freeze | `contract_frozen` (1x) | Once per freeze call | +| unfreeze | `contract_unfrozen` (1x) | Once per unfreeze call | +| upgrade | `contract_upgraded` (1x) | Once per upgrade call | + +**Important:** Events are **idempotent** in the sense that re-calling a function may re-emit the same event structure, but downstream systems should use **transaction hash + event index** for deduplication, not just event topic. + +--- + +## Authorization & Access Control + +### Creator-Only Functions +The following functions require `campaign.creator.require_auth()`: +- `end_campaign` +- `cancel_campaign` +- `extend_deadline` +- `release_milestone` (via wrapper in `lib.rs`) +- `release_milestone_multi_asset` (via wrapper in `lib.rs`) +- `freeze` +- `unfreeze` +- `upgrade` + +**Rationale:** Only the campaign creator should be able to control campaign lifecycle and fund release. + +### Donor-Specific Functions +The following functions require the caller (donor) to call `require_auth()`: +- `donate` — Donor authorizes their donation +- `claim_refund` — Donor authorizes refund claim + +**Rationale:** Individual donors control their own contributions and refund claims. + +### Public/Read-Only Functions +The following functions require no authorization: +- All `get_*` view functions (campaign status, total raised, donor info, milestone info) + +**Rationale:** These are read-only and do not mutate state. + +### Authorization Check Order +Every mutating entrypoint follows this pattern: +1. **Authorization gate first** (fail-fast on invalid caller) +2. **Freeze gate second** (fail-fast on global lock) +3. **Status/business logic gates third** (campaign state validation) +4. **Storage mutations last** (only after all gates pass) + +--- + +## Pre-Upgrade Contract Migration Checklist + +When deploying a new contract WASM hash via `upgrade()`: + +- [ ] **Freeze the contract** (call `freeze()` before initiating upgrade deployment) + - Prevents concurrent donations/refunds during upgrade window + - Gives operators a clean "pause" point for migration scripting + +- [ ] **Verify all milestones released or campaign not Active** + - If campaign is still `Active` or `GoalReached`, ensure no pending milestone releases + - Optionally end campaign early via `end_campaign()` + +- [ ] **Backup storage state** (operator responsibility, not contract-enforced) + - Snapshot all persistent storage keys before deploying new WASM + - Save campaign data, milestone records, donor records, and asset accounting + +- [ ] **Test new WASM against storage schema** + - Ensure new code can read old `DataKey` enum variants (backwards compatibility) + - If schema changed, add migration logic in new `initialize_v2()` or equivalent + +- [ ] **Deploy new WASM** (call `upgrade(new_wasm_hash)`) + - Contract stays frozen during deployment; caller auth check still enforced + - Soroban runtime updates the contract code atomically + +- [ ] **Emit `contract_upgraded` event** (automatic on upgrade call) + - Topics: `("campaign", "contract_upgraded")` + - Data includes old admin address, new WASM hash, timestamp + +- [ ] **Unfreeze the contract** (call `unfreeze()`) + - Restore normal operations; donors can resume donations and refunds + +- [ ] **Monitor first few operations post-upgrade** + - Watch for any panic/error spikes in new code paths + - Validate event emission matches expected schema in `docs/events.md` + +--- + +## Storage & TTL Management + +### Persistent Storage (Campaign Lifetime) +All persistent keys are bumped to **~60-day TTL** on every access: + +| Key | Type | Bumped On | Purpose | +|---|---|---|---| +| `CampaignData` | Struct | Every read/write | Campaign metadata + status | +| `MilestoneData(u32)` | Vec | Per-milestone ops | Milestone targets, release status | +| `DonorData(Address)` | Struct | Donation, refund | Donor contribution history | +| `TotalRaised` | i128 | Donation, release | Global funding counter | +| `AssetRaised(Address)` | i128 | Donation, release | Per-asset funding (for proportional release math) | +| `DonorAssetDonation(Address, Address)` | i128 | Donation | Per-donor per-asset contribution (for pro-rata refunds) | +| `DonationCount` | u64 | Donation | Total donations accepted | +| `UniqueDonorCount` | u32 | Donation | Distinct donor count | +| `ReleaseCount` | u64 | Release | Total releases completed | +| `Frozen` | bool | Freeze/unfreeze | Global freeze flag | + +### Temporary Storage (Short-lived) +Temporary keys have **~7-day TTL** and are used for transient state: + +| Key | Type | Expires After | Purpose | +|---|---|---|---| +| `ReentrancyLock` | bool | ~7 days | Per-call mutex (released after function exit) | +| `ContractStatus` | u32 | ~7 days | Transient campaign status flag (rarely used) | + +--- + +## Testing Entrypoints by Issue Number + +Each entrypoint is tracked in the GitHub issue system. Test files in `campaign/src/test/` validate both success and failure paths: + +| Entrypoint | Primary Issue(s) | Test File(s) | +|---|---|---| +| initialize | #175, #194 | `integration_tests.rs` (success), `negative_path_tests.rs` (validation) | +| donate | #194, #195, #198, #242, #243 | `integration_tests.rs`, `invariant_tests.rs` | +| claim_refund | #211, #242, #243 | `claim_refund_tests.rs`, `refund_eligibility_tests.rs` | +| end_campaign | #212, #243 | `concluded_ledger_tests.rs`, `negative_path_tests.rs` | +| cancel_campaign | #214, #243 | `concluded_ledger_tests.rs`, `negative_path_tests.rs` | +| extend_deadline | #215, #243 | `negative_path_tests.rs` | +| release_milestone | #207, #242, #244 | `release_milestone_tests.rs` | +| release_milestone_multi_asset | #208, #242, #244 | `release_milestone_tests.rs` | +| freeze | #246 | `negative_path_tests.rs` (freeze guard tests) | +| unfreeze | #246 | `negative_path_tests.rs` (freeze guard tests) | +| upgrade | #246 | `negative_path_tests.rs` (freeze guard tests) | + +--- + +## Inline Documentation Cross-References + +All entrypoint function signatures in `campaign/src/lib.rs` and submodules include doc comments that reference this matrix: + +```rust +/// Issue #207 – `release_milestone` function +/// +/// See `docs/state-machine.md` for authorization matrix and freeze/reentrancy gates. +pub fn release_milestone(env: Env, milestone_index: u32, recipient: Address) { ... } +``` + +**Pattern:** Each entrypoint doc comment should include: +1. Issue number(s) for ownership tracking +2. One-line purpose statement +3. **Reference to `docs/state-machine.md` for detailed security posture** +4. Pre/post conditions (panics) + +--- + +## Quick Reference: "Who can do what, when?" + +### Campaign Active & Goal Not Reached +| Actor | Can... | Cannot... | +|---|---|---| +| Any donor | Donate (auth required) | End campaign, release milestones, claim refund | +| Creator | End campaign, cancel campaign, extend deadline, release milestones, freeze/unfreeze, upgrade | Donate (different caller) | + +### Campaign GoalReached (Goal Met) +| Actor | Can... | Cannot... | +|---|---|---| +| Any donor | Donate until deadline (auth required) | Claim refund, release milestones | +| Creator | End campaign, cancel campaign, extend deadline, release milestones, freeze/unfreeze, upgrade | Donate | + +### Campaign Ended (Deadline Passed or Ended Early) +| Actor | Can... | Cannot... | +|---|---|---| +| Any donor | Claim refund (if within 30-day window + goal not met, auth required) | Donate, release milestones | +| Creator | Cancel campaign, freeze/unfreeze, upgrade | Extend deadline, release milestones (must end first) | + +### Campaign Cancelled (Creator Action) +| Actor | Can... | Cannot... | +|---|---|---| +| Any donor | Claim refund (always, auth required) | Donate, release milestones | +| Creator | Freeze/unfreeze, upgrade | End campaign (already terminal), release milestones | + +### Contract Frozen (Admin Lock) +| Actor | Can... | Cannot... | +|---|---|---| +| Any donor | (No mutations) | Donate, claim refund | +| Creator | Unfreeze (always), initialize new contract | Donate (as donor), release milestones, end/cancel campaign, extend deadline, upgrade | + +--- + +## Appendix: Error Codes & Meanings + +See `campaign/src/types.rs` for the canonical error enum. Key codes for state machine validation: + +| Code | Name | Trigger | Recovery | +|---|---|---|---| +| 1 | `AlreadyInitialized` | `initialize()` called twice | Create a new contract instance | +| 2 | `NotInitialized` | Any mutation before `initialize()` | Call `initialize()` first | +| 3 | `Unauthorized` | Caller not creator/donor | Provide correct authority | +| 4 | `CampaignEnded` | Donation past deadline | Wait for refund window or retry before deadline | +| 5 | `CampaignNotActive` | Status not Active/GoalReached | Cancel campaign or end campaign first if intended | +| 20 | `InvalidMilestones` | Milestones not strictly ascending | Initialize with valid milestone sequence | +| 22 | `InvalidCampaignTransition` | Invalid status state change | Transition only follows valid path (see diagram) | +| 50 | `RefundNotPermitted` | Campaign not Cancelled/Ended or goal met | End or cancel campaign first | +| 60 | `ReentrantCall` | Re-entrant mutation detected | Likely cross-contract call issue; retry transaction | +| 80 | `ContractFrozen` | Contract is frozen | Call `unfreeze()` to re-enable mutations | + +--- + +## References + +- **docs/events.md** — Event schema and topic structure +- **docs/deployment.md** — Deployment workflow and bootstrap steps +- **campaign/src/types.rs** — Error enum and state definitions +- **campaign/src/storage.rs** — Storage layer and freeze/lock implementation +- **campaign/src/lib.rs** — Entrypoint definitions and test module