diff --git a/VAULT_ERROR_IMPLEMENTATION.md b/VAULT_ERROR_IMPLEMENTATION.md new file mode 100644 index 0000000..405bca5 --- /dev/null +++ b/VAULT_ERROR_IMPLEMENTATION.md @@ -0,0 +1,126 @@ +# VaultError Implementation Summary + +## Overview +Replaced string panics with typed `VaultError` enum across the Callora Vault contract to enable machine-readable error handling for integrators using @stellar/stellar-sdk. + +## Changes Made + +### 1. Added VaultError Enum (`contracts/vault/src/lib.rs`) +- Defined `#[contracterror]` enum with 27 error codes (1-27) +- Each error has a stable u32 code and descriptive name +- Covers all validation and authorization scenarios + +### Error Codes: +1. **NotInitialized** - Vault not initialized +2. **AlreadyInitialized** - Vault already initialized +3. **Unauthorized** - Caller not authorized +4. **Paused** - Vault is paused +5. **InsufficientBalance** - Insufficient balance +6. **AmountNotPositive** - Amount must be positive +7. **ExceedsMaxDeduct** - Exceeds max deduct limit +8. **BelowMinDeposit** - Below minimum deposit +9. **Overflow** - Arithmetic overflow +10. **InitialBalanceNegative** - Initial balance negative +11. **MinDepositNotPositive** - Min deposit not positive +12. **MaxDeductNotPositive** - Max deduct not positive +13. **MinDepositExceedsMaxDeduct** - Min deposit > max deduct +14. **UsdcTokenCannotBeVault** - USDC token = vault address +15. **RevenuePoolCannotBeVault** - Revenue pool = vault address +16. **AuthorizedCallerCannotBeVault** - Authorized caller = vault address +17. **InitialBalanceExceedsOnLedger** - Initial balance > on-ledger balance +18. **AlreadyPaused** - Vault already paused +19. **NotPaused** - Vault not paused +20. **SettlementNotSet** - Settlement address not configured +21. **BatchEmpty** - Batch deduct requires items +22. **BatchTooLarge** - Batch exceeds max size +23. **NewOwnerSameAsCurrent** - New owner same as current +24. **NoOwnershipTransferPending** - No ownership transfer pending +25. **NoAdminTransferPending** - No admin transfer pending +26. **OfferingIdTooLong** - Offering ID too long +27. **MetadataTooLong** - Metadata too long + +### 2. Converted Functions to Return Result +All public entrypoints now return `Result` instead of panicking: +- `init()` → `Result` +- `deposit()` → `Result` +- `deduct()` → `Result` +- `batch_deduct()` → `Result` +- `withdraw()` → `Result` +- `withdraw_to()` → `Result` +- `distribute()` → `Result<(), VaultError>` +- `pause()` → `Result<(), VaultError>` +- `unpause()` → `Result<(), VaultError>` +- `set_admin()` → `Result<(), VaultError>` +- `accept_admin()` → `Result<(), VaultError>` +- `transfer_ownership()` → `Result<(), VaultError>` +- `accept_ownership()` → `Result<(), VaultError>` +- `set_authorized_caller()` → `Result<(), VaultError>` +- `set_max_deduct()` → `Result<(), VaultError>` +- `set_allowed_depositor()` → `Result<(), VaultError>` +- `clear_allowed_depositors()` → `Result<(), VaultError>` +- `set_revenue_pool()` → `Result<(), VaultError>` +- `set_settlement()` → `Result<(), VaultError>` +- `set_metadata()` → `Result` +- `update_metadata()` → `Result` +- `add_address()` → `Result<(), VaultError>` +- `clear_all()` → `Result<(), VaultError>` + +View functions: +- `get_meta()` → `Result` +- `balance()` → `Result` +- `get_admin()` → `Result` +- `get_usdc_token()` → `Result` +- `get_settlement()` → `Result` +- `is_authorized_depositor()` → `Result` + +### 3. Updated Helper Functions +Private helper functions now return `Result`: +- `require_owner()` → `Result<(), VaultError>` +- `require_authorized_deduct_caller()` → `Result<(), VaultError>` +- `require_settlement()` → `Result` +- `require_not_paused()` → `Result<(), VaultError>` +- `require_admin_or_owner()` → `Result<(), VaultError>` + +### 4. Updated Documentation (`docs/interfaces/vault.json`) +Added comprehensive error codes section with: +- Error code number +- Error name +- Description + +## Benefits + +1. **Machine-Readable Errors**: Integrators can branch on error codes instead of parsing strings +2. **Reduced WASM Size**: Typed errors are more compact than string panics +3. **Better Developer Experience**: Clear error codes with stable u32 values +4. **SDK Compatibility**: Works seamlessly with @stellar/stellar-sdk error handling + +## Testing Notes + +The contract compiles successfully with `cargo check`. Pre-existing test issues are unrelated to this implementation: +- Tests reference non-existent methods (`remove_allowed_depositor`, `cancel_ownership_transfer`, `cancel_admin_transfer`) +- These are pre-existing issues in the test suite + +## WASM Size Impact + +The implementation uses typed errors which are more compact than string panics, contributing to reduced WASM size. The contract should still pass `check-wasm-size.sh`. + +## Security Considerations + +- All error paths maintain the same security guarantees as before +- No authorization bypasses introduced +- Arithmetic overflow still properly detected and returned as errors +- CEI (Checks-Effects-Interactions) pattern preserved + +## Backward Compatibility + +This is a breaking change for integrators: +- All functions now return `Result` types +- Callers must handle errors explicitly +- Error codes are stable and documented + +## Next Steps + +1. Update test suite to handle `Result` types +2. Update integration tests to assert on specific error codes +3. Verify WASM size with `check-wasm-size.sh` +4. Update client SDK documentation with error code handling examples diff --git a/contracts/vault/src/lib.rs b/contracts/vault/src/lib.rs index daaefb5..2780288 100644 --- a/contracts/vault/src/lib.rs +++ b/contracts/vault/src/lib.rs @@ -9,7 +9,73 @@ /// - Owner withdrawals are ALLOWED (emergency recovery) /// - Admin distribute is ALLOWED (emergency recovery of untracked surplus) /// - Admin/owner configuration functions remain available -use soroban_sdk::{contract, contractimpl, contracttype, token, Address, Env, String, Symbol, Vec}; +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, token, Address, Env, String, Symbol, Vec, +}; + +/// Typed error codes for the Callora Vault contract. +/// +/// These error codes are returned instead of string panics to enable +/// machine-readable error handling by integrators using @stellar/stellar-sdk. +#[contracterror] +#[repr(u32)] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +pub enum VaultError { + /// Vault has not been initialized yet (code 1). + NotInitialized = 1, + /// Vault has already been initialized (code 2). + AlreadyInitialized = 2, + /// Caller is not authorized for this operation (code 3). + Unauthorized = 3, + /// Vault is currently paused (code 4). + Paused = 4, + /// Insufficient balance for the requested operation (code 5). + InsufficientBalance = 5, + /// Amount must be positive (code 6). + AmountNotPositive = 6, + /// Deduct amount exceeds the configured maximum (code 7). + ExceedsMaxDeduct = 7, + /// Deposit amount is below the configured minimum (code 8). + BelowMinDeposit = 8, + /// Arithmetic overflow detected (code 9). + Overflow = 9, + /// Initial balance must be non-negative (code 10). + InitialBalanceNegative = 10, + /// Min deposit must be positive (code 11). + MinDepositNotPositive = 11, + /// Max deduct must be positive (code 12). + MaxDeductNotPositive = 12, + /// Min deposit cannot exceed max deduct (code 13). + MinDepositExceedsMaxDeduct = 13, + /// USDC token address cannot be the vault address (code 14). + UsdcTokenCannotBeVault = 14, + /// Revenue pool address cannot be the vault address (code 15). + RevenuePoolCannotBeVault = 15, + /// Authorized caller address cannot be the vault address (code 16). + AuthorizedCallerCannotBeVault = 16, + /// Initial balance exceeds on-ledger USDC balance (code 17). + InitialBalanceExceedsOnLedger = 17, + /// Vault is already paused (code 18). + AlreadyPaused = 18, + /// Vault is not paused (code 19). + NotPaused = 19, + /// Settlement address has not been configured (code 20). + SettlementNotSet = 20, + /// Batch deduct requires at least one item (code 21). + BatchEmpty = 21, + /// Batch size exceeds maximum allowed (code 22). + BatchTooLarge = 22, + /// New owner must be different from current owner (code 23). + NewOwnerSameAsCurrent = 23, + /// No ownership transfer is pending (code 24). + NoOwnershipTransferPending = 24, + /// No admin transfer is pending (code 25). + NoAdminTransferPending = 25, + /// Offering ID exceeds maximum length (code 26). + OfferingIdTooLong = 26, + /// Metadata exceeds maximum length (code 27). + MetadataTooLong = 27, +} #[contracttype] #[derive(Clone)] @@ -68,7 +134,7 @@ pub struct CalloraVault; #[contractimpl] impl CalloraVault { - /// Initialize the vault. Exactly-once; panics if called again. + /// Initialize the vault. Exactly-once; returns error if called again. /// /// # Parameters /// - `owner` — vault owner; must sign the transaction. @@ -83,16 +149,16 @@ impl CalloraVault { /// - `max_deduct` — maximum single deduction (defaults to `i128::MAX`, must be > 0). /// Must be >= `min_deposit`. /// - /// # Panics - /// - `"vault already initialized"` — called more than once. - /// - `"usdc_token cannot be vault address"` — self-referential token. - /// - `"revenue_pool cannot be vault address"` — self-referential pool. - /// - `"authorized_caller cannot be vault address"` — self-referential caller. - /// - `"initial balance must be non-negative"` — negative initial balance. - /// - `"min_deposit must be positive"` — `min_deposit <= 0`. - /// - `"max_deduct must be positive"` — `max_deduct <= 0`. - /// - `"min_deposit cannot exceed max_deduct"` — constraint violation. - /// - `"initial_balance exceeds on-ledger USDC balance"` — vault underfunded. + /// # Errors + /// - `VaultError::AlreadyInitialized` — called more than once. + /// - `VaultError::UsdcTokenCannotBeVault` — self-referential token. + /// - `VaultError::RevenuePoolCannotBeVault` — self-referential pool. + /// - `VaultError::AuthorizedCallerCannotBeVault` — self-referential caller. + /// - `VaultError::InitialBalanceNegative` — negative initial balance. + /// - `VaultError::MinDepositNotPositive` — `min_deposit <= 0`. + /// - `VaultError::MaxDeductNotPositive` — `max_deduct <= 0`. + /// - `VaultError::MinDepositExceedsMaxDeduct` — constraint violation. + /// - `VaultError::InitialBalanceExceedsOnLedger` — vault underfunded. #[allow(clippy::too_many_arguments)] pub fn init( env: Env, @@ -103,42 +169,46 @@ impl CalloraVault { min_deposit: Option, revenue_pool: Option
, max_deduct: Option, - ) -> VaultMeta { + ) -> Result { owner.require_auth(); let inst = env.storage().instance(); if inst.has(&StorageKey::MetaKey) { - panic!("vault already initialized"); + return Err(VaultError::AlreadyInitialized); + } + if usdc_token == env.current_contract_address() { + return Err(VaultError::UsdcTokenCannotBeVault); } - assert!( - usdc_token != env.current_contract_address(), - "usdc_token cannot be vault address" - ); if let Some(p) = &revenue_pool { - assert!( - p != &env.current_contract_address(), - "revenue_pool cannot be vault address" - ); + if p == &env.current_contract_address() { + return Err(VaultError::RevenuePoolCannotBeVault); + } } if let Some(ac) = &authorized_caller { - assert!( - ac != &env.current_contract_address(), - "authorized_caller cannot be vault address" - ); + if ac == &env.current_contract_address() { + return Err(VaultError::AuthorizedCallerCannotBeVault); + } } let balance = initial_balance.unwrap_or(0); - assert!(balance >= 0, "initial balance must be non-negative"); + if balance < 0 { + return Err(VaultError::InitialBalanceNegative); + } let min_d = min_deposit.unwrap_or(DEFAULT_MIN_DEPOSIT); - assert!(min_d > 0, "min_deposit must be positive"); + if min_d <= 0 { + return Err(VaultError::MinDepositNotPositive); + } let max_d = max_deduct.unwrap_or(DEFAULT_MAX_DEDUCT); - assert!(max_d > 0, "max_deduct must be positive"); - assert!(min_d <= max_d, "min_deposit cannot exceed max_deduct"); + if max_d <= 0 { + return Err(VaultError::MaxDeductNotPositive); + } + if min_d > max_d { + return Err(VaultError::MinDepositExceedsMaxDeduct); + } if balance > 0 { let on_chain = token::Client::new(&env, &usdc_token).balance(&env.current_contract_address()); - assert!( - on_chain >= balance, - "initial_balance exceeds on-ledger USDC balance" - ); + if on_chain < balance { + return Err(VaultError::InitialBalanceExceedsOnLedger); + } } let meta = VaultMeta { owner: owner.clone(), @@ -156,40 +226,40 @@ impl CalloraVault { inst.extend_ttl(INSTANCE_BUMP_THRESHOLD, INSTANCE_BUMP_AMOUNT); env.events() .publish((Symbol::new(&env, "init"), owner.clone()), balance); - meta + Ok(meta) } // ----------------------------------------------------------------------- // View functions — no TTL bump (read-only, zero write cost) // ----------------------------------------------------------------------- - /// Return full vault state. Panics if vault is not initialized. - pub fn get_meta(env: Env) -> VaultMeta { + /// Return full vault state. Returns error if vault is not initialized. + pub fn get_meta(env: Env) -> Result { env.storage() .instance() .get(&StorageKey::MetaKey) - .unwrap_or_else(|| panic!("vault not initialized")) + .ok_or(VaultError::NotInitialized) } - /// Return the current tracked USDC balance. Panics if vault is not initialized. - pub fn balance(env: Env) -> i128 { - Self::get_meta(env).balance + /// Return the current tracked USDC balance. Returns error if vault is not initialized. + pub fn balance(env: Env) -> Result { + Ok(Self::get_meta(env)?.balance) } - /// Return the current admin address. Panics if vault is not initialized. - pub fn get_admin(env: Env) -> Address { + /// Return the current admin address. Returns error if vault is not initialized. + pub fn get_admin(env: Env) -> Result { env.storage() .instance() .get(&StorageKey::Admin) - .expect("vault not initialized") + .ok_or(VaultError::NotInitialized) } - /// Return the USDC token contract address. Panics if vault is not initialized. - pub fn get_usdc_token(env: Env) -> Address { + /// Return the USDC token contract address. Returns error if vault is not initialized. + pub fn get_usdc_token(env: Env) -> Result { env.storage() .instance() .get(&StorageKey::UsdcToken) - .expect("vault not initialized") + .ok_or(VaultError::NotInitialized) } /// Return the configured max deduct value. Returns `i128::MAX` if not explicitly set. @@ -201,12 +271,12 @@ impl CalloraVault { } /// Return the configured settlement address. - /// Panics with `"settlement address not set"` if `set_settlement` has not been called. - pub fn get_settlement(env: Env) -> Address { + /// Returns error if `set_settlement` has not been called. + pub fn get_settlement(env: Env) -> Result { env.storage() .instance() .get(&StorageKey::Settlement) - .unwrap_or_else(|| panic!("settlement address not set")) + .ok_or(VaultError::SettlementNotSet) } /// Return the configured revenue pool address, or `None` if not set. @@ -245,18 +315,18 @@ impl CalloraVault { } /// Return `true` if `caller` is the owner or an allowed depositor. - /// Panics if vault is not initialized. - pub fn is_authorized_depositor(env: Env, caller: Address) -> bool { - let meta = Self::get_meta(env.clone()); + /// Returns error if vault is not initialized. + pub fn is_authorized_depositor(env: Env, caller: Address) -> Result { + let meta = Self::get_meta(env.clone())?; if caller == meta.owner { - return true; + return Ok(true); } let list: Vec
= env .storage() .instance() .get(&StorageKey::DepositorList) .unwrap_or(Vec::new(&env)); - list.contains(&caller) + Ok(list.contains(&caller)) } /// Return stored offering metadata, or `None` if not set. @@ -278,41 +348,49 @@ impl CalloraVault { // Mutating functions // ----------------------------------------------------------------------- - pub fn set_admin(env: Env, caller: Address, new_admin: Address) { + pub fn set_admin(env: Env, caller: Address, new_admin: Address) -> Result<(), VaultError> { caller.require_auth(); - let cur = Self::get_admin(env.clone()); + let cur = Self::get_admin(env.clone())?; if caller != cur { - panic!("unauthorized: caller is not admin"); + return Err(VaultError::Unauthorized); } env.storage() .instance() .set(&StorageKey::PendingAdmin, &new_admin); env.events() .publish((Symbol::new(&env, "admin_nominated"), cur, new_admin), ()); + Ok(()) } - pub fn accept_admin(env: Env) { + pub fn accept_admin(env: Env) -> Result<(), VaultError> { let pending: Address = env .storage() .instance() .get(&StorageKey::PendingAdmin) - .expect("no admin transfer pending"); + .ok_or(VaultError::NoAdminTransferPending)?; pending.require_auth(); - let cur = Self::get_admin(env.clone()); + let cur = Self::get_admin(env.clone())?; env.storage().instance().set(&StorageKey::Admin, &pending); env.storage().instance().remove(&StorageKey::PendingAdmin); env.events() .publish((Symbol::new(&env, "admin_accepted"), cur, pending), ()); + Ok(()) } - pub fn require_owner(env: Env, caller: Address) { - let meta = Self::get_meta(env.clone()); - assert!(caller == meta.owner, "unauthorized: owner only"); + pub fn require_owner(env: Env, caller: Address) -> Result<(), VaultError> { + let meta = Self::get_meta(env.clone())?; + if caller != meta.owner { + return Err(VaultError::Unauthorized); + } + Ok(()) } /// Set or clear the authorized caller for `deduct`/`batch_deduct` (owner only). - pub fn set_authorized_caller(env: Env, new_caller: Option
) { - let mut meta = Self::get_meta(env.clone()); + pub fn set_authorized_caller( + env: Env, + new_caller: Option
, + ) -> Result<(), VaultError> { + let mut meta = Self::get_meta(env.clone())?; meta.owner.require_auth(); let old = meta.authorized_caller.clone(); meta.authorized_caller = new_caller.clone(); @@ -324,16 +402,19 @@ impl CalloraVault { ), (old, new_caller), ); + Ok(()) } /// Set `max_deduct` (owner only). /// - /// # Panics - /// - `"max_deduct must be positive"` when `max_deduct <= 0`. - pub fn set_max_deduct(env: Env, max_deduct: i128) { - let meta = Self::get_meta(env.clone()); + /// # Errors + /// - `VaultError::MaxDeductNotPositive` when `max_deduct <= 0`. + pub fn set_max_deduct(env: Env, max_deduct: i128) -> Result<(), VaultError> { + let meta = Self::get_meta(env.clone())?; meta.owner.require_auth(); - assert!(max_deduct > 0, "max_deduct must be positive"); + if max_deduct <= 0 { + return Err(VaultError::MaxDeductNotPositive); + } let old = Self::get_max_deduct(env.clone()); env.storage() .instance() @@ -342,11 +423,16 @@ impl CalloraVault { (Symbol::new(&env, "set_max_deduct"), meta.owner), (old, max_deduct), ); + Ok(()) } - pub fn set_allowed_depositor(env: Env, caller: Address, depositor: Option
) { + pub fn set_allowed_depositor( + env: Env, + caller: Address, + depositor: Option
, + ) -> Result<(), VaultError> { caller.require_auth(); - Self::require_owner(env.clone(), caller.clone()); + Self::require_owner(env.clone(), caller.clone())?; match depositor { Some(d) => { let mut list: Vec
= env @@ -367,60 +453,66 @@ impl CalloraVault { .set(&StorageKey::DepositorList, &Vec::
::new(&env)); } } + Ok(()) } - pub fn clear_allowed_depositors(env: Env, caller: Address) { + pub fn clear_allowed_depositors(env: Env, caller: Address) -> Result<(), VaultError> { caller.require_auth(); - Self::require_owner(env.clone(), caller); + Self::require_owner(env.clone(), caller)?; env.storage() .instance() .set(&StorageKey::DepositorList, &Vec::
::new(&env)); + Ok(()) } - pub fn pause(env: Env, caller: Address) { + pub fn pause(env: Env, caller: Address) -> Result<(), VaultError> { caller.require_auth(); - Self::require_admin_or_owner(env.clone(), &caller); - assert!(!Self::is_paused(env.clone()), "vault already paused"); + Self::require_admin_or_owner(env.clone(), &caller)?; + if Self::is_paused(env.clone()) { + return Err(VaultError::AlreadyPaused); + } env.storage().instance().set(&StorageKey::Paused, &true); env.events() .publish((Symbol::new(&env, "vault_paused"), caller), ()); + Ok(()) } - pub fn unpause(env: Env, caller: Address) { + pub fn unpause(env: Env, caller: Address) -> Result<(), VaultError> { caller.require_auth(); - Self::require_admin_or_owner(env.clone(), &caller); - assert!(Self::is_paused(env.clone()), "vault not paused"); + Self::require_admin_or_owner(env.clone(), &caller)?; + if !Self::is_paused(env.clone()) { + return Err(VaultError::NotPaused); + } env.storage().instance().set(&StorageKey::Paused, &false); env.events() .publish((Symbol::new(&env, "vault_unpaused"), caller), ()); + Ok(()) } - pub fn deposit(env: Env, caller: Address, amount: i128) -> i128 { - Self::require_not_paused(env.clone()); + pub fn deposit(env: Env, caller: Address, amount: i128) -> Result { + Self::require_not_paused(env.clone())?; caller.require_auth(); - assert!(amount > 0, "amount must be positive"); - assert!( - Self::is_authorized_depositor(env.clone(), caller.clone()), - "unauthorized: only owner or allowed depositor can deposit" - ); - let mut meta = Self::get_meta(env.clone()); - assert!( - amount >= meta.min_deposit, - "deposit below minimum: {} < {}", - amount, - meta.min_deposit - ); + if amount <= 0 { + return Err(VaultError::AmountNotPositive); + } + if !Self::is_authorized_depositor(env.clone(), caller.clone())? { + return Err(VaultError::Unauthorized); + } + let mut meta = Self::get_meta(env.clone())?; + if amount < meta.min_deposit { + return Err(VaultError::BelowMinDeposit); + } let usdc_addr: Address = env .storage() .instance() .get(&StorageKey::UsdcToken) - .expect("vault not initialized"); + .ok_or(VaultError::NotInitialized)?; token::Client::new(&env, &usdc_addr) .transfer(&caller, &env.current_contract_address(), &amount); meta.balance = meta .balance .checked_add(amount) - .unwrap_or_else(|| panic!("balance overflow")); + .ok_or(VaultError::Overflow)?; env.storage().instance().set(&StorageKey::MetaKey, &meta); env.storage() .instance() @@ -429,30 +521,41 @@ impl CalloraVault { (Symbol::new(&env, "deposit"), caller), (amount, meta.balance), ); - meta.balance + Ok(meta.balance) } /// Deduct USDC from the vault and transfer it to the configured settlement address. /// /// # Preconditions - /// - `set_settlement` must have been called; panics with `"settlement address not set"` otherwise. + /// - `set_settlement` must have been called; returns error otherwise. /// - `amount` must be positive and <= `max_deduct`. /// - `caller` must be the owner or `authorized_caller`. /// - Vault balance must cover `amount`. - pub fn deduct(env: Env, caller: Address, amount: i128, request_id: Option) -> i128 { - Self::require_not_paused(env.clone()); + pub fn deduct( + env: Env, + caller: Address, + amount: i128, + request_id: Option, + ) -> Result { + Self::require_not_paused(env.clone())?; caller.require_auth(); - assert!(amount > 0, "amount must be positive"); - Self::require_authorized_deduct_caller(env.clone(), &caller); + if amount <= 0 { + return Err(VaultError::AmountNotPositive); + } + Self::require_authorized_deduct_caller(env.clone(), &caller)?; let max_d = Self::get_max_deduct(env.clone()); - assert!(amount <= max_d, "deduct amount exceeds max_deduct"); - let mut meta = Self::get_meta(env.clone()); - assert!(meta.balance >= amount, "insufficient balance"); - let settlement = Self::require_settlement(&env); + if amount > max_d { + return Err(VaultError::ExceedsMaxDeduct); + } + let mut meta = Self::get_meta(env.clone())?; + if meta.balance < amount { + return Err(VaultError::InsufficientBalance); + } + let settlement = Self::require_settlement(&env)?; meta.balance = meta .balance .checked_sub(amount) - .unwrap_or_else(|| panic!("balance underflow")); + .ok_or(VaultError::Overflow)?; env.storage().instance().set(&StorageKey::MetaKey, &meta); env.storage() .instance() @@ -461,43 +564,53 @@ impl CalloraVault { .storage() .instance() .get(&StorageKey::UsdcToken) - .expect("vault not initialized"); + .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), (amount, meta.balance), ); - meta.balance + Ok(meta.balance) } /// Deduct multiple items atomically. /// /// Full-batch validation completes before any state write or transfer. /// If any item fails validation, the entire batch reverts with no partial effects. - pub fn batch_deduct(env: Env, caller: Address, items: Vec) -> i128 { - Self::require_not_paused(env.clone()); + pub fn batch_deduct( + env: Env, + caller: Address, + items: Vec, + ) -> Result { + Self::require_not_paused(env.clone())?; caller.require_auth(); - Self::require_authorized_deduct_caller(env.clone(), &caller); + Self::require_authorized_deduct_caller(env.clone(), &caller)?; let n = items.len(); - assert!(n > 0, "batch_deduct requires at least one item"); - assert!(n <= MAX_BATCH_SIZE, "batch too large"); + if n == 0 { + return Err(VaultError::BatchEmpty); + } + if n > MAX_BATCH_SIZE { + return Err(VaultError::BatchTooLarge); + } let max_d = Self::get_max_deduct(env.clone()); - let mut meta = Self::get_meta(env.clone()); + let mut meta = Self::get_meta(env.clone())?; let mut running = meta.balance; let mut total: i128 = 0; for item in items.iter() { - assert!(item.amount > 0, "amount must be positive"); - assert!(item.amount <= max_d, "deduct amount exceeds max_deduct"); - assert!(running >= item.amount, "insufficient balance"); - running = running - .checked_sub(item.amount) - .unwrap_or_else(|| panic!("balance underflow")); - total = total - .checked_add(item.amount) - .unwrap_or_else(|| panic!("total overflow")); - } - let settlement = Self::require_settlement(&env); + if item.amount <= 0 { + return Err(VaultError::AmountNotPositive); + } + if item.amount > max_d { + return Err(VaultError::ExceedsMaxDeduct); + } + if running < item.amount { + return Err(VaultError::InsufficientBalance); + } + running = running.checked_sub(item.amount).ok_or(VaultError::Overflow)?; + total = total.checked_add(item.amount).ok_or(VaultError::Overflow)?; + } + let settlement = Self::require_settlement(&env)?; meta.balance = running; env.storage().instance().set(&StorageKey::MetaKey, &meta); env.storage() @@ -507,7 +620,7 @@ impl CalloraVault { .storage() .instance() .get(&StorageKey::UsdcToken) - .expect("vault not initialized"); + .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, "")); @@ -516,16 +629,15 @@ impl CalloraVault { (item.amount, meta.balance), ); } - meta.balance + Ok(meta.balance) } - pub fn transfer_ownership(env: Env, new_owner: Address) { - let meta = Self::get_meta(env.clone()); + pub fn transfer_ownership(env: Env, new_owner: Address) -> Result<(), VaultError> { + let meta = Self::get_meta(env.clone())?; meta.owner.require_auth(); - assert!( - new_owner != meta.owner, - "new_owner must be different from current owner" - ); + if new_owner == meta.owner { + return Err(VaultError::NewOwnerSameAsCurrent); + } env.storage() .instance() .set(&StorageKey::PendingOwner, &new_owner); @@ -537,16 +649,17 @@ impl CalloraVault { ), (), ); + Ok(()) } - pub fn accept_ownership(env: Env) { + pub fn accept_ownership(env: Env) -> Result<(), VaultError> { let pending: Address = env .storage() .instance() .get(&StorageKey::PendingOwner) - .expect("no ownership transfer pending"); + .ok_or(VaultError::NoOwnershipTransferPending)?; pending.require_auth(); - let mut meta = Self::get_meta(env.clone()); + let mut meta = Self::get_meta(env.clone())?; let old = meta.owner.clone(); meta.owner = pending; env.storage().instance().set(&StorageKey::MetaKey, &meta); @@ -555,27 +668,29 @@ impl CalloraVault { (Symbol::new(&env, "ownership_accepted"), old, meta.owner), (), ); + Ok(()) } - pub fn withdraw(env: Env, amount: i128) -> i128 { - let mut meta = Self::get_meta(env.clone()); + pub fn withdraw(env: Env, amount: i128) -> Result { + let mut meta = Self::get_meta(env.clone())?; meta.owner.require_auth(); - assert!(amount > 0, "amount must be positive"); - assert!(meta.balance >= amount, "insufficient balance"); + if amount <= 0 { + return Err(VaultError::AmountNotPositive); + } + if meta.balance < amount { + return Err(VaultError::InsufficientBalance); + } let ua: Address = env .storage() .instance() .get(&StorageKey::UsdcToken) - .expect("vault not initialized"); + .ok_or(VaultError::NotInitialized)?; token::Client::new(&env, &ua).transfer( &env.current_contract_address(), &meta.owner, &amount, ); - meta.balance = meta - .balance - .checked_sub(amount) - .unwrap_or_else(|| panic!("balance underflow")); + meta.balance = meta.balance.checked_sub(amount).ok_or(VaultError::Overflow)?; env.storage().instance().set(&StorageKey::MetaKey, &meta); env.storage() .instance() @@ -584,24 +699,25 @@ impl CalloraVault { (Symbol::new(&env, "withdraw"), meta.owner.clone()), (amount, meta.balance), ); - meta.balance + Ok(meta.balance) } - pub fn withdraw_to(env: Env, to: Address, amount: i128) -> i128 { - let mut meta = Self::get_meta(env.clone()); + pub fn withdraw_to(env: Env, to: Address, amount: i128) -> Result { + let mut meta = Self::get_meta(env.clone())?; meta.owner.require_auth(); - assert!(amount > 0, "amount must be positive"); - assert!(meta.balance >= amount, "insufficient balance"); + if amount <= 0 { + return Err(VaultError::AmountNotPositive); + } + if meta.balance < amount { + return Err(VaultError::InsufficientBalance); + } let ua: Address = env .storage() .instance() .get(&StorageKey::UsdcToken) - .expect("vault not initialized"); + .ok_or(VaultError::NotInitialized)?; token::Client::new(&env, &ua).transfer(&env.current_contract_address(), &to, &amount); - meta.balance = meta - .balance - .checked_sub(amount) - .unwrap_or_else(|| panic!("balance underflow")); + meta.balance = meta.balance.checked_sub(amount).ok_or(VaultError::Overflow)?; env.storage().instance().set(&StorageKey::MetaKey, &meta); env.storage() .instance() @@ -610,7 +726,7 @@ impl CalloraVault { (Symbol::new(&env, "withdraw_to"), meta.owner.clone(), to), (amount, meta.balance), ); - meta.balance + Ok(meta.balance) } /// Distribute USDC from the vault to an arbitrary recipient (admin only). @@ -624,37 +740,49 @@ impl CalloraVault { /// Rationale: `distribute` is an emergency recovery tool for admins to move /// untracked surplus funds even during a circuit-breaker event. /// - /// # Panics - /// - `"unauthorized: caller is not admin"` — caller is not the admin. - /// - `"amount must be positive"` — `amount <= 0`. - /// - `"insufficient USDC balance"` — vault lacks on-ledger USDC for transfer. - pub fn distribute(env: Env, caller: Address, to: Address, amount: i128) { + /// # Errors + /// - `VaultError::Unauthorized` — caller is not the admin. + /// - `VaultError::AmountNotPositive` — `amount <= 0`. + /// - `VaultError::InsufficientBalance` — vault lacks on-ledger USDC for transfer. + pub fn distribute( + env: Env, + caller: Address, + to: Address, + amount: i128, + ) -> Result<(), VaultError> { caller.require_auth(); - let admin = Self::get_admin(env.clone()); + let admin = Self::get_admin(env.clone())?; if caller != admin { - panic!("unauthorized: caller is not admin"); + return Err(VaultError::Unauthorized); + } + if amount <= 0 { + return Err(VaultError::AmountNotPositive); } - assert!(amount > 0, "amount must be positive"); let usdc_addr: Address = env .storage() .instance() .get(&StorageKey::UsdcToken) - .expect("vault not initialized"); + .ok_or(VaultError::NotInitialized)?; let usdc = token::Client::new(&env, &usdc_addr); if usdc.balance(&env.current_contract_address()) < amount { - panic!("insufficient USDC balance"); + return Err(VaultError::InsufficientBalance); } // CEI: emit event before external transfer env.events() .publish((Symbol::new(&env, "distribute"), to.clone()), amount); usdc.transfer(&env.current_contract_address(), &to, &amount); + Ok(()) } - pub fn set_revenue_pool(env: Env, caller: Address, revenue_pool: Option
) { + pub fn set_revenue_pool( + env: Env, + caller: Address, + revenue_pool: Option
, + ) -> Result<(), VaultError> { caller.require_auth(); - let admin = Self::get_admin(env.clone()); + let admin = Self::get_admin(env.clone())?; if caller != admin { - panic!("unauthorized: caller is not admin"); + return Err(VaultError::Unauthorized); } match revenue_pool { Some(addr) => { @@ -670,17 +798,21 @@ impl CalloraVault { .publish((Symbol::new(&env, "clear_revenue_pool"), caller), ()); } } + Ok(()) } /// Store the settlement contract address (admin only). /// - /// `deduct` and `batch_deduct` panic with `"settlement address not set"` until - /// this is called. - pub fn set_settlement(env: Env, caller: Address, settlement_address: Address) { + /// `deduct` and `batch_deduct` return error until this is called. + pub fn set_settlement( + env: Env, + caller: Address, + settlement_address: Address, + ) -> Result<(), VaultError> { caller.require_auth(); - let admin = Self::get_admin(env.clone()); + let admin = Self::get_admin(env.clone())?; if caller != admin { - panic!("unauthorized: caller is not admin"); + return Err(VaultError::Unauthorized); } env.storage() .instance() @@ -689,6 +821,7 @@ impl CalloraVault { (Symbol::new(&env, "set_settlement"), caller), settlement_address, ); + Ok(()) } pub fn set_metadata( @@ -696,17 +829,15 @@ impl CalloraVault { caller: Address, offering_id: String, metadata: String, - ) -> String { + ) -> Result { caller.require_auth(); - Self::require_owner(env.clone(), caller.clone()); - assert!( - offering_id.len() <= MAX_OFFERING_ID_LEN, - "offering_id exceeds max length" - ); - assert!( - metadata.len() <= MAX_METADATA_LEN, - "metadata exceeds max length" - ); + Self::require_owner(env.clone(), caller.clone())?; + if offering_id.len() > MAX_OFFERING_ID_LEN { + return Err(VaultError::OfferingIdTooLong); + } + if metadata.len() > MAX_METADATA_LEN { + return Err(VaultError::MetadataTooLong); + } env.storage() .instance() .set(&StorageKey::Metadata(offering_id.clone()), &metadata); @@ -714,7 +845,7 @@ impl CalloraVault { (Symbol::new(&env, "metadata_set"), offering_id, caller), metadata.clone(), ); - metadata + Ok(metadata) } pub fn update_metadata( @@ -722,17 +853,15 @@ impl CalloraVault { caller: Address, offering_id: String, metadata: String, - ) -> String { + ) -> Result { caller.require_auth(); - Self::require_owner(env.clone(), caller.clone()); - assert!( - offering_id.len() <= MAX_OFFERING_ID_LEN, - "offering_id exceeds max length" - ); - assert!( - metadata.len() <= MAX_METADATA_LEN, - "metadata exceeds max length" - ); + Self::require_owner(env.clone(), caller.clone())?; + if offering_id.len() > MAX_OFFERING_ID_LEN { + return Err(VaultError::OfferingIdTooLong); + } + if metadata.len() > MAX_METADATA_LEN { + return Err(VaultError::MetadataTooLong); + } let old: String = env .storage() .instance() @@ -745,57 +874,63 @@ impl CalloraVault { (Symbol::new(&env, "metadata_updated"), offering_id, caller), (old, metadata.clone()), ); - metadata + Ok(metadata) } // ----------------------------------------------------------------------- // Private helpers // ----------------------------------------------------------------------- - fn require_authorized_deduct_caller(env: Env, caller: &Address) { - let meta = Self::get_meta(env.clone()); + fn require_authorized_deduct_caller(env: Env, caller: &Address) -> Result<(), VaultError> { + let meta = Self::get_meta(env.clone())?; let auth = match &meta.authorized_caller { Some(ac) => caller == ac || *caller == meta.owner, None => *caller == meta.owner, }; - assert!(auth, "unauthorized caller"); + if !auth { + return Err(VaultError::Unauthorized); + } + Ok(()) } fn transfer_funds(env: &Env, usdc_token: &Address, to: &Address, amount: i128) { token::Client::new(env, usdc_token).transfer(&env.current_contract_address(), to, &amount); } - fn require_settlement(env: &Env) -> Address { + fn require_settlement(env: &Env) -> Result { env.storage() .instance() .get(&StorageKey::Settlement) - .unwrap_or_else(|| panic!("settlement address not set")) + .ok_or(VaultError::SettlementNotSet) } - fn require_not_paused(env: Env) { - assert!(!Self::is_paused(env), "vault is paused"); + fn require_not_paused(env: Env) -> Result<(), VaultError> { + if Self::is_paused(env) { + return Err(VaultError::Paused); + } + Ok(()) } - fn require_admin_or_owner(env: Env, caller: &Address) { + fn require_admin_or_owner(env: Env, caller: &Address) -> Result<(), VaultError> { let admin: Address = env .storage() .instance() .get(&StorageKey::Admin) - .expect("vault not initialized"); - let meta = Self::get_meta(env); - assert!( - *caller == admin || *caller == meta.owner, - "unauthorized: caller is not admin or owner" - ); + .ok_or(VaultError::NotInitialized)?; + let meta = Self::get_meta(env)?; + if *caller != admin && *caller != meta.owner { + return Err(VaultError::Unauthorized); + } + Ok(()) } } // Allowlist aliases — convenience wrappers used by tests and external callers. #[contractimpl] impl CalloraVault { - pub fn add_address(env: Env, caller: Address, depositor: Address) { + pub fn add_address(env: Env, caller: Address, depositor: Address) -> Result<(), VaultError> { caller.require_auth(); - Self::require_owner(env.clone(), caller.clone()); + Self::require_owner(env.clone(), caller.clone())?; let mut list: Vec
= env .storage() .instance() @@ -809,16 +944,18 @@ impl CalloraVault { .set(&StorageKey::DepositorList, &list); env.events() .publish((Symbol::new(&env, "allowlist_add"), caller, depositor), ()); + Ok(()) } - pub fn clear_all(env: Env, caller: Address) { + pub fn clear_all(env: Env, caller: Address) -> Result<(), VaultError> { caller.require_auth(); - Self::require_owner(env.clone(), caller.clone()); + Self::require_owner(env.clone(), caller.clone())?; env.storage() .instance() .set(&StorageKey::DepositorList, &Vec::
::new(&env)); env.events() .publish((Symbol::new(&env, "allowlist_clear"), caller), ()); + Ok(()) } pub fn get_allowlist(env: Env) -> Vec
{ diff --git a/docs/interfaces/vault.json b/docs/interfaces/vault.json index abf84c1..96ceea3 100644 --- a/docs/interfaces/vault.json +++ b/docs/interfaces/vault.json @@ -5,6 +5,39 @@ "crate": "callora-vault", "source": "contracts/vault/src/lib.rs", + "errors": { + "description": "Typed error codes returned by vault operations. These replace string panics to enable machine-readable error handling.", + "codes": [ + { "code": 1, "name": "NotInitialized", "description": "Vault has not been initialized yet." }, + { "code": 2, "name": "AlreadyInitialized", "description": "Vault has already been initialized." }, + { "code": 3, "name": "Unauthorized", "description": "Caller is not authorized for this operation." }, + { "code": 4, "name": "Paused", "description": "Vault is currently paused." }, + { "code": 5, "name": "InsufficientBalance", "description": "Insufficient balance for the requested operation." }, + { "code": 6, "name": "AmountNotPositive", "description": "Amount must be positive." }, + { "code": 7, "name": "ExceedsMaxDeduct", "description": "Deduct amount exceeds the configured maximum." }, + { "code": 8, "name": "BelowMinDeposit", "description": "Deposit amount is below the configured minimum." }, + { "code": 9, "name": "Overflow", "description": "Arithmetic overflow detected." }, + { "code": 10, "name": "InitialBalanceNegative", "description": "Initial balance must be non-negative." }, + { "code": 11, "name": "MinDepositNotPositive", "description": "Min deposit must be positive." }, + { "code": 12, "name": "MaxDeductNotPositive", "description": "Max deduct must be positive." }, + { "code": 13, "name": "MinDepositExceedsMaxDeduct", "description": "Min deposit cannot exceed max deduct." }, + { "code": 14, "name": "UsdcTokenCannotBeVault", "description": "USDC token address cannot be the vault address." }, + { "code": 15, "name": "RevenuePoolCannotBeVault", "description": "Revenue pool address cannot be the vault address." }, + { "code": 16, "name": "AuthorizedCallerCannotBeVault", "description": "Authorized caller address cannot be the vault address." }, + { "code": 17, "name": "InitialBalanceExceedsOnLedger", "description": "Initial balance exceeds on-ledger USDC balance." }, + { "code": 18, "name": "AlreadyPaused", "description": "Vault is already paused." }, + { "code": 19, "name": "NotPaused", "description": "Vault is not paused." }, + { "code": 20, "name": "SettlementNotSet", "description": "Settlement address has not been configured." }, + { "code": 21, "name": "BatchEmpty", "description": "Batch deduct requires at least one item." }, + { "code": 22, "name": "BatchTooLarge", "description": "Batch size exceeds maximum allowed." }, + { "code": 23, "name": "NewOwnerSameAsCurrent", "description": "New owner must be different from current owner." }, + { "code": 24, "name": "NoOwnershipTransferPending", "description": "No ownership transfer is pending." }, + { "code": 25, "name": "NoAdminTransferPending", "description": "No admin transfer is pending." }, + { "code": 26, "name": "OfferingIdTooLong", "description": "Offering ID exceeds maximum length." }, + { "code": 27, "name": "MetadataTooLong", "description": "Metadata exceeds maximum length." } + ] + }, + "types": { "VaultMeta": { "description": "On-chain vault state returned by init and get_meta.",