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
The MilestoneX campaign contract contains 11 mutating entrypoints across three domains:
- Campaign Lifecycle (5 functions)
- Donor Operations (2 functions)
- Milestone Release (2 functions)
- 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 | 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
┌────────────────────┐
│ 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:
initializeonly succeeds before any prior initializationcancel_campaignalways succeeds before terminal state is reached (can cancel fromEnded)end_campaignonly works whileActiveorGoalReached- Deadline is enforced per-transaction: all mutations gate on
now < campaign.end_time
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()orrelease_milestone_multi_asset() - Release order is enforced: previous milestone must be
Releasedbefore current - No skipping: attempting to release milestone N while milestone N-1 is
Unlockedpanics - Milestone unlock is idempotent: re-reaching target does not re-emit
milestone_unlocked
Location: campaign/src/storage.rs:355–375
Global admin safety mechanism to block all contract mutations during emergency or upgrade scenarios.
- Key:
DataKey::Frozen(persistent, ~60-day TTL) - Type:
bool - Default:
false(contract not frozen untilfreeze()is called)
// 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:
- Creator authorization is validated first (fail-fast on auth)
- Freeze is checked second (fail-fast on global lock)
- Business logic gates come last (status, reentrancy)
- ✅ 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
| 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 |
Location: campaign/src/storage.rs:300–313
Prevent re-entrant call chains during cross-contract token transfers.
- Key:
DataKey::ReentrancyLock(temporary, ~7-day TTL) - Type:
bool(presence indicates lock held) - Behavior:
acquire_lock()panics withError::ReentrantCallif already present;release_lock()removes the key
- donate — Protect donation + milestone unlock + storage updates
- claim_refund — Protect multi-asset refund transfers
- release_milestone — Protect single-asset release
- release_milestone_multi_asset — Protect multi-asset release + accounting updates
// 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)
}- 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::ReentrantCallsurfaced to caller - CEI pattern in multi-asset — State written before transfers to prevent double-release via re-entrant call on same milestone
All events are immutable audit logs published to Stellar Horizon. See docs/events.md for full event schema.
| 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.
The following functions require campaign.creator.require_auth():
end_campaigncancel_campaignextend_deadlinerelease_milestone(via wrapper inlib.rs)release_milestone_multi_asset(via wrapper inlib.rs)freezeunfreezeupgrade
Rationale: Only the campaign creator should be able to control campaign lifecycle and fund release.
The following functions require the caller (donor) to call require_auth():
donate— Donor authorizes their donationclaim_refund— Donor authorizes refund claim
Rationale: Individual donors control their own contributions and refund claims.
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.
Every mutating entrypoint follows this pattern:
- Authorization gate first (fail-fast on invalid caller)
- Freeze gate second (fail-fast on global lock)
- Status/business logic gates third (campaign state validation)
- Storage mutations last (only after all gates pass)
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
ActiveorGoalReached, ensure no pending milestone releases - Optionally end campaign early via
end_campaign()
- If campaign is still
-
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
DataKeyenum variants (backwards compatibility) - If schema changed, add migration logic in new
initialize_v2()or equivalent
- Ensure new code can read old
-
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_upgradedevent (automatic on upgrade call)- Topics:
("campaign", "contract_upgraded") - Data includes old admin address, new WASM hash, timestamp
- Topics:
-
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
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 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) |
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) |
All entrypoint function signatures in campaign/src/lib.rs and submodules include doc comments that reference this matrix:
/// 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:
- Issue number(s) for ownership tracking
- One-line purpose statement
- Reference to
docs/state-machine.mdfor detailed security posture - Pre/post conditions (panics)
| 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) |
| 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 |
| 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) |
| Actor | Can... | Cannot... |
|---|---|---|
| Any donor | Claim refund (always, auth required) | Donate, release milestones |
| Creator | Freeze/unfreeze, upgrade | End campaign (already terminal), release milestones |
| 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 |
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 |
- 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