diff --git a/app/onchain/contracts/aid_escrow/README.md b/app/onchain/contracts/aid_escrow/README.md new file mode 100644 index 0000000..811e300 --- /dev/null +++ b/app/onchain/contracts/aid_escrow/README.md @@ -0,0 +1,250 @@ +# aid_escrow — Soroban Smart Contract + +A Soroban smart contract that implements a **pool-based aid escrow** on the Stellar network. NGOs and distributors fund a shared token pool, create claimable packages for individual recipients, and recipients (or admins) redeem them on-chain. Every state change is recorded as an immutable ledger event. + +--- + +## How It Works + +``` +Funder ──fund()──► Contract Pool + │ +Operator ──create_package()──► Package (Created, funds locked) + │ +Recipient ──claim()──► Package (Claimed, funds transferred) +``` + +1. A funder calls `fund()` to deposit tokens into the contract. +2. An admin or distributor calls `create_package()` (or `batch_create_packages()`) to lock a portion of the pool for a specific recipient. +3. The recipient calls `claim()` to receive their tokens. Alternatively, the admin can push funds via `disburse()`. +4. Unclaimed packages can be cancelled (`revoke` / `cancel_package`) or refunded to the admin after expiry. + +--- + +## Package State Machine + +``` +Created ──claim() / disburse()──► Claimed +Created ──revoke() / cancel_package()──► Cancelled ──refund()──► Refunded +Created ──(expires_at passed)──► Expired ──refund()──► Refunded +``` + +A package in `Claimed` or `Refunded` status is terminal and cannot be modified. + +--- + +## Public Functions + +### Initialisation + +#### `init(env, admin)` +Initialises the contract. Must be called exactly once. + +| Argument | Type | Description | +|----------|-----------|--------------------------------------| +| `admin` | `Address` | Address granted admin privileges. | + +Errors: `AlreadyInitialized` + +--- + +### Configuration + +#### `get_admin(env) → Address` +Returns the stored admin address. + +Errors: `NotInitialized` + +#### `set_config(env, config)` +Replaces the contract configuration. Admin only. + +| Argument | Type | Description | +|----------|----------|-----------------------------------------------------------------------------| +| `config` | `Config` | `min_amount` (i128), `max_expires_in` (u64 seconds, 0 = unlimited), `allowed_tokens` (Vec\, empty = all allowed). | + +Errors: `NotInitialized`, `NotAuthorized`, `InvalidAmount` + +#### `get_config(env) → Config` +Returns the current configuration (read-only, no auth required). + +#### `add_distributor(env, addr)` / `remove_distributor(env, addr)` +Grants or revokes distributor privileges. Admin only. Distributors may call `create_package` and `batch_create_packages`. + +Errors: `NotInitialized`, `NotAuthorized` + +#### `pause(env)` / `unpause(env)` +Halts or resumes `fund`, `create_package`, `batch_create_packages`, and `claim`. Admin only. + +Errors: `NotInitialized`, `NotAuthorized` + +#### `is_paused(env) → bool` +Returns `true` when the contract is paused (read-only). + +--- + +### Funding + +#### `fund(env, token, from, amount)` +Deposits tokens into the contract pool. + +| Argument | Type | Description | +|----------|-----------|--------------------------------------------------| +| `token` | `Address` | SEP-41 token contract address. | +| `from` | `Address` | Funding address; must authorise this call. | +| `amount` | `i128` | Amount to deposit (must be > 0). | + +Errors: `InvalidAmount` + +--- + +### Package Management + +#### `create_package(env, operator, id, recipient, amount, token, expires_at) → u64` +Creates a single aid package and locks funds from the pool. + +| Argument | Type | Description | +|--------------|-----------|----------------------------------------------------------------------| +| `operator` | `Address` | Admin or distributor; must authorise this call. | +| `id` | `u64` | Caller-supplied unique package ID. | +| `recipient` | `Address` | Address entitled to claim the package. | +| `amount` | `i128` | Token amount to lock (must be ≥ `config.min_amount`). | +| `token` | `Address` | SEP-41 token contract address. | +| `expires_at` | `u64` | Unix timestamp after which the package expires (`0` = no expiry). | + +Returns the package `id` on success. + +Errors: `ContractPaused`, `NotAuthorized`, `InvalidAmount`, `InvalidState`, `PackageIdExists`, `InsufficientFunds` + +#### `batch_create_packages(env, operator, recipients, amounts, token, expires_in) → Vec` +Creates multiple packages in one transaction using auto-incremented IDs. + +| Argument | Type | Description | +|--------------|-----------------|----------------------------------------------------------------| +| `operator` | `Address` | Admin or distributor; must authorise this call. | +| `recipients` | `Vec
` | Ordered list of recipient addresses. | +| `amounts` | `Vec` | Ordered list of amounts, one per recipient. | +| `token` | `Address` | SEP-41 token contract address. | +| `expires_in` | `u64` | Seconds from now until all packages in this batch expire. | + +Returns a `Vec` of the created package IDs. + +Errors: `ContractPaused`, `NotAuthorized`, `MismatchedArrays`, `InvalidAmount`, `InsufficientFunds` + +--- + +### Recipient Actions + +#### `claim(env, id)` +Recipient claims their package. Transfers locked tokens to the recipient. + +| Argument | Type | Description | +|----------|-------|--------------------------| +| `id` | `u64` | Package ID to claim. | + +The recipient address stored in the package must authorise this call. + +Errors: `ContractPaused`, `PackageNotFound`, `PackageNotActive`, `PackageExpired` + +--- + +### Admin Actions + +#### `disburse(env, id)` +Admin pushes funds to the recipient without requiring the recipient's signature. Useful for field operations. + +Errors: `NotInitialized`, `NotAuthorized`, `PackageNotFound`, `PackageNotActive` + +#### `revoke(env, id)` +Cancels a `Created` package and returns its funds to the pool. + +Errors: `NotInitialized`, `NotAuthorized`, `PackageNotFound`, `InvalidState` + +#### `cancel_package(env, package_id)` +Cancels a `Created` package that has not yet expired. Funds return to the pool. + +Errors: `NotInitialized`, `NotAuthorized`, `PackageNotFound`, `PackageNotActive`, `PackageExpired` + +#### `refund(env, id)` +Transfers the package amount back to the admin. Only valid for `Expired` or `Cancelled` packages. + +Errors: `NotInitialized`, `NotAuthorized`, `PackageNotFound`, `InvalidState` + +#### `extend_expiration(env, package_id, additional_time)` +Extends the expiry of a `Created` package by `additional_time` seconds. Cannot extend unbounded packages (`expires_at == 0`). + +Errors: `NotInitialized`, `NotAuthorized`, `PackageNotFound`, `PackageNotActive`, `InvalidAmount`, `InvalidState`, `PackageExpired` + +#### `withdraw_surplus(env, to, amount, token)` +Withdraws unallocated (surplus) tokens from the contract to `to`. Surplus = contract balance − total locked. + +Errors: `NotInitialized`, `NotAuthorized`, `InvalidAmount`, `InsufficientSurplus` + +--- + +### Read-Only Queries + +#### `get_package(env, id) → Package` +Returns the full `Package` struct for the given ID. + +Errors: `PackageNotFound` + +#### `get_aggregates(env, token) → Aggregates` +Returns aggregate token statistics across all packages: + +| Field | Description | +|--------------------------|----------------------------------------------------------| +| `total_committed` | Sum of amounts in `Created` packages. | +| `total_claimed` | Sum of amounts in `Claimed` packages. | +| `total_expired_cancelled`| Sum of amounts in `Expired`, `Cancelled`, or `Refunded`. | + +--- + +## Error Reference + +| Code | Variant | When it is returned | +|------|------------------------|------------------------------------------------------------------------| +| 1 | `NotInitialized` | Contract has not been initialised via `init`. | +| 2 | `AlreadyInitialized` | `init` was called more than once. | +| 3 | `NotAuthorized` | Caller lacks the required role (admin or distributor). | +| 4 | `InvalidAmount` | Amount is ≤ 0, below `min_amount`, or `additional_time` is 0. | +| 5 | `PackageNotFound` | No package exists for the given ID. | +| 6 | `PackageNotActive` | Package status is not `Created`. | +| 7 | `PackageExpired` | Package has passed its `expires_at` timestamp. | +| 8 | `PackageNotExpired` | Operation requires the package to be expired, but it is not. | +| 9 | `InsufficientFunds` | Contract pool balance cannot cover the requested lock amount. | +| 10 | `PackageIdExists` | A package with the supplied ID already exists. | +| 11 | `InvalidState` | General state violation (token not allowed, expiry out of range, etc). | +| 12 | `MismatchedArrays` | `recipients` and `amounts` arrays have different lengths. | +| 13 | `InsufficientSurplus` | Requested withdrawal exceeds unallocated contract balance. | +| 14 | `ContractPaused` | Contract is paused; mutating operations are blocked. | + +--- + +## Events + +| Event | Emitted by | Key fields | +|--------------------------|-------------------------------------|-----------------------------------------| +| `FundEvent` | `fund` | `from`, `token`, `amount` | +| `PackageCreatedEvent` | `create_package`, `batch_create_packages` | `id`, `recipient`, `amount` | +| `ClaimedEvent` | `claim` | `id`, `recipient`, `amount` | +| `DisbursedEvent` | `disburse` | `id`, `admin`, `amount` | +| `RevokedEvent` | `revoke`, `cancel_package` | `id`, `admin`, `amount` | +| `RefundedEvent` | `refund` | `id`, `admin`, `amount` | +| `BatchCreatedEvent` | `batch_create_packages` | `ids`, `admin`, `total_amount` | +| `ExtendedEvent` | `extend_expiration` | `id`, `admin`, `old_expires_at`, `new_expires_at` | +| `SurplusWithdrawnEvent` | `withdraw_surplus` | `to`, `token`, `amount` | +| `ContractPausedEvent` | `pause` | `admin` | +| `ContractUnpausedEvent` | `unpause` | `admin` | + +--- + +## Building & Testing + +```bash +# From app/onchain/ +make build # cargo build --target wasm32-unknown-unknown --release +make test # cargo test +make deploy # see scripts/deploy.sh for env vars required +``` + +See [`app/onchain/README.md`](../../README.md) for full CLI setup and deployment instructions. diff --git a/app/onchain/contracts/aid_escrow/src/lib.rs b/app/onchain/contracts/aid_escrow/src/lib.rs index 3684b31..bfb38db 100644 --- a/app/onchain/contracts/aid_escrow/src/lib.rs +++ b/app/onchain/contracts/aid_escrow/src/lib.rs @@ -178,6 +178,13 @@ pub struct AidEscrow; impl AidEscrow { // --- Admin & Config --- + /// Initialises the contract. Must be called exactly once before any other function. + /// + /// # Arguments + /// * `admin` - The address that will own the contract and hold admin privileges. + /// + /// # Errors + /// * [`Error::AlreadyInitialized`] – contract has already been initialised. pub fn init(env: Env, admin: Address) -> Result<(), Error> { if env.storage().instance().has(&KEY_ADMIN) { return Err(Error::AlreadyInitialized); @@ -193,6 +200,10 @@ impl AidEscrow { Ok(()) } + /// Returns the current admin address. + /// + /// # Errors + /// * [`Error::NotInitialized`] – contract has not been initialised yet. pub fn get_admin(env: Env) -> Result { env.storage() .instance() @@ -241,6 +252,14 @@ impl AidEscrow { Ok(()) } + /// Revokes distributor privileges from `addr`. + /// + /// # Arguments + /// * `addr` - Address to demote. + /// + /// # Errors + /// * [`Error::NotInitialized`] – contract not initialised. + /// * [`Error::NotAuthorized`] – caller is not the admin. pub fn remove_distributor(env: Env, addr: Address) -> Result<(), Error> { let admin = Self::get_admin(env.clone())?; admin.require_auth(); @@ -258,6 +277,15 @@ impl AidEscrow { Ok(()) } + /// Updates the contract configuration. + /// + /// # Arguments + /// * `config` - New [`Config`] value containing `min_amount`, `max_expires_in`, and `allowed_tokens`. + /// + /// # Errors + /// * [`Error::NotInitialized`] – contract not initialised. + /// * [`Error::NotAuthorized`] – caller is not the admin. + /// * [`Error::InvalidAmount`] – `config.min_amount` is ≤ 0. pub fn set_config(env: Env, config: Config) -> Result<(), Error> { let admin = Self::get_admin(env.clone())?; admin.require_auth(); @@ -270,6 +298,11 @@ impl AidEscrow { Ok(()) } + /// Pauses the contract, blocking `create_package`, `batch_create_packages`, and `claim`. + /// + /// # Errors + /// * [`Error::NotInitialized`] – contract not initialised. + /// * [`Error::NotAuthorized`] – caller is not the admin. pub fn pause(env: Env) -> Result<(), Error> { let admin = Self::get_admin(env.clone())?; admin.require_auth(); @@ -278,6 +311,11 @@ impl AidEscrow { Ok(()) } + /// Resumes normal contract operation after a pause. + /// + /// # Errors + /// * [`Error::NotInitialized`] – contract not initialised. + /// * [`Error::NotAuthorized`] – caller is not the admin. pub fn unpause(env: Env) -> Result<(), Error> { let admin = Self::get_admin(env.clone())?; admin.require_auth(); @@ -286,10 +324,12 @@ impl AidEscrow { Ok(()) } + /// Returns `true` if the contract is currently paused. pub fn is_paused(env: Env) -> bool { env.storage().instance().get(&KEY_PAUSED).unwrap_or(false) } + /// Returns the current [`Config`]. Falls back to safe defaults if not yet set. pub fn get_config(env: Env) -> Config { env.storage().instance().get(&KEY_CONFIG).unwrap_or(Config { min_amount: 1, @@ -300,9 +340,15 @@ impl AidEscrow { // --- Funding & Packages --- - /// Funds the contract (Pool Model). - /// Transfers `amount` of `token` from `from` to this contract. - /// This increases the contract's balance, allowing new packages to be created. + /// Deposits tokens into the contract pool so packages can be created against them. + /// + /// # Arguments + /// * `token` - SEP-41 token contract address. + /// * `from` - Address funding the contract; must authorise this call. + /// * `amount` - Amount to transfer (must be > 0). + /// + /// # Errors + /// * [`Error::InvalidAmount`] – `amount` is ≤ 0. pub fn fund(env: Env, token: Address, from: Address, amount: i128) -> Result<(), Error> { if amount <= 0 { return Err(Error::InvalidAmount); @@ -325,8 +371,27 @@ impl AidEscrow { Ok(()) } - /// Creates a package with a specific ID. - /// Locks funds from the available pool (Contract Balance - Total Locked). + /// Creates a single aid package and locks the specified funds for the recipient. + /// + /// The caller must be the admin or an authorised distributor. Funds are reserved + /// from the contract's unallocated pool; the package cannot be created if the pool + /// has insufficient balance. + /// + /// # Arguments + /// * `operator` - Admin or distributor address; must authorise this call. + /// * `id` - Caller-supplied unique package identifier. + /// * `recipient` - Address that may claim the package. + /// * `amount` - Token amount to lock (must be ≥ `config.min_amount`). + /// * `token` - SEP-41 token contract address. + /// * `expires_at` - Unix timestamp after which the package expires (`0` = no expiry). + /// + /// # Errors + /// * [`Error::ContractPaused`] – contract is paused. + /// * [`Error::NotAuthorized`] – `operator` is neither admin nor distributor. + /// * [`Error::InvalidAmount`] – `amount` ≤ 0 or below `min_amount`. + /// * [`Error::InvalidState`] – token not in `allowed_tokens`, or expiry violates `max_expires_in`. + /// * [`Error::PackageIdExists`] – a package with `id` already exists. + /// * [`Error::InsufficientFunds`] – contract pool cannot cover the new package. pub fn create_package( env: Env, operator: Address, @@ -418,8 +483,24 @@ impl AidEscrow { Ok(id) } - /// Creates multiple packages in a single transaction for multiple recipients. - /// Uses an auto-incrementing counter for package IDs. + /// Creates multiple aid packages in a single transaction using auto-incremented IDs. + /// + /// Package IDs are assigned sequentially from the internal counter. Each package + /// shares the same `token` and expiry window (`expires_in` seconds from now). + /// + /// # Arguments + /// * `operator` - Admin or distributor address; must authorise this call. + /// * `recipients` - Ordered list of recipient addresses. + /// * `amounts` - Ordered list of amounts, one per recipient (must be > 0 each). + /// * `token` - SEP-41 token contract address. + /// * `expires_in` - Seconds from the current ledger timestamp until expiry. + /// + /// # Errors + /// * [`Error::ContractPaused`] – contract is paused. + /// * [`Error::NotAuthorized`] – `operator` is neither admin nor distributor. + /// * [`Error::MismatchedArrays`] – `recipients` and `amounts` have different lengths. + /// * [`Error::InvalidAmount`] – any individual amount is ≤ 0. + /// * [`Error::InsufficientFunds`] – contract pool cannot cover all packages. pub fn batch_create_packages( env: Env, operator: Address, @@ -531,7 +612,20 @@ impl AidEscrow { // --- Recipient Actions --- - /// Recipient claims the package. + /// Allows the designated recipient to claim their aid package. + /// + /// The recipient must authorise this call. On success the package status transitions + /// to `Claimed`, the locked funds are released, and the token amount is transferred + /// directly to the recipient. + /// + /// # Arguments + /// * `id` - Package identifier to claim. + /// + /// # Errors + /// * [`Error::ContractPaused`] – contract is paused. + /// * [`Error::PackageNotFound`] – no package exists with `id`. + /// * [`Error::PackageNotActive`] – package is not in `Created` status. + /// * [`Error::PackageExpired`] – package has passed its `expires_at` timestamp. pub fn claim(env: Env, id: u64) -> Result<(), Error> { Self::check_paused(&env)?; let key = (symbol_short!("pkg"), id); @@ -587,7 +681,19 @@ impl AidEscrow { // --- Admin Actions --- - /// Admin manually triggers disbursement (overrides recipient claim need, strictly checks status). + /// Admin-initiated disbursement — pushes funds to the recipient without requiring their signature. + /// + /// Useful when the recipient cannot sign (e.g. field operations). Transitions the package + /// to `Claimed` and transfers funds to the recipient address on record. + /// + /// # Arguments + /// * `id` - Package identifier to disburse. + /// + /// # Errors + /// * [`Error::NotInitialized`] – contract not initialised. + /// * [`Error::NotAuthorized`] – caller is not the admin. + /// * [`Error::PackageNotFound`] – no package exists with `id`. + /// * [`Error::PackageNotActive`] – package is not in `Created` status. pub fn disburse(env: Env, id: u64) -> Result<(), Error> { let admin = Self::get_admin(env.clone())?; admin.require_auth(); @@ -631,7 +737,19 @@ impl AidEscrow { Ok(()) } - /// Admin revokes a package (Cancels it). Funds are effectively unlocked but remain in contract pool. + /// Cancels a `Created` package and returns its locked funds to the pool. + /// + /// Unlike `cancel_package`, this does not check whether the package has expired, + /// making it suitable for administrative clean-up of any active package. + /// + /// # Arguments + /// * `id` - Package identifier to revoke. + /// + /// # Errors + /// * [`Error::NotInitialized`] – contract not initialised. + /// * [`Error::NotAuthorized`] – caller is not the admin. + /// * [`Error::PackageNotFound`] – no package exists with `id`. + /// * [`Error::InvalidState`] – package is not in `Created` status. pub fn revoke(env: Env, id: u64) -> Result<(), Error> { let admin = Self::get_admin(env.clone())?; admin.require_auth(); @@ -667,6 +785,19 @@ impl AidEscrow { Ok(()) } + /// Transfers a package's tokens back to the admin after the package is `Expired` or `Cancelled`. + /// + /// If the package is still in `Created` status but its `expires_at` has passed, this + /// function will auto-transition it to `Expired` before processing the refund. + /// + /// # Arguments + /// * `id` - Package identifier to refund. + /// + /// # Errors + /// * [`Error::NotInitialized`] – contract not initialised. + /// * [`Error::NotAuthorized`] – caller is not the admin. + /// * [`Error::PackageNotFound`] – no package exists with `id`. + /// * [`Error::InvalidState`] – package is `Claimed`, already `Refunded`, or `Created` but not yet expired. pub fn refund(env: Env, id: u64) -> Result<(), Error> { let admin = Self::get_admin(env.clone())?; admin.require_auth(); @@ -720,8 +851,17 @@ impl AidEscrow { Ok(()) } - /// Admin-only package cancellation. - /// Requirements: Admin auth, existing package, status must be 'Created'. + /// Cancels a `Created` package that has not yet expired, returning funds to the pool. + /// + /// # Arguments + /// * `package_id` - Package identifier to cancel. + /// + /// # Errors + /// * [`Error::NotInitialized`] – contract not initialised. + /// * [`Error::NotAuthorized`] – caller is not the admin. + /// * [`Error::PackageNotFound`] – no package exists with `package_id`. + /// * [`Error::PackageNotActive`] – package is not in `Created` status. + /// * [`Error::PackageExpired`] – package has already passed its `expires_at` timestamp. pub fn cancel_package(env: Env, package_id: u64) -> Result<(), Error> { // 1. Only the admin can cancel (check stored admin and require_auth) let admin = Self::get_admin(env.clone())?; @@ -765,10 +905,23 @@ impl AidEscrow { Ok(()) } - /// Admin-only package expiration extension. - /// Requirements: Admin auth, existing package, status must be 'Created', additional_time > 0. - /// Behavior: Adds additional_time to the package's expires_at timestamp. - /// Cannot extend unbounded packages (expires_at == 0). + /// Extends the expiry of a `Created` package by `additional_time` seconds. + /// + /// Cannot be used on unbounded packages (`expires_at == 0`). The resulting + /// `expires_at` must still satisfy `config.max_expires_in` if that limit is set. + /// + /// # Arguments + /// * `package_id` - Package identifier to extend. + /// * `additional_time` - Seconds to add to the current `expires_at` (must be > 0). + /// + /// # Errors + /// * [`Error::NotInitialized`] – contract not initialised. + /// * [`Error::NotAuthorized`] – caller is not the admin. + /// * [`Error::PackageNotFound`] – no package exists with `package_id`. + /// * [`Error::PackageNotActive`] – package is not in `Created` status. + /// * [`Error::InvalidAmount`] – `additional_time` is 0. + /// * [`Error::InvalidState`] – package is unbounded or new expiry violates `max_expires_in`. + /// * [`Error::PackageExpired`] – package has already expired. pub fn extend_expiration(env: Env, package_id: u64, additional_time: u64) -> Result<(), Error> { // 1. Only the admin can extend (check stored admin and require_auth) let admin = Self::get_admin(env.clone())?; @@ -827,9 +980,21 @@ impl AidEscrow { Ok(()) } - /// Admin-only function to withdraw surplus (unallocated) funds from the contract. - /// Requirements: Admin auth, valid amount, sufficient surplus available. - /// Behavior: Transfers amount of token from contract to the specified address. + /// Withdraws surplus (unallocated) tokens from the contract to `to`. + /// + /// Surplus = contract token balance − total locked amount. Only tokens not + /// committed to any active package may be withdrawn. + /// + /// # Arguments + /// * `to` - Destination address for the withdrawn tokens. + /// * `amount` - Amount to withdraw (must be > 0). + /// * `token` - SEP-41 token contract address. + /// + /// # Errors + /// * [`Error::NotInitialized`] – contract not initialised. + /// * [`Error::NotAuthorized`] – caller is not the admin. + /// * [`Error::InvalidAmount`] – `amount` is ≤ 0. + /// * [`Error::InsufficientSurplus`] – requested amount exceeds available surplus. pub fn withdraw_surplus( env: Env, to: Address, @@ -924,6 +1089,13 @@ impl AidEscrow { } } + /// Returns the full [`Package`] struct for the given ID. + /// + /// # Arguments + /// * `id` - Package identifier to look up. + /// + /// # Errors + /// * [`Error::PackageNotFound`] – no package exists with `id`. pub fn get_package(env: Env, id: u64) -> Result { let key = (symbol_short!("pkg"), id); env.storage()