Proof-of-Impact Funding Protocol (PIFP) — Trust-minimized conditional funding on Stellar/Soroban.
- System Overview
- Component Architecture
- Data Model
- Access Control (RBAC)
- Core Flows
- Storage Design
- Oracle Service Architecture
- Data Dictionary
- Error Catalogue
- Threat Model
- Security Properties
- Known Limitations & Future Work
- Deployment Checklist
PIFP replaces trust-based donations with cryptographic accountability. Funds are locked inside a Soroban smart contract and can only be released when a designated Oracle submits a proof hash that matches the one registered at project creation.
For detailed function signatures, parameters, and CLI examples, please see the Smart Contract API Reference.
┌──────────────┐ register_project ┌─────────────────────────┐
│ ProjectManager│ ───────────────────────► │ │
└──────────────┘ │ PifpProtocol │
│ (Soroban Contract) │
┌──────────────┐ deposit │ │
│ Donor │ ───────────────────────► │ • RBAC │
└──────────────┘ │ • Project Registry │
│ • Proof Verification │
┌──────────────┐ verify_and_release │ • Fund Release │
│ Oracle │ ───────────────────────► │ │
└──────────────┘ └─────────────────────────┘
│
┌───────────▼──────────────┐
│ Stellar Token Contract │
│ (SAC / custom asset) │
└──────────────────────────┘
Key properties:
- Non-custodial — funds live in the contract, never in a third-party wallet.
- Permissioned writes — only addresses with the correct RBAC role may mutate state.
- Immutable project config — goal, token, deadline, and proof hash are set once and never changed.
- Event-driven audit trail — every role change and fund movement emits an on-chain event.
contracts/pifp_protocol/src/
├── lib.rs — Public entry points (contract interface)
├── errors.rs — Error catalogue (#[contracterror] enum with documented variants)
├── rbac.rs — Role-Based Access Control
├── storage.rs — Persistent & instance storage helpers + TTL management
├── types.rs — Shared data types (Project, ProjectConfig, ProjectState, Role)
├── events.rs — On-chain event emission helpers
├── invariants.rs — Invariant assertions used in tests
├── test.rs — Unit & integration tests
├── test_errors.rs — Comprehensive error-path tests
└── fuzz_test.rs — Property-based fuzz tests (proptest)
For detailed function signatures, parameters, and CLI examples for
lib.rs, please see the Smart Contract API Reference.
The single entry point file. Every public fn here is a Soroban contract method callable by transactions. Responsibilities:
- Delegates RBAC checks to
rbac.rsbefore any mutation. - Delegates storage reads/writes to
storage.rs. - Emits events for off-chain indexers.
Manages the role hierarchy and enforces authorization. All role data is stored in persistent storage under RbacKey::Role(address).
Abstracts all env.storage() calls behind typed helpers. Manages TTL bumping to prevent ledger entry expiry.
Defines ProjectConfig (immutable, written once) and ProjectState (mutable, updated on deposits/verification). The split reduces write costs on high-frequency operations.
| Field | Type | Description |
|---|---|---|
id |
u64 |
Auto-incremented unique identifier |
creator |
Address |
Address that registered the project |
token |
Address |
Stellar token contract address |
goal |
i128 |
Target funding amount (must be > 0) |
proof_hash |
BytesN<32> |
Expected proof artifact hash (e.g. IPFS CID digest) |
deadline |
u64 |
Ledger timestamp by which work must complete |
| Field | Type | Description |
|---|---|---|
balance |
i128 |
Current funded amount (never < 0) |
status |
ProjectStatus |
Lifecycle state (see below) |
[Funding] ──deposit──► [Funding] (balance increases, status unchanged)
│
├──verify_and_release──► [Completed] (proof matches, funds releasable)
│
└──deadline passed ──► [Expired] (triggered via `expire_project` entry point)
[Active] ──verify_and_release──► [Completed]
[Completed] ──(any)──► PANIC (MilestoneAlreadyReleased)
[Expired] ──(any)──► PANIC (ProjectNotFound)
Valid forward transitions only — status can never regress.
SuperAdmin
│
├── Admin — manage roles, configure protocol parameters
├── Oracle — call verify_and_release; trigger fund releases
├── Auditor — read-only observer (off-chain checks, no on-chain gate)
└── ProjectManager — register and manage own projects
| Caller Role | Can Grant | Cannot Grant |
|---|---|---|
| SuperAdmin | Any role | — |
| Admin | Admin, Oracle, Auditor, ProjectManager | SuperAdmin |
| Others | — | Anything |
- Single SuperAdmin — stored separately at
RbacKey::SuperAdmin. Can only be changed viatransfer_super_admin. - No self-demotion —
revoke_rolecannot be called on the SuperAdmin address; usetransfer_super_admin. - One role per address — granting a new role to an address that already holds one replaces it.
- Immutable init —
initcan be called exactly once; subsequent calls panic withAlreadyInitialized.
| Entry Point | Allowed Roles |
|---|---|
init |
Any (first caller becomes SuperAdmin) |
grant_role |
SuperAdmin, Admin (SuperAdmin only for SuperAdmin grant) |
revoke_role |
SuperAdmin, Admin |
transfer_super_admin |
SuperAdmin only |
register_project |
SuperAdmin, Admin, ProjectManager |
set_oracle |
SuperAdmin, Admin |
verify_and_release |
Oracle only (read from storage) |
deposit |
Any address (no RBAC gate) |
expire_project |
Any address (no RBAC gate) |
get_project |
Any address (read-only) |
role_of / has_role |
Any address (read-only) |
sequenceDiagram
participant Creator
participant Contract as PIFP Contract
participant Storage as Persistent Storage
Creator->>Contract: register_project(creator, tokens, goal, proof_hash, deadline, metadata_uri)
Contract->>Contract: creator.require_auth()
Contract->>Contract: rbac::require_can_register(creator)
Contract->>Contract: validate: goal > 0
Contract->>Contract: validate: deadline > now
Contract->>Contract: validate: tokens.len() ∈ [1,10]
Contract->>Storage: id = get_and_increment_project_id()
Contract->>Storage: save ProjectConfig (immutable)
Contract->>Storage: save ProjectState (balance=0, status=Funding)
Contract->>Contract: emit ProjectCreated event
Contract-->>Creator: return Project
sequenceDiagram
participant Donor
participant Contract as PIFP Contract
participant Token as Token Contract
participant Storage as Persistent Storage
Donor->>Contract: deposit(project_id, token, donator, amount)
Contract->>Contract: donator.require_auth()
Contract->>Storage: load_project_pair(project_id)
Contract->>Contract: validate: token in accepted_tokens
Contract->>Contract: validate: status ∈ {Funding, Active}
Contract->>Contract: validate: not expired
Contract->>Token: transfer(donator → contract, amount)
Contract->>Storage: add_to_token_balance(project_id, token, amount)
Contract->>Storage: add_to_donator_balance(project_id, token, donator, amount)
Contract->>Storage: state.donation_count += 1
Contract->>Storage: save_project_state()
Contract->>Contract: emit DonationReceived event
Contract-->>Donor: success
sequenceDiagram
participant Oracle
participant IPFS
participant OracleService as Oracle Service
participant Contract as PIFP Contract
participant Token as Token Contract
participant Creator
Note over Oracle,Creator: Off-chain Proof Verification
Oracle->>OracleService: POST /verify {project_id, proof_cid}
OracleService->>IPFS: GET /ipfs/{proof_cid}
IPFS-->>OracleService: proof artifact bytes
OracleService->>OracleService: compute SHA-256 hash
Note over OracleService,Contract: On-chain Transaction Submission
OracleService->>Contract: simulate verify_and_release(project_id, proof_hash)
Contract-->>OracleService: simulation OK
OracleService->>Contract: submit verify_and_release(oracle, project_id, proof_hash)
Note over Contract: Contract Execution
Contract->>Contract: oracle.require_auth()
Contract->>Contract: rbac::require_oracle(oracle)
Contract->>Contract: load_project_pair(project_id)
Contract->>Contract: validate: status ∈ {Funding, Active}
Contract->>Contract: validate: proof_hash == config.proof_hash
Contract->>Contract: state.status = Completed
Contract->>Contract: save_project_state()
Note over Contract,Creator: Fund Release
loop for each token in accepted_tokens
Contract->>Contract: balance = get_token_balance(project_id, token)
alt balance > 0
Contract->>Contract: calculate fee = balance * fee_bps / 10000
Contract->>Token: transfer(contract → fee_recipient, fee)
Contract->>Token: transfer(contract → creator, balance - fee)
Contract->>Contract: set_token_balance(project_id, token, 0)
end
end
Contract->>Contract: emit ProjectVerified event
Contract-->>OracleService: tx_hash
OracleService-->>Oracle: success
PIFP uses two Soroban storage tiers with optimized TTL management:
| Key | Type | Description |
|---|---|---|
ProjectCount |
u64 |
Global auto-increment project ID counter |
IsPaused |
bool |
Protocol pause state |
ProtocolConfig |
ProtocolConfig |
Global protocol configuration (fee_recipient, fee_bps) |
TTL: bumped by 7 days whenever below 1 day remaining.
| Key | Type | Description |
|---|---|---|
ProjConfig(id) |
ProjectConfig |
Immutable project configuration |
ProjState(id) |
ProjectState |
Mutable project state |
TokenBalance(project_id, token) |
i128 |
Balance of specific token for a project |
DonatorBalance(project_id, token, donator) |
i128 |
Per-donator refundable balance |
Whitelist(project_id, address) |
() |
Whitelisted donator for private projects |
RbacKey::Role(address) |
Role |
RBAC role for an address |
RbacKey::SuperAdmin |
Address |
The single SuperAdmin address |
TTL: bumped by 30 days whenever below 7 days remaining.
PIFP exposes several retrieval helpers designed to minimize storage reads and TTL bumps:
project_exists(id)— cheap existence check (no TTL bump)maybe_load_project_config/maybe_load_project_state— returnOptionand only bump TTL when entry is foundload_project_pair— atomic two-entry read with single call; used by high-frequency operations likedepositandverify_and_releasemaybe_load_project— convenience wrapper returning fullProjectorNoneif absent
These helpers underpin the optimized storage retrieval patterns that reduce gas costs and simplify contract logic by centralizing dual-read behavior.
Deposits are high-frequency. Writing the full Project struct (~150 bytes) on every deposit is wasteful. ProjectState is ~20 bytes — separating it reduces ledger write costs by ~87% per deposit.
// Approximate ledgers per day (~5 seconds per ledger)
const DAY_IN_LEDGERS: u32 = 17_280;
// Instance storage: bump by 7 days when below 1 day remaining
const INSTANCE_BUMP_AMOUNT: u32 = 7 * DAY_IN_LEDGERS;
const INSTANCE_LIFETIME_THRESHOLD: u32 = DAY_IN_LEDGERS;
// Persistent storage: bump by 30 days when below 7 days remaining
const PERSISTENT_BUMP_AMOUNT: u32 = 30 * DAY_IN_LEDGERS;
const PERSISTENT_LIFETIME_THRESHOLD: u32 = 7 * DAY_IN_LEDGERS;| Actor | Trust Level | Notes |
|---|---|---|
| SuperAdmin | High | Full protocol control; set at deployment |
| Admin | Medium-High | Can configure roles and oracle; cannot elevate to SuperAdmin |
| Oracle | Medium | Trusted to verify off-chain proof correctly; single point of failure |
| ProjectManager | Low-Medium | Can register projects; cannot release funds |
| Donor | Untrusted | Can deposit; cannot affect project config or status |
| Auditor | Untrusted | Read-only; no on-chain enforcement needed |
| Threat | Mitigation |
|---|---|
| Impersonating the Oracle to trigger unauthorized fund release | oracle.require_auth() + rbac::require_oracle() — both address authentication and role check required |
| Claiming SuperAdmin before initialization | init checks RbacKey::SuperAdmin not set; panics with AlreadyInitialized on second call |
| Impersonating a ProjectManager to register malicious projects | creator.require_auth() + rbac::require_can_register() — role must be pre-granted by Admin/SuperAdmin |
| Threat | Mitigation |
|---|---|
Modifying proof_hash after registration to match a fake proof |
ProjectConfig is written once and never updated; no update entry point exists |
Changing project goal after funding to prevent completion |
goal is in immutable ProjectConfig; no mutation path |
| Replaying a valid proof on a completed project | verify_and_release panics with MilestoneAlreadyReleased if status == Completed |
| Directly writing to contract storage | Soroban contracts enforce that only the contract itself can write to its own storage |
| Threat | Mitigation |
|---|---|
| Oracle denies triggering a release | Every verify_and_release call emits a verified event with project_id; events are immutable on-chain |
| Admin denies granting a role | grant_role / revoke_role emit role_set / role_del events with the caller address as data |
| Threat | Mitigation |
|---|---|
| Donor identity leak | Donor address is emitted in donation_received event; privacy-preserving frontend (e.g. commitment schemes) must be handled off-chain |
| Proof artifact exposure | Only the hash of the proof is stored on-chain; the raw proof remains off-chain (e.g. IPFS) |
| Threat | Mitigation |
|---|---|
| Flooding contract with zero-value deposits | deposit performs a real token transfer — attacker pays token transfer fees |
Preventing oracle from calling verify_and_release by revoking Oracle role |
Only SuperAdmin/Admin can revoke; SuperAdmin cannot be removed without explicit transfer |
| Storage expiry causing project data loss | Persistent storage TTL is bumped on every access; 30-day extension with 7-day threshold |
| Threat | Mitigation |
|---|---|
| Admin self-escalating to SuperAdmin | grant_role checks: only a SuperAdmin can grant Role::SuperAdmin |
| ProjectManager granting roles to arbitrary addresses | grant_role panics with NotAuthorized for any caller without Admin or SuperAdmin role |
SuperAdmin removal via revoke_role |
revoke_role explicitly guards: if target == super_admin → panic NotAuthorized |
Scenario: The Oracle private key is stolen. An attacker calls verify_and_release with a fabricated proof hash.
Impact: Funds released to project creator without genuine impact.
Mitigations:
- Oracle role can be revoked by SuperAdmin/Admin immediately upon compromise detection.
verify_and_releaserequires the submitted hash to match theproof_hashset at registration — attacker cannot alter the stored hash.- Future mitigation: ZK-STARK proof verification (placeholder hook exists in
verify_and_release).
Scenario: SuperAdmin private key is lost or compromised.
Impact: Full protocol control lost or hijacked.
Mitigations:
transfer_super_adminallows key rotation.- Recommend using a multi-sig wallet or hardware security module as the SuperAdmin address.
- Future mitigation: time-locked SuperAdmin operations.
Scenario: A rogue ProjectManager registers a project with an attacker-controlled creator address and a proof hash they already know.
Impact: Attacker could collect donations and immediately trigger release.
Mitigations:
- ProjectManager role must be explicitly granted by Admin/SuperAdmin — not self-assignable.
- Donors should verify project legitimacy off-chain before depositing.
deadlineenforces a time constraint; a suspiciously short deadline is a red flag.
Scenario: Attacker finds a different input that produces the same 32-byte proof hash.
Impact: Fake proof accepted as valid.
Mitigations:
- Proof hash is a 32-byte value — assumed to be a SHA-256 or similar cryptographic hash produced off-chain.
- The Oracle is responsible for verifying the pre-image before submitting.
- Future mitigation: replace hash comparison with on-chain ZK verification.
Scenario: An attacker avoids interacting with a project, letting its storage TTL expire, then registers a new project that reuses the expired ID.
Impact: Stale project data, potential ID collision.
Mitigations:
ProjectCountis instance storage and never expires with the contract.- IDs are monotonically increasing — even after expiry a new project gets a fresh ID.
- Project configs and states are bumped on every read/write.
The Oracle service is a standalone Rust application that bridges off-chain proof verification with on-chain fund release.
graph TB
subgraph "Oracle Service"
Main[main.rs<br/>CLI & Task Orchestration]
Verifier[verifier.rs<br/>IPFS Fetch & Hash]
Chain[chain.rs<br/>Transaction Builder]
Config[config.rs<br/>Environment Config]
Health[health.rs<br/>HTTP Health/Metrics]
Metrics[metrics.rs<br/>Prometheus Metrics]
end
subgraph "External Services"
IPFS[IPFS Gateway<br/>ipfs.io / Pinata]
RPC[Soroban RPC<br/>Transaction Simulation]
Horizon[Horizon API<br/>Transaction Submission]
end
subgraph "Smart Contract"
Contract[PIFP Protocol<br/>verify_and_release]
end
Main-->Verifier
Main-->Chain
Main-->Config
Main-->Health
Main-->Metrics
Verifier-->IPFS
Chain-->RPC
Chain-->Horizon
Chain-->Contract
style Main fill:#e1f5ff
style Verifier fill:#fff4e1
style Chain fill:#ffe1e1
sequenceDiagram
participant CLI as Oracle CLI
participant Main as main.rs
participant Verifier as verifier.rs
participant IPFS as IPFS Gateway
participant Chain as chain.rs
participant RPC as Soroban RPC
participant Contract as PIFP Contract
CLI->>Main: --project-id 42 --proof-cid QmXxx
Main->>Main: parse CLI args & load config
Main->>Main: build_task_list()
Note over Main: Step 1: Fetch & Hash Proof
Main->>Verifier: fetch_and_hash_proof(cid, config)
Verifier->>IPFS: GET /ipfs/{cid}
IPFS-->>Verifier: proof artifact bytes
Verifier->>Verifier: compute SHA-256 hash
Verifier-->>Main: proof_hash [u8; 32]
alt dry_run mode
Main->>CLI: log hash, exit without submission
else normal mode
Note over Main,Contract: Step 2: Submit Transaction
Main->>Chain: submit_verification(config, project_id, proof_hash)
Note over Chain,RPC: Simulation Phase
Chain->>Chain: build_transaction_params()
Chain->>RPC: simulateTransaction
RPC-->>Chain: simulation result
Chain->>Chain: check for contract errors
Note over Chain,Contract: Submission Phase
Chain->>RPC: sendTransaction (signed)
RPC->>Contract: verify_and_release(oracle, project_id, proof_hash)
Contract->>Contract: validate proof_hash matches
Contract->>Contract: status = Completed
Contract->>Contract: release funds to creator
Contract-->>RPC: success
RPC-->>Chain: tx_hash
Chain-->>Main: tx_hash
Main->>CLI: log success with tx_hash
end
The Oracle service requires the following environment variables:
| Variable | Description | Required |
|---|---|---|
IPFS_GATEWAY |
IPFS gateway URL (e.g., https://ipfs.io) | Yes |
RPC_URL |
Soroban RPC endpoint | Yes |
HORIZON_URL |
Stellar Horizon API endpoint | Yes |
CONTRACT_ID |
Deployed PIFP contract address | Yes |
ORACLE_SECRET_KEY |
Oracle's Stellar secret key (S...) | Yes |
NETWORK_PASSPHRASE |
Stellar network passphrase | Yes |
TIMEOUT_SECS |
HTTP request timeout (default: 30) | No |
SENTRY_DSN |
Sentry error tracking DSN | No |
METRICS_PORT |
Prometheus metrics port (default: 9090) | No |
The Oracle supports three operation modes:
- Single Verification:
--project-id <id> --proof-cid <cid> - Batch Verification:
--batch "1:QmAbc,2:QmDef,3:QmGhi"(max 5 concurrent) - Service Mode:
--serve(HTTP server with /health and /metrics endpoints)
| Field | Type | Size | Description |
|---|---|---|---|
id |
u64 |
8 bytes | Auto-incremented unique identifier |
creator |
Address |
32 bytes | Address that registered and will receive released funds |
accepted_tokens |
Vec<Address> |
Variable | List of token contract addresses (1-10 tokens) |
goal |
i128 |
16 bytes | Target funding amount in first token's units |
proof_hash |
BytesN<32> |
32 bytes | Expected SHA-256 hash of proof artifact |
deadline |
u64 |
8 bytes | Ledger timestamp by which work must complete |
is_private |
bool |
1 byte | Whether project requires whitelist for deposits |
metadata_uri |
Bytes |
Variable | IPFS CID or URI pointing to project metadata |
Storage Key: DataKey::ProjConfig(id)
Storage Tier: Persistent
Mutability: Immutable after creation (except deadline via extension)
| Field | Type | Size | Description |
|---|---|---|---|
status |
ProjectStatus |
4 bytes | Current lifecycle state (enum) |
donation_count |
u32 |
4 bytes | Count of unique (donator, token) pairs |
refund_expiry |
u64 |
8 bytes | Timestamp after which refunds are no longer valid |
Storage Key: DataKey::ProjState(id)
Storage Tier: Persistent
Mutability: Updated on deposits, verification, expiry, cancellation
Size: ~20 bytes (87% smaller than full Project struct)
Combines ProjectConfig + ProjectState for public API. Not stored directly.
| Variant | Value | Description |
|---|---|---|
Funding |
0 | Accepting donations, goal not yet reached |
Active |
1 | Goal reached; work in progress |
Completed |
2 | Oracle verified proof; funds released to creator |
Expired |
3 | Deadline passed without completion |
Cancelled |
4 | Manually cancelled; refunds available |
State Transitions:
stateDiagram-v2
[*] --> Funding: register_project
Funding --> Active: goal reached
Funding --> Completed: verify_and_release
Funding --> Expired: deadline passed
Active --> Completed: verify_and_release
Active --> Expired: deadline passed
Active --> Cancelled: cancel_project
Completed --> [*]
Expired --> [*]
Cancelled --> [*]
| Field | Type | Size | Description |
|---|---|---|---|
fee_recipient |
Address |
32 bytes | Address receiving platform fees |
fee_bps |
u32 |
4 bytes | Platform fee in basis points (1 BPS = 0.01%) |
Storage Key: DataKey::ProtocolConfig
Storage Tier: Instance
Max Fee: 10,000 BPS (100%)
| Field | Type | Size | Description |
|---|---|---|---|
token |
Address |
32 bytes | Token contract address |
balance |
i128 |
16 bytes | Current balance for this token |
Storage Key: DataKey::TokenBalance(project_id, token)
Storage Tier: Persistent
| Variant | Value | Capabilities |
|---|---|---|
SuperAdmin |
0 | Full protocol control; grant/revoke any role |
Admin |
1 | Manage roles (except SuperAdmin); configure protocol |
Oracle |
2 | Call verify_and_release; trigger fund releases |
ProjectManager |
3 | Register and manage projects |
Auditor |
4 | Read-only observer (no on-chain enforcement) |
Storage Key: RbacKey::Role(address)
Storage Tier: Persistent
Constraint: One role per address (granting new role replaces existing)
All storage keys are defined in the DataKey enum:
pub enum DataKey {
ProjectCount, // Instance
ProjConfig(u64), // Persistent
ProjState(u64), // Persistent
TokenBalance(u64, Address), // Persistent
IsPaused, // Instance
DonatorBalance(u64, Address, Address), // Persistent
ProtocolConfig, // Instance
Whitelist(u64, Address), // Persistent
}| Operation | Keys Read | Keys Written | TTL Bumps |
|---|---|---|---|
register_project |
ProjectCount |
ProjConfig, ProjState, ProjectCount |
3 |
deposit |
ProjConfig, ProjState, TokenBalance, DonatorBalance |
ProjState, TokenBalance, DonatorBalance |
6 |
verify_and_release |
ProjConfig, ProjState, TokenBalance (per token), ProtocolConfig |
ProjState, TokenBalance (per token) |
4+ |
get_project |
ProjConfig, ProjState |
None | 2 |
refund |
ProjConfig, ProjState, DonatorBalance, TokenBalance |
DonatorBalance, TokenBalance |
4 |
Every contract error is defined in errors.rs as a #[contracterror] enum.
Soroban surfaces these on-chain as Error(Contract, #N) where N is the
discriminant shown below.
| Code | Variant | Typical Trigger |
|---|---|---|
| 1 | ProjectNotFound |
Querying or operating on a project ID that does not exist |
| 2 | MilestoneNotFound |
Reserved for future milestone-level operations |
| 3 | MilestoneAlreadyReleased |
verify_and_release on an already-completed project |
| 4 | InsufficientBalance |
Refund requested but donator has zero balance for that token |
| 5 | InvalidMilestones |
Reserved for future milestone validation |
| 6 | NotAuthorized |
Caller lacks the RBAC role required for the operation |
| 7 | InvalidGoal |
Goal is ≤ 0 or exceeds the 10^30 upper bound |
| 8 | AlreadyInitialized |
init called more than once |
| 9 | RoleNotFound |
Reserved for role-query edge cases |
| 10 | TooManyTokens |
accepted_tokens list exceeds 10 tokens |
| 11 | InvalidAmount |
Deposit or transfer amount is ≤ 0 |
| 12 | DuplicateToken |
accepted_tokens contains duplicate addresses |
| 13 | InvalidDeadline |
Deadline is in the past or more than 5 years away |
| 14 | ProjectExpired |
Operation attempted after the project deadline |
| 15 | ProjectNotActive |
Deposit/verify on a Completed or invalid-status project |
| 16 | VerificationFailed |
Submitted proof hash ≠ stored proof hash |
| 17 | EmptyAcceptedTokens |
Registration with an empty token list |
| 18 | Overflow |
Arithmetic overflow on balance addition |
| 19 | ProtocolPaused |
Mutating operation while the protocol is paused |
| 20 | GoalMismatch |
Reserved for cross-token goal validation |
| 21 | ProjectNotExpired |
Refund/expire attempted before the deadline |
| 22 | InvalidTransition |
FSM transition not allowed (e.g. expiring a Completed project) |
| 23 | TokenNotAccepted |
Deposit with a token not in the project's accepted list |
Backward compatibility: Error codes are append-only. Existing codes must never be renumbered or removed to avoid breaking off-chain error-handling logic.
| Actor | Trust Level | Notes |
|---|---|---|
| SuperAdmin | High | Full protocol control; set at deployment |
| Admin | Medium-High | Can configure roles and oracle; cannot elevate to SuperAdmin |
| Oracle | Medium | Trusted to verify off-chain proof correctly; single point of failure |
| ProjectManager | Low-Medium | Can register projects; cannot release funds |
| Donor | Untrusted | Can deposit; cannot affect project config or status |
| Auditor | Untrusted | Read-only; no on-chain enforcement needed |
| Threat | Mitigation |
|---|---|
| Impersonating the Oracle to trigger unauthorized fund release | oracle.require_auth() + rbac::require_oracle() — both address authentication and role check required |
| Claiming SuperAdmin before initialization | init checks RbacKey::SuperAdmin not set; panics with AlreadyInitialized on second call |
| Impersonating a ProjectManager to register malicious projects | creator.require_auth() + rbac::require_can_register() — role must be pre-granted by Admin/SuperAdmin |
| Threat | Mitigation |
|---|---|
Modifying proof_hash after registration to match a fake proof |
ProjectConfig is written once and never updated; no update entry point exists |
Changing project goal after funding to prevent completion |
goal is in immutable ProjectConfig; no mutation path |
| Replaying a valid proof on a completed project | verify_and_release panics with MilestoneAlreadyReleased if status == Completed |
| Directly writing to contract storage | Soroban contracts enforce that only the contract itself can write to its own storage |
| Threat | Mitigation |
|---|---|
| Oracle denies triggering a release | Every verify_and_release call emits a verified event with project_id; events are immutable on-chain |
| Admin denies granting a role | grant_role / revoke_role emit role_set / role_del events with the caller address as data |
| Threat | Mitigation |
|---|---|
| Donor identity leak | Donor address is emitted in donation_received event; privacy-preserving frontend (e.g. commitment schemes) must be handled off-chain |
| Proof artifact exposure | Only the hash of the proof is stored on-chain; the raw proof remains off-chain (e.g. IPFS) |
| Threat | Mitigation |
|---|---|
| Flooding contract with zero-value deposits | deposit performs a real token transfer — attacker pays token transfer fees |
Preventing oracle from calling verify_and_release by revoking Oracle role |
Only SuperAdmin/Admin can revoke; SuperAdmin cannot be removed without explicit transfer |
| Storage expiry causing project data loss | Persistent storage TTL is bumped on every access; 30-day extension with 7-day threshold |
| Threat | Mitigation |
|---|---|
| Admin self-escalating to SuperAdmin | grant_role checks: only a SuperAdmin can grant Role::SuperAdmin |
| ProjectManager granting roles to arbitrary addresses | grant_role panics with NotAuthorized for any caller without Admin or SuperAdmin role |
SuperAdmin removal via revoke_role |
revoke_role explicitly guards: if target == super_admin → panic NotAuthorized |
Scenario: The Oracle private key is stolen. An attacker calls verify_and_release with a fabricated proof hash.
Impact: Funds released to project creator without genuine impact.
Mitigations:
- Oracle role can be revoked by SuperAdmin/Admin immediately upon compromise detection.
verify_and_releaserequires the submitted hash to match theproof_hashset at registration — attacker cannot alter the stored hash.- Future mitigation: ZK-STARK proof verification (placeholder hook exists in
verify_and_release).
Scenario: SuperAdmin private key is lost or compromised.
Impact: Full protocol control lost or hijacked.
Mitigations:
transfer_super_adminallows key rotation.- Recommend using a multi-sig wallet or hardware security module as the SuperAdmin address.
- Future mitigation: time-locked SuperAdmin operations.
Scenario: A rogue ProjectManager registers a project with an attacker-controlled creator address and a proof hash they already know.
Impact: Attacker could collect donations and immediately trigger release.
Mitigations:
- ProjectManager role must be explicitly granted by Admin/SuperAdmin — not self-assignable.
- Donors should verify project legitimacy off-chain before depositing.
deadlineenforces a time constraint; a suspiciously short deadline is a red flag.
Scenario: Attacker finds a different input that produces the same 32-byte proof hash.
Impact: Fake proof accepted as valid.
Mitigations:
- Proof hash is a 32-byte value — assumed to be a SHA-256 or similar cryptographic hash produced off-chain.
- The Oracle is responsible for verifying the pre-image before submitting.
- Future mitigation: replace hash comparison with on-chain ZK verification.
Scenario: An attacker avoids interacting with a project, letting its storage TTL expire, then registers a new project that reuses the expired ID.
Impact: Stale project data, potential ID collision.
Mitigations:
ProjectCountis instance storage and never expires with the contract.- IDs are monotonically increasing — even after expiry a new project gets a fresh ID.
- Project configs and states are bumped on every read/write.
The following invariants must hold at all times:
| ID | Invariant |
|---|---|
| INV-1 | project.balance >= 0 for all projects |
| INV-2 | project.goal > 0 for all projects |
| INV-3 | project.deadline > 0 for all projects |
| INV-4 | A Completed project's status is terminal — no further state changes |
| INV-5 | After a deposit of amount, balance_after == balance_before + amount |
| INV-6 | Project IDs are sequential starting from 0 |
| INV-7 | Status transitions are strictly forward: `Funding → Active |
| INV-8 | An address holds at most one RBAC role at a time |
| INV-9 | The SuperAdmin address is always set after init and can only change via transfer_super_admin |
| INV-10 | ProjectConfig fields (creator, token, goal, proof_hash, deadline) are immutable after registration |
- Deploy the contract to a Soroban-enabled Stellar network.
- Call
init(super_admin)exactly once immediately after deployment with a secure multi-sig address assuper_admin. - Call
set_oracle(super_admin, oracle_address)to register the trusted Oracle. - Use
grant_roleto assignAdminandProjectManagerroles as needed. - Verify
has_role(super_admin, SuperAdmin) == trueandhas_role(oracle, Oracle) == trueon-chain before opening to users. - Monitor on-chain events (
role_set,role_del,donation_received,verified) via an off-chain indexer. - Store the SuperAdmin key in a hardware security module or multi-sig; never in a hot wallet.
- Audit TTL thresholds against expected contract lifetime before production deployment.