diff --git a/contracts/async-vault/src/deposit.rs b/contracts/async-vault/src/deposit.rs index bf2ead2..ba42e2f 100644 --- a/contracts/async-vault/src/deposit.rs +++ b/contracts/async-vault/src/deposit.rs @@ -9,6 +9,19 @@ use crate::state::{self, DepositRequest, EpochStatus}; use crate::treasury; use crate::wind_down; +fn refuse_if_exceeds_cap(e: &Env, amount: i128) { + let Some(cap) = state::deposit_cap(e) else { + return; + }; + let current = treasury::deposited_capital(e); + let new_total = current + .checked_add(amount) + .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)); + if new_total > cap { + panic_with_error!(e, VaultError::DepositCapExceeded); + } +} + pub(crate) fn request(e: &Env, from: &Address, amount: i128) -> u64 { wind_down::refuse_if_active(e); from.require_auth(); @@ -16,6 +29,7 @@ pub(crate) fn request(e: &Env, from: &Address, amount: i128) -> u64 { if amount <= 0 { panic_with_error!(e, VaultError::InvalidAmount); } + refuse_if_exceeds_cap(e, amount); let epoch_id = state::current_epoch(e); diff --git a/contracts/async-vault/src/error.rs b/contracts/async-vault/src/error.rs index 8bb2e8f..9d487ff 100644 --- a/contracts/async-vault/src/error.rs +++ b/contracts/async-vault/src/error.rs @@ -109,4 +109,6 @@ pub enum VaultError { /// The notice exceeds the upgrade delay, so an investor could not complete /// an exit before an upgrade lands. NoticeAboveUpgradeDelay = 6061, + /// A deposit request would breach the vault's configured deposit cap. + DepositCapExceeded = 6062, } diff --git a/contracts/async-vault/src/event.rs b/contracts/async-vault/src/event.rs index 5262869..26f1cf5 100644 --- a/contracts/async-vault/src/event.rs +++ b/contracts/async-vault/src/event.rs @@ -149,3 +149,9 @@ pub struct WindDownClaimed { pub surrendered: i128, pub assets: i128, } + +#[contractevent] +pub struct DepositCapUpdated { + pub old_cap: Option, + pub new_cap: Option, +} diff --git a/contracts/async-vault/src/keys.rs b/contracts/async-vault/src/keys.rs index c2cb363..401cd27 100644 --- a/contracts/async-vault/src/keys.rs +++ b/contracts/async-vault/src/keys.rs @@ -25,4 +25,5 @@ pub(crate) enum DataKey { WindDownAcc, WindDownOwed, WindDownPosition(Address), + DepositCap, } diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index 1db8ecb..69b552d 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -27,10 +27,11 @@ use state::FIRST_EPOCH; pub use error::VaultError; pub use event::{ - CustodianSet, Deployed, DepositClaimed, DepositRequested, EpochClosed, EpochFulfilled, Funded, - NoticeSet, RedeemClaimed, RedeemRequested, UpgradeCancelled, UpgradeDelayProposed, - UpgradeDelaySet, UpgradeProposed, Upgraded, WindDownActivated, WindDownClaimed, - WindDownDelaySet, WindDownProposalCancelled, WindDownProposed, WindDownRoundFinalized, + CustodianSet, Deployed, DepositCapUpdated, DepositClaimed, DepositRequested, EpochClosed, + EpochFulfilled, Funded, NoticeSet, RedeemClaimed, RedeemRequested, UpgradeCancelled, + UpgradeDelayProposed, UpgradeDelaySet, UpgradeProposed, Upgraded, WindDownActivated, + WindDownClaimed, WindDownDelaySet, WindDownProposalCancelled, WindDownProposed, + WindDownRoundFinalized, }; pub use pricing::{DirectUnitPricing, PricingScheme}; pub use roles::VaultRoles; @@ -164,6 +165,27 @@ impl AsyncVault { .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)) } + pub fn deposit_cap(e: &Env) -> Option { + state::deposit_cap(e) + } + + #[only_admin] + pub fn set_deposit_cap(e: &Env, cap: Option, caller: Address) { + caller.require_auth(); + if let Some(c) = cap { + if c < 0 { + panic_with_error!(e, VaultError::InvalidAmount); + } + } + let old_cap = state::deposit_cap(e); + state::set_deposit_cap(e, cap); + DepositCapUpdated { + old_cap, + new_cap: cap, + } + .publish(e); + } + #[only_admin] pub fn set_custodian(e: &Env, custodian: Address, caller: Address) { caller.require_auth(); diff --git a/contracts/async-vault/src/state.rs b/contracts/async-vault/src/state.rs index 55f29fa..b201dce 100644 --- a/contracts/async-vault/src/state.rs +++ b/contracts/async-vault/src/state.rs @@ -170,3 +170,14 @@ pub(crate) fn set_pending_burn_shares(e: &Env, shares: i128) { pub(crate) fn pending_burn_shares(e: &Env) -> i128 { storage::get_instance(e, &DataKey::PendingBurnShares).unwrap_or(0) } + +pub(crate) fn deposit_cap(e: &Env) -> Option { + storage::get_instance(e, &DataKey::DepositCap) +} + +pub(crate) fn set_deposit_cap(e: &Env, cap: Option) { + match cap { + Some(val) => storage::set_instance(e, &DataKey::DepositCap, &val), + None => e.storage().instance().remove(&DataKey::DepositCap), + } +} diff --git a/contracts/async-vault/src/test/deposit_cap.rs b/contracts/async-vault/src/test/deposit_cap.rs new file mode 100644 index 0000000..a0332b5 --- /dev/null +++ b/contracts/async-vault/src/test/deposit_cap.rs @@ -0,0 +1,188 @@ +use super::*; + +#[test] +fn deposit_cap_defaults_to_none_unbounded() { + let f = setup(); + assert_eq!(f.vault.deposit_cap(), None); + + let investor = f.investor(50_000); + let epoch = f.vault.request_deposit(&investor, &50_000); + assert_eq!(f.vault.get_epoch(&epoch).unwrap().total_deposited, 50_000); +} + +#[test] +fn governance_sets_updates_and_clears_cap() { + let f = setup(); + + // Set cap + f.vault.set_deposit_cap(&Some(1_000), &f.admin); + assert_eq!(f.vault.deposit_cap(), Some(1_000)); + + // Update cap + f.vault.set_deposit_cap(&Some(2_000), &f.admin); + assert_eq!(f.vault.deposit_cap(), Some(2_000)); + + // Clear cap + f.vault.set_deposit_cap(&None, &f.admin); + assert_eq!(f.vault.deposit_cap(), None); +} + +#[test] +fn negative_cap_rejected() { + let f = setup(); + refused( + f.vault.try_set_deposit_cap(&Some(-1), &f.admin), + VaultError::InvalidAmount, + ); +} + +#[test] +fn unauthorized_caller_rejected() { + let f = setup(); + let rando = Address::generate(&f.e); + + f.e.set_auths(&[]); + assert!(f.vault.try_set_deposit_cap(&Some(1_000), &rando).is_err()); + assert!(f + .vault + .try_set_deposit_cap(&Some(1_000), &f.manager) + .is_err()); + assert!(f + .vault + .try_set_deposit_cap(&Some(1_000), &f.guardian) + .is_err()); + assert!(f + .vault + .try_set_deposit_cap(&Some(1_000), &f.treasury) + .is_err()); +} + +#[test] +fn boundary_value_enforcement() { + let f = setup(); + f.vault.set_deposit_cap(&Some(1_000), &f.admin); + + let investor1 = f.investor(2_000); + let investor2 = f.investor(2_000); + + // Deposit exceeding cap from empty vault fails + refused( + f.vault.try_request_deposit(&investor1, &1_001), + VaultError::DepositCapExceeded, + ); + + // Deposit exactly equal to cap succeeds + let epoch = f.vault.request_deposit(&investor1, &1_000); + assert_eq!(f.vault.get_epoch(&epoch).unwrap().total_deposited, 1_000); + + // Any subsequent deposit fails + refused( + f.vault.try_request_deposit(&investor2, &1), + VaultError::DepositCapExceeded, + ); +} + +#[test] +fn cancellation_frees_capacity() { + let f = setup(); + f.vault.set_deposit_cap(&Some(1_000), &f.admin); + + let investor1 = f.investor(1_000); + let investor2 = f.investor(1_000); + + let epoch_id = f.vault.request_deposit(&investor1, &1_000); + + // Blocked while request is pending + refused( + f.vault.try_request_deposit(&investor2, &500), + VaultError::DepositCapExceeded, + ); + + // Cancel refund restores capacity + f.vault.cancel_deposit(&investor1, &epoch_id); + assert_eq!(f.vault.get_epoch(&epoch_id).unwrap().total_deposited, 0); + + // Now investor2 can deposit + f.vault.request_deposit(&investor2, &500); + assert_eq!(f.vault.get_epoch(&epoch_id).unwrap().total_deposited, 500); +} + +#[test] +fn deployed_capital_and_multi_epoch_accounting() { + let f = setup(); + f.vault.set_deposit_cap(&Some(1_000), &f.admin); + + let investor1 = f.investor(1_000); + let investor2 = f.investor(1_000); + + // Epoch 1 deposit + f.vault.request_deposit(&investor1, &1_000); + f.vault.close_epoch(&f.manager); + f.attest(wad(1)); + f.vault.fulfill_epoch(&1); + + // Deploy 800 to custodian + f.vault.set_custodian(&f.custodian, &f.admin); + f.vault.deploy_to_custodian(&f.treasury, &800); + assert_eq!(f.vault.net_deployed(), 800); + + // In Epoch 2, deposit of 1 still breaches cap because deployed capital is counted + refused( + f.vault.try_request_deposit(&investor2, &1), + VaultError::DepositCapExceeded, + ); + + // Governance raises cap to 1_500 + f.vault.set_deposit_cap(&Some(1_500), &f.admin); + + // Now investor2 can deposit 500 + let epoch2 = f.vault.request_deposit(&investor2, &500); + assert_eq!(f.vault.get_epoch(&epoch2).unwrap().total_deposited, 500); +} + +#[test] +fn redemptions_free_capacity() { + let f = setup(); + f.vault.set_deposit_cap(&Some(1_000), &f.admin); + + let investor1 = f.investor(1_000); + let investor2 = f.investor(1_000); + + // Epoch 1: investor1 deposits 1_000 and claims 1_000 shares + f.vault.request_deposit(&investor1, &1_000); + f.vault.close_epoch(&f.manager); + f.attest(wad(1)); + f.vault.fulfill_epoch(&1); + f.vault.claim_deposit(&investor1, &1); + + // In Epoch 2, deposited capital is 1_000, so new deposit of 500 fails + refused( + f.vault.try_request_deposit(&investor2, &500), + VaultError::DepositCapExceeded, + ); + + // Investor 1 requests redemption of 500 shares + f.vault.request_redeem(&investor1, &500); + f.vault.close_epoch(&f.manager); + f.attest(wad(1)); + f.vault.fulfill_epoch(&2); + + // Redemption is now priced and committed (500 committed to exit) + assert_eq!(f.vault.committed(), 500); + + // In Epoch 3, headroom has opened up by 500, so investor2 can deposit 500 + let epoch3 = f.vault.request_deposit(&investor2, &500); + assert_eq!(f.vault.get_epoch(&epoch3).unwrap().total_deposited, 500); +} + +#[test] +fn zero_cap_blocks_all_deposits() { + let f = setup(); + f.vault.set_deposit_cap(&Some(0), &f.admin); + + let investor = f.investor(100); + refused( + f.vault.try_request_deposit(&investor, &1), + VaultError::DepositCapExceeded, + ); +} diff --git a/contracts/async-vault/src/test/mod.rs b/contracts/async-vault/src/test/mod.rs index be3bcd5..0c9a5c2 100644 --- a/contracts/async-vault/src/test/mod.rs +++ b/contracts/async-vault/src/test/mod.rs @@ -4,6 +4,7 @@ mod constructor; mod controls; mod conversions; mod deposit; +mod deposit_cap; mod epochs; mod multi_epoch; mod notice; diff --git a/contracts/async-vault/src/treasury.rs b/contracts/async-vault/src/treasury.rs index a3bb1e6..4958a1f 100644 --- a/contracts/async-vault/src/treasury.rs +++ b/contracts/async-vault/src/treasury.rs @@ -36,6 +36,12 @@ pub(crate) fn uncovered(e: &Env) -> i128 { (state::committed(e) - liquid_reserve(e)).max(0) } +/// Capital in the vault and deployed to the custodian, less liabilities already +/// committed to priced exits. Used to evaluate headroom against the deposit cap. +pub(crate) fn deposited_capital(e: &Env) -> i128 { + (held(e) + state::net_deployed(e)).saturating_sub(state::committed(e)) +} + pub(crate) fn deploy(e: &Env, assets: i128) -> i128 { wind_down::refuse_if_active(e); if assets <= 0 {