diff --git a/.gitignore b/.gitignore index ea9e667f9..be80e754b 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ _site/ # generated soroban test snapshots contract/vault/**/test_snapshots/ +contract/proxy-4626-soroban/test_snapshots/ # generated bindings client/vault/dist/ diff --git a/Cargo.lock b/Cargo.lock index 91cf88b25..733ee0e87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13116,6 +13116,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "templar-4626-proxy-soroban" +version = "0.1.0" +dependencies = [ + "soroban-sdk 25.3.0", + "templar-curator-primitives", + "templar-soroban-runtime", + "templar-soroban-shared-types", + "templar-vault-kernel", +] + [[package]] name = "templar-accumulator" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 6c7193690..540f43b62 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "common", "contract/lst-oracle", "contract/market", + "contract/proxy-4626-soroban", "contract/proxy-oracle", "contract/redstone-adapter", "contract/registry", diff --git a/contract/proxy-4626-soroban/Cargo.toml b/contract/proxy-4626-soroban/Cargo.toml new file mode 100644 index 000000000..2a760e9d4 --- /dev/null +++ b/contract/proxy-4626-soroban/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "templar-4626-proxy-soroban" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "Stub Soroban ERC-4626 proxy contract for Templar Protocol" + +[lib] +crate-type = ["cdylib", "rlib"] +path = "src/lib.rs" +doctest = false + +[features] +default = [] +testutils = ["soroban-sdk/testutils"] + +[dependencies] +soroban-sdk = { version = "25.0.1", features = ["alloc"] } +templar-soroban-shared-types = { path = "../vault/soroban/shared-types" } +templar-vault-kernel = { path = "../vault/kernel", features = ["postcard", "soroban"] } +templar-curator-primitives = { path = "../vault/curator-primitives", features = ["postcard"] } + +[dev-dependencies] +soroban-sdk = { version = "25.0.1", features = ["testutils", "alloc"] } +templar-soroban-runtime = { path = "../vault/soroban", features = ["testutils"] } diff --git a/contract/proxy-4626-soroban/src/contract.rs b/contract/proxy-4626-soroban/src/contract.rs new file mode 100644 index 000000000..8c12d987f --- /dev/null +++ b/contract/proxy-4626-soroban/src/contract.rs @@ -0,0 +1,548 @@ +//! Core contract structure and helpers for the Soroban ERC-4626 proxy. + +use alloc::string::String as AllocString; + +use soroban_sdk::{ + contract, contractimpl, symbol_short, Address, Bytes, Env, IntoVal, InvokeError, Symbol, +}; +use templar_soroban_shared_types::{ + VaultCommand as WireVaultCommand, VaultCommandResult as WireVaultCommandResult, +}; + +use crate::{error::ContractError, ProxyPreviewView, ProxyViewResponse}; + +#[derive(Clone, Eq, PartialEq)] +pub(crate) enum VaultCommand { + DepositWithMin { + owner: Address, + receiver: Address, + assets: i128, + min_shares_out: i128, + }, + RequestWithdraw { + owner: Address, + receiver: Address, + shares: i128, + min_assets_out: i128, + }, + ExecuteWithdraw { + caller: Address, + }, +} + +impl VaultCommand { + fn into_wire(self) -> Result { + match self { + Self::DepositWithMin { + owner, + receiver, + assets, + min_shares_out, + } => Ok(WireVaultCommand::DepositWithMin { + owner: address_to_wire(&owner)?, + receiver: address_to_wire(&receiver)?, + assets, + min_shares_out, + }), + Self::RequestWithdraw { + owner, + receiver, + shares, + min_assets_out, + } => Ok(WireVaultCommand::RequestWithdraw { + owner: address_to_wire(&owner)?, + receiver: address_to_wire(&receiver)?, + shares, + min_assets_out, + }), + Self::ExecuteWithdraw { caller } => Ok(WireVaultCommand::ExecuteWithdraw { + caller: address_to_wire(&caller)?, + }), + } + } +} + +/// Internal storage keys for proxy config. +#[allow(non_upper_case_globals)] +pub struct ProxyDataKey; + +#[allow(non_upper_case_globals)] +impl ProxyDataKey { + pub const VaultAddress: Symbol = symbol_short!("vault"); + pub const AssetToken: Symbol = symbol_short!("asset"); + pub const ShareToken: Symbol = symbol_short!("share"); + pub const Initialized: Symbol = symbol_short!("init"); +} + +#[contract] +pub struct Soroban4626ProxyContract; + +#[contractimpl] +impl Soroban4626ProxyContract { + pub fn deposit( + env: Env, + caller: Address, + assets: i128, + receiver: Address, + ) -> Result { + caller.require_auth(); + let shares = expect_i128_result(invoke_vault_execute( + &env, + VaultCommand::DepositWithMin { + owner: caller.clone(), + receiver: receiver.clone(), + assets, + min_shares_out: 0, + }, + )?)?; + emit_deposit_event(&env, &caller, &receiver, assets, shares); + Ok(shares) + } + + pub fn mint( + env: Env, + caller: Address, + shares: i128, + receiver: Address, + ) -> Result { + caller.require_auth(); + let preview = call_proxy_view(&env, &caller, 0, shares)?; + let assets = preview.6; + expect_i128_result(invoke_vault_execute( + &env, + VaultCommand::DepositWithMin { + owner: caller.clone(), + receiver: receiver.clone(), + assets, + min_shares_out: shares, + }, + )?)?; + emit_deposit_event(&env, &caller, &receiver, assets, shares); + Ok(assets) + } + + pub fn withdraw( + env: Env, + caller: Address, + assets: i128, + receiver: Address, + owner: Address, + ) -> Result { + let share_token = read_share_token(&env)?; + let preview = call_proxy_view(&env, &owner, assets, 0)?; + let shares = preview.7; + require_auth_or_allowance(&env, &caller, &owner, &share_token, shares)?; + let request_id = expect_u64_result(invoke_vault_execute( + &env, + VaultCommand::RequestWithdraw { + owner: owner.clone(), + receiver: receiver.clone(), + shares, + min_assets_out: assets, + }, + )?)?; + emit_withdraw_event(&env, &caller, &receiver, &owner, assets, shares); + Ok(request_id) + } + + pub fn redeem( + env: Env, + caller: Address, + shares: i128, + receiver: Address, + owner: Address, + ) -> Result { + let share_token = read_share_token(&env)?; + require_auth_or_allowance(&env, &caller, &owner, &share_token, shares)?; + let preview = call_proxy_view(&env, &owner, 0, shares)?; + let assets = preview.1; + let request_id = expect_u64_result(invoke_vault_execute( + &env, + VaultCommand::RequestWithdraw { + owner: owner.clone(), + receiver: receiver.clone(), + shares, + min_assets_out: assets, + }, + )?)?; + emit_withdraw_event(&env, &caller, &receiver, &owner, assets, shares); + Ok(request_id) + } + + pub fn request_withdraw( + env: Env, + owner: Address, + receiver: Address, + shares: i128, + min_assets_out: i128, + ) -> Result { + owner.require_auth(); + expect_u64_result(invoke_vault_execute( + &env, + VaultCommand::RequestWithdraw { + owner, + receiver, + shares, + min_assets_out, + }, + )?) + } + + pub fn execute_withdraw(env: Env, caller: Address) -> Result<(), ContractError> { + caller.require_auth(); + expect_unit_result(invoke_vault_execute( + &env, + VaultCommand::ExecuteWithdraw { caller }, + )?) + } + + pub fn asset(env: Env) -> Result { + read_asset_token(&env) + } + + pub fn total_assets(env: Env) -> Result { + let response = call_proxy_view_full(&env, &env.current_contract_address(), 0, 0)?; + Ok(response.0 .2 .3) + } + + pub fn total_supply(env: Env) -> Result { + let share_token = read_share_token(&env)?; + call_token_view_no_args(&env, &share_token, "total_supply") + } + + pub fn balance_of(env: Env, owner: Address) -> Result { + let share_token = read_share_token(&env)?; + call_token_view_with_address(&env, &share_token, "balance", &owner) + } + + pub fn convert_to_shares(env: Env, assets: i128) -> Result { + let preview = call_proxy_view(&env, &env.current_contract_address(), assets, 0)?; + Ok(preview.0) + } + + pub fn convert_to_assets(env: Env, shares: i128) -> Result { + let preview = call_proxy_view(&env, &env.current_contract_address(), 0, shares)?; + Ok(preview.1) + } + + pub fn preview_deposit(env: Env, assets: i128) -> Result { + Self::convert_to_shares(env, assets) + } + + pub fn preview_mint(env: Env, shares: i128) -> Result { + let preview = call_proxy_view(&env, &env.current_contract_address(), 0, shares)?; + Ok(preview.6) + } + + pub fn preview_withdraw(env: Env, assets: i128) -> Result { + let preview = call_proxy_view(&env, &env.current_contract_address(), assets, 0)?; + Ok(preview.7) + } + + pub fn preview_redeem(env: Env, shares: i128) -> Result { + Self::convert_to_assets(env, shares) + } + + pub fn max_deposit(env: Env, receiver: Address) -> Result { + let preview = call_proxy_view(&env, &receiver, 0, 0)?; + Ok(preview.2) + } + + pub fn max_mint(env: Env, receiver: Address) -> Result { + let preview = call_proxy_view(&env, &receiver, 0, 0)?; + Ok(preview.3) + } + + pub fn max_withdraw(env: Env, owner: Address) -> Result { + let preview = call_proxy_view(&env, &owner, 0, 0)?; + Ok(preview.4) + } + + pub fn max_redeem(env: Env, owner: Address) -> Result { + let preview = call_proxy_view(&env, &owner, 0, 0)?; + Ok(preview.5) + } + + pub fn decimals(env: Env) -> Result { + let share_token = read_share_token(&env)?; + call_token_view_no_args(&env, &share_token, "decimals") + } + + pub fn name(env: Env) -> Result { + let share_token = read_share_token(&env)?; + call_token_view_no_args(&env, &share_token, "name") + } + + pub fn symbol(env: Env) -> Result { + let share_token = read_share_token(&env)?; + call_token_view_no_args(&env, &share_token, "symbol") + } + + pub fn initialize( + env: Env, + vault_address: Address, + asset_token: Address, + share_token: Address, + ) -> Result<(), ContractError> { + if is_initialized(&env) { + return Err(ContractError::AlreadyInitialized); + } + + env.storage() + .instance() + .set(&ProxyDataKey::VaultAddress, &vault_address); + env.storage() + .instance() + .set(&ProxyDataKey::AssetToken, &asset_token); + env.storage() + .instance() + .set(&ProxyDataKey::ShareToken, &share_token); + env.storage() + .instance() + .set(&ProxyDataKey::Initialized, &true); + Ok(()) + } +} + +pub(crate) fn is_initialized(env: &Env) -> bool { + env.storage() + .instance() + .get(&ProxyDataKey::Initialized) + .unwrap_or(false) +} + +pub(crate) fn require_initialized(env: &Env) -> Result<(), ContractError> { + is_initialized(env) + .then_some(()) + .ok_or(ContractError::NotInitialized) +} + +pub(crate) fn read_vault_address(env: &Env) -> Result { + require_initialized(env)?; + env.storage() + .instance() + .get(&ProxyDataKey::VaultAddress) + .ok_or(ContractError::NotInitialized) +} + +pub(crate) fn read_asset_token(env: &Env) -> Result { + require_initialized(env)?; + env.storage() + .instance() + .get(&ProxyDataKey::AssetToken) + .ok_or(ContractError::NotInitialized) +} + +pub(crate) fn read_share_token(env: &Env) -> Result { + require_initialized(env)?; + env.storage() + .instance() + .get(&ProxyDataKey::ShareToken) + .ok_or(ContractError::NotInitialized) +} + +pub(crate) fn invoke_vault_execute( + env: &Env, + command: VaultCommand, +) -> Result { + let vault_address = read_vault_address(env)?; + let command = command.into_wire()?; + let payload = Bytes::from_slice(env, &command.encode()); + let execute = Symbol::new(env, "execute"); + + let result = env.try_invoke_contract::( + &vault_address, + &execute, + (&payload,).into_val(env), + ); + + let bytes = match result { + Ok(Ok(bytes)) => bytes, + Ok(Err(_)) => return Err(ContractError::VaultError), + Err(Ok(error)) => return Err(error), + Err(Err(invoke_error)) => { + return Err(match invoke_error { + InvokeError::Abort => ContractError::VaultError, + InvokeError::Contract(code) => ContractError::from_vault_error_code(code), + }) + } + }; + + WireVaultCommandResult::decode(&bytes.to_alloc_vec()).map_err(Into::into) +} + +fn call_proxy_view_full( + env: &Env, + owner: &Address, + assets: i128, + shares: i128, +) -> Result { + let vault_address = read_vault_address(env)?; + let proxy_view = Symbol::new(env, "proxy_view"); + let result = env.try_invoke_contract::( + &vault_address, + &proxy_view, + (owner.clone(), assets, shares).into_val(env), + ); + + match result { + Ok(Ok(response)) => Ok(response), + Ok(Err(_)) => Err(ContractError::VaultError), + Err(Ok(error)) => Err(error), + Err(Err(invoke_error)) => Err(match invoke_error { + InvokeError::Abort => ContractError::VaultError, + InvokeError::Contract(code) => ContractError::from_vault_error_code(code), + }), + } +} + +fn call_proxy_view( + env: &Env, + owner: &Address, + assets: i128, + shares: i128, +) -> Result { + let response = call_proxy_view_full(env, owner, assets, shares)?; + let (_, _, preview) = response; + Ok(preview) +} + +fn call_token_view_no_args(env: &Env, token: &Address, method: &str) -> Result +where + T: soroban_sdk::TryFromVal, +{ + map_token_invoke_result(env.try_invoke_contract::( + token, + &Symbol::new(env, method), + soroban_sdk::vec![env], + )) +} + +fn call_token_view_with_address( + env: &Env, + token: &Address, + method: &str, + address: &Address, +) -> Result +where + T: soroban_sdk::TryFromVal, +{ + map_token_invoke_result(env.try_invoke_contract::( + token, + &Symbol::new(env, method), + soroban_sdk::vec![env, address.into_val(env)], + )) +} + +fn map_token_invoke_result( + result: Result, Result>, +) -> Result +where + T: soroban_sdk::TryFromVal, +{ + match result { + Ok(Ok(value)) => Ok(value), + Ok(Err(_)) => Err(ContractError::VaultError), + Err(Ok(_)) => Err(ContractError::VaultError), + Err(Err(InvokeError::Abort | InvokeError::Contract(_))) => Err(ContractError::VaultError), + } +} + +fn expect_i128_result(result: WireVaultCommandResult) -> Result { + match result { + WireVaultCommandResult::I128(value) => Ok(value), + _ => Err(ContractError::VaultError), + } +} + +fn expect_u64_result(result: WireVaultCommandResult) -> Result { + match result { + WireVaultCommandResult::U64(value) => Ok(value), + _ => Err(ContractError::VaultError), + } +} + +fn expect_unit_result(result: WireVaultCommandResult) -> Result<(), ContractError> { + match result { + WireVaultCommandResult::Unit => Ok(()), + _ => Err(ContractError::VaultError), + } +} + +pub(crate) fn require_auth_or_allowance( + env: &Env, + caller: &Address, + owner: &Address, + token: &Address, + amount: i128, +) -> Result<(), ContractError> { + if caller == owner { + owner.require_auth(); + return Ok(()); + } + + caller.require_auth(); + let proxy = env.current_contract_address(); + let allowance: i128 = + call_token_view_with_two_addresses(env, token, "allowance", owner, &proxy)?; + + (allowance >= amount) + .then_some(()) + .ok_or(ContractError::InsufficientAllowance) +} + +fn call_token_view_with_two_addresses( + env: &Env, + token: &Address, + method: &str, + first: &Address, + second: &Address, +) -> Result +where + T: soroban_sdk::TryFromVal, +{ + map_token_invoke_result(env.try_invoke_contract::( + token, + &Symbol::new(env, method), + soroban_sdk::vec![env, first.into_val(env), second.into_val(env)], + )) +} + +#[allow(deprecated)] +pub(crate) fn emit_deposit_event( + env: &Env, + caller: &Address, + owner: &Address, + assets: i128, + shares: i128, +) { + env.events().publish( + (symbol_short!("Deposit"), caller.clone(), owner.clone()), + (assets, shares), + ); +} + +#[allow(deprecated)] +pub(crate) fn emit_withdraw_event( + env: &Env, + caller: &Address, + receiver: &Address, + owner: &Address, + assets: i128, + shares: i128, +) { + env.events().publish( + ( + symbol_short!("Withdraw"), + caller.clone(), + receiver.clone(), + owner.clone(), + ), + (assets, shares), + ); +} + +fn address_to_wire(address: &Address) -> Result { + let raw = address.to_string().to_bytes().to_alloc_vec(); + AllocString::from_utf8(raw).map_err(|_| ContractError::InvalidInput) +} diff --git a/contract/proxy-4626-soroban/src/error.rs b/contract/proxy-4626-soroban/src/error.rs new file mode 100644 index 000000000..bfbbefffc --- /dev/null +++ b/contract/proxy-4626-soroban/src/error.rs @@ -0,0 +1,63 @@ +//! Error types for the Soroban ERC-4626 proxy crate. + +use soroban_sdk::contracterror; + +use templar_soroban_shared_types::CodecError; + +#[contracterror] +#[repr(u32)] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum ContractError { + NotInitialized = 1, + AlreadyInitialized = 2, + InvalidInput = 3, + VaultError = 4, + InsufficientAllowance = 5, + NotImplemented = 6, +} + +#[cfg_attr(not(target_arch = "wasm32"), derive(Debug))] +#[derive(Copy, Clone, Eq, PartialEq)] +pub enum RuntimeError { + NotInitialized, + AlreadyInitialized, + VaultError, + InsufficientAllowance, + NotImplemented, + InvalidInput, +} + +impl From for ContractError { + fn from(error: RuntimeError) -> Self { + match error { + RuntimeError::NotInitialized => Self::NotInitialized, + RuntimeError::AlreadyInitialized => Self::AlreadyInitialized, + RuntimeError::VaultError => Self::VaultError, + RuntimeError::InsufficientAllowance => Self::InsufficientAllowance, + RuntimeError::NotImplemented => Self::NotImplemented, + RuntimeError::InvalidInput => Self::InvalidInput, + } + } +} + +impl From for ContractError { + fn from(_: CodecError) -> Self { + Self::InvalidInput + } +} + +impl From for RuntimeError { + fn from(_: CodecError) -> Self { + Self::InvalidInput + } +} + +impl ContractError { + pub(crate) const fn from_vault_error_code(code: u32) -> Self { + match code { + 3 => Self::InvalidInput, + 8 => Self::AlreadyInitialized, + _ => Self::VaultError, + } + } +} diff --git a/contract/proxy-4626-soroban/src/lib.rs b/contract/proxy-4626-soroban/src/lib.rs new file mode 100644 index 000000000..2d9e61395 --- /dev/null +++ b/contract/proxy-4626-soroban/src/lib.rs @@ -0,0 +1,32 @@ +#![no_std] + +extern crate alloc; + +#[cfg(test)] +extern crate std; + +use soroban_sdk::Address; + +mod contract; +mod error; + +pub(crate) type ProxyCoreView = ( + (Address, Address, Address, Address), + (i128, i128, bool), + (i128, i128, i128, i128), + (i128, u64, i128, i128), +); +pub(crate) type ProxyPolicyView = ( + soroban_sdk::Vec, + soroban_sdk::Vec<(soroban_sdk::String, i128, i128)>, +); +pub(crate) type ProxyPreviewView = (i128, i128, i128, i128, i128, i128, i128, i128); +pub(crate) type ProxyViewResponse = (ProxyCoreView, ProxyPolicyView, ProxyPreviewView); + +pub use { + contract::Soroban4626ProxyContract, error::ContractError, + templar_soroban_shared_types::VaultCommandResult, +}; + +#[cfg(test)] +mod tests; diff --git a/contract/proxy-4626-soroban/src/tests.rs b/contract/proxy-4626-soroban/src/tests.rs new file mode 100644 index 000000000..4a19089e1 --- /dev/null +++ b/contract/proxy-4626-soroban/src/tests.rs @@ -0,0 +1,615 @@ +use alloc::{format, string::String as AllocString}; + +use soroban_sdk::testutils::{Address as _, Events as _}; +use soroban_sdk::{contract, contractimpl, contracttype, Address, Bytes, Env, IntoVal, Vec}; +use templar_soroban_shared_types::{ + VaultCommand as WireVaultCommand, VaultCommandResult as WireVaultCommandResult, +}; + +use crate::{ + contract::{ProxyDataKey, Soroban4626ProxyContract}, + error::ContractError, + ProxyPreviewView, ProxyViewResponse, +}; + +#[derive(Clone)] +#[contracttype] +enum MockVaultDataKey { + Preview, + RecordedPayloads, + LastProxyViewCall, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +#[contracttype] +struct MockPreviewConfig { + convert_to_shares: i128, + convert_to_assets: i128, + max_deposit: i128, + max_mint: i128, + max_withdraw: i128, + max_redeem: i128, + preview_mint_assets: i128, + preview_withdraw_shares: i128, +} + +impl MockPreviewConfig { + const fn into_preview(self) -> ProxyPreviewView { + ( + self.convert_to_shares, + self.convert_to_assets, + self.max_deposit, + self.max_mint, + self.max_withdraw, + self.max_redeem, + self.preview_mint_assets, + self.preview_withdraw_shares, + ) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +struct MockProxyViewCall { + owner: Address, + assets: i128, + shares: i128, +} + +#[contract] +struct MockVaultContract; + +#[contractimpl] +impl MockVaultContract { + pub fn set_preview(env: Env, preview: MockPreviewConfig) { + env.storage() + .instance() + .set(&MockVaultDataKey::Preview, &preview); + } + + pub fn recorded_payloads(env: Env) -> Vec { + env.storage() + .instance() + .get(&MockVaultDataKey::RecordedPayloads) + .unwrap_or(Vec::new(&env)) + } + + pub fn last_proxy_view_call(env: Env) -> Option { + env.storage() + .instance() + .get(&MockVaultDataKey::LastProxyViewCall) + } + + pub fn execute(env: Env, payload: Bytes) -> Bytes { + let mut payloads = Self::recorded_payloads(env.clone()); + payloads.push_back(payload.clone()); + env.storage() + .instance() + .set(&MockVaultDataKey::RecordedPayloads, &payloads); + + let command = WireVaultCommand::decode(&payload.to_alloc_vec()).expect("decode command"); + let result = match command { + WireVaultCommand::DepositWithMin { .. } => WireVaultCommandResult::I128(1000), + WireVaultCommand::RequestWithdraw { .. } => WireVaultCommandResult::U64(42), + WireVaultCommand::ExecuteWithdraw { .. } => WireVaultCommandResult::Unit, + _ => WireVaultCommandResult::Unit, + }; + + Bytes::from_slice(&env, &result.encode()) + } + + pub fn proxy_view(env: Env, owner: Address, assets: i128, shares: i128) -> ProxyViewResponse { + env.storage().instance().set( + &MockVaultDataKey::LastProxyViewCall, + &MockProxyViewCall { + owner: owner.clone(), + assets, + shares, + }, + ); + + let preview: MockPreviewConfig = env + .storage() + .instance() + .get(&MockVaultDataKey::Preview) + .unwrap_or_default(); + let self_address = env.current_contract_address(); + + ( + ( + ( + self_address.clone(), + self_address.clone(), + self_address.clone(), + self_address, + ), + (0, 0, false), + (0, 0, 0, 0), + (0, 0, 0, 0), + ), + (Vec::new(&env), Vec::new(&env)), + preview.into_preview(), + ) + } +} + +#[derive(Clone)] +#[contracttype] +enum MockShareTokenDataKey { + Allowance(Address, Address), +} + +#[contract] +struct MockShareTokenContract; + +#[contractimpl] +impl MockShareTokenContract { + pub fn set_allowance(env: Env, owner: Address, spender: Address, amount: i128) { + env.storage() + .instance() + .set(&MockShareTokenDataKey::Allowance(owner, spender), &amount); + } + + pub fn allowance(env: Env, owner: Address, spender: Address) -> i128 { + env.storage() + .instance() + .get(&MockShareTokenDataKey::Allowance(owner, spender)) + .unwrap_or(0) + } +} + +struct Fixture { + env: Env, + proxy: Address, + vault: Address, + asset: Address, + share: Address, +} + +impl Fixture { + fn new() -> Self { + let env = Env::default(); + let proxy = env.register(Soroban4626ProxyContract, ()); + let vault = env.register(MockVaultContract, ()); + let share = env.register(MockShareTokenContract, ()); + let asset = Address::generate(&env); + Self { + env, + proxy, + vault, + asset, + share, + } + } + + fn initialize(&self) -> Result<(), ContractError> { + self.env.as_contract(&self.proxy, || { + Soroban4626ProxyContract::initialize( + self.env.clone(), + self.vault.clone(), + self.asset.clone(), + self.share.clone(), + ) + }) + } + + fn set_preview(&self, preview: MockPreviewConfig) { + self.env.as_contract(&self.vault, || { + MockVaultContract::set_preview(self.env.clone(), preview) + }); + } + + fn set_allowance(&self, owner: &Address, spender: &Address, amount: i128) { + self.env.as_contract(&self.share, || { + MockShareTokenContract::set_allowance( + self.env.clone(), + owner.clone(), + spender.clone(), + amount, + ) + }); + } + + fn recorded_payloads(&self) -> Vec { + self.env.as_contract(&self.vault, || { + MockVaultContract::recorded_payloads(self.env.clone()) + }) + } + + fn last_proxy_view_call(&self) -> MockProxyViewCall { + self.env + .as_contract(&self.vault, || { + MockVaultContract::last_proxy_view_call(self.env.clone()) + }) + .expect("proxy_view call recorded") + } + + fn proxy_events_debug(&self) -> AllocString { + let events = self.env.events().all().filter_by_contract(&self.proxy); + format!("{:?}", events.events()) + } +} + +fn decode_command(payload: &Bytes) -> WireVaultCommand { + WireVaultCommand::decode(&payload.to_alloc_vec()).expect("decode recorded payload") +} + +fn address_wire(address: &Address) -> AllocString { + AllocString::from_utf8(address.to_string().to_bytes().to_alloc_vec()).expect("valid address") +} + +#[test] +fn test_initialize_success() { + let fixture = Fixture::new(); + + fixture.initialize().expect("initialize succeeds"); + + fixture.env.as_contract(&fixture.proxy, || { + let storage = fixture.env.storage().instance(); + assert_eq!( + storage.get(&ProxyDataKey::VaultAddress), + Some(fixture.vault.clone()) + ); + assert_eq!( + storage.get(&ProxyDataKey::AssetToken), + Some(fixture.asset.clone()) + ); + assert_eq!( + storage.get(&ProxyDataKey::ShareToken), + Some(fixture.share.clone()) + ); + assert_eq!(storage.get(&ProxyDataKey::Initialized), Some(true)); + }); +} + +#[test] +fn test_initialize_already_initialized() { + let fixture = Fixture::new(); + + fixture.initialize().expect("first initialize succeeds"); + let result = fixture.initialize(); + + assert_eq!(result, Err(ContractError::AlreadyInitialized)); +} + +#[test] +fn test_deposit_command_serialization() { + let fixture = Fixture::new(); + let caller = Address::generate(&fixture.env); + let receiver = Address::generate(&fixture.env); + + fixture.env.mock_all_auths(); + fixture.initialize().expect("initialize succeeds"); + + let minted = fixture.env.as_contract(&fixture.proxy, || { + Soroban4626ProxyContract::deposit( + fixture.env.clone(), + caller.clone(), + 250, + receiver.clone(), + ) + }); + + assert_eq!(minted, Ok(1000)); + let payloads = fixture.recorded_payloads(); + assert_eq!(payloads.len(), 1); + let command = decode_command(&payloads.get(0).expect("payload exists")); + assert_eq!( + command, + WireVaultCommand::DepositWithMin { + owner: address_wire(&caller), + receiver: address_wire(&receiver), + assets: 250, + min_shares_out: 0, + } + ); +} + +#[test] +fn test_deposit_emits_event() { + let fixture = Fixture::new(); + let caller = Address::generate(&fixture.env); + let receiver = Address::generate(&fixture.env); + + fixture.env.mock_all_auths(); + fixture.initialize().expect("initialize succeeds"); + + let minted = fixture.env.as_contract(&fixture.proxy, || { + Soroban4626ProxyContract::deposit( + fixture.env.clone(), + caller.clone(), + 444, + receiver.clone(), + ) + }); + + assert_eq!(minted, Ok(1000)); + let events = fixture + .env + .events() + .all() + .filter_by_contract(&fixture.proxy); + assert_eq!(events.events().len(), 1); + let rendered = fixture.proxy_events_debug(); + assert!(rendered.contains("Deposit")); + assert!(rendered.contains("444")); + assert!(rendered.contains("1000")); +} + +#[test] +fn test_mint_uses_preview() { + let fixture = Fixture::new(); + let caller = Address::generate(&fixture.env); + let receiver = Address::generate(&fixture.env); + + fixture.env.mock_all_auths(); + fixture.initialize().expect("initialize succeeds"); + fixture.set_preview(MockPreviewConfig { + preview_mint_assets: 333, + ..Default::default() + }); + + let assets = fixture.env.as_contract(&fixture.proxy, || { + Soroban4626ProxyContract::mint(fixture.env.clone(), caller.clone(), 77, receiver.clone()) + }); + + assert_eq!(assets, Ok(333)); + assert_eq!( + fixture.last_proxy_view_call(), + MockProxyViewCall { + owner: caller, + assets: 0, + shares: 77, + } + ); + let payloads = fixture.recorded_payloads(); + assert_eq!(payloads.len(), 1); + let command = decode_command(&payloads.get(0).expect("payload exists")); + assert_eq!( + command, + WireVaultCommand::DepositWithMin { + owner: address_wire(&fixture.last_proxy_view_call().owner), + receiver: address_wire(&receiver), + assets: 333, + min_shares_out: 77, + } + ); +} + +#[test] +fn test_request_withdraw_returns_request_id() { + let fixture = Fixture::new(); + let owner = Address::generate(&fixture.env); + let receiver = Address::generate(&fixture.env); + + fixture.env.mock_all_auths(); + fixture.initialize().expect("initialize succeeds"); + + let request_id = fixture.env.as_contract(&fixture.proxy, || { + Soroban4626ProxyContract::request_withdraw( + fixture.env.clone(), + owner.clone(), + receiver.clone(), + 80, + 70, + ) + }); + + assert_eq!(request_id, Ok(42)); +} + +#[test] +fn test_execute_withdraw_returns_unit() { + let fixture = Fixture::new(); + let caller = Address::generate(&fixture.env); + + fixture.env.mock_all_auths(); + fixture.initialize().expect("initialize succeeds"); + + let result = fixture.env.as_contract(&fixture.proxy, || { + Soroban4626ProxyContract::execute_withdraw(fixture.env.clone(), caller) + }); + + assert_eq!(result, Ok(())); +} + +#[test] +fn test_withdraw_queued_flow() { + let fixture = Fixture::new(); + let owner = Address::generate(&fixture.env); + let receiver = Address::generate(&fixture.env); + + fixture.env.mock_all_auths(); + fixture.initialize().expect("initialize succeeds"); + fixture.set_preview(MockPreviewConfig { + preview_withdraw_shares: 75, + ..Default::default() + }); + + let request_id = fixture.env.as_contract(&fixture.proxy, || { + Soroban4626ProxyContract::withdraw( + fixture.env.clone(), + owner.clone(), + 500, + receiver.clone(), + owner.clone(), + ) + }); + + assert_eq!(request_id, Ok(42)); + let payloads = fixture.recorded_payloads(); + assert_eq!(payloads.len(), 1); + assert_eq!( + decode_command(&payloads.get(0).expect("request payload exists")), + WireVaultCommand::RequestWithdraw { + owner: address_wire(&owner), + receiver: address_wire(&receiver), + shares: 75, + min_assets_out: 500, + } + ); +} + +#[test] +fn test_withdraw_emits_event() { + let fixture = Fixture::new(); + let owner = Address::generate(&fixture.env); + let receiver = Address::generate(&fixture.env); + + fixture.env.mock_all_auths(); + fixture.initialize().expect("initialize succeeds"); + fixture.set_preview(MockPreviewConfig { + preview_withdraw_shares: 61, + ..Default::default() + }); + + let request_id = fixture.env.as_contract(&fixture.proxy, || { + Soroban4626ProxyContract::withdraw( + fixture.env.clone(), + owner.clone(), + 222, + receiver.clone(), + owner.clone(), + ) + }); + + assert_eq!(request_id, Ok(42)); + let events = fixture + .env + .events() + .all() + .filter_by_contract(&fixture.proxy); + assert_eq!(events.events().len(), 1); + let rendered = fixture.proxy_events_debug(); + assert!(rendered.contains("Withdraw")); + assert!(rendered.contains("222")); + assert!(rendered.contains("61")); +} + +#[test] +fn test_redeem_queued_flow() { + let fixture = Fixture::new(); + let owner = Address::generate(&fixture.env); + let receiver = Address::generate(&fixture.env); + + fixture.env.mock_all_auths(); + fixture.initialize().expect("initialize succeeds"); + fixture.set_preview(MockPreviewConfig { + convert_to_assets: 88, + ..Default::default() + }); + + let request_id = fixture.env.as_contract(&fixture.proxy, || { + Soroban4626ProxyContract::redeem( + fixture.env.clone(), + owner.clone(), + 55, + receiver.clone(), + owner.clone(), + ) + }); + + assert_eq!(request_id, Ok(42)); + let payloads = fixture.recorded_payloads(); + assert_eq!(payloads.len(), 1); + assert_eq!( + decode_command(&payloads.get(0).expect("request payload exists")), + WireVaultCommand::RequestWithdraw { + owner: address_wire(&owner), + receiver: address_wire(&receiver), + shares: 55, + min_assets_out: 88, + } + ); +} + +#[test] +fn test_convert_to_shares_queries_proxy_view() { + let fixture = Fixture::new(); + + fixture.initialize().expect("initialize succeeds"); + fixture.set_preview(MockPreviewConfig { + convert_to_shares: 1234, + ..Default::default() + }); + + let shares = fixture.env.as_contract(&fixture.proxy, || { + Soroban4626ProxyContract::convert_to_shares(fixture.env.clone(), 777) + }); + + assert_eq!(shares, Ok(1234)); + assert_eq!( + fixture.last_proxy_view_call(), + MockProxyViewCall { + owner: fixture.proxy.clone(), + assets: 777, + shares: 0, + } + ); +} + +#[test] +fn test_max_withdraw_returns_zero_when_not_idle() { + let fixture = Fixture::new(); + let owner = Address::generate(&fixture.env); + + fixture.initialize().expect("initialize succeeds"); + fixture.set_preview(MockPreviewConfig { + max_withdraw: 0, + ..Default::default() + }); + + let max_withdraw = fixture.env.as_contract(&fixture.proxy, || { + Soroban4626ProxyContract::max_withdraw(fixture.env.clone(), owner.clone()) + }); + + assert_eq!(max_withdraw, Ok(0)); + assert_eq!( + fixture.last_proxy_view_call(), + MockProxyViewCall { + owner, + assets: 0, + shares: 0, + } + ); +} + +#[test] +#[should_panic] +fn test_deposit_fails_without_auth() { + let fixture = Fixture::new(); + let caller = Address::generate(&fixture.env); + let receiver = Address::generate(&fixture.env); + + fixture.initialize().expect("initialize succeeds"); + fixture.env.mock_auths(&[]); + + fixture.env.invoke_contract::( + &fixture.proxy, + &soroban_sdk::Symbol::new(&fixture.env, "deposit"), + (&caller, &100i128, &receiver).into_val(&fixture.env), + ); +} + +#[test] +fn test_withdraw_fails_without_allowance() { + let fixture = Fixture::new(); + let caller = Address::generate(&fixture.env); + let owner = Address::generate(&fixture.env); + let receiver = Address::generate(&fixture.env); + + fixture.env.mock_all_auths(); + fixture.initialize().expect("initialize succeeds"); + fixture.set_preview(MockPreviewConfig { + preview_withdraw_shares: 50, + ..Default::default() + }); + fixture.set_allowance(&owner, &fixture.proxy, 0); + + let result = fixture.env.as_contract(&fixture.proxy, || { + Soroban4626ProxyContract::withdraw(fixture.env.clone(), caller, 111, receiver, owner) + }); + + assert_eq!(result, Err(ContractError::InsufficientAllowance)); + assert_eq!(fixture.recorded_payloads().len(), 0); +} diff --git a/contract/proxy-4626-soroban/tests/integration_tests.rs b/contract/proxy-4626-soroban/tests/integration_tests.rs new file mode 100644 index 000000000..b415ae606 --- /dev/null +++ b/contract/proxy-4626-soroban/tests/integration_tests.rs @@ -0,0 +1,449 @@ +use soroban_sdk::{ + testutils::{Address as _, Ledger as _, LedgerInfo}, + token::{Client as TokenClient, StellarAssetClient}, + Address, Env, IntoVal, Symbol, +}; +use templar_4626_proxy_soroban::Soroban4626ProxyContract; +use templar_soroban_runtime::SorobanVaultContract; +use templar_vault_kernel::DEFAULT_COOLDOWN_NS; + +type ProxyCoreView = ( + (Address, Address, Address, Address), + (i128, i128, bool), + (i128, i128, i128, i128), + (i128, u64, i128, i128), +); +type ProxyPolicyView = ( + soroban_sdk::Vec, + soroban_sdk::Vec<(soroban_sdk::String, i128, i128)>, +); +type ProxyPreviewView = (i128, i128, i128, i128, i128, i128, i128, i128); +type ProxyViewResponse = (ProxyCoreView, ProxyPolicyView, ProxyPreviewView); + +const INITIAL_TIMESTAMP: u64 = 100; +const AUTH_EXPIRATION_LEDGER: u32 = 200; + +#[derive(Clone)] +struct Users { + curator: Address, + governance: Address, + asset_token_admin: Address, + user: Address, + receiver: Address, +} + +struct Harness { + env: Env, + vault: Address, + proxy: Address, + asset_token: Address, + share_token: Address, + users: Users, +} + +fn setup_harness() -> Harness { + let env = Env::default(); + env.mock_all_auths(); + set_timestamp(&env, INITIAL_TIMESTAMP); + + let users = Users { + curator: Address::generate(&env), + governance: Address::generate(&env), + asset_token_admin: Address::generate(&env), + user: Address::generate(&env), + receiver: Address::generate(&env), + }; + + let vault = env.register(SorobanVaultContract, ()); + let proxy = env.register(Soroban4626ProxyContract, ()); + let asset_token = env + .register_stellar_asset_contract_v2(users.asset_token_admin.clone()) + .address(); + let share_token = env + .register_stellar_asset_contract_v2(vault.clone()) + .address(); + + env.invoke_contract::<()>( + &vault, + &Symbol::new(&env, "initialize"), + ( + &users.curator, + &users.governance, + &asset_token, + &share_token, + &0i128, + &0i128, + ) + .into_val(&env), + ); + + env.invoke_contract::<()>( + &proxy, + &Symbol::new(&env, "initialize"), + (&vault, &asset_token, &share_token).into_val(&env), + ); + + Harness { + env, + vault, + proxy, + asset_token, + share_token, + users, + } +} + +fn set_timestamp(env: &Env, timestamp: u64) { + env.ledger().set(LedgerInfo { + timestamp, + protocol_version: 25, + sequence_number: 1, + min_temp_entry_ttl: 16, + min_persistent_entry_ttl: 16, + max_entry_ttl: 1_000, + ..Default::default() + }); +} + +fn advance_past_cooldown(env: &Env) { + let cooldown_seconds = DEFAULT_COOLDOWN_NS / 1_000_000_000; + set_timestamp( + env, + env.ledger() + .timestamp() + .saturating_add(cooldown_seconds) + .saturating_add(1), + ); +} + +fn asset_admin_client(harness: &Harness) -> StellarAssetClient<'_> { + StellarAssetClient::new(&harness.env, &harness.asset_token) +} + +fn asset_client(harness: &Harness) -> TokenClient<'_> { + TokenClient::new(&harness.env, &harness.asset_token) +} + +fn share_client(harness: &Harness) -> TokenClient<'_> { + TokenClient::new(&harness.env, &harness.share_token) +} + +fn vault_total_shares(harness: &Harness) -> i128 { + vault_proxy_view(harness, &harness.proxy, 0, 0).0 .2 .0 +} + +fn mint_and_approve_assets(harness: &Harness, owner: &Address, amount: i128) { + asset_admin_client(harness).mint(owner, &amount); + asset_client(harness).approve(owner, &harness.proxy, &amount, &AUTH_EXPIRATION_LEDGER); +} + +fn proxy_deposit(harness: &Harness, caller: &Address, assets: i128, receiver: &Address) -> i128 { + harness.env.invoke_contract::( + &harness.proxy, + &Symbol::new(&harness.env, "deposit"), + (caller, &assets, receiver).into_val(&harness.env), + ) +} + +fn proxy_request_withdraw( + harness: &Harness, + owner: &Address, + receiver: &Address, + shares: i128, + min_assets_out: i128, +) -> u64 { + harness.env.invoke_contract::( + &harness.proxy, + &Symbol::new(&harness.env, "request_withdraw"), + (owner, receiver, &shares, &min_assets_out).into_val(&harness.env), + ) +} + +fn proxy_execute_withdraw(harness: &Harness, caller: &Address) { + harness.env.invoke_contract::<()>( + &harness.proxy, + &Symbol::new(&harness.env, "execute_withdraw"), + (caller,).into_val(&harness.env), + ); +} + +fn proxy_total_assets(harness: &Harness) -> i128 { + harness.env.invoke_contract::( + &harness.proxy, + &Symbol::new(&harness.env, "total_assets"), + soroban_sdk::vec![&harness.env], + ) +} + +fn proxy_convert_to_shares(harness: &Harness, assets: i128) -> i128 { + harness.env.invoke_contract::( + &harness.proxy, + &Symbol::new(&harness.env, "convert_to_shares"), + (&assets,).into_val(&harness.env), + ) +} + +fn proxy_convert_to_assets(harness: &Harness, shares: i128) -> i128 { + harness.env.invoke_contract::( + &harness.proxy, + &Symbol::new(&harness.env, "convert_to_assets"), + (&shares,).into_val(&harness.env), + ) +} + +fn proxy_max_deposit(harness: &Harness, receiver: &Address) -> i128 { + harness.env.invoke_contract::( + &harness.proxy, + &Symbol::new(&harness.env, "max_deposit"), + (receiver,).into_val(&harness.env), + ) +} + +fn proxy_withdraw( + harness: &Harness, + caller: &Address, + assets: i128, + receiver: &Address, + owner: &Address, +) -> u64 { + harness.env.invoke_contract::( + &harness.proxy, + &Symbol::new(&harness.env, "withdraw"), + (caller, &assets, receiver, owner).into_val(&harness.env), + ) +} + +fn proxy_redeem( + harness: &Harness, + caller: &Address, + shares: i128, + receiver: &Address, + owner: &Address, +) -> u64 { + harness.env.invoke_contract::( + &harness.proxy, + &Symbol::new(&harness.env, "redeem"), + (caller, &shares, receiver, owner).into_val(&harness.env), + ) +} + +fn vault_proxy_view( + harness: &Harness, + owner: &Address, + assets: i128, + shares: i128, +) -> ProxyViewResponse { + harness.env.invoke_contract::( + &harness.vault, + &Symbol::new(&harness.env, "proxy_view"), + (owner, &assets, &shares).into_val(&harness.env), + ) +} + +#[test] +fn deposit_flow_mints_shares_and_increases_total_assets() { + let harness = setup_harness(); + let deposit_assets = 500_i128; + + mint_and_approve_assets(&harness, &harness.users.user, deposit_assets); + + let minted_shares = proxy_deposit( + &harness, + &harness.users.user, + deposit_assets, + &harness.users.user, + ); + + assert_eq!(minted_shares, deposit_assets); + assert_eq!( + share_client(&harness).balance(&harness.users.user), + minted_shares + ); + assert_eq!(proxy_total_assets(&harness), deposit_assets); + assert_eq!( + asset_client(&harness).balance(&harness.vault), + deposit_assets + ); +} + +#[test] +fn view_methods_match_vault_proxy_view() { + let harness = setup_harness(); + let deposit_assets = 750_i128; + let preview_assets = 123_i128; + + mint_and_approve_assets(&harness, &harness.users.user, deposit_assets); + proxy_deposit( + &harness, + &harness.users.user, + deposit_assets, + &harness.users.user, + ); + + let vault_view = vault_proxy_view(&harness, &harness.users.receiver, preview_assets, 0); + let expected_total_assets = vault_view.0 .2 .3; + let expected_convert_to_shares = vault_view.2 .0; + let expected_max_deposit = vault_view.2 .2; + + assert_eq!(proxy_total_assets(&harness), expected_total_assets); + assert_eq!( + proxy_convert_to_shares(&harness, preview_assets), + expected_convert_to_shares + ); + assert_eq!( + proxy_max_deposit(&harness, &harness.users.receiver), + expected_max_deposit + ); + assert_eq!(expected_max_deposit, i128::MAX); +} + +#[test] +fn request_execute_withdraw_flow_burns_shares_and_returns_assets() { + let harness = setup_harness(); + let deposit_assets = 1_200_i128; + + mint_and_approve_assets(&harness, &harness.users.user, deposit_assets); + let minted_shares = proxy_deposit( + &harness, + &harness.users.user, + deposit_assets, + &harness.users.user, + ); + let supply_after_deposit = vault_total_shares(&harness); + + let request_id = proxy_request_withdraw( + &harness, + &harness.users.user, + &harness.users.receiver, + minted_shares, + 0, + ); + + assert_eq!(request_id, 0); + assert_eq!(share_client(&harness).balance(&harness.users.user), 0); + assert_eq!( + share_client(&harness).balance(&harness.vault), + minted_shares + ); + assert_eq!(vault_total_shares(&harness), supply_after_deposit); + + advance_past_cooldown(&harness.env); + proxy_execute_withdraw(&harness, &harness.users.curator); + + assert_eq!(share_client(&harness).balance(&harness.vault), 0); + assert_eq!(vault_total_shares(&harness), 0); + assert_eq!( + asset_client(&harness).balance(&harness.users.receiver), + deposit_assets + ); + assert_eq!(proxy_total_assets(&harness), 0); +} + +#[test] +fn withdraw_flow_completes_queued_withdrawal() { + let harness = setup_harness(); + let deposit_assets = 2_000_i128; + let withdraw_assets = 1_200_i128; + + mint_and_approve_assets(&harness, &harness.users.user, deposit_assets); + let minted_shares = proxy_deposit( + &harness, + &harness.users.user, + deposit_assets, + &harness.users.user, + ); + let withdraw_shares = proxy_convert_to_shares(&harness, withdraw_assets); + let request_id = proxy_withdraw( + &harness, + &harness.users.user, + withdraw_assets, + &harness.users.receiver, + &harness.users.user, + ); + + assert_eq!(request_id, 0); + assert_eq!( + share_client(&harness).balance(&harness.users.user), + minted_shares - withdraw_shares + ); + assert_eq!(vault_total_shares(&harness), minted_shares); + assert_eq!( + share_client(&harness).balance(&harness.vault), + withdraw_shares + ); + assert_eq!(asset_client(&harness).balance(&harness.users.receiver), 0); + assert_eq!(proxy_total_assets(&harness), deposit_assets); + + advance_past_cooldown(&harness.env); + proxy_execute_withdraw(&harness, &harness.users.curator); + + assert_eq!( + share_client(&harness).balance(&harness.users.user), + minted_shares - withdraw_shares + ); + assert_eq!( + vault_total_shares(&harness), + minted_shares - withdraw_shares + ); + assert_eq!( + asset_client(&harness).balance(&harness.users.receiver), + withdraw_assets + ); + assert_eq!( + proxy_total_assets(&harness), + deposit_assets - withdraw_assets + ); +} + +#[test] +fn redeem_flow_completes_queued_withdrawal() { + let harness = setup_harness(); + let deposit_assets = 2_000_i128; + let redeem_shares = 1_200_i128; + + mint_and_approve_assets(&harness, &harness.users.user, deposit_assets); + let minted_shares = proxy_deposit( + &harness, + &harness.users.user, + deposit_assets, + &harness.users.user, + ); + let request_id = proxy_redeem( + &harness, + &harness.users.user, + redeem_shares, + &harness.users.receiver, + &harness.users.user, + ); + + assert_eq!(request_id, 0); + assert_eq!( + share_client(&harness).balance(&harness.users.user), + minted_shares - redeem_shares + ); + assert_eq!(vault_total_shares(&harness), minted_shares); + assert_eq!( + share_client(&harness).balance(&harness.vault), + redeem_shares + ); + assert_eq!(asset_client(&harness).balance(&harness.users.receiver), 0); + assert_eq!(proxy_total_assets(&harness), deposit_assets); + + advance_past_cooldown(&harness.env); + proxy_execute_withdraw(&harness, &harness.users.curator); + + let redeemed_assets = proxy_convert_to_assets(&harness, redeem_shares); + assert_eq!( + share_client(&harness).balance(&harness.users.user), + minted_shares - redeem_shares + ); + assert_eq!(vault_total_shares(&harness), minted_shares - redeem_shares); + assert_eq!( + asset_client(&harness).balance(&harness.users.receiver), + redeemed_assets + ); + assert_eq!( + proxy_total_assets(&harness), + deposit_assets - redeemed_assets + ); +}