Skip to content
Merged
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
247 changes: 214 additions & 33 deletions contracts/strategy_vault/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ use soroban_sdk::{contract, contractimpl, contracttype, token, Address, Env, Map
const MAX_ALLOC_BPS: i128 = 3_500;
const BPS_DENOM: i128 = 10_000;

/// Default buffer: 5% of total capital kept idle for fast withdrawals.
/// Documented explicitly so it is never implicitly zero.
const DEFAULT_BUFFER_BPS: i128 = 500; // 5 %

#[contracttype]
pub enum DataKey {
Admin,
Expand All @@ -12,13 +16,22 @@ pub enum DataKey {
Allocations,
TierVaults,
PoolAllowlist,
/// Governance-settable buffer: percentage of total capital (in BPS)
/// that must remain as idle USDC balance, not deployed to pool addresses.
BufferBps,
}

#[contract]
pub struct StrategyVault;

#[contractimpl]
impl StrategyVault {
/// Initialise the vault.
///
/// `admin` **must** be the address of the Governance contract so that all
/// admin-gated functions (including `set_buffer_bps`) are protected by the
/// governance timelock/veto flow, and the bypass path described in the
/// NF-09 audit note is closed by convention.
pub fn initialize(env: Env, admin: Address, usdc_token: Address) {
if env.storage().instance().has(&DataKey::Admin) {
panic!("already initialized");
Expand All @@ -34,6 +47,11 @@ impl StrategyVault {
env.storage()
.instance()
.set(&DataKey::PoolAllowlist, &Map::<Address, bool>::new(&env));
// Explicitly initialise the buffer to the documented default rather
// than leaving it implicitly zero.
env.storage()
.instance()
.set(&DataKey::BufferBps, &DEFAULT_BUFFER_BPS);
}

pub fn set_harvester(env: Env, harvester: Address) {
Expand Down Expand Up @@ -67,6 +85,38 @@ impl StrategyVault {
.set(&DataKey::PoolAllowlist, &allowlist);
}

// ── Buffer management ─────────────────────────────────────────────────────

/// Set the percentage of total capital (in BPS) that `rebalance()` must
/// leave as idle USDC balance for fast withdrawals.
///
/// Only callable via Governance (the registered `admin` address).
/// Valid range: 0 – 10 000 BPS (0 % – 100 %).
pub fn set_buffer_bps(env: Env, bps: i128) {
Self::require_admin(&env);
if bps < 0 || bps > BPS_DENOM {
panic!("buffer_bps must be in [0, 10000]");
}
env.storage().instance().set(&DataKey::BufferBps, &bps);
}

/// Read-only: returns the current buffer BPS setting.
pub fn buffer_bps(env: Env) -> i128 {
env.storage()
.instance()
.get(&DataKey::BufferBps)
.unwrap_or(DEFAULT_BUFFER_BPS)
}

/// Read-only: returns the current idle (un-deployed) USDC balance held
/// on this contract.
pub fn idle_balance(env: Env) -> i128 {
let usdc: Address = env.storage().instance().get(&DataKey::Usdc).unwrap();
token::Client::new(&env, &usdc).balance(&env.current_contract_address())
}

// ── Capital flows ─────────────────────────────────────────────────────────

pub fn deposit_capital(env: Env, caller: Address, amount: i128) {
caller.require_auth();
let vaults: Map<Address, bool> = env
Expand All @@ -85,6 +135,13 @@ impl StrategyVault {
);
}

/// Withdraw capital to a registered tier vault.
///
/// Draws from the idle buffer first. If the requested amount is within
/// the current idle USDC balance the transfer succeeds immediately without
/// touching pool allocations. Withdrawals that exceed the idle balance
/// are rejected (pulling from pools is out of scope for this issue and
/// tracked as a follow-up).
pub fn withdraw_capital(env: Env, caller: Address, amount: i128) {
caller.require_auth();
let vaults: Map<Address, bool> = env
Expand All @@ -96,6 +153,10 @@ impl StrategyVault {
panic!("caller is not a registered tier vault");
}
let usdc: Address = env.storage().instance().get(&DataKey::Usdc).unwrap();
let idle = token::Client::new(&env, &usdc).balance(&env.current_contract_address());
if amount > idle {
panic!("withdrawal exceeds idle buffer; pool liquidity pull not yet supported");
}
token::Client::new(&env, &usdc).transfer(
&env.current_contract_address(),
&caller,
Expand Down Expand Up @@ -155,6 +216,12 @@ impl StrategyVault {
token::Client::new(&env, &usdc).balance(&env.current_contract_address())
}

/// Rebalance capital across pool allocations, honouring the liquidity buffer.
///
/// The buffer is deducted from total capital first; only
/// `total_capital - reserved` is distributed across pool addresses.
/// This ensures `buffer_bps` of capital is always left idle on the
/// contract for fast withdrawals.
pub fn rebalance(env: Env) {
let harvester: Address = env
.storage()
Expand All @@ -165,13 +232,29 @@ impl StrategyVault {
let usdc: Address = env.storage().instance().get(&DataKey::Usdc).unwrap();
let client = token::Client::new(&env, &usdc);
let total = client.balance(&env.current_contract_address());

// Compute the idle buffer that must remain on the contract.
let buffer_bps: i128 = env
.storage()
.instance()
.get(&DataKey::BufferBps)
.unwrap_or(DEFAULT_BUFFER_BPS);
let reserved = total
.checked_mul(buffer_bps)
.expect("buffer calc overflow")
/ BPS_DENOM;

// Only deploy capital beyond the reserved buffer amount.
let deployable = total.saturating_sub(reserved);

let allocs: Map<Address, i128> = env
.storage()
.instance()
.get(&DataKey::Allocations)
.unwrap();
for (pool, bps) in allocs.iter() {
let target = total
// Each allocation's target is computed from `deployable`, not `total`.
let target = deployable
.checked_mul(bps)
.expect("rebalance overflow")
/ BPS_DENOM;
Expand All @@ -190,7 +273,29 @@ impl StrategyVault {
#[cfg(test)]
mod tests {
use super::*;
use soroban_sdk::{testutils::Address as _, Env};
use soroban_sdk::{
testutils::{Address as _, MockAuth, MockAuthInvoke},
token::{Client as TokenClient, StellarAssetClient},
Address, Env, IntoVal,
};

// ── Helpers ───────────────────────────────────────────────────────────────

fn setup(env: &Env) -> (Address, Address, Address, StrategyVaultClient) {
let admin = Address::generate(env);
let usdc_id = env.register_stellar_asset_contract_v2(Address::generate(env)).address();
let cid = env.register_contract(None, StrategyVault);
let client = StrategyVaultClient::new(env, &cid);
env.mock_all_auths();
client.initialize(&admin, &usdc_id);
(admin, usdc_id, cid, client)
}

fn mint(env: &Env, usdc_id: &Address, to: &Address, amount: i128) {
StellarAssetClient::new(env, usdc_id).mint(to, &amount);
}

// ── Basic instantiation / pre-existing tests ──────────────────────────────

#[test]
fn contract_instantiates() {
Expand All @@ -202,24 +307,16 @@ mod tests {
fn initialize_and_read_empty_allocations() {
let env = Env::default();
env.mock_all_auths();
let cid = env.register_contract(None, StrategyVault);
let client = StrategyVaultClient::new(&env, &cid);
let admin = Address::generate(&env);
let usdc = Address::generate(&env);
client.initialize(&admin, &usdc);
let (_, _, _, client) = setup(&env);
assert_eq!(client.allocations().len(), 0);
}

#[test]
fn set_allocation_at_35_pct_cap_succeeds() {
let env = Env::default();
env.mock_all_auths();
let cid = env.register_contract(None, StrategyVault);
let client = StrategyVaultClient::new(&env, &cid);
let admin = Address::generate(&env);
let usdc = Address::generate(&env);
let (_, _, _, client) = setup(&env);
let pool = Address::generate(&env);
client.initialize(&admin, &usdc);
client.allow_pool(&pool);
client.set_allocation(&pool, &3500);
assert_eq!(client.allocations().get(pool).unwrap(), 3500);
Expand All @@ -230,11 +327,7 @@ mod tests {
fn set_allocation_rejects_unlisted_pool() {
let env = Env::default();
env.mock_all_auths();
let cid = env.register_contract(None, StrategyVault);
let client = StrategyVaultClient::new(&env, &cid);
let admin = Address::generate(&env);
let usdc = Address::generate(&env);
client.initialize(&admin, &usdc);
let (_, _, _, client) = setup(&env);
client.set_allocation(&Address::generate(&env), &1000);
}

Expand All @@ -243,12 +336,8 @@ mod tests {
fn set_allocation_rejects_above_35_pct() {
let env = Env::default();
env.mock_all_auths();
let cid = env.register_contract(None, StrategyVault);
let client = StrategyVaultClient::new(&env, &cid);
let admin = Address::generate(&env);
let usdc = Address::generate(&env);
let (_, _, _, client) = setup(&env);
let pool = Address::generate(&env);
client.initialize(&admin, &usdc);
client.allow_pool(&pool);
client.set_allocation(&pool, &3501);
}
Expand All @@ -258,11 +347,7 @@ mod tests {
fn double_initialize_panics() {
let env = Env::default();
env.mock_all_auths();
let cid = env.register_contract(None, StrategyVault);
let client = StrategyVaultClient::new(&env, &cid);
let admin = Address::generate(&env);
let usdc = Address::generate(&env);
client.initialize(&admin, &usdc);
let (admin, usdc, _, client) = setup(&env);
client.initialize(&admin, &usdc);
}

Expand All @@ -271,17 +356,113 @@ mod tests {
fn total_allocation_cannot_exceed_100_pct() {
let env = Env::default();
env.mock_all_auths();
let cid = env.register_contract(None, StrategyVault);
let client = StrategyVaultClient::new(&env, &cid);
let admin = Address::generate(&env);
let usdc = Address::generate(&env);
let (_, _, _, client) = setup(&env);
let pool_a = Address::generate(&env);
let pool_b = Address::generate(&env);
client.initialize(&admin, &usdc);
client.allow_pool(&pool_a);
client.allow_pool(&pool_b);
client.set_allocation(&pool_a, &3500);
client.set_allocation(&pool_b, &3500);
client.set_allocation(&Address::generate(&env), &3500);
}
}

// ── Buffer BPS ────────────────────────────────────────────────────────────

#[test]
fn default_buffer_bps_is_500() {
let env = Env::default();
env.mock_all_auths();
let (_, _, _, client) = setup(&env);
assert_eq!(client.buffer_bps(), 500);
}

#[test]
fn set_buffer_bps_updates_value() {
let env = Env::default();
env.mock_all_auths();
let (_, _, _, client) = setup(&env);
client.set_buffer_bps(&1000);
assert_eq!(client.buffer_bps(), 1000);
}

#[test]
#[should_panic(expected = "buffer_bps must be in [0, 10000]")]
fn set_buffer_bps_rejects_above_10000() {
let env = Env::default();
env.mock_all_auths();
let (_, _, _, client) = setup(&env);
client.set_buffer_bps(&10001);
}

// ── Rebalance respects the buffer ─────────────────────────────────────────

#[test]
fn rebalance_leaves_buffer_idle() {
let env = Env::default();
env.mock_all_auths();
let (admin, usdc_id, cid, client) = setup(&env);

// Fund the vault with 1_000_000 units.
mint(&env, &usdc_id, &cid, 1_000_000);

// Set buffer to 10% and a single pool allocation.
client.set_buffer_bps(&1000); // 10 %
let pool = Address::generate(&env);
client.allow_pool(&pool);
client.set_allocation(&pool, &5000); // 50% of deployable
let harvester = Address::generate(&env);
client.set_harvester(&harvester);

client.rebalance();

// deployable = 1_000_000 * (1 - 10%) = 900_000
// target for pool = 900_000 * 50% = 450_000
// idle remaining = 1_000_000 - 450_000 = 550_000
// required buffer = 1_000_000 * 10% = 100_000
// idle (550_000) >= reserved (100_000) ✓
let idle = client.idle_balance();
let reserved = 1_000_000i128 * 1000 / BPS_DENOM;
assert!(
idle >= reserved,
"idle balance {idle} should be >= reserved {reserved}"
);
}

// ── Withdrawal within buffer succeeds ─────────────────────────────────────

#[test]
fn withdraw_within_idle_buffer_succeeds() {
let env = Env::default();
env.mock_all_auths();
let (admin, usdc_id, cid, client) = setup(&env);

// Fund the vault and register a tier vault.
mint(&env, &usdc_id, &cid, 1_000_000);
let tier_vault = Address::generate(&env);
client.register_tier_vault(&tier_vault);

// With default buffer (5%) and no pool allocations, all 1_000_000 is idle.
// A withdrawal of 50_000 is well within the buffer.
client.withdraw_capital(&tier_vault, &50_000);

let remaining = TokenClient::new(&env, &usdc_id).balance(&cid);
assert_eq!(remaining, 950_000);
}

// ── Withdrawal exceeding idle balance panics ──────────────────────────────

#[test]
#[should_panic(expected = "withdrawal exceeds idle buffer")]
fn withdraw_exceeding_idle_panics() {
let env = Env::default();
env.mock_all_auths();
let (_, usdc_id, cid, client) = setup(&env);

mint(&env, &usdc_id, &cid, 100);
let tier_vault = Address::generate(&env);
client.register_tier_vault(&tier_vault);

// Try to withdraw more than available.
client.withdraw_capital(&tier_vault, &101);
}
}
Loading