diff --git a/README.md b/README.md index a1f14c2b..3dc87e38 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ This repository contains the **Soroban smart contract** that powers the on-chain ## Documentation +- Administrative trust model and security assumptions: `docs/TRUST_MODEL.md` - Authorization requirements for every public method: `docs/AUTHORIZATION.md` - Campaign lifecycle state machine: `docs/CAMPAIGN_LIFECYCLE.md` - Contribution cap semantics: `docs/CONTRIBUTION_CAP_POLICY.md` diff --git a/docs/AUTHORIZATION.md b/docs/AUTHORIZATION.md index 3503c186..3ee1bc2a 100644 --- a/docs/AUTHORIZATION.md +++ b/docs/AUTHORIZATION.md @@ -2,7 +2,7 @@ This contract uses Soroban `Address::require_auth()` checks to ensure only the correct party can call state-changing methods. -> Note: Some functions take an `Address` argument (e.g. `contributor`, `voter`, `admin`) and require that address to authorize the call. Others derive the authorized address from contract storage (e.g. `get_admin`) or from campaign state (e.g. `campaign.creator`). +> Note: Some functions take an `Address` argument (e.g. `contributor`, `voter`, `admin`) and require that address to authorize the call. Others derive the authorized address from contract storage (e.g. `get_admin`) or from campaign state (e.g. `campaign.creator`). For detailed information on administrative privileges and security assumptions, see [docs/TRUST_MODEL.md](TRUST_MODEL.md). | Public method | Who must authorize | | --- | --- | diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index 1fdd524a..8e9856a4 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -39,7 +39,7 @@ This is a **known limitation** of the current implementation. A robust fix would This is a **known limitation** of the current implementation. `set_vesting_params` is intentionally admin-gated (`assert_admin`) and rejects internally inconsistent values (`reserve_bps > BPS_DENOMINATOR`, `delay_days > 365`, or a nonzero reserve paired with a zero delay via `Error::InvalidVestingDelay`), but it does not, and currently cannot, protect a specific campaign's withdrawal terms from a later admin policy change, since vesting parameters are stored as a single global pair (`WithdrawReleaseDelayDays`, `WithdrawReservePercentage`) rather than snapshotted onto `Campaign` at creation time. **Risk Management**: -- **Trust assumption**: The admin key is already a fully trusted role in this contract (it also controls `platform_fee`, `creation_disabled`, campaign verification, and pausing) — this is consistent with, not an escalation beyond, the existing admin trust model. +- **Trust assumption**: The admin key is already a fully trusted role in this contract (it also controls `platform_fee`, `creation_disabled`, campaign verification, and pausing) — see [docs/TRUST_MODEL.md](TRUST_MODEL.md) for full documentation of the administrative trust model and operational security assumptions. - **Monitoring**: The `vesting_params_updated` / `vesting_disabled` events give integrators an on-chain signal to alert creators and contributors whenever the policy changes, so a sudden tightening ahead of expected withdrawals can be flagged. - **Future Improvements**: A future version could snapshot `delay_days`/`reserve_bps` onto each `Campaign` at creation (mirroring how `fee_override` already pins a per-campaign fee independent of the global `platform_fee`), so in-flight campaigns are unaffected by later global changes. diff --git a/docs/TRUST_MODEL.md b/docs/TRUST_MODEL.md new file mode 100644 index 00000000..48286fdd --- /dev/null +++ b/docs/TRUST_MODEL.md @@ -0,0 +1,104 @@ +# Trust Model & Administrative Security Assumptions + +This document describes the administrative trust model, privileged contract operations, security assumptions, and planned future security enhancements for the **ProofOfHeart** Soroban smart contract. + +--- + +## 1. Current Trust Model + +The ProofOfHeart protocol currently operates under a **single administrative authority** model. During contract initialization (`init`), a single Stellar/Soroban `Address` is designated as the protocol administrator (`admin`). + +### Core Characteristics + +- **Monolithic Privilege**: The administrative key holds full authority over protocol configuration, operational control, campaign verification, parameter tuning, token migration, and storage maintenance. +- **Single Point of Authority**: State-changing administrative functions verify the caller against the stored `admin` address using `assert_admin(&env, &admin)` or `require_auth()`. +- **Two-Step Admin Handover**: Changing the administrator address requires a two-step transfer workflow (`initiate_admin_transfer` -> `accept_admin_transfer`), preventing accidental loss of access to an invalid or mistyped address. + +--- + +## 2. Privileged Operations + +Administrative capabilities are divided into several key operational categories. Below is the comprehensive list of privileged methods protected by administrative authorization. + +### Operational & Emergency Controls + +| Operation | Description | Impact | +| --- | --- | --- | +| `pause` | Halts all non-administrative state-changing functions across the contract. | Freezes contributions, withdrawals, refunds, and voting during security incidents. | +| `unpause` | Restores normal protocol functionality and clears auto-pause status. | Re-enables public interactions. | +| `set_creation_disabled` | Toggles whether new campaigns can be created via `create_campaign`. | Gates new campaign intake independently of the global contract pause status. | + +### Campaign Verification & Governance + +| Operation | Description | Impact | +| --- | --- | --- | +| `verify_campaign` | Directly marks a single campaign as verified. | Bypasses community voting to verify a campaign immediately. | +| `verify_campaigns` | Batch-verifies up to 50 campaigns in a single invocation. | Bulk verification for operational efficiency. | +| `purge_voting_state` | Deletes stored vote records and aggregate voting state for completed or cancelled campaigns. | Reclaims contract instance/temporary storage rentals after voting finishes. | + +### Fee & Financial Parameters + +| Operation | Description | Impact | +| --- | --- | --- | +| `update_platform_fee` | Updates the global platform fee rate in basis points (capped at `1000` bps / 10%). | Alters the platform fee deducted from creator withdrawals for future withdrawals. | +| `set_campaign_fee_override` | Sets a per-campaign fee rate (in bps, max 10%) that overrides the global platform fee. | Customizes fee terms for specific campaigns. | +| `set_vesting_params` | Configures global withdrawal release delay (up to 365 days) and reserve percentage (up to 100%). | Alters fund vesting terms applied to subsequent campaign withdrawals. | + +### Protocol Configuration & Bounds + +| Operation | Description | Impact | +| --- | --- | --- | +| `set_voting_params` | Adjusts global minimum vote quorum and approval threshold (basis points). | Modifies community voting verification rules. | +| `set_min_voting_balance` | Sets the minimum token balance required for an address to cast a vote. | Modifies voting eligibility criteria. | +| `set_min_campaign_funding_goal` | Sets the global minimum funding goal allowed for new campaigns. | Constrains valid funding goal range for creation. | +| `set_max_campaign_funding_goal` | Sets the global maximum funding goal allowed for new campaigns. | Constrains valid funding goal range for creation. | +| `set_category_duration_cap` | Sets a maximum campaign duration for a specific campaign category. | Limits maximum runtime for new campaigns in that category. | +| `remove_category_duration_cap` | Removes the custom duration cap for a category, reverting to global limits. | Removes category-specific duration restrictions. | +| `set_category_voting_threshold` | Sets a custom approval threshold (bps) for a specific campaign category. | Overrides global voting threshold for that category. | +| `remove_category_voting_threshold` | Removes the custom voting threshold for a category. | Reverts category voting to global threshold. | + +### System Lifecycle & Maintenance + +| Operation | Description | Impact | +| --- | --- | --- | +| `propose_token_update` | Proposes changing the protocol payment token and starts a time delay window. | Initiates two-step token migration. | +| `accept_token_update` | Finalizes payment token update after the delay, requiring zero active campaigns and zero escrowed funds. | Changes the contract's accepted payment token address. | +| `cancel_token_update` | Cancels a pending proposed token update. | Aborts payment token migration. | +| `migrate` | Updates the stored contract version marker following code upgrades. | Synchronizes contract storage with newly deployed code versions. | +| `initiate_admin_transfer` | Nominates a new address to assume the admin role. | Begins two-step administrative handover. | +| `accept_admin_transfer` | Called by pending admin to finalize transfer of admin rights. | Completes handover of administrative authority. | +| `cancel_admin_transfer` | Cancels an in-flight administrative transfer. | Revokes pending administrative nomination. | + +--- + +## 3. Security Assumptions & Operational Risks + +### Key Security Assumptions + +1. **Trusted Administrator**: The current protocol implementation assumes that the account holding the `admin` key is trusted, secure, and acts in the best interest of protocol users. +2. **Key Security**: Operational security of the admin key (e.g. key storage, signing environment) is assumed to prevent unauthorized access or disclosure. + +### Risks Associated with Admin Authority + +Users and contributors should be aware of the operational risks inherent to the single-admin model: + +- **Admin Key Compromise**: If the single administrator key is compromised by a malicious actor, the attacker could pause protocol operations, modify global fee structures, set punitive vesting parameters, or force-verify unvalidated campaigns. +- **Key Loss / Inaccessibility**: If the administrator key is lost without initiating a transfer to a backup address, administrative functions (such as contract unpausing, fee adjustments, or migration) will become permanently inaccessible. +- **Centralized Parameter Discretion**: Because global parameter changes (such as fee updates or vesting policies) take effect immediately upon administrative invocation, existing participants rely on administrative discretion when parameter changes occur. + +> **User Advisory**: Participants should understand these single-administrator trust assumptions and associated operational risks before interacting with the protocol or depositing funds into campaigns. + +--- + +## 4. Future Improvements + +To progressively decentralize governance and minimize administrative trust assumptions, future protocol upgrades may introduce the following architectural enhancements: + +1. **Soroban Multisig Governance**: Replacing the single administrative `Address` with a multi-signature account contract (requiring $M$-of-$N$ consensus among distinct keyholders) for all administrative actions. +2. **Timelock Mechanisms**: Introducing on-chain timelock delays for sensitive parameter modifications (such as fee updates, vesting changes, or token migration), allowing users time to inspect proposed changes and exit if desired. +3. **Hardware-Backed Operational Security**: Requiring administrative interactions to originate from Hardware Security Modules (HSMs) or multi-party computation (MPC) key management solutions. +4. **Role Separation (Least Privilege Architecture)**: Decomposing the monolithic `admin` role into granular, purpose-built permissions (e.g., separating an emergency `Pauser` role, a `CampaignVerifier` role, and a `ParameterGovernor` role) to restrict blast radius in case of partial key compromise. + +--- + +*Note: The improvements described above are planned directions for future protocol iterations and are not implemented in the current contract release.* diff --git a/src/tests/test_admin.rs b/src/tests/test_admin.rs index c66f919f..245c9a70 100644 --- a/src/tests/test_admin.rs +++ b/src/tests/test_admin.rs @@ -369,6 +369,143 @@ fn test_token_swap_blocked_with_unrefunded_cancelled_campaign() { assert_eq!(client.get_token(), new_token_address); } +#[test] +fn test_token_swap_blocked_with_partial_refund_remaining_liabilities() { + let (env, admin, creator, contributor1, contributor2, _token, token_admin, client) = + setup_env(); + + let orig_token_address = client.get_token(); + let orig_token_client = soroban_sdk::token::Client::new(&env, &orig_token_address); + + token_admin.mint(&contributor1, &2000); + token_admin.mint(&contributor2, &2000); + + // 1. Deploy protocol & Create a campaign + let campaign_id = client.create_campaign(&make_params( + creator.clone(), + String::from_str(&env, "Partial Refund Campaign"), + String::from_str(&env, "Testing token update with outstanding refund liabilities"), + 2000, + 30, + Category::Educator, + false, + 0, + 0i128, + )); + client.verify_campaign(&campaign_id); + + // 2. Accept contributions from multiple contributors + client.contribute(&campaign_id, &contributor1, &500); + client.contribute(&campaign_id, &contributor2, &700); + + assert_eq!(client.get_total_raised_global(), 1200); + + // 3. Cancel the campaign + client.cancel_campaign(&campaign_id); + + // 4. Process a partial refund (only contributor1 claims) + client.claim_refund(&campaign_id, &contributor1); + assert_eq!(orig_token_client.balance(&contributor1), 2000); + + // 5. Leave contributor2's refund unclaimed (700 tokens still escrowed) + assert_eq!(client.get_total_raised_global(), 700); + + // 6. Propose a token update + let new_token_address = env.register_stellar_asset_contract(admin.clone()); + client.propose_token_update(&admin, &new_token_address); + + env.ledger().with_mut(|l| { + l.timestamp += TOKEN_UPDATE_DELAY_SECS + 1; + }); + + // 7. Attempt accept_token_update and assert it is rejected + let res = client.try_accept_token_update(&admin); + assert_eq!(res.unwrap_err().unwrap(), Error::ValidationFailed); + + // 8. Verify stored token address is unchanged (still original token) + assert_eq!(client.get_token(), orig_token_address); + + // 9. Verify refund claims continue to succeed using original token + let c2_balance_before = orig_token_client.balance(&contributor2); + client.claim_refund(&campaign_id, &contributor2); + assert_eq!( + orig_token_client.balance(&contributor2), + c2_balance_before + 700 + ); + + // 10. Verify no escrowed funds remain, and now accept_token_update succeeds + assert_eq!(client.get_total_raised_global(), 0); + let res2 = client.try_accept_token_update(&admin); + assert!(res2.is_ok()); + assert_eq!(client.get_token(), new_token_address); +} + +#[test] +fn test_token_swap_blocked_with_expired_unfunded_campaign_refund_liabilities() { + let (env, admin, creator, contributor1, contributor2, _token, token_admin, client) = + setup_env(); + + let orig_token_address = client.get_token(); + let orig_token_client = soroban_sdk::token::Client::new(&env, &orig_token_address); + + token_admin.mint(&contributor1, &2000); + token_admin.mint(&contributor2, &2000); + + // Create a campaign with funding goal 5000 and 10-day duration + let campaign_id = client.create_campaign(&make_params( + creator.clone(), + String::from_str(&env, "Expired Unfunded Campaign"), + String::from_str(&env, "Deadline passes without meeting goal"), + 5000, + 10, + Category::Publisher, + false, + 0, + 0i128, + )); + client.verify_campaign(&campaign_id); + + client.contribute(&campaign_id, &contributor1, &1000); + client.contribute(&campaign_id, &contributor2, &1500); + + // Advance time past deadline (10 days = 864,000 seconds) + env.ledger().with_mut(|l| { + l.timestamp += 10 * 86400 + 1; + }); + + // Cancel expired unfunded campaign so active_campaign_count -> 0 while refund liabilities remain + client.cancel_campaign(&campaign_id); + + // Process partial refund for contributor1 after deadline & cancellation + client.claim_refund(&campaign_id, &contributor1); + assert_eq!(orig_token_client.balance(&contributor1), 2000); + + // contributor2's 1500 tokens remain unclaimed + assert_eq!(client.get_total_raised_global(), 1500); + + let new_token_address = env.register_stellar_asset_contract(admin.clone()); + client.propose_token_update(&admin, &new_token_address); + + env.ledger().with_mut(|l| { + l.timestamp += TOKEN_UPDATE_DELAY_SECS + 1; + }); + + // Accept token update must be rejected because contributor2 has unclaimed refund + let res = client.try_accept_token_update(&admin); + assert_eq!(res.unwrap_err().unwrap(), Error::ValidationFailed); + assert_eq!(client.get_token(), orig_token_address); + + // contributor2 claims refund in original token + client.claim_refund(&campaign_id, &contributor2); + assert_eq!(orig_token_client.balance(&contributor2), 2000); + assert_eq!(client.get_total_raised_global(), 0); + + // Now accept_token_update succeeds + let res2 = client.try_accept_token_update(&admin); + assert!(res2.is_ok()); + assert_eq!(client.get_token(), new_token_address); +} + // ── initialisation & config ───────────────────────────────────────────────────── #[test]