From 28aa86156d689ca9bf5557bddd96a522d0bf79b0 Mon Sep 17 00:00:00 2001 From: senmalong Date: Tue, 23 Jun 2026 23:33:31 +0100 Subject: [PATCH] feat(token): integrate SAC yield-bearing LP token for liquidity vault MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements issue #171 — Stellar Asset Contract (SAC) standard for yield-bearing pool shares in the TradeFlow factoring pools. ## What changed Added contracts/liquidity_vault — a new Soroban contract that acts as the yield-bearing vault for liquidity providers. When an LP deposits USDC (or any SAC-compatible token) they receive tfUSDC shares in return. As the pool earns revenue (factoring discounts, interest, flash-loan fees) the underlying value of each share grows, so LPs automatically accrue yield without any extra claim transaction. ## SAC Token Interface The vault implements the full standard Soroban token interface: balance, total_supply, decimals, name, symbol (read) transfer, transfer_from, approve, allowance (write) This makes tfUSDC indistinguishable from any other SAC token, so any wallet, DEX aggregator or lending protocol that understands the Stellar Asset Contract standard can interact with it out of the box. ## Vault / ERC-4626 mechanics deposit(from, assets, min_shares) -> shares Pulls underlying from the caller, mints shares at the current exchange rate (assets * total_shares / total_assets). Returns shares minted. min_shares is a slippage guard. withdraw(from, shares, min_assets_out) -> assets Burns the caller's shares and releases proportional underlying. min_assets_out is a slippage guard. preview_deposit / preview_redeem Read-only helpers so frontends can quote before submitting. total_assets — physical SAC balance held by the vault total_supply — shares outstanding ## Inflation-attack mitigation On the very first deposit MINIMUM_LIQUIDITY (1 000) shares are permanently locked by crediting them to total_shares without issuing a balance entry. This is the standard ERC-4626 / Uniswap V2 technique: the locked shares force the share-price denominator to always be >= 1 000, making the classic first-depositor rounding attack economically infeasible regardless of how much an attacker donates to the vault. ## Admin & compliance set_paused(bool) — emergency circuit breaker set_frozen(address, bool) — per-address compliance freeze (mirrors the freeze pattern already in amm_pool) is_paused / is_frozen — read helpers Closes #171 --- Cargo.toml | 1 + contracts/liquidity_vault/Cargo.toml | 13 + contracts/liquidity_vault/src/lib.rs | 694 +++++++++++++++++++++++++ contracts/liquidity_vault/src/tests.rs | 499 ++++++++++++++++++ 4 files changed, 1207 insertions(+) create mode 100644 contracts/liquidity_vault/Cargo.toml create mode 100644 contracts/liquidity_vault/src/lib.rs create mode 100644 contracts/liquidity_vault/src/tests.rs 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); +}