Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion docs/AUTHORIZATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| --- | --- |
Expand Down
2 changes: 1 addition & 1 deletion docs/THREAT_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
104 changes: 104 additions & 0 deletions docs/TRUST_MODEL.md
Original file line number Diff line number Diff line change
@@ -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.*
137 changes: 137 additions & 0 deletions src/tests/test_admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading