diff --git a/README.md b/README.md index 56a185a4..7df50477 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ The catalogue below omits the Soroban `Env` argument. See the [vault contract in - `transfer_ownership(caller, new_owner)` / `accept_ownership()` — Two-step ownership transfer; acceptance is authorized by the pending owner. - `prune_processed_requests(caller, ids)` — Owner-only removal of processed request markers. - `add_address(caller, depositor)` / `clear_all(caller)` — Owner-only deposit-allowlist management. -- `set_reserve_cap(caller, token, cap)` — Owner-only reserve-cap update for a token. +- `set_reserve_cap(caller, token, cap)` — Owner-only inclusive reserve-cap update for the tracked USDC balance; other tokens are rejected. **Admin and pending-admin actions** diff --git a/contracts/vault/src/errors.rs b/contracts/vault/src/errors.rs index 5ddccd47..a6a74141 100644 --- a/contracts/vault/src/errors.rs +++ b/contracts/vault/src/errors.rs @@ -164,3 +164,22 @@ pub enum VaultError { /// Proposal has passed its grace period and is no longer executable (code 58). ProposalExpired = 58, } + +/// Stable error codes returned by `set_reserve_cap`. +/// +/// Existing setter errors retain the corresponding `VaultError` numeric +/// values. `CapTokenNotUsdc` uses the previously unused code 59. +/// A separate enum preserves `VaultError`, which is at the XDR 50-case limit. +#[contracterror] +#[repr(u32)] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +pub enum ReserveCapError { + /// Vault has not been initialized (code 1). + NotInitialized = 1, + /// Caller is not the vault owner (code 3). + Unauthorized = 3, + /// Cap must be positive (code 6). + AmountNotPositive = 6, + /// Cap token must be the configured USDC token (code 59). + CapTokenNotUsdc = 59, +} diff --git a/contracts/vault/src/lib.rs b/contracts/vault/src/lib.rs index f12bee3f..613b318c 100644 --- a/contracts/vault/src/lib.rs +++ b/contracts/vault/src/lib.rs @@ -70,7 +70,7 @@ pub use timelock::{ pub use callora_validators as validators; mod errors; -pub use errors::VaultError; +pub use errors::{ReserveCapError, VaultError}; /// A single item in a batch deduction. #[contracttype] @@ -427,25 +427,27 @@ impl CalloraVault { /// `Ok(())` on success. /// /// ### Errors + /// - Missing caller authentication fails at `require_auth` with a host auth error. /// - [`VaultError::Paused`] — vault is paused (circuit-breaker active). /// - [`VaultError::AmountNotPositive`] — `amount ≤ 0`. /// - [`VaultError::BelowMinDeposit`] — `amount < min_deposit`. /// - [`VaultError::CallerNotInAllowlist`] — non-owner caller not in allowlist. - /// - [`VaultError::Overflow`] — tracked balance overflow. + /// - [`VaultError::ExceedsReserveCap`] — tracked USDC balance after deposit + /// would exceed the configured inclusive cap. + /// - [`VaultError::Overflow`] — tracked balance plus amount overflows `i128`. + /// + /// Checks run in this order: authentication, pause, amount validation, + /// allowlist, reserve cap, then arithmetic overflow. An unset cap or a cap + /// of `i128::MAX` is unlimited. A cap may be lowered below the current + /// tracked balance; positive deposits then fail until balance is withdrawn + /// or deducted, or the cap is raised. Withdrawals and deducts are unaffected. + /// A rejected deposit writes no balance, transfers no tokens, and emits no events. /// /// ### Events /// Emits `deposit` with `caller` as topic and `(amount, new_balance)` as data. pub fn deposit(env: Env, caller: Address, amount: i128) -> Result<(), VaultError> { caller.require_auth(); Self::require_not_paused(env.clone())?; - if env - .storage() - .instance() - .get::<_, bool>(&DataKey::Paused) - .unwrap_or(false) - { - return Err(VaultError::Paused); - } let min_dep = env .storage() .instance() @@ -460,18 +462,18 @@ impl CalloraVault { .instance() .get::<_, i128>(&DataKey::Balance) .unwrap_or(0); - let new_bal = current_bal - .checked_add(amount) - .ok_or(VaultError::Overflow)?; - env.storage().instance().set(&DataKey::Balance, &new_bal); - - let token_addr = env + let usdc_token = env .storage() .instance() .get::<_, Address>(&DataKey::UsdcToken) .unwrap_or_else(|| panic!("USDC Token not set")); + limits::check(&env, &usdc_token, current_bal, amount)?; + let new_bal = current_bal + .checked_add(amount) + .ok_or(VaultError::Overflow)?; + env.storage().instance().set(&DataKey::Balance, &new_bal); - let token_client = token::Client::new(&env, &token_addr); + let token_client = token::Client::new(&env, &usdc_token); token_client.transfer(&caller, &env.current_contract_address(), &amount); env.events().publish( @@ -2787,32 +2789,46 @@ impl CalloraVault { Ok(()) } - /// Set or update the reserve cap for a token (owner only). + /// Set or update the reserve cap for the configured USDC token (owner only). /// - /// The reserve cap is the maximum total balance the vault may hold for - /// `token`. Any `deposit` call that would push the balance beyond `cap` - /// is rejected with [`VaultError::ExceedsReserveCap`]. + /// The reserve cap is the maximum tracked `DataKey::Balance` for the + /// configured USDC token. The boundary is inclusive. An unset cap and + /// `i128::MAX` both mean unlimited deposits. Lowering a cap below the + /// current balance is allowed, but subsequent positive deposits are + /// rejected until withdrawals or deducts reduce the balance or the cap is + /// raised; withdrawals and deducts are not restricted by this cap. /// /// Pass `i128::MAX` to remove the effective cap (restore unlimited deposits). /// /// # Parameters /// - `caller` — must be the vault owner. - /// - `token` — token contract address the cap applies to. + /// - `token` — must equal the configured USDC token address. /// - `cap` — maximum balance in token stroops; must be > 0. /// /// # Errors - /// - [`VaultError::Unauthorized`] — `caller` is not the owner. - /// - [`VaultError::AmountNotPositive`] — `cap <= 0`. + /// - [`ReserveCapError::Unauthorized`] — `caller` is not the owner. + /// - [`ReserveCapError::AmountNotPositive`] — `cap <= 0`. + /// - [`ReserveCapError::NotInitialized`] — the configured USDC token is missing. + /// - [`ReserveCapError::CapTokenNotUsdc`] — `token` is not the configured USDC token. pub fn set_reserve_cap( env: Env, caller: Address, token: Address, cap: i128, - ) -> Result<(), VaultError> { + ) -> Result<(), ReserveCapError> { caller.require_auth(); - Self::require_owner(env.clone(), caller.clone())?; + Self::require_owner(env.clone(), caller.clone()) + .map_err(|_| ReserveCapError::Unauthorized)?; if cap <= 0 { - return Err(VaultError::AmountNotPositive); + return Err(ReserveCapError::AmountNotPositive); + } + let usdc_token = env + .storage() + .instance() + .get::<_, Address>(&DataKey::UsdcToken) + .ok_or(ReserveCapError::NotInitialized)?; + if token != usdc_token { + return Err(ReserveCapError::CapTokenNotUsdc); } let prev = limits::set(&env, &token, cap); env.events().publish( @@ -2942,6 +2958,9 @@ mod test_views; #[cfg(test)] mod test_reentrancy; +#[cfg(test)] +mod test_reserve_cap; + #[cfg(test)] mod test; diff --git a/contracts/vault/src/limits.rs b/contracts/vault/src/limits.rs index 109f8a70..726aef10 100644 --- a/contracts/vault/src/limits.rs +++ b/contracts/vault/src/limits.rs @@ -1,7 +1,7 @@ //! Per-token reserve caps for the Callora Vault. //! -//! A reserve cap sets the maximum total balance the vault may hold for a given -//! token. Deposit attempts that would push the balance past the cap are +//! A reserve cap sets the maximum tracked balance the vault may hold for a +//! given token. Deposit attempts that would push the balance past the cap are //! rejected with [`VaultError::ExceedsReserveCap`]. //! //! # Storage @@ -10,7 +10,8 @@ //! //! # Default //! When no cap has been set for a token, [`get`] returns `i128::MAX`, which -//! is effectively unlimited and keeps [`check`] on the fast path. +//! is effectively unlimited and keeps [`check`] on the fast path. A configured +//! cap of `i128::MAX` has the same unlimited behavior. use soroban_sdk::{Address, Env}; @@ -58,9 +59,10 @@ pub fn get(env: &Env, token: &Address) -> i128 { /// Guard called inside `deposit()`. /// -/// Checks whether `current_balance + deposit_amount` would exceed the -/// configured cap for `token` and returns [`VaultError::ExceedsReserveCap`] -/// if so. When no cap is set the check is skipped entirely (fast path). +/// Checks whether the post-deposit tracked balance would exceed the +/// configured cap for `token`. The cap is inclusive. When the cap is unset or +/// `i128::MAX`, this performs no addition (fast path). A finite-cap addition +/// that overflows returns [`VaultError::Overflow`] before checking the cap. /// /// # Errors /// - [`VaultError::Overflow`] — if the addition overflows `i128`. diff --git a/contracts/vault/src/test_event_schema.rs b/contracts/vault/src/test_event_schema.rs index 4cc1f245..3a585097 100644 --- a/contracts/vault/src/test_event_schema.rs +++ b/contracts/vault/src/test_event_schema.rs @@ -277,9 +277,14 @@ fn event_version_shape_admin_rescue() { fn event_version_shape_set_reserve_cap() { let env = Env::default(); let (owner, vault, client, _) = setup_full(&env); - let dummy_token = Address::generate(&env); + let usdc_token = env.as_contract(&vault, || { + env.storage() + .instance() + .get::<_, Address>(&DataKey::UsdcToken) + .unwrap() + }); - client.set_reserve_cap(&owner, &dummy_token, &100_000); + client.set_reserve_cap(&owner, &usdc_token, &100_000); let evts = vault_events(&env, &vault); assert_eq!(evts.len(), 1); @@ -350,7 +355,13 @@ fn lifecycle_all_events_carry_version_topic() { assert_vault_events_versioned!(env, vault, "distribute"); // set_reserve_cap - client.set_reserve_cap(&owner, &Address::generate(&env), &999_999); + let usdc_token = env.as_contract(&vault, || { + env.storage() + .instance() + .get::<_, Address>(&DataKey::UsdcToken) + .unwrap() + }); + client.set_reserve_cap(&owner, &usdc_token, &999_999); assert_vault_events_versioned!(env, vault, "set_reserve_cap"); // admin_rescue (use a second token to avoid the protected-balance guard) diff --git a/contracts/vault/src/test_reserve_cap.rs b/contracts/vault/src/test_reserve_cap.rs index eb44c850..ace8b043 100644 --- a/contracts/vault/src/test_reserve_cap.rs +++ b/contracts/vault/src/test_reserve_cap.rs @@ -1,312 +1,371 @@ extern crate std; -use soroban_sdk::testutils::{Address as _, Events as _}; -use soroban_sdk::{token, Address, Env, IntoVal, Symbol}; +use soroban_sdk::testutils::{Address as _, Events as _, MockAuth, MockAuthInvoke}; +use soroban_sdk::{token, Address, Env, Error, IntoVal, InvokeError}; use super::*; +use callora_settlement::CalloraSettlement; -// --------------------------------------------------------------------------- -// Helpers (mirror the patterns in test.rs) -// --------------------------------------------------------------------------- - -fn create_usdc<'a>( - env: &'a Env, - admin: &Address, -) -> (Address, token::Client<'a>, token::StellarAssetClient<'a>) { - let contract_address = env.register_stellar_asset_contract_v2(admin.clone()); - let address = contract_address.address(); - let client = token::Client::new(env, &address); - let admin_client = token::StellarAssetClient::new(env, &address); - (address, client, admin_client) -} - -fn create_vault(env: &Env) -> (Address, CalloraVaultClient<'_>) { - let address = env.register(CalloraVault, ()); - let client = CalloraVaultClient::new(env, &address); - (address, client) -} - -/// Set up a fully initialised vault ready for deposits. -/// Returns `(vault_address, client, usdc_address, usdc_client, usdc_admin, owner)`. -fn setup( - env: &Env, -) -> ( +type Setup<'a> = ( Address, - CalloraVaultClient<'_>, + CalloraVaultClient<'a>, Address, - token::Client<'_>, - token::StellarAssetClient<'_>, Address, -) { + token::Client<'a>, + token::StellarAssetClient<'a>, + Address, +); + +fn setup(env: &Env, tracked_balance: i128) -> Setup<'_> { + env.mock_all_auths_allowing_non_root_auth(); let owner = Address::generate(env); - let (usdc, usdc_client, usdc_admin) = create_usdc(env, &owner); - let (vault_address, client) = create_vault(env); - env.mock_all_auths(); - client.init(&owner, &usdc, &Some(0), &Some(owner.clone()), &Some(1), &None, &None); - (vault_address, client, usdc, usdc_client, usdc_admin, owner) + let usdc = env + .register_stellar_asset_contract_v2(owner.clone()) + .address(); + let usdc_client = token::Client::new(env, &usdc); + let usdc_admin = token::StellarAssetClient::new(env, &usdc); + let vault_address = env.register(CalloraVault, ()); + let client = CalloraVaultClient::new(env, &vault_address); + let settlement_address = env.register(CalloraSettlement, ()); + let settlement_client = + callora_settlement::CalloraSettlementClient::new(env, &settlement_address); + settlement_client.init(&owner, &vault_address); + + if tracked_balance > 0 { + usdc_admin.mint(&vault_address, &tracked_balance); + } + client.init( + &owner, + &usdc, + &Some(tracked_balance), + &Some(owner.clone()), + &Some(1), + &None, + &Some(i128::MAX), + &Some(settlement_address.clone()), + ); + + ( + owner, + client, + usdc, + vault_address, + usdc_client, + usdc_admin, + settlement_address, + ) } -// --------------------------------------------------------------------------- -// get_reserve_cap — default -// --------------------------------------------------------------------------- - -#[test] -fn get_reserve_cap_returns_max_when_not_set() { - let env = Env::default(); - let (_vault_address, client, usdc, _usdc_client, _usdc_admin, _owner) = setup(&env); - assert_eq!(client.get_reserve_cap(&usdc), i128::MAX); +fn prepare_deposit( + owner: &Address, + vault_address: &Address, + usdc_client: &token::Client<'_>, + usdc_admin: &token::StellarAssetClient<'_>, + amount: i128, +) { + usdc_admin.mint(owner, &amount); + usdc_client.approve(owner, vault_address, &amount, &10_000); } -// --------------------------------------------------------------------------- -// set_reserve_cap — validation -// --------------------------------------------------------------------------- - -#[test] -fn set_reserve_cap_stores_value() { - let env = Env::default(); - let (_vault_address, client, usdc, _usdc_client, _usdc_admin, owner) = setup(&env); - env.mock_all_auths(); - client.set_reserve_cap(&owner, &usdc, &1_000_000); - assert_eq!(client.get_reserve_cap(&usdc), 1_000_000); +fn expect_contract_error< + V: core::fmt::Debug, + CE: Into + core::fmt::Debug, + E: Into + core::fmt::Debug + Copy, +>( + result: Result, Result>, + expected_code: u32, +) { + let actual_code = match &result { + Err(Ok(error)) => Some((*error).into().get_code()), + _ => None, + }; + assert_eq!( + actual_code, + Some(expected_code), + "unexpected result: {result:?}" + ); } #[test] -fn set_reserve_cap_can_be_updated() { +fn deposit_succeeds_with_unset_cap() { let env = Env::default(); - let (_vault_address, client, usdc, _usdc_client, _usdc_admin, owner) = setup(&env); - env.mock_all_auths(); - client.set_reserve_cap(&owner, &usdc, &500); - client.set_reserve_cap(&owner, &usdc, &2_000); - assert_eq!(client.get_reserve_cap(&usdc), 2_000); + let (owner, client, _, vault_address, usdc, usdc_admin, _) = setup(&env, 900); + prepare_deposit(&owner, &vault_address, &usdc, &usdc_admin, 1_000_000); + + client.deposit(&owner, &1_000_000); + + assert_eq!(client.balance(), 1_000_900); } #[test] -fn set_reserve_cap_to_max_restores_unlimited() { +fn deposit_succeeds_at_inclusive_cap_boundary() { let env = Env::default(); - let (_vault_address, client, usdc, _usdc_client, _usdc_admin, owner) = setup(&env); - env.mock_all_auths(); - client.set_reserve_cap(&owner, &usdc, &100); - client.set_reserve_cap(&owner, &usdc, &i128::MAX); - assert_eq!(client.get_reserve_cap(&usdc), i128::MAX); + let (owner, client, usdc_token, vault_address, usdc, usdc_admin, _) = setup(&env, 900); + client.set_reserve_cap(&owner, &usdc_token, &1_000); + prepare_deposit(&owner, &vault_address, &usdc, &usdc_admin, 100); + + client.deposit(&owner, &100); + + assert_eq!(client.balance(), 1_000); } #[test] -fn set_reserve_cap_rejects_zero() { +fn deposit_over_cap_is_rejected_without_mutating_anything_or_emitting_deposit() { let env = Env::default(); - let (_vault_address, client, usdc, _usdc_client, _usdc_admin, owner) = setup(&env); - env.mock_all_auths(); - let result = client.try_set_reserve_cap(&owner, &usdc, &0); - assert_eq!(result.unwrap_err().unwrap(), VaultError::AmountNotPositive); + let (owner, client, usdc_token, vault_address, usdc, usdc_admin, _) = setup(&env, 900); + client.set_reserve_cap(&owner, &usdc_token, &1_000); + prepare_deposit(&owner, &vault_address, &usdc, &usdc_admin, 101); + + let tracked_before = client.balance(); + let sender_before = usdc.balance(&owner); + let vault_before = usdc.balance(&vault_address); + let result = client.try_deposit(&owner, &101); + + let rejected_events = env.events().all(); + expect_contract_error(result, VaultError::ExceedsReserveCap as u32); + assert_eq!(client.balance(), tracked_before); + assert_eq!(usdc.balance(&owner), sender_before); + assert_eq!(usdc.balance(&vault_address), vault_before); + assert!(rejected_events.is_empty()); } #[test] -fn set_reserve_cap_rejects_negative() { +fn explicitly_unlimited_cap_accepts_deposit() { let env = Env::default(); - let (_vault_address, client, usdc, _usdc_client, _usdc_admin, owner) = setup(&env); - env.mock_all_auths(); - let result = client.try_set_reserve_cap(&owner, &usdc, &-1); - assert_eq!(result.unwrap_err().unwrap(), VaultError::AmountNotPositive); + let (owner, client, usdc_token, vault_address, usdc, usdc_admin, _) = setup(&env, 900); + client.set_reserve_cap(&owner, &usdc_token, &i128::MAX); + prepare_deposit(&owner, &vault_address, &usdc, &usdc_admin, 1_000_000); + + client.deposit(&owner, &1_000_000); + + assert_eq!(client.balance(), 1_000_900); } #[test] -fn set_reserve_cap_requires_owner() { +fn lowering_cap_rejects_deposits_but_withdraw_and_deduct_remain_available() { let env = Env::default(); - let (_vault_address, client, usdc, _usdc_client, _usdc_admin, _owner) = setup(&env); - let non_owner = Address::generate(&env); - env.mock_all_auths(); - let result = client.try_set_reserve_cap(&non_owner, &usdc, &1_000); - assert_eq!(result.unwrap_err().unwrap(), VaultError::Unauthorized); + let (owner, client, usdc_token, vault_address, usdc, usdc_admin, settlement) = setup(&env, 900); + client.set_reserve_cap(&owner, &usdc_token, &800); + prepare_deposit(&owner, &vault_address, &usdc, &usdc_admin, 1); + + expect_contract_error( + client.try_deposit(&owner, &1), + VaultError::ExceedsReserveCap as u32, + ); + assert_eq!(client.balance(), 900); + + client.withdraw(&50); + assert_eq!(client.balance(), 850); + client.deduct(&owner, &100, &0); + + assert_eq!(client.balance(), 750); + client.deposit(&owner, &1); + assert_eq!(client.balance(), 751); + assert_eq!(usdc.balance(&settlement), 100); } #[test] -fn set_reserve_cap_is_per_token() { +fn set_reserve_cap_rejects_non_usdc_token_without_state_change() { let env = Env::default(); - let (_vault_address, client, usdc, _usdc_client, _usdc_admin, owner) = setup(&env); - let other_token = Address::generate(&env); - env.mock_all_auths(); - client.set_reserve_cap(&owner, &usdc, &500); - // Other token still has no cap - assert_eq!(client.get_reserve_cap(&other_token), i128::MAX); + let (owner, client, usdc_token, _, _, _, _) = setup(&env, 0); + client.set_reserve_cap(&owner, &usdc_token, &500); + let unsupported_token = Address::generate(&env); + + expect_contract_error( + client.try_set_reserve_cap(&owner, &unsupported_token, &700), + ReserveCapError::CapTokenNotUsdc as u32, + ); + + assert!(env.events().all().is_empty()); + assert_eq!(client.get_reserve_cap(&usdc_token), 500); + assert_eq!(client.get_reserve_cap(&unsupported_token), i128::MAX); } -// --------------------------------------------------------------------------- -// set_reserve_cap — events -// --------------------------------------------------------------------------- - #[test] -fn set_reserve_cap_emits_event() { +fn auth_is_checked_before_pause_and_amount_validation() { let env = Env::default(); - let (vault_address, client, usdc, _usdc_client, _usdc_admin, owner) = setup(&env); - env.mock_all_auths(); - client.set_reserve_cap(&owner, &usdc, &1_000); - - let events = env.events().all(); - let ev = events - .iter() - .find(|e| { - if e.0 != vault_address { - return false; - } - if e.1.is_empty() { - return false; - } - let s: Symbol = e.1.get(0).unwrap().into_val(&env); - s == Symbol::new(&env, "reserve_cap_set") - }) - .expect("expected reserve_cap_set event"); - - let (prev, cap): (Option, i128) = ev.2.into_val(&env); - assert_eq!(prev, None); - assert_eq!(cap, 1_000); + let (owner, client, _, vault_address, _, _, _) = setup(&env, 900); + client.set_reserve_cap(&owner, &client.get_usdc_token(), &1_000); + env.as_contract(&vault_address, || { + env.storage().instance().set(&DataKey::Paused, &true); + }); + + env.set_auths(&[]); + let result = client.try_deposit(&owner, &0); + + assert!( + matches!(result, Err(Err(_))), + "missing caller authorization must fail before pause or amount checks: {result:?}" + ); } #[test] -fn set_reserve_cap_event_includes_previous_value() { +fn paused_vault_is_rejected_before_invalid_amount_and_cap() { let env = Env::default(); - let (vault_address, client, usdc, _usdc_client, _usdc_admin, owner) = setup(&env); - env.mock_all_auths(); - client.set_reserve_cap(&owner, &usdc, &500); - client.set_reserve_cap(&owner, &usdc, &999); - - let events = env.events().all(); - // Find the last reserve_cap_set event - let ev = events - .iter() - .filter(|e| { - if e.0 != vault_address { - return false; - } - if e.1.is_empty() { - return false; - } - let s: Symbol = e.1.get(0).unwrap().into_val(&env); - s == Symbol::new(&env, "reserve_cap_set") - }) - .last() - .expect("expected reserve_cap_set event"); - - let (prev, cap): (Option, i128) = ev.2.into_val(&env); - assert_eq!(prev, Some(500)); - assert_eq!(cap, 999); + let (owner, client, usdc_token, vault_address, usdc, usdc_admin, _) = setup(&env, 900); + client.set_reserve_cap(&owner, &usdc_token, &1_000); + prepare_deposit(&owner, &vault_address, &usdc, &usdc_admin, 101); + env.as_contract(&vault_address, || { + env.storage().instance().set(&DataKey::Paused, &true); + }); + + expect_contract_error(client.try_deposit(&owner, &0), VaultError::Paused as u32); + expect_contract_error(client.try_deposit(&owner, &101), VaultError::Paused as u32); } -// --------------------------------------------------------------------------- -// deposit — cap enforcement -// --------------------------------------------------------------------------- - #[test] -fn deposit_succeeds_when_no_cap_set() { +fn invalid_amount_is_rejected_before_allowlist_and_cap() { let env = Env::default(); - let (vault_address, client, _usdc, usdc_client, usdc_admin, owner) = setup(&env); - env.mock_all_auths(); - usdc_admin.mint(&owner, &1_000_000); - usdc_client.approve(&owner, &vault_address, &1_000_000, &99999); - let balance = client.deposit(&owner, &1_000_000); - assert_eq!(balance, 1_000_000); + let (owner, client, usdc_token, vault_address, _, _, _) = setup(&env, 900); + client.set_reserve_cap(&owner, &usdc_token, &1_000); + env.as_contract(&vault_address, || { + env.storage().instance().set(&DataKey::MinDeposit, &10_i128); + }); + let stranger = Address::generate(&env); + expect_contract_error( + client.try_deposit(&stranger, &1), + VaultError::BelowMinDeposit as u32, + ); + expect_contract_error( + client.try_deposit(&stranger, &0), + VaultError::AmountNotPositive as u32, + ); } #[test] -fn deposit_succeeds_when_below_cap() { +fn allowlist_error_precedes_reserve_cap_error() { let env = Env::default(); - let (vault_address, client, _usdc, usdc_client, usdc_admin, owner) = setup(&env); - env.mock_all_auths(); - client.set_reserve_cap(&owner, &usdc, &500); - usdc_admin.mint(&owner, &300); - usdc_client.approve(&owner, &vault_address, &300, &99999); - let balance = client.deposit(&owner, &300); - assert_eq!(balance, 300); + let (owner, client, usdc_token, vault_address, usdc, usdc_admin, _) = setup(&env, 900); + client.set_reserve_cap(&owner, &usdc_token, &1_000); + let stranger = Address::generate(&env); + prepare_deposit(&stranger, &vault_address, &usdc, &usdc_admin, 101); + + expect_contract_error( + client.try_deposit(&stranger, &101), + VaultError::CallerNotInAllowlist as u32, + ); } -#[test] -fn deposit_succeeds_at_exact_cap() { - let env = Env::default(); - let (vault_address, client, _usdc, usdc_client, usdc_admin, owner) = setup(&env); - env.mock_all_auths(); - client.set_reserve_cap(&owner, &usdc, &1_000); - usdc_admin.mint(&owner, &1_000); - usdc_client.approve(&owner, &vault_address, &1_000, &99999); - let balance = client.deposit(&owner, &1_000); - assert_eq!(balance, 1_000); +fn force_tracked_balance_to_max( + env: &Env, + vault_address: &Address, + client: &CalloraVaultClient<'_>, + usdc: &token::Client<'_>, + usdc_admin: &token::StellarAssetClient<'_>, + owner: &Address, +) { + // Deliberately inconsistent ledger state isolates i128 overflow in deposit + // without requiring the token contract to hold i128::MAX tokens. + env.as_contract(vault_address, || { + env.storage().instance().set(&DataKey::Balance, &i128::MAX); + }); + prepare_deposit(owner, vault_address, usdc, usdc_admin, 1); + assert_eq!(client.balance(), i128::MAX); } #[test] -fn deposit_fails_when_exceeds_cap() { +fn deposit_overflow_with_finite_cap_returns_overflow() { let env = Env::default(); - let (vault_address, client, _usdc, usdc_client, usdc_admin, owner) = setup(&env); - env.mock_all_auths(); - client.set_reserve_cap(&owner, &usdc, &1_000); - usdc_admin.mint(&owner, &1_001); - usdc_client.approve(&owner, &vault_address, &1_001, &99999); - let result = client.try_deposit(&owner, &1_001); - assert_eq!(result.unwrap_err().unwrap(), VaultError::ExceedsReserveCap); + let (owner, client, usdc_token, vault_address, usdc, usdc_admin, _) = setup(&env, 0); + client.set_reserve_cap(&owner, &usdc_token, &1); + force_tracked_balance_to_max(&env, &vault_address, &client, &usdc, &usdc_admin, &owner); + + expect_contract_error(client.try_deposit(&owner, &1), VaultError::Overflow as u32); } #[test] -fn deposit_fails_when_cumulative_total_exceeds_cap() { +fn deposit_overflow_with_unlimited_cap_returns_overflow() { let env = Env::default(); - let (vault_address, client, _usdc, usdc_client, usdc_admin, owner) = setup(&env); - env.mock_all_auths(); - client.set_reserve_cap(&owner, &usdc, &500); - - usdc_admin.mint(&owner, &800); - usdc_client.approve(&owner, &vault_address, &800, &99999); + let (owner, client, usdc_token, vault_address, usdc, usdc_admin, _) = setup(&env, 0); + client.set_reserve_cap(&owner, &usdc_token, &i128::MAX); + force_tracked_balance_to_max(&env, &vault_address, &client, &usdc, &usdc_admin, &owner); - // First deposit of 400 — succeeds - let b1 = client.deposit(&owner, &400); - assert_eq!(b1, 400); + expect_contract_error(client.try_deposit(&owner, &1), VaultError::Overflow as u32); +} - // Second deposit of 200 — succeeds (total = 600 > cap? No, 400+200=600 > 500 — fails!) - let result = client.try_deposit(&owner, &200); - assert_eq!(result.unwrap_err().unwrap(), VaultError::ExceedsReserveCap); +#[test] +fn reserve_cap_deposit_cpu() { + for cap in [None, Some(i128::MAX), Some(1_000)] { + let env = Env::default(); + let (owner, client, usdc_token, vault_address, usdc, usdc_admin, _) = setup(&env, 900); + if let Some(cap) = cap { + client.set_reserve_cap(&owner, &usdc_token, &cap); + } + prepare_deposit(&owner, &vault_address, &usdc, &usdc_admin, 100); + env.cost_estimate().budget().reset_unlimited(); + client.deposit(&owner, &100); + let cpu = env.cost_estimate().budget().cpu_instruction_cost(); + std::println!("reserve_cap deposit cap={cap:?} cpu={cpu}"); + assert_eq!(client.balance(), 1_000); + } } #[test] -fn deposit_succeeds_again_after_cap_raised() { +fn reserve_cap_auth_rejects_wrong_signer_before_pause() { let env = Env::default(); - let (vault_address, client, _usdc, usdc_client, usdc_admin, owner) = setup(&env); - env.mock_all_auths(); - client.set_reserve_cap(&owner, &usdc, &300); - - usdc_admin.mint(&owner, &600); - usdc_client.approve(&owner, &vault_address, &600, &99999); - - client.deposit(&owner, &300); - - // Would fail with cap=300 - let result = client.try_deposit(&owner, &1); - assert_eq!(result.unwrap_err().unwrap(), VaultError::ExceedsReserveCap); - - // Raise the cap - client.set_reserve_cap(&owner, &usdc, &600); - - // Now deposit of 300 more succeeds - let b = client.deposit(&owner, &300); - assert_eq!(b, 600); + let (owner, client, usdc_token, vault_address, _, _, _) = setup(&env, 900); + client.set_reserve_cap(&owner, &usdc_token, &1_000); + client.pause(&owner); + let intruder = Address::generate(&env); + env.mock_auths(&[MockAuth { + address: &intruder, + invoke: &MockAuthInvoke { + contract: &vault_address, + fn_name: "deposit", + args: (owner.clone(), 101_i128).into_val(&env), + sub_invokes: &[], + }, + }]); + assert!(matches!(client.try_deposit(&owner, &101), Err(Err(_)))); + env.mock_auths(&[MockAuth { + address: &owner, + invoke: &MockAuthInvoke { + contract: &vault_address, + fn_name: "deposit", + args: (owner.clone(), 101_i128).into_val(&env), + sub_invokes: &[], + }, + }]); + expect_contract_error(client.try_deposit(&owner, &101), VaultError::Paused as u32); } #[test] -fn deposit_at_one_above_cap_fails() { +fn reserve_cap_setter_preserves_owner_and_amount_checks() { let env = Env::default(); - let (vault_address, client, _usdc, usdc_client, usdc_admin, owner) = setup(&env); - env.mock_all_auths(); - client.set_reserve_cap(&owner, &usdc, &99); - usdc_admin.mint(&owner, &100); - usdc_client.approve(&owner, &vault_address, &100, &99999); - let result = client.try_deposit(&owner, &100); - assert_eq!(result.unwrap_err().unwrap(), VaultError::ExceedsReserveCap); + let (owner, client, usdc_token, _, _, _, _) = setup(&env, 0); + let stranger = Address::generate(&env); + expect_contract_error(client.try_set_reserve_cap(&stranger, &usdc_token, &0), 3); + expect_contract_error(client.try_set_reserve_cap(&owner, &usdc_token, &0), 6); + env.set_auths(&[]); + assert!(matches!( + client.try_set_reserve_cap(&owner, &usdc_token, &1), + Err(Err(_)) + )); } #[test] -fn reserve_cap_does_not_affect_withdrawal() { +fn reserve_cap_limits_branches() { let env = Env::default(); - let (vault_address, client, _usdc, usdc_client, usdc_admin, owner) = setup(&env); - env.mock_all_auths(); - usdc_admin.mint(&owner, &500); - usdc_client.approve(&owner, &vault_address, &500, &99999); - client.deposit(&owner, &500); - - // Set a tight cap (lower than current balance — should not block withdrawal) - client.set_reserve_cap(&owner, &usdc, &100); - let new_bal = client.withdraw(&200); - assert_eq!(new_bal, 300); + let (owner, client, usdc_token, vault_address, _, _, _) = setup(&env, 0); + env.as_contract(&vault_address, || { + assert_eq!(limits::check(&env, &usdc_token, i128::MAX, 1), Ok(())); + }); + client.set_reserve_cap(&owner, &usdc_token, &i128::MAX); + env.as_contract(&vault_address, || { + // Unlimited caps skip arithmetic, including an otherwise overflowing sum. + assert_eq!(limits::check(&env, &usdc_token, i128::MAX, 1), Ok(())); + }); + client.set_reserve_cap(&owner, &usdc_token, &1_000); + env.as_contract(&vault_address, || { + assert_eq!(limits::check(&env, &usdc_token, 900, 99), Ok(())); + assert_eq!(limits::check(&env, &usdc_token, 900, 100), Ok(())); + assert_eq!( + limits::check(&env, &usdc_token, 900, 101), + Err(VaultError::ExceedsReserveCap) + ); + assert_eq!( + limits::check(&env, &usdc_token, i128::MAX, 1), + Err(VaultError::Overflow) + ); + }); } diff --git a/contracts/vault/src/test_ttl_bump.rs b/contracts/vault/src/test_ttl_bump.rs index 30cd8e60..ca80a2c6 100644 --- a/contracts/vault/src/test_ttl_bump.rs +++ b/contracts/vault/src/test_ttl_bump.rs @@ -159,7 +159,6 @@ fn every_view_call_bumps_instance_ttl() { let (usdc, _, _) = create_usdc(&env, &owner); let (vault_address, client) = create_vault(&env); let settlement = create_settlement(&env, &owner, &vault_address); - let token_cap = Address::generate(&env); env.mock_all_auths(); client.init( @@ -174,7 +173,7 @@ fn every_view_call_bumps_instance_ttl() { ); // Put an entry so views have something non-default to return. client.set_authorized_caller(&owner); - client.set_reserve_cap(&owner, &token_cap, &1_000_000_000); + client.set_reserve_cap(&owner, &usdc, &1_000_000_000); // For each view call, advance to THRESHOLD-1 (about to need bump), call // the view, then advance THRESHOLD-1 again and verify it's still live. diff --git a/contracts/vault/tests/err_stab.rs b/contracts/vault/tests/err_stab.rs index d3852e5d..9769d9d5 100644 --- a/contracts/vault/tests/err_stab.rs +++ b/contracts/vault/tests/err_stab.rs @@ -6,7 +6,7 @@ extern crate std; -use callora_vault::VaultError; +use callora_vault::{ReserveCapError, VaultError}; use std::collections::BTreeSet; /// Frozen snapshot of the error code mapping. @@ -96,6 +96,17 @@ fn test_vault_error_codes_frozen_against_snapshot() { ); } +#[test] +fn reserve_cap_error_uses_new_non_colliding_code() { + assert_eq!(ReserveCapError::NotInitialized as u32, 1); + assert_eq!(ReserveCapError::Unauthorized as u32, 3); + assert_eq!(ReserveCapError::AmountNotPositive as u32, 6); + assert_eq!(ReserveCapError::CapTokenNotUsdc as u32, 59); + assert!(FROZEN_ERROR_SNAPSHOT + .iter() + .all(|(code, _)| *code != ReserveCapError::CapTokenNotUsdc as u32)); +} + /// Verify `NotInitialized` still has discriminant 1. #[test] fn test_not_initialized_is_code_1() { diff --git a/docs/ERROR_CODES.md b/docs/ERROR_CODES.md index 94ddd164..057756fa 100644 --- a/docs/ERROR_CODES.md +++ b/docs/ERROR_CODES.md @@ -65,6 +65,11 @@ must not be reassigned once released. | 48 | `BelowMinTransferAmount` | Vault | Amount is below the vault's configured minimum transfer unit (rejects sub-unit/dust transfers); currently enforced on `propose_sweep` | | 49 | `AdminCooldownActive` | Vault | A critical admin action is still inside the global cool-off window | | 50 | `InvalidAdminCooldown` | Vault | Admin cool-off window is outside the accepted bounds | +| 59 | `CapTokenNotUsdc` | Vault reserve-cap setter | Token is not the configured USDC token | + +The reserve-cap setter returns this error through `ReserveCapError`. Its +existing `NotInitialized` (1), `Unauthorized` (3), and `AmountNotPositive` (6) +cases preserve the corresponding `VaultError` numeric codes. ## Settlement diff --git a/docs/EVENT_SCHEMA.md b/docs/EVENT_SCHEMA.md index 9c1387b3..2cdf7d14 100644 --- a/docs/EVENT_SCHEMA.md +++ b/docs/EVENT_SCHEMA.md @@ -581,15 +581,16 @@ Updated in Issue #1118 to include the version marker at topic[1]. ### `reserve_cap_set` -Emitted when the owner sets or updates the deposit reserve cap for a token via -`set_reserve_cap()`. Updated in Issue #1118 to include the version marker at topic[1]. +Emitted when the owner sets or updates the deposit reserve cap for the +configured USDC token via `set_reserve_cap()`. Updated in Issue #1118 to include +the version marker at topic[1]. | Index | Location | Type | Description | |---------|----------|--------------|-----------------------------------------| | topic 0 | topics | Symbol | `"reserve_cap_set"` | | topic 1 | topics | Symbol | `"callora_v1"` (version marker) | | topic 2 | topics | Address | `caller` — owner address | -| topic 3 | topics | Address | `token` — token the cap applies to | +| topic 3 | topics | Address | `token` — configured USDC token | | data | data | (Option\, i128) | `(prev_cap, new_cap)` — previous cap (None if unset) and new cap | ```json diff --git a/docs/INVARIANTS.md b/docs/INVARIANTS.md index 58bb5c2c..ce1c484f 100644 --- a/docs/INVARIANTS.md +++ b/docs/INVARIANTS.md @@ -68,6 +68,34 @@ Helper and view functions such as `get_meta`, `get_max_deduct`, `get_revenue_poo - Because `amount >= 0` in practice (negative amounts are not useful and would fail at the token layer) and `balance` is already non-negative, we maintain: - `VaultMeta.balance' >= 0` +### Deposit reserve cap + +The configured reserve cap applies only to the tracked `DataKey::Balance` for +the vault's configured USDC token. It does not inspect or cap unrelated token +balances. A deposit is accepted at the cap boundary (`new_balance <= cap`); +only a resulting balance greater than the cap is rejected with +`VaultError::ExceedsReserveCap` (code 43). + +- An unset cap and an explicitly configured `i128::MAX` cap are both + unlimited. The unlimited path reads the cap once and does not perform cap + arithmetic; deposit's normal checked addition still detects overflow. +- Lowering a cap below the existing tracked balance is permitted. The existing + balance is not changed, but every positive deposit remains rejected until + the resulting balance, including the new deposit, fits within the cap or the cap is raised. +- Withdrawals and deducts are not subject to the reserve cap and remain + available under their existing authorization, pause, and balance rules. +- `set_reserve_cap` accepts only the configured USDC token. A different token + returns `ReserveCapError::CapTokenNotUsdc` (code 59) without changing cap + storage or emitting the cap-set event. + +Deposit validation order is authentication, pause, amount validation, +allowlist, reserve-cap check, then checked balance addition. On a finite cap, +the cap guard performs checked addition before comparing with the cap, so an +overflow returns `VaultError::Overflow` rather than +`VaultError::ExceedsReserveCap`. On the unlimited path, the normal checked +addition returns the same overflow error. A rejected deposit changes no +tracked balance or token balance and emits no deposit event. + --- ### `deduct` diff --git a/docs/RESERVE_CAP_1394_VALIDATION.md b/docs/RESERVE_CAP_1394_VALIDATION.md new file mode 100644 index 00000000..00cc0230 --- /dev/null +++ b/docs/RESERVE_CAP_1394_VALIDATION.md @@ -0,0 +1,73 @@ +# Reserve cap validation for issue #1394 + +Final upstream base: `87fba99a7d7cc9eca9354e5a0aa408f9dfe4549b`. Its vault production sources are unchanged from the regression baseline. + +Baseline: `f4ab47e03e9f7eb45ec6c5f022b1a1badad13ee2`, matching fetched `origin/main` at the start of this work. The baseline checkout's production `lib.rs` and `limits.rs` have no Git diff. A baseline-only integration harness uses the current test setup, 900 balance / 1,000 cap / 101 deposit regression, and budget test, without compiling the unrelated unit-test modules. + +Baseline command: `cargo test -p callora-vault --test reserve_cap_regression -- --nocapture` (isolated target directory; unchanged production sources). + +Actual regression output: + +```text +assertion `left == right` failed: unexpected result: Ok(Ok(())) + left: None + right: Some(43) +test result: FAILED. 1 passed; 1 failed +``` + +Updated command: `cargo test -p callora-vault reserve_cap -- --nocapture`. +Result: 18 matching unit tests and 2 matching error-stability integration tests passed; no failures. The same over-cap regression now passes, and checks tracked balance, both token balances, and the events captured immediately after rejection. + +## Deposit CPU budget + +| Cap | Baseline | Updated | Delta | Change | +| --- | ---: | ---: | ---: | ---: | +| Unset | 272,314 | 272,457 | +143 | +0.053% | +| Explicit `i128::MAX` | 278,427 | 280,700 | +2,273 | +0.816% | +| Finite (1,000) | 277,838 | 279,806 | +1,968 | +0.708% | + +Both runs use balance 900, deposit 100, identical native registered contracts and `mock_all_auths_allowing_non_root_auth`, and reset the SDK budget immediately before the deposit. Minting, approvals, initialization and cap configuration are outside measurement. The counter is read immediately after deposit, before balance assertions. Baseline `Env::budget()` and the current `Env::cost_estimate().budget()` expose the same host budget; the latter avoids a deprecation warning. + +These are Soroban SDK CPU-budget instruction estimates for native test contracts, excluding Wasm VM instantiation/execution. They are not hardware instruction counts or production fee measurements. + +The added guard performs exactly one SDK `instance().get(ReserveCap(token))` lookup. In installed SDK 22.0.11, `Storage::get` calls `has_contract_data` and, when present, `get_contract_data`: one logical lookup is not one raw host call. The guard adds no separate `has`, write, or TTL bump. The duplicate pause lookup is removed. Cap-key encoding, address/value conversion, and instance-map searches explain the small measured overhead; no additional ledger entry is introduced. Unset/unlimited caps return before cap addition; finite sums use checked addition and return `Overflow` before comparison if unrepresentable. + +## Operational and API implications + +Previously stored USDC caps become effective when this code is adopted. Lowering a cap below balance is allowed and blocks positive deposits until the resulting balance fits or the cap is raised. Withdrawals and deductions retain their existing rules. Other token caps cannot be newly configured; old unrelated-token entries are left untouched and do not limit USDC deposits. No storage layout or dependency changes are needed. + +The setter returns `ReserveCapError` with legacy numeric errors preserved and unused code 59 for `CapTokenNotUsdc`. `VaultError` already occupies the XDR error-enum limit of 50 cases. Rust callers explicitly expecting `VaultError` from the setter need a source update; existing on-chain numeric errors remain stable. + +## Verification on the final upstream base + +- `cargo test -p callora-vault reserve_cap -- --nocapture`: passed (18 unit tests and 2 integration tests). +- `cargo fmt -p callora-vault -- --check`: passed. +- `cargo build --release --target wasm32v1-none -p callora-vault -p callora-settlement -p callora-revenue-pool`: passed. +- `cargo fmt --all -- --check`: blocked by the unchanged `contracts/yield/src/lib.rs:26` inner `#![no_std]` attribute following an outer doc comment. +- `cargo test --workspace`: blocked by seven compilation errors in unchanged `contracts/checkpoint/src/lib.rs` (migration API arity, missing `IntoVal`, private address constructor and value type mismatches). +- `cargo clippy --all-targets --all-features -- -D warnings`: blocked by those checkpoint errors and the unchanged registry `needless_question_mark` at `contracts/registry/src/lib.rs:276`. + +Before upstream advanced, the complete workspace suite passed on baseline `f4ab47e03e9f7eb45ec6c5f022b1a1badad13ee2` plus this change: 1,873 passed, 0 failed, 170 existing ignored. Global formatting also passed there. These earlier results do not establish a passing workspace on final base `87fba99a7d7cc9eca9354e5a0aa408f9dfe4549b`. The diff does not modify checkpoint, yield or registry. Unrelated upstream failures are left for their respective fixes. + +## Function-specific coverage + +Tool: cargo-llvm-cov 0.9.1 with Rust 1.98.1 and its matching LLVM 22.1.8 llvm-tools-preview component, on Windows MSVC. Command run from the repository root: + +```text +cargo llvm-cov --lib -p callora-vault --json --output-path coverage.json -- reserve_cap +``` + +The actual output path was the external validation evidence directory; no generated coverage artifacts are committed. All 18 instrumented matching unit tests passed. `llvm-cov report --show-functions` on the emitted test executable and merged profile gives these individual production function results, without substituting crate totals or generated client wrappers: + +| Function | Covered lines / instrumented lines | Line coverage | Covered regions / regions | +| --- | ---: | ---: | ---: | +| `CalloraVault::deposit` (`lib.rs:448`) | 38 / 38 | 100.00% | 65 / 65 | +| `limits::check` (`limits.rs:71`) | 18 / 18 | 100.00% | 20 / 20 | + +Both exceed the requested 95% line-coverage target. This stable-toolchain run does not instrument LLVM branch coverage; branch semantics are separately pinned by the targeted tests. The uninitialized-token panic closure is a separate generated function, not included in the deposit row. + +Reproduce the function report after the coverage command (substitute the emitted executable hash and profile basename for your checkout): + +```text +llvm-cov report target/llvm-cov-target/debug/deps/callora_vault-.exe --show-functions contracts/vault/src/lib.rs contracts/vault/src/limits.rs -instr-profile=target/llvm-cov-target/.profdata +```