Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**

Expand Down
19 changes: 19 additions & 0 deletions contracts/vault/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
73 changes: 46 additions & 27 deletions contracts/vault/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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()
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -2942,6 +2958,9 @@ mod test_views;
#[cfg(test)]
mod test_reentrancy;

#[cfg(test)]
mod test_reserve_cap;

#[cfg(test)]
mod test;

Expand Down
14 changes: 8 additions & 6 deletions contracts/vault/src/limits.rs
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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};

Expand Down Expand Up @@ -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`.
Expand Down
17 changes: 14 additions & 3 deletions contracts/vault/src/test_event_schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading