diff --git a/SETTLEMENT_IMPLEMENTATION.md b/SETTLEMENT_IMPLEMENTATION.md index fbf308b..bd10cce 100644 --- a/SETTLEMENT_IMPLEMENTATION.md +++ b/SETTLEMENT_IMPLEMENTATION.md @@ -4,6 +4,18 @@ This document describes the implementation of revenue settlement functionality that allows the vault contract to automatically transfer USDC to a settlement contract when deductions occur. The settlement contract then credits either a global pool or specific developer balances. +## Reconciliation Contract (Vault ↔ Settlement) + +The integration between the vault and settlement contracts ensures that tracked balances stay in sync across both systems. Here's how it works: + +1. **Atomic Operations**: All operations (validation → token transfer → settlement contract call → state update) happen atomically. If any step fails, the entire transaction reverts with no partial state changes. +2. **Reconciliation Flow**: + - The vault contract first validates the deduct/batch-deduct request + - It transfers USDC tokens to the settlement contract + - It calls `settlement_client.receive_payment(..., to_pool=true, developer=None)` to notify the settlement contract to credit the global pool + - Only after the cross‑contract call succeeds does the vault update its own internal balance +3. **`to_pool` Semantics**: For all vault‑originated deducts and batch deducts, the deducted amount is always credited to the **global pool** in the settlement contract. + ## Architecture ### Components @@ -12,6 +24,7 @@ This document describes the implementation of revenue settlement functionality t - Enhanced with settlement contract integration - Automatically transfers USDC to settlement on `deduct()` and `batch_deduct()` - Maintains settlement contract address configuration + - Uses cross‑contract calls to `settlement_client.receive_payment()` to ensure reconciliation 2. **Settlement Contract (`callora-settlement`)** - Receives USDC payments from vault @@ -29,13 +42,13 @@ sequenceDiagram API->>Vault: deduct(env, caller, amount, request_id) Vault->>Vault: Validate Auth & Balance - Vault->>Vault: Update internal balance Vault->>USDC: transfer(vault, settlement, amount) USDC-->>Vault: Transfer complete - Vault->>Settlement: receive_payment(env, vault, amount, ...) + Vault->>Settlement: receive_payment(vault, amount, to_pool=true, developer=None) Settlement->>Settlement: Validate caller (vault) - Settlement->>Settlement: Update Global Pool or Dev Balance + Settlement->>Settlement: Update Global Pool Settlement-->>Vault: Payment successful + Vault->>Vault: Update internal balance & mark request processed Vault-->>API: Return new balance ``` diff --git a/contracts/settlement/src/lib.rs b/contracts/settlement/src/lib.rs index 2a00956..59cb32f 100644 --- a/contracts/settlement/src/lib.rs +++ b/contracts/settlement/src/lib.rs @@ -11,28 +11,36 @@ pub const MAX_BATCH_SIZE: u32 = 50; /// Callers and indexers can match on the code rather than parsing raw panic strings, /// and the WASM binary shrinks because no error string literals are embedded. /// -/// | Code | Variant | When | -/// |------|----------------------|---------------------------------------------------| -/// | 1 | NotInitialized | A function is called before `init` | -/// | 2 | AlreadyInitialized | `init` is called more than once | -/// | 3 | Unauthorized | Caller is not the vault or admin | -/// | 4 | AmountNotPositive | `amount` is zero or negative | -/// | 5 | DeveloperRequired | `to_pool=false` but no developer address supplied | -/// | 6 | DeveloperMustBeNone | `to_pool=true` but a developer address was given | -/// | 7 | PoolOverflow | Global pool `i128` addition would overflow | -/// | 8 | DeveloperOverflow | Developer balance `i128` addition would overflow | +/// | Code | Variant | When | +/// |------|------------------------------|---------------------------------------------------| +/// | 1 | NotInitialized | A function is called before `init` | +/// | 2 | AlreadyInitialized | `init` is called more than once | +/// | 3 | Unauthorized | Caller is not the vault or admin | +/// | 4 | AmountNotPositive | `amount` is zero or negative | +/// | 5 | DeveloperRequired | `to_pool=false` but no developer address supplied | +/// | 6 | DeveloperMustBeNone | `to_pool=true` but a developer address was given | +/// | 7 | PoolOverflow | Global pool `i128` addition would overflow | +/// | 8 | DeveloperOverflow | Developer balance `i128` addition would overflow | +/// | 9 | UsdcTokenNotConfigured | USDC token address not configured for withdrawals | +/// | 10 | InsufficientDeveloperBalance | Developer balance is less than withdrawal amount | +/// | 11 | DeveloperBalanceUnderflow | Developer balance subtraction would overflow | +/// | 12 | InsufficientContractBalance | Settlement contract lacks on-ledger USDC | #[contracterror] #[derive(Clone, Copy, Debug, PartialEq)] #[repr(u32)] pub enum SettlementError { - NotInitialized = 1, - AlreadyInitialized = 2, - Unauthorized = 3, - AmountNotPositive = 4, - DeveloperRequired = 5, - DeveloperMustBeNone = 6, - PoolOverflow = 7, - DeveloperOverflow = 8, + NotInitialized = 1, + AlreadyInitialized = 2, + Unauthorized = 3, + AmountNotPositive = 4, + DeveloperRequired = 5, + DeveloperMustBeNone = 6, + PoolOverflow = 7, + DeveloperOverflow = 8, + UsdcTokenNotConfigured = 9, + InsufficientDeveloperBalance = 10, + DeveloperBalanceUnderflow = 11, + InsufficientContractBalance = 12, } /// Maximum number of items accepted by `batch_receive_payment`. @@ -111,6 +119,15 @@ pub struct VaultAcceptedEvent { pub accepted_by: Address, } +/// Emitted when a developer withdraws their balance. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct DeveloperWithdrawEvent { + pub developer: Address, + pub amount: i128, + pub remaining_balance: i128, +} + #[contract] pub struct CalloraSettlement; @@ -662,12 +679,12 @@ impl CalloraSettlement { let inst = env.storage().instance(); let old_vault = Self::get_vault(env.clone()); - inst.set(&StorageKey::Vault, &new_vault); + inst.set(&StorageKey::PendingVault, &new_vault); env.events().publish( (Symbol::new(&env, "vault_proposed"), caller), VaultProposedEvent { - current_vault, + current_vault: old_vault, proposed_vault: new_vault, }, ); diff --git a/contracts/vault/src/lib.rs b/contracts/vault/src/lib.rs index e26c6dc..ea70b9f 100644 --- a/contracts/vault/src/lib.rs +++ b/contracts/vault/src/lib.rs @@ -563,7 +563,8 @@ impl CalloraVault { Ok(meta.balance) } - /// Deduct USDC from the vault and transfer it to the configured settlement address. + /// Deduct USDC from the vault and transfer it to the configured settlement address, + /// then notify the settlement contract to credit the global pool. /// /// # Preconditions /// - `set_settlement` must have been called; returns error otherwise. @@ -578,6 +579,11 @@ impl CalloraVault { /// is persisted in temporary storage for `REQUEST_ID_BUMP_AMOUNT` ledgers. /// /// When `request_id` is `None`, no deduplication is performed. + /// + /// # `to_pool` Semantics (Vault-Originated Deducts) + /// For deducts initiated via this vault contract, the deducted amount is always + /// credited to the **global pool** in the settlement contract. This is done + /// by calling `settlement_client.receive_payment(..., to_pool=true, developer=None)`. pub fn deduct( env: Env, caller: Address, @@ -598,11 +604,36 @@ impl CalloraVault { if let Some(ref rid) = request_id { Self::require_not_duplicate(&env, rid)?; } - let mut meta = Self::get_meta(env.clone())?; + let meta = Self::get_meta(env.clone())?; if meta.balance < amount { return Err(VaultError::InsufficientBalance); } let settlement = Self::require_settlement(&env)?; + let ut: Address = env + .storage() + .instance() + .get(&StorageKey::UsdcToken) + .ok_or(VaultError::NotInitialized)?; + + // Perform all external operations FIRST, so that if any fail, + // the entire transaction reverts with no partial state changes. + Self::transfer_funds(&env, &ut, &settlement, amount); + + // Create a settlement client and call receive_payment to credit the global pool + #[contractclient(name = "SettlementClient")] + trait Settlement { + fn receive_payment(env: Env, caller: Address, amount: i128, to_pool: bool, developer: Option
); + } + let settlement_client = SettlementClient::new(&env, &settlement); + settlement_client.receive_payment( + env.current_contract_address(), + amount, + true, // to_pool = true: credit global pool + None, // no specific developer + ); + + // Now that external operations succeeded, update internal state + let mut meta = Self::get_meta(env.clone())?; meta.balance = meta .balance .checked_sub(amount) @@ -615,12 +646,7 @@ impl CalloraVault { if let Some(ref rid) = request_id { Self::mark_request_processed(&env, rid); } - let ut: Address = env - .storage() - .instance() - .get(&StorageKey::UsdcToken) - .ok_or(VaultError::NotInitialized)?; - Self::transfer_funds(&env, &ut, &settlement, amount); + let rid = request_id.unwrap_or(Symbol::new(&env, "")); env.events().publish( (Symbol::new(&env, "deduct"), caller, rid), @@ -642,6 +668,11 @@ impl CalloraVault { /// the batch are marked as processed. /// /// Items with `request_id = None` are not deduplicated. + /// + /// # `to_pool` Semantics (Vault-Originated Batch Deducts) + /// For batch deducts initiated via this vault contract, the total deducted amount + /// is always credited to the **global pool** in the settlement contract. + /// This is done by calling `settlement_client.receive_payment(..., to_pool=true, developer=None)`. pub fn batch_deduct( env: Env, caller: Address, @@ -687,6 +718,31 @@ impl CalloraVault { total = total.checked_add(item.amount).ok_or(VaultError::Overflow)?; } let settlement = Self::require_settlement(&env)?; + let ut: Address = env + .storage() + .instance() + .get(&StorageKey::UsdcToken) + .ok_or(VaultError::NotInitialized)?; + + // Perform all external operations FIRST, so that if any fail, + // the entire transaction reverts with no partial state changes. + Self::transfer_funds(&env, &ut, &settlement, total); + + // Create a settlement client and call receive_payment to credit the global pool + #[contractclient(name = "SettlementClient")] + trait Settlement { + fn receive_payment(env: Env, caller: Address, amount: i128, to_pool: bool, developer: Option
); + } + let settlement_client = SettlementClient::new(&env, &settlement); + settlement_client.receive_payment( + env.current_contract_address(), + total, + true, // to_pool = true: credit global pool + None, // no specific developer + ); + + // Now that external operations succeeded, update internal state + let mut meta = Self::get_meta(env.clone())?; meta.balance = running; env.storage().instance().set(&StorageKey::MetaKey, &meta); env.storage() @@ -698,12 +754,7 @@ impl CalloraVault { Self::mark_request_processed(&env, rid); } } - let ut: Address = env - .storage() - .instance() - .get(&StorageKey::UsdcToken) - .ok_or(VaultError::NotInitialized)?; - Self::transfer_funds(&env, &ut, &settlement, total); + for item in items.iter() { let rid = item.request_id.unwrap_or(Symbol::new(&env, "")); env.events().publish(