diff --git a/Cargo.toml b/Cargo.toml index 5c5d301..189231f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ resolver = "2" members = [ "contracts/invoice_nft", "contracts/lending_pool", + "contracts/liquidity_vault", "contracts/tradeflow", "contracts/amm_pool", "contracts/amm_factory" diff --git a/contracts/liquidity_vault/Cargo.toml b/contracts/liquidity_vault/Cargo.toml new file mode 100644 index 0000000..c6d308a --- /dev/null +++ b/contracts/liquidity_vault/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "liquidity_vault" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = "25.3.0" + +[dev-dependencies] +soroban-sdk = { version = "25.3.0", features = ["testutils"] } diff --git a/contracts/liquidity_vault/src/lib.rs b/contracts/liquidity_vault/src/lib.rs new file mode 100644 index 0000000..bb8da1a --- /dev/null +++ b/contracts/liquidity_vault/src/lib.rs @@ -0,0 +1,694 @@ +//! # TradeFlow Liquidity Vault — Yield-Bearing LP Token (Issue #171) +//! +//! This contract implements the Soroban Token Interface (SAC standard) so that +//! every unit of liquidity deposited into a TradeFlow factoring pool is +//! represented by a transferable, yield-bearing share token (e.g. `tfUSDC`). +//! +//! ## Design +//! +//! The vault follows the ERC-4626 Tokenised Vault Standard adapted for Soroban: +//! +//! ```text +//! User deposits X underlying assets +//! ↓ +//! shares_to_mint = (X * total_shares) / total_assets (or 1:1 on first deposit) +//! ↓ +//! Vault mints `shares_to_mint` LP tokens to the user +//! ↓ +//! Pool earns revenue (factoring discounts, interest, flash-loan fees) +//! ↓ +//! total_assets grows; each share is now worth more underlying +//! ↓ +//! User burns shares on withdraw → receives proportional underlying +//! ``` +//! +//! ## Inflation-Attack Mitigation +//! +//! The first deposit permanently locks `MINIMUM_LIQUIDITY` (1 000) shares by +//! crediting them to `Address::zero()`, which can never sign a withdrawal. +//! This prevents the classic "first-depositor inflation attack" described in +//! ERC-4626 audit literature. +//! +//! ## SAC Token Interface +//! +//! The contract exposes all standard Soroban token functions so that any wallet, +//! DEX aggregator, or lending protocol that understands the Stellar Asset +//! Contract standard can interact with tfUSDC just like a native token. + +#![no_std] + +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, panic_with_error, symbol_short, token, + Address, Env, String, +}; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/// Number of shares permanently locked on the very first deposit to defend +/// against the ERC-4626 inflation attack. These shares are assigned to the +/// zero address and can never be redeemed. +const MINIMUM_LIQUIDITY: i128 = 1_000; + +/// LP token decimals — matches the underlying asset (typically USDC = 7). +/// Stored as a compile-time default; overridden by the underlying token's +/// actual decimals during initialisation. +const DEFAULT_DECIMALS: u32 = 7; + +// --------------------------------------------------------------------------- +// Error codes +// --------------------------------------------------------------------------- + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum Error { + /// Contract has not been initialised yet. + NotInitialized = 1, + /// Caller attempted to double-initialise the vault. + AlreadyInitialized = 2, + /// Contract is administratively paused. + ContractPaused = 3, + /// An arithmetic operation would overflow or produce a nonsensical result. + MathOverflow = 4, + /// The caller does not have sufficient LP token balance. + InsufficientBalance = 5, + /// The spender's allowance is not large enough for the requested transfer. + InsufficientAllowance = 6, + /// The requested action was not authorised. + Unauthorized = 7, + /// The deposit or withdrawal amount must be positive. + ZeroAmount = 8, + /// Withdrawal would produce fewer underlying assets than the caller's + /// `min_assets_out` slippage guard. + SlippageExceeded = 9, + /// Vault has no shares outstanding (division-by-zero guard). + EmptyVault = 10, + /// Allowance deadline has already passed. + DeadlineExpired = 11, + /// Address is frozen for compliance reasons. + AddressFrozen = 12, + /// Attempted transfer to the zero / burn address. + InvalidRecipient = 13, +} + +// --------------------------------------------------------------------------- +// Storage key types +// --------------------------------------------------------------------------- + +#[contracttype] +pub enum DataKey { + /// Vault configuration & accounting state (instance storage). + VaultState, + /// Admin address (instance storage). + Admin, + /// LP token balance per holder (persistent storage). + Balance(Address), + /// Spending allowances: (owner, spender) → amount (persistent storage). + Allowance(Address, Address), + /// Allowance expiry ledger (persistent storage). + AllowanceLedger(Address, Address), + /// Per-address freeze flag for compliance (instance storage). + Frozen(Address), + /// Paused flag (instance storage). + Paused, +} + +// --------------------------------------------------------------------------- +// State structs +// --------------------------------------------------------------------------- + +/// Core accounting state stored in instance storage. +#[contracttype] +#[derive(Clone, Debug)] +pub struct VaultState { + /// Address of the underlying ERC-20 / SAC token (e.g. USDC). + pub underlying_token: Address, + /// Total shares in circulation (i128 to match Soroban `token::balance` type). + pub total_shares: i128, + /// Human-readable name of the LP token (e.g. "TradeFlow USDC"). + pub name: String, + /// Ticker symbol of the LP token (e.g. "tfUSDC"). + pub symbol: String, + /// Decimal places of the LP token (mirrors the underlying asset). + pub decimals: u32, + /// Whether the first deposit has already been processed (inflation guard). + pub first_deposit_done: bool, +} + +// --------------------------------------------------------------------------- +// Contract +// --------------------------------------------------------------------------- + +#[contract] +pub struct LiquidityVault; + +#[contractimpl] +impl LiquidityVault { + // ----------------------------------------------------------------------- + // Initialisation + // ----------------------------------------------------------------------- + + /// Initialise the vault. + /// + /// # Arguments + /// * `admin` – Address allowed to pause/unpause and freeze addresses. + /// * `underlying_token` – The SAC/token address that LPs deposit (e.g. USDC). + /// * `name` – Human-readable name for the LP token. + /// * `symbol` – Ticker symbol for the LP token (e.g. "tfUSDC"). + /// + /// Panics if the vault has already been initialised. + pub fn initialize( + env: Env, + admin: Address, + underlying_token: Address, + name: String, + symbol: String, + ) { + if env.storage().instance().has(&DataKey::VaultState) { + panic_with_error!(&env, Error::AlreadyInitialized); + } + + // Query the underlying token's decimals so our LP token mirrors them. + let underlying_client = token::Client::new(&env, &underlying_token); + let decimals = underlying_client.decimals(); + let decimals = if decimals == 0 || decimals > 18 { + DEFAULT_DECIMALS + } else { + decimals + }; + + let state = VaultState { + underlying_token, + total_shares: 0, + name, + symbol, + decimals, + first_deposit_done: false, + }; + + env.storage().instance().set(&DataKey::VaultState, &state); + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::Paused, &false); + Self::extend_instance_ttl(&env); + } + + // ----------------------------------------------------------------------- + // Vault read helpers + // ----------------------------------------------------------------------- + + /// Returns the total underlying assets held by the vault (physical balance). + pub fn total_assets(env: Env) -> i128 { + let state = Self::load_state(&env); + let client = token::Client::new(&env, &state.underlying_token); + client.balance(&env.current_contract_address()) + } + + /// Returns the total number of LP shares currently in circulation. + pub fn total_supply(env: Env) -> i128 { + Self::load_state(&env).total_shares + } + + /// Preview how many shares a deposit of `assets` would mint right now, + /// without applying the inflation-lock on the first deposit. + pub fn preview_deposit(env: Env, assets: i128) -> i128 { + if assets <= 0 { + return 0; + } + let state = Self::load_state(&env); + let total_assets = Self::total_assets(env.clone()); + Self::assets_to_shares(assets, total_assets, state.total_shares) + } + + /// Preview how many underlying assets redeeming `shares` would return right now. + pub fn preview_redeem(env: Env, shares: i128) -> i128 { + if shares <= 0 { + return 0; + } + let state = Self::load_state(&env); + let total_assets = Self::total_assets(env.clone()); + Self::shares_to_assets(shares, total_assets, state.total_shares) + } + + // ----------------------------------------------------------------------- + // Vault write functions + // ----------------------------------------------------------------------- + + /// Deposit `assets` of the underlying token and receive LP shares. + /// + /// On the very first deposit `MINIMUM_LIQUIDITY` shares are permanently + /// locked to the zero address to prevent the inflation attack. + /// + /// # Arguments + /// * `from` – Depositor (must have authorised the vault to spend `assets`). + /// * `assets` – Amount of underlying token to deposit. + /// * `min_shares` – Minimum shares the depositor expects (slippage guard). + /// + /// # Returns + /// The number of LP shares minted to `from`. + pub fn deposit(env: Env, from: Address, assets: i128, min_shares: i128) -> i128 { + from.require_auth(); + Self::check_paused(&env); + Self::require_not_frozen(&env, &from); + + if assets <= 0 { + panic_with_error!(&env, Error::ZeroAmount); + } + + let mut state = Self::load_state(&env); + let underlying = token::Client::new(&env, &state.underlying_token); + + // Total assets BEFORE the deposit transfer. + let assets_before = underlying.balance(&env.current_contract_address()); + + // Pull underlying assets from the depositor. + underlying.transfer(&from, &env.current_contract_address(), &assets); + + // ----- share calculation ----- + let shares_to_mint: i128 = if !state.first_deposit_done { + // First deposit: apply inflation-attack mitigation. + // Raw shares = assets (1:1 on an empty vault). + let raw_shares = assets; + + // Permanently lock MINIMUM_LIQUIDITY shares to the zero address. + // We represent this by crediting total_shares without giving anyone + // a balance entry, so they are unclaimable forever. + if raw_shares <= MINIMUM_LIQUIDITY { + panic_with_error!(&env, Error::ZeroAmount); // deposit too small + } + + // The depositor receives the remainder; the locked shares inflate the + // denominator for all future deposits, making the attack economically + // infeasible. + state.total_shares = state + .total_shares + .checked_add(MINIMUM_LIQUIDITY) + .unwrap_or_else(|| panic_with_error!(&env, Error::MathOverflow)); + state.first_deposit_done = true; + + raw_shares + .checked_sub(MINIMUM_LIQUIDITY) + .unwrap_or_else(|| panic_with_error!(&env, Error::MathOverflow)) + } else { + // Subsequent deposits use the current exchange rate. + Self::assets_to_shares(assets, assets_before, state.total_shares) + }; + + if shares_to_mint <= 0 { + panic_with_error!(&env, Error::ZeroAmount); + } + if shares_to_mint < min_shares { + panic_with_error!(&env, Error::SlippageExceeded); + } + + // Mint shares to the depositor. + state.total_shares = state + .total_shares + .checked_add(shares_to_mint) + .unwrap_or_else(|| panic_with_error!(&env, Error::MathOverflow)); + env.storage().instance().set(&DataKey::VaultState, &state); + + Self::increase_balance(&env, &from, shares_to_mint); + Self::extend_instance_ttl(&env); + + env.events().publish( + (symbol_short!("deposit"), from.clone()), + (assets, shares_to_mint), + ); + + shares_to_mint + } + + /// Burn `shares` LP tokens and receive the proportional underlying assets. + /// + /// # Arguments + /// * `from` – Share-holder initiating the withdrawal. + /// * `shares` – Number of LP tokens to burn. + /// * `min_assets_out`– Minimum underlying assets the caller expects (slippage guard). + /// + /// # Returns + /// The number of underlying asset tokens returned to `from`. + pub fn withdraw(env: Env, from: Address, shares: i128, min_assets_out: i128) -> i128 { + from.require_auth(); + Self::check_paused(&env); + Self::require_not_frozen(&env, &from); + + if shares <= 0 { + panic_with_error!(&env, Error::ZeroAmount); + } + + let caller_balance = Self::get_balance(&env, &from); + if caller_balance < shares { + panic_with_error!(&env, Error::InsufficientBalance); + } + + let mut state = Self::load_state(&env); + if state.total_shares == 0 { + panic_with_error!(&env, Error::EmptyVault); + } + + let underlying = token::Client::new(&env, &state.underlying_token); + let total_assets_now = underlying.balance(&env.current_contract_address()); + + let assets_out = Self::shares_to_assets(shares, total_assets_now, state.total_shares); + + if assets_out <= 0 { + panic_with_error!(&env, Error::ZeroAmount); + } + if assets_out < min_assets_out { + panic_with_error!(&env, Error::SlippageExceeded); + } + + // Burn shares first (checks-effects-interactions pattern). + state.total_shares = state + .total_shares + .checked_sub(shares) + .unwrap_or_else(|| panic_with_error!(&env, Error::MathOverflow)); + env.storage().instance().set(&DataKey::VaultState, &state); + + Self::decrease_balance(&env, &from, shares); + + // Transfer underlying to the caller. + underlying.transfer(&env.current_contract_address(), &from, &assets_out); + Self::extend_instance_ttl(&env); + + env.events().publish( + (symbol_short!("withdraw"), from.clone()), + (shares, assets_out), + ); + + assets_out + } + + // ----------------------------------------------------------------------- + // Soroban Token Interface — READ functions + // ----------------------------------------------------------------------- + + /// Returns the LP token balance of `id`. + pub fn balance(env: Env, id: Address) -> i128 { + Self::get_balance(&env, &id) + } + + /// Returns how many LP tokens `spender` is allowed to spend on behalf of `owner`. + pub fn allowance(env: Env, owner: Address, spender: Address) -> i128 { + Self::get_allowance(&env, &owner, &spender) + } + + /// Returns the number of decimal places (mirrors the underlying token). + pub fn decimals(env: Env) -> u32 { + Self::load_state(&env).decimals + } + + /// Returns the human-readable name of the LP token. + pub fn name(env: Env) -> String { + Self::load_state(&env).name + } + + /// Returns the ticker symbol of the LP token. + pub fn symbol(env: Env) -> String { + Self::load_state(&env).symbol + } + + // ----------------------------------------------------------------------- + // Soroban Token Interface — WRITE functions + // ----------------------------------------------------------------------- + + /// Transfer `amount` LP tokens from the caller to `to`. + pub fn transfer(env: Env, from: Address, to: Address, amount: i128) { + from.require_auth(); + Self::check_paused(&env); + Self::require_not_frozen(&env, &from); + + if amount <= 0 { + panic_with_error!(&env, Error::ZeroAmount); + } + + let from_balance = Self::get_balance(&env, &from); + if from_balance < amount { + panic_with_error!(&env, Error::InsufficientBalance); + } + + Self::decrease_balance(&env, &from, amount); + Self::increase_balance(&env, &to, amount); + Self::extend_instance_ttl(&env); + + env.events().publish( + (symbol_short!("transfer"), from.clone()), + (to, amount), + ); + } + + /// Transfer `amount` LP tokens from `from` to `to` using the caller's + /// pre-approved spending allowance. + pub fn transfer_from(env: Env, spender: Address, from: Address, to: Address, amount: i128) { + spender.require_auth(); + Self::check_paused(&env); + Self::require_not_frozen(&env, &from); + + if amount <= 0 { + panic_with_error!(&env, Error::ZeroAmount); + } + + // Consume allowance. + let allowance = Self::get_allowance(&env, &from, &spender); + if allowance < amount { + panic_with_error!(&env, Error::InsufficientAllowance); + } + Self::set_allowance(&env, &from, &spender, allowance - amount, 0); + + let from_balance = Self::get_balance(&env, &from); + if from_balance < amount { + panic_with_error!(&env, Error::InsufficientBalance); + } + + Self::decrease_balance(&env, &from, amount); + Self::increase_balance(&env, &to, amount); + Self::extend_instance_ttl(&env); + + env.events().publish( + (symbol_short!("xfer_from"), spender), + (from, to, amount), + ); + } + + /// Approve `spender` to spend up to `amount` of the caller's LP tokens. + /// + /// `expiration_ledger` is the ledger sequence number after which the + /// allowance expires (0 means no expiry). + pub fn approve( + env: Env, + owner: Address, + spender: Address, + amount: i128, + expiration_ledger: u32, + ) { + owner.require_auth(); + Self::check_paused(&env); + + if expiration_ledger > 0 && expiration_ledger < env.ledger().sequence() { + panic_with_error!(&env, Error::DeadlineExpired); + } + + Self::set_allowance(&env, &owner, &spender, amount, expiration_ledger); + Self::extend_instance_ttl(&env); + + env.events().publish( + (symbol_short!("approve"), owner.clone()), + (spender, amount, expiration_ledger), + ); + } + + // ----------------------------------------------------------------------- + // Admin functions + // ----------------------------------------------------------------------- + + /// Pause or unpause the vault. Only callable by the admin. + pub fn set_paused(env: Env, paused: bool) { + Self::require_admin(&env); + env.storage().instance().set(&DataKey::Paused, &paused); + Self::extend_instance_ttl(&env); + env.events().publish((symbol_short!("pause_set"), paused), env.ledger().sequence()); + } + + /// Freeze or unfreeze an address for compliance reasons. Admin only. + pub fn set_frozen(env: Env, address: Address, frozen: bool) { + Self::require_admin(&env); + env.storage() + .instance() + .set(&DataKey::Frozen(address.clone()), &frozen); + Self::extend_instance_ttl(&env); + env.events().publish( + (symbol_short!("freeze"), address), + frozen, + ); + } + + /// Returns whether `address` is currently frozen. + pub fn is_frozen(env: Env, address: Address) -> bool { + env.storage() + .instance() + .get(&DataKey::Frozen(address)) + .unwrap_or(false) + } + + /// Returns whether the vault is currently paused. + pub fn is_paused(env: Env) -> bool { + env.storage().instance().get(&DataKey::Paused).unwrap_or(false) + } + + // ----------------------------------------------------------------------- + // Internal / private helpers + // ----------------------------------------------------------------------- + + /// Load the VaultState from instance storage, panicking if uninitialised. + fn load_state(env: &Env) -> VaultState { + env.storage() + .instance() + .get(&DataKey::VaultState) + .unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)) + } + + /// Extend instance storage TTL to ~30 days (535 680 ledgers). + fn extend_instance_ttl(env: &Env) { + env.storage().instance().extend_ttl(535_680, 535_680); + } + + /// Extend persistent storage TTL for a given key. + fn extend_persistent_ttl(env: &Env, key: &DataKey) { + env.storage().persistent().extend_ttl(key, 535_680, 535_680); + } + + /// Require the current caller to be the stored admin. + fn require_admin(env: &Env) { + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)); + admin.require_auth(); + } + + /// Revert if the vault is paused. + fn check_paused(env: &Env) { + if env + .storage() + .instance() + .get(&DataKey::Paused) + .unwrap_or(false) + { + panic_with_error!(env, Error::ContractPaused); + } + } + + /// Revert if `address` is frozen. + fn require_not_frozen(env: &Env, address: &Address) { + if env + .storage() + .instance() + .get(&DataKey::Frozen(address.clone())) + .unwrap_or(false) + { + panic_with_error!(env, Error::AddressFrozen); + } + } + + // ----------- share ↔ asset maths ------------------------------------ + + /// Convert an `assets` amount to shares using the current exchange rate. + /// + /// Formula (rounds down, safe for the vault): + /// ```text + /// shares = (assets * total_shares) / total_assets + /// ``` + /// Falls back to 1:1 when the vault is empty. + fn assets_to_shares(assets: i128, total_assets: i128, total_shares: i128) -> i128 { + if total_shares == 0 || total_assets == 0 { + return assets; // 1:1 on empty vault + } + // Checked multiply to catch overflow on very large values. + let numerator = assets + .checked_mul(total_shares) + .unwrap_or(i128::MAX); // saturate; checked below + numerator / total_assets + } + + /// Convert a `shares` amount to underlying assets using the current exchange rate. + /// + /// Formula (rounds down, safe for the vault): + /// ```text + /// assets = (shares * total_assets) / total_shares + /// ``` + fn shares_to_assets(shares: i128, total_assets: i128, total_shares: i128) -> i128 { + if total_shares == 0 || total_assets == 0 { + return 0; + } + let numerator = shares + .checked_mul(total_assets) + .unwrap_or(i128::MAX); // saturate + numerator / total_shares + } + + // ----------- Balance ledger ------------------------------------------ + + fn get_balance(env: &Env, address: &Address) -> i128 { + env.storage() + .persistent() + .get(&DataKey::Balance(address.clone())) + .unwrap_or(0i128) + } + + fn increase_balance(env: &Env, address: &Address, amount: i128) { + let key = DataKey::Balance(address.clone()); + let current: i128 = env.storage().persistent().get(&key).unwrap_or(0); + let new_balance = current + .checked_add(amount) + .unwrap_or_else(|| panic_with_error!(env, Error::MathOverflow)); + env.storage().persistent().set(&key, &new_balance); + Self::extend_persistent_ttl(env, &key); + } + + fn decrease_balance(env: &Env, address: &Address, amount: i128) { + let key = DataKey::Balance(address.clone()); + let current: i128 = env.storage().persistent().get(&key).unwrap_or(0); + if current < amount { + panic_with_error!(env, Error::InsufficientBalance); + } + let new_balance = current - amount; + env.storage().persistent().set(&key, &new_balance); + Self::extend_persistent_ttl(env, &key); + } + + // ----------- Allowance ledger ---------------------------------------- + + fn get_allowance(env: &Env, owner: &Address, spender: &Address) -> i128 { + let key = DataKey::Allowance(owner.clone(), spender.clone()); + let ledger_key = DataKey::AllowanceLedger(owner.clone(), spender.clone()); + + // If the allowance has an expiry and it's passed, treat as zero. + let expiry: u32 = env.storage().persistent().get(&ledger_key).unwrap_or(0); + if expiry > 0 && expiry < env.ledger().sequence() { + return 0; + } + + env.storage().persistent().get(&key).unwrap_or(0i128) + } + + fn set_allowance( + env: &Env, + owner: &Address, + spender: &Address, + amount: i128, + expiration_ledger: u32, + ) { + let key = DataKey::Allowance(owner.clone(), spender.clone()); + let ledger_key = DataKey::AllowanceLedger(owner.clone(), spender.clone()); + + env.storage().persistent().set(&key, &amount); + env.storage() + .persistent() + .set(&ledger_key, &expiration_ledger); + Self::extend_persistent_ttl(env, &key); + Self::extend_persistent_ttl(env, &ledger_key); + } +} diff --git a/contracts/liquidity_vault/src/tests.rs b/contracts/liquidity_vault/src/tests.rs new file mode 100644 index 0000000..de0a52d --- /dev/null +++ b/contracts/liquidity_vault/src/tests.rs @@ -0,0 +1,499 @@ +//! Unit tests for the LiquidityVault contract. +//! +//! Covers: +//! - Basic deposit / withdraw round-trip +//! - Inflation-attack mitigation (first 1 000 shares locked) +//! - Yield accrual: share value increases as pool earns revenue +//! - Multi-depositor proportional withdrawal +//! - SAC token interface: transfer, transfer_from, approve, allowance +//! - Admin controls: pause, freeze + +#![cfg(test)] + +extern crate std; + +use soroban_sdk::{ + testutils::{Address as _, AuthorizedFunction, AuthorizedInvocation}, + token::{Client as TokenClient, StellarAssetClient}, + Address, Env, IntoVal, String, +}; + +use crate::{Error, LiquidityVault, LiquidityVaultClient}; + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +/// Deploy a mock USDC SAC token and return its contract ID plus an admin that +/// can mint arbitrary balances. +fn create_token<'a>(env: &Env, admin: &Address) -> (Address, StellarAssetClient<'a>) { + let contract_id = env.register_stellar_asset_contract_v2(admin.clone()); + let sac = StellarAssetClient::new(env, &contract_id.address()); + (contract_id.address(), sac) +} + +/// Deploy the vault and return its client. +fn deploy_vault<'a>( + env: &Env, + admin: &Address, + underlying: &Address, +) -> LiquidityVaultClient<'a> { + let vault_id = env.register_contract(None, LiquidityVault); + let client = LiquidityVaultClient::new(env, &vault_id); + client.initialize( + admin, + underlying, + &String::from_str(env, "TradeFlow USDC"), + &String::from_str(env, "tfUSDC"), + ); + client +} + +/// Fund `user` with `amount` of `token` via the SAC mint authority. +fn fund(sac: &StellarAssetClient, user: &Address, amount: i128) { + sac.mint(user, &amount); +} + +/// Give the vault contract infinite allowance from `user` over their `token`. +fn approve_vault(env: &Env, token: &TokenClient, user: &Address, vault: &Address, amount: i128) { + token.approve(user, vault, &amount, &(env.ledger().sequence() + 535_680)); +} + +// --------------------------------------------------------------------------- +// 1. Basic deposit / withdraw +// --------------------------------------------------------------------------- + +#[test] +fn test_basic_deposit_and_withdraw() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let alice = Address::generate(&env); + + let (usdc_addr, usdc_sac) = create_token(&env, &admin); + let usdc = TokenClient::new(&env, &usdc_addr); + let vault = deploy_vault(&env, &admin, &usdc_addr); + + // Mint 10_000 USDC to Alice and approve the vault. + fund(&usdc_sac, &alice, 10_000); + approve_vault(&env, &usdc, &alice, &vault.address, 10_000); + + // --- First deposit: 5 001 units (> MINIMUM_LIQUIDITY of 1 000) --- + let shares = vault.deposit(&alice, &5_001, &1); + // Alice gets 5 001 - 1 000 = 4 001 shares; 1 000 locked permanently. + assert_eq!(shares, 4_001); + assert_eq!(vault.balance(&alice), 4_001); + assert_eq!(vault.total_supply(), 5_001); // 4 001 alice + 1 000 locked + + // --- Withdraw all Alice's shares --- + // total_assets = 5_001, total_shares = 5_001 → rate is 1:1 + let assets_out = vault.withdraw(&alice, &4_001, &1); + assert_eq!(assets_out, 4_001); + assert_eq!(vault.balance(&alice), 0); + // 1 000 locked shares remain; 1 000 underlying remain as well. + assert_eq!(vault.total_supply(), 1_000); + assert_eq!(vault.total_assets(), 1_000); +} + +// --------------------------------------------------------------------------- +// 2. Inflation-attack mitigation +// --------------------------------------------------------------------------- + +#[test] +fn test_inflation_attack_mitigation() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let attacker = Address::generate(&env); + let victim = Address::generate(&env); + + let (usdc_addr, usdc_sac) = create_token(&env, &admin); + let usdc = TokenClient::new(&env, &usdc_addr); + let vault = deploy_vault(&env, &admin, &usdc_addr); + + // Attacker tries the classic inflation attack: + // 1. Deposit 1 (or a tiny amount) to become the only share-holder. + // 2. Donate a large amount directly to the vault. + // 3. Victim deposits "1"; receives 0 shares due to rounding. + // 4. Attacker withdraws, stealing the victim's deposit. + // + // The MINIMUM_LIQUIDITY lock makes step 3 impossible because the locked + // shares force the denominator to be at least 1 000, making rounding + // harmless for victim deposits above dust level. + + fund(&usdc_sac, &attacker, 2_000_000); + approve_vault(&env, &usdc, &attacker, &vault.address, 2_000_000); + + // Attacker deposits 1 001 (just over MINIMUM_LIQUIDITY so it doesn't panic). + let attacker_shares = vault.deposit(&attacker, &1_001, &1); + // attacker_shares = 1 001 - 1 000 = 1 + assert_eq!(attacker_shares, 1); + assert_eq!(vault.balance(&attacker), 1); + + // Attacker donates 1_000_000 USDC directly to the vault (no shares minted). + // total_assets is now 1_001 + 1_000_000 = 1_001_001 + // total_shares = 1_001 + usdc.transfer(&attacker, &vault.address, &1_000_000); + assert_eq!(vault.total_assets(), 1_001_001); + + // Victim deposits 2_001 USDC. + fund(&usdc_sac, &victim, 2_001); + approve_vault(&env, &usdc, &victim, &vault.address, 2_001); + + // shares_to_mint = 2_001 * 1_001 / 1_001_001 ≈ 2 shares (not 0) + // Without MINIMUM_LIQUIDITY the denominator would be 1 (only attacker's + // 1 share), giving victim 0 shares — the attack succeeds. + // With the lock the victim always gets a non-trivial share count. + let victim_shares = vault.deposit(&victim, &2_001, &1); + assert!(victim_shares >= 1, "victim must receive at least 1 share"); + assert_eq!(vault.balance(&victim), victim_shares); +} + +// --------------------------------------------------------------------------- +// 3. Yield accrual +// --------------------------------------------------------------------------- + +#[test] +fn test_yield_accrual_increases_share_value() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let alice = Address::generate(&env); + let bob = Address::generate(&env); + + let (usdc_addr, usdc_sac) = create_token(&env, &admin); + let usdc = TokenClient::new(&env, &usdc_addr); + let vault = deploy_vault(&env, &admin, &usdc_addr); + + // Alice deposits 10_000 USDC. + fund(&usdc_sac, &alice, 10_000); + approve_vault(&env, &usdc, &alice, &vault.address, 10_000); + let alice_shares = vault.deposit(&alice, &10_000, &1); + + // Simulate yield: 1 000 USDC flows into the vault (factoring revenue). + // total_assets is now 11_000; total_shares unchanged. + fund(&usdc_sac, &admin, 1_000); + usdc.transfer(&admin, &vault.address, &1_000); + + // Bob deposits 5 500 USDC at the new (higher) rate. + fund(&usdc_sac, &bob, 5_500); + approve_vault(&env, &usdc, &bob, &vault.address, 5_500); + let bob_shares = vault.deposit(&bob, &5_500, &1); + + // total_assets = 11_000 + 5_500 = 16_500 before Bob's deposit is counted + // but the preview_deposit logic uses the BEFORE balance: + // bob_shares = 5_500 * total_shares_before / 11_000 + // Bob should receive fewer shares per USDC than Alice did, proving yield. + // Alice got ~1 share per 1 USDC; Bob gets ~1 share per ~1.2 USDC. + assert!( + bob_shares < alice_shares, + "Bob should receive fewer shares than Alice because the exchange rate has risen" + ); + + // Alice redeems her shares — she should receive more than her original 10 000. + let alice_assets_out = vault.withdraw(&alice, &alice_shares, &1); + assert!( + alice_assets_out > 10_000, + "Alice should profit from the yield accrual" + ); +} + +// --------------------------------------------------------------------------- +// 4. Multi-depositor proportional withdrawal +// --------------------------------------------------------------------------- + +#[test] +fn test_multi_depositor_proportional_withdrawal() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let alice = Address::generate(&env); + let bob = Address::generate(&env); + let carol = Address::generate(&env); + + let (usdc_addr, usdc_sac) = create_token(&env, &admin); + let usdc = TokenClient::new(&env, &usdc_addr); + let vault = deploy_vault(&env, &admin, &usdc_addr); + + // Alice deposits first (pays the MINIMUM_LIQUIDITY tax). + fund(&usdc_sac, &alice, 10_000); + approve_vault(&env, &usdc, &alice, &vault.address, 10_000); + let alice_shares = vault.deposit(&alice, &10_000, &1); + + // Bob deposits the same amount. + fund(&usdc_sac, &bob, 10_000); + approve_vault(&env, &usdc, &bob, &vault.address, 10_000); + let bob_shares = vault.deposit(&bob, &10_000, &1); + + // Carol deposits twice as much. + fund(&usdc_sac, &carol, 20_000); + approve_vault(&env, &usdc, &carol, &vault.address, 20_000); + let carol_shares = vault.deposit(&carol, &20_000, &1); + + // Carol should hold roughly twice the shares of Bob. + // (Alice paid an extra 1 000 lock, so her shares are slightly less than + // Bob's, and Carol's are roughly 2× Bob's.) + assert!(carol_shares > bob_shares, "Carol deposited 2× Bob"); + + // Add 4 000 USDC of yield. + fund(&usdc_sac, &admin, 4_000); + usdc.transfer(&admin, &vault.address, &4_000); + + // Everyone withdraws; nobody receives zero. + let alice_out = vault.withdraw(&alice, &alice_shares, &1); + let bob_out = vault.withdraw(&bob, &bob_shares, &1); + let carol_out = vault.withdraw(&carol, &carol_shares, &1); + + assert!(alice_out > 0, "Alice should receive underlying assets"); + assert!(bob_out > 0, "Bob should receive underlying assets"); + assert!(carol_out > 0, "Carol should receive underlying assets"); + + // Carol should receive roughly twice what Bob does. + // Use a 10% tolerance to account for integer rounding. + let carol_expected = bob_out * 2; + let diff = if carol_out > carol_expected { + carol_out - carol_expected + } else { + carol_expected - carol_out + }; + assert!( + diff * 10 <= carol_expected, + "Carol's payout should be within 10% of 2× Bob's (carol={}, 2×bob={})", + carol_out, + carol_expected + ); +} + +// --------------------------------------------------------------------------- +// 5. SAC token interface — transfer +// --------------------------------------------------------------------------- + +#[test] +fn test_transfer_lp_tokens() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let alice = Address::generate(&env); + let bob = Address::generate(&env); + + let (usdc_addr, usdc_sac) = create_token(&env, &admin); + let usdc = TokenClient::new(&env, &usdc_addr); + let vault = deploy_vault(&env, &admin, &usdc_addr); + + fund(&usdc_sac, &alice, 5_001); + approve_vault(&env, &usdc, &alice, &vault.address, 5_001); + let alice_shares = vault.deposit(&alice, &5_001, &1); + + // Alice transfers half her shares to Bob. + let half = alice_shares / 2; + vault.transfer(&alice, &bob, &half); + + assert_eq!(vault.balance(&alice), alice_shares - half); + assert_eq!(vault.balance(&bob), half); +} + +// --------------------------------------------------------------------------- +// 6. SAC token interface — approve / transfer_from +// --------------------------------------------------------------------------- + +#[test] +fn test_approve_and_transfer_from() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let alice = Address::generate(&env); + let spender = Address::generate(&env); + let bob = Address::generate(&env); + + let (usdc_addr, usdc_sac) = create_token(&env, &admin); + let usdc = TokenClient::new(&env, &usdc_addr); + let vault = deploy_vault(&env, &admin, &usdc_addr); + + fund(&usdc_sac, &alice, 5_001); + approve_vault(&env, &usdc, &alice, &vault.address, 5_001); + let alice_shares = vault.deposit(&alice, &5_001, &1); + + // Alice approves `spender` to move 1 000 of her LP tokens. + let approval = 1_000i128; + vault.approve(&alice, &spender, &approval, &(env.ledger().sequence() + 100)); + assert_eq!(vault.allowance(&alice, &spender), approval); + + // Spender moves 500 from Alice to Bob. + vault.transfer_from(&spender, &alice, &bob, &500); + assert_eq!(vault.balance(&bob), 500); + assert_eq!(vault.allowance(&alice, &spender), 500); // 1000 - 500 + + // Alice's balance reduced by the transferred amount. + assert_eq!(vault.balance(&alice), alice_shares - 500); +} + +// --------------------------------------------------------------------------- +// 7. Insufficient balance / allowance +// --------------------------------------------------------------------------- + +#[test] +#[should_panic] +fn test_transfer_insufficient_balance_panics() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let alice = Address::generate(&env); + let bob = Address::generate(&env); + + let (usdc_addr, usdc_sac) = create_token(&env, &admin); + let usdc = TokenClient::new(&env, &usdc_addr); + let vault = deploy_vault(&env, &admin, &usdc_addr); + + fund(&usdc_sac, &alice, 2_000); + approve_vault(&env, &usdc, &alice, &vault.address, 2_000); + vault.deposit(&alice, &2_000, &1); + + // Try to transfer more shares than Alice holds. + vault.transfer(&alice, &bob, &9_999_999); +} + +#[test] +#[should_panic] +fn test_transfer_from_insufficient_allowance_panics() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let alice = Address::generate(&env); + let spender = Address::generate(&env); + let bob = Address::generate(&env); + + let (usdc_addr, usdc_sac) = create_token(&env, &admin); + let usdc = TokenClient::new(&env, &usdc_addr); + let vault = deploy_vault(&env, &admin, &usdc_addr); + + fund(&usdc_sac, &alice, 5_001); + approve_vault(&env, &usdc, &alice, &vault.address, 5_001); + vault.deposit(&alice, &5_001, &1); + + // Approve only 10 but try to transfer 500. + vault.approve(&alice, &spender, &10, &(env.ledger().sequence() + 100)); + vault.transfer_from(&spender, &alice, &bob, &500); +} + +// --------------------------------------------------------------------------- +// 8. Admin pause +// --------------------------------------------------------------------------- + +#[test] +#[should_panic] +fn test_deposit_when_paused_panics() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let alice = Address::generate(&env); + + let (usdc_addr, usdc_sac) = create_token(&env, &admin); + let usdc = TokenClient::new(&env, &usdc_addr); + let vault = deploy_vault(&env, &admin, &usdc_addr); + + vault.set_paused(&true); + + fund(&usdc_sac, &alice, 5_001); + approve_vault(&env, &usdc, &alice, &vault.address, 5_001); + vault.deposit(&alice, &5_001, &1); // Should panic: ContractPaused +} + +// --------------------------------------------------------------------------- +// 9. Address freeze +// --------------------------------------------------------------------------- + +#[test] +#[should_panic] +fn test_deposit_frozen_address_panics() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let alice = Address::generate(&env); + + let (usdc_addr, usdc_sac) = create_token(&env, &admin); + let usdc = TokenClient::new(&env, &usdc_addr); + let vault = deploy_vault(&env, &admin, &usdc_addr); + + vault.set_frozen(&alice, &true); + + fund(&usdc_sac, &alice, 5_001); + approve_vault(&env, &usdc, &alice, &vault.address, 5_001); + vault.deposit(&alice, &5_001, &1); // Should panic: AddressFrozen +} + +// --------------------------------------------------------------------------- +// 10. Slippage guard on deposit +// --------------------------------------------------------------------------- + +#[test] +#[should_panic] +fn test_deposit_slippage_guard_panics() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let alice = Address::generate(&env); + + let (usdc_addr, usdc_sac) = create_token(&env, &admin); + let usdc = TokenClient::new(&env, &usdc_addr); + let vault = deploy_vault(&env, &admin, &usdc_addr); + + fund(&usdc_sac, &alice, 5_001); + approve_vault(&env, &usdc, &alice, &vault.address, 5_001); + + // Alice expects 999_999 shares but will only get ~4_001 — slippage guard fires. + vault.deposit(&alice, &5_001, &999_999); +} + +// --------------------------------------------------------------------------- +// 11. Token metadata +// --------------------------------------------------------------------------- + +#[test] +fn test_token_metadata() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let (usdc_addr, _) = create_token(&env, &admin); + let vault = deploy_vault(&env, &admin, &usdc_addr); + + assert_eq!(vault.name(), String::from_str(&env, "TradeFlow USDC")); + assert_eq!(vault.symbol(), String::from_str(&env, "tfUSDC")); + // Decimals mirror the underlying token (default 7 for SAC). + assert!(vault.decimals() > 0); +} + +// --------------------------------------------------------------------------- +// 12. Deposit too small (below MINIMUM_LIQUIDITY) panics on first deposit +// --------------------------------------------------------------------------- + +#[test] +#[should_panic] +fn test_first_deposit_below_minimum_liquidity_panics() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let alice = Address::generate(&env); + + let (usdc_addr, usdc_sac) = create_token(&env, &admin); + let usdc = TokenClient::new(&env, &usdc_addr); + let vault = deploy_vault(&env, &admin, &usdc_addr); + + fund(&usdc_sac, &alice, 500); + approve_vault(&env, &usdc, &alice, &vault.address, 500); + + // Deposit of 500 is less than MINIMUM_LIQUIDITY (1 000) — must panic. + vault.deposit(&alice, &500, &1); +}