diff --git a/CHANGELOG.md b/CHANGELOG.md index 19462b1..28278f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,39 @@ Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixed + +- `VaultRouter` <-> tier-vault ABI mismatch: `VaultFlex`, `VaultL3`, `VaultL6`, + and `VaultL12` now all expose the same `deposit(user, asset, amount)` / + `withdraw(user, asset, amount) -> i128` / `early_exit(user, asset, amount) + -> i128` / `balance(user, asset)` / `shares(user, asset)` / + `lock_until(user, asset)` signatures that `VaultRouter` already called + with, and `relock(user, asset)` (both on the router and on the locked + tier vaults) so it isn't ambiguous once locks are asset-scoped. +- Every tier vault's storage (`Balance`, `Shares`, `LockUntil`, `Checkpoint`, + and VaultL3's `EmergencyUnlock`) is now keyed by `(user, asset)` instead + of `user` alone, so two different assets held by the same user in the + same tier no longer share balance, lock, or checkpoint state. + `shared::checkpoint` is asset-scoped for the same reason. +- Removed a duplicate token transfer inside each tier vault's `deposit()`: + `VaultRouter` already moves the tokens from the user to the vault before + invoking it, so the vault's own additional `user -> strategy` transfer + (which also hardcoded the tier's original single USDC address regardless + of which `asset` was actually deposited) double-charged the user and used + the wrong token for any non-USDC asset. Tier vaults are now bookkeeping- + only; VaultRouter owns all token movement. +- `VaultFlex` now supports partial withdrawal with the same semantics as + the locked tiers, and gained `early_exit`/`lock_until` for ABI parity. + Its previous pro-rata yield payout depended on an externally supplied + `strategy_balance` argument that the router can no longer provide per + the unified signature; `VaultFlex` now tracks its own principal balance + like the locked tiers (see the doc comment on `VaultFlex::withdraw`). + ### Added +- Integration tests in `vault_router/tests/` that exercise `VaultRouter` + against the real `VaultFlex`/`VaultL3`/`VaultL6`/`VaultL12` contracts + (not the unit-test `MockVault`) across two allowlisted assets. - Cargo workspace with contract stubs for all eight vault roles. - Next.js dashboard scaffold. - TypeScript SDK skeleton. diff --git a/Cargo.lock b/Cargo.lock index e7d9816..f308d12 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -784,6 +784,13 @@ version = "3.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9245c6e7c5a6bcdd7977fdf6d1e1c67f4cc2d0d58c041df0ea5940953033e6ca" +[[package]] +name = "position_token" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "powerfmt" version = "0.2.0" diff --git a/contracts/shared/src/checkpoint.rs b/contracts/shared/src/checkpoint.rs index b1e6f02..9363370 100644 --- a/contracts/shared/src/checkpoint.rs +++ b/contracts/shared/src/checkpoint.rs @@ -1,17 +1,21 @@ -use soroban_sdk::{Env, Address, symbol_short, Symbol}; +use soroban_sdk::{Env, Address}; -const LEDGER_KEY: Symbol = symbol_short!("chkpnt"); - -pub fn record_deposit_checkpoint(env: &Env, user: &Address) { +/// Record a deposit checkpoint for a specific (user, asset) position. +/// +/// Asset-scoped so that a user holding positions in two different assets in +/// the same tier vault doesn't share a single eligibility window between +/// them (issue #138). +pub fn record_deposit_checkpoint(env: &Env, user: &Address, asset: &Address) { // Audit M-01 Fix: yield accrual window activates on the NEXT ledger sequence let next_eligible_ledger = env.ledger().sequence().checked_add(1).expect("Ledger max seq overflow"); - env.storage().persistent().set(&user, &next_eligible_ledger); + env.storage().persistent().set(&(user.clone(), asset.clone()), &next_eligible_ledger); } -pub fn is_eligible_for_yield(env: &Env, user: &Address) -> bool { - if !env.storage().persistent().has(user) { +pub fn is_eligible_for_yield(env: &Env, user: &Address, asset: &Address) -> bool { + let key = (user.clone(), asset.clone()); + if !env.storage().persistent().has(&key) { return false; } - let activation_ledger: u32 = env.storage().persistent().get(user).unwrap(); + let activation_ledger: u32 = env.storage().persistent().get(&key).unwrap(); env.ledger().sequence() >= activation_ledger -} \ No newline at end of file +} diff --git a/contracts/shared/src/tests/checkpoint_tests.rs b/contracts/shared/src/tests/checkpoint_tests.rs index d4609e2..efc174c 100644 --- a/contracts/shared/src/tests/checkpoint_tests.rs +++ b/contracts/shared/src/tests/checkpoint_tests.rs @@ -1,19 +1,39 @@ #![cfg(test)] -use soroban_sdk::{Env, Address}; +use soroban_sdk::{Env, Address, testutils::Address as _}; use crate::checkpoint::{record_deposit_checkpoint, is_eligible_for_yield}; #[test] fn test_same_ledger_frontrunning_protection() { let env = Env::default(); let user = Address::generate(&env); + let asset = Address::generate(&env); env.ledger().set_sequence(10050); - record_deposit_checkpoint(&env, &user); + record_deposit_checkpoint(&env, &user, &asset); // Audit M-01 verification checkpoint: Depositing inside active sequence block MUST yield ineligible for execution rewards - assert_eq!(is_eligible_for_yield(&env, &user), false); + assert_eq!(is_eligible_for_yield(&env, &user, &asset), false); // Advance sequence frame explicitly to activate yield window eligibility env.ledger().set_sequence(10051); - assert_eq!(is_eligible_for_yield(&env, &user), true); -} \ No newline at end of file + assert_eq!(is_eligible_for_yield(&env, &user, &asset), true); +} + +#[test] +fn test_checkpoint_is_scoped_per_asset() { + let env = Env::default(); + let user = Address::generate(&env); + let asset_a = Address::generate(&env); + let asset_b = Address::generate(&env); + + env.ledger().set_sequence(500); + record_deposit_checkpoint(&env, &user, &asset_a); + + // A checkpoint recorded for asset_a must not make asset_b eligible. + assert_eq!(is_eligible_for_yield(&env, &user, &asset_a), false); + assert_eq!(is_eligible_for_yield(&env, &user, &asset_b), false); + + env.ledger().set_sequence(501); + assert_eq!(is_eligible_for_yield(&env, &user, &asset_a), true); + assert_eq!(is_eligible_for_yield(&env, &user, &asset_b), false); +} diff --git a/contracts/vault_flex/src/lib.rs b/contracts/vault_flex/src/lib.rs index c2de8d3..4f0c925 100644 --- a/contracts/vault_flex/src/lib.rs +++ b/contracts/vault_flex/src/lib.rs @@ -1,19 +1,20 @@ #![no_std] use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, Symbol, symbol_short}; -use shared::fixed_point::{mul_fp, div_fp, SCALING_FACTOR}; -use shared::checkpoint::{record_deposit_checkpoint, is_eligible_for_yield}; +use shared::fixed_point::SCALING_FACTOR; +use shared::checkpoint::record_deposit_checkpoint; // Storage keys setup using Soroban instance and persistent frameworks const ADMIN_KEY: Symbol = symbol_short!("admin"); const STRATEGY_KEY: Symbol = symbol_short!("strategy"); const TOTAL_SHARES_KEY: Symbol = symbol_short!("t_shares"); +const TOTAL_BALANCE_KEY: Symbol = symbol_short!("t_bal"); #[derive(Clone)] #[contracttype] pub enum DataKey { - Balance(Address), - Shares(Address), + Balance(Address, Address), // (user, asset) + Shares(Address, Address), // (user, asset) } #[contract] @@ -29,10 +30,13 @@ impl VaultFlex { env.storage().instance().set(&ADMIN_KEY, &admin); env.storage().instance().set(&STRATEGY_KEY, &strategy); env.storage().instance().set(&TOTAL_SHARES_KEY, &0i128); + env.storage().instance().set(&TOTAL_BALANCE_KEY, &0i128); } - /// Deposits USDC capital for a target user. Can only be invoked by the registered Admin. - pub fn deposit(env: Env, user: Address, amount: i128) { + /// Records a deposit of `asset` for `user`. Can only be invoked by the + /// registered Admin (VaultRouter), which has already moved the tokens + /// from `user` to this contract before calling. + pub fn deposit(env: Env, user: Address, asset: Address, amount: i128) { let admin: Address = env.storage().instance().get(&ADMIN_KEY).expect("Uninitialized"); admin.require_auth(); // Authorization Restriction: Only VaultRouter/Admin can call @@ -41,62 +45,247 @@ impl VaultFlex { panic!("VaultFlex: Deposit amount below minimum requirement of 1 USDC"); } - let current_balance = env.storage().persistent().get::(&DataKey::Balance(user.clone())).unwrap_or(0); - let current_shares = env.storage().persistent().get::(&DataKey::Shares(user.clone())).unwrap_or(0); + let current_balance = env.storage().persistent().get::(&DataKey::Balance(user.clone(), asset.clone())).unwrap_or(0); + let current_shares = env.storage().persistent().get::(&DataKey::Shares(user.clone(), asset.clone())).unwrap_or(0); let total_shares = env.storage().instance().get::(&TOTAL_SHARES_KEY).unwrap_or(0); + let total_balance = env.storage().instance().get::(&TOTAL_BALANCE_KEY).unwrap_or(0); // Flex tier features a constant 1.00x share multiplier - let structural_shares = amount; + let structural_shares = amount; // Commit state parameters to persistent ledger - env.storage().persistent().set(&DataKey::Balance(user.clone()), &(current_balance + amount)); - env.storage().persistent().set(&DataKey::Shares(user.clone()), &(current_shares + structural_shares)); + env.storage().persistent().set(&DataKey::Balance(user.clone(), asset.clone()), &(current_balance + amount)); + env.storage().persistent().set(&DataKey::Shares(user.clone(), asset.clone()), &(current_shares + structural_shares)); env.storage().instance().set(&TOTAL_SHARES_KEY, &(total_shares + structural_shares)); + env.storage().instance().set(&TOTAL_BALANCE_KEY, &(total_balance + amount)); // Audit M-01 Safeguard: Log checkpoint rule delay sequence via shared infrastructure - record_deposit_checkpoint(&env, &user); + record_deposit_checkpoint(&env, &user, &asset); } - /// Withdraws a user's entire position, calculating accrued yield dynamically. Only callable by Admin. - pub fn withdraw(env: Env, user: Address, strategy_balance: i128) -> i128 { + /// Withdraw `amount` from the caller's Flex position for `asset`. Only + /// callable by Admin (VaultRouter), which pays the user out of its own + /// balance after this call returns. + /// + /// Full-vs-partial semantics match VaultL3/VaultL6/VaultL12: + /// - `amount >= balance`: full withdrawal, storage entries removed. + /// - `amount < balance`: partial withdrawal, proportional shares burned. + /// - `amount > balance`: rejected. + /// + /// Note: Flex previously computed a dynamic pro-rata payout from an + /// externally supplied `strategy_balance` argument. VaultRouter now + /// calls every tier vault with the same `(user, asset, amount)` + /// signature and can no longer supply that argument, and StrategyVault + /// pools capital from all tier vaults without attributing balance back + /// to a single caller, so there is no accurate callback available + /// without a StrategyVault redesign (out of scope for issue #138). + /// Flex now tracks its own principal balance exactly like the locked + /// tiers instead. + pub fn withdraw(env: Env, user: Address, asset: Address, amount: i128) -> i128 { let admin: Address = env.storage().instance().get(&ADMIN_KEY).expect("Uninitialized"); admin.require_auth(); - let user_shares = env.storage().persistent().get::(&DataKey::Shares(user.clone())).unwrap_or(0); - if user_shares <= 0 { + let balance: i128 = env.storage().persistent().get::(&DataKey::Balance(user.clone(), asset.clone())).unwrap_or(0); + let user_shares: i128 = env.storage().persistent().get::(&DataKey::Shares(user.clone(), asset.clone())).unwrap_or(0); + + if user_shares <= 0 || balance <= 0 { panic!("VaultFlex: Zero share profile detected or double-withdraw requested"); } + if amount > balance { + panic!("VaultFlex: withdrawal amount exceeds balance"); + } - let total_shares = env.storage().instance().get::(&TOTAL_SHARES_KEY).unwrap_or(0); - let principal_balance = env.storage().persistent().get::(&DataKey::Balance(user.clone())).unwrap_or(0); + let total_shares: i128 = env.storage().instance().get::(&TOTAL_SHARES_KEY).unwrap_or(0); + let total_balance: i128 = env.storage().instance().get::(&TOTAL_BALANCE_KEY).unwrap_or(0); + + if amount >= balance { + // Full withdrawal — remove storage entries + env.storage().instance().set(&TOTAL_SHARES_KEY, &(total_shares - user_shares)); + env.storage().instance().set(&TOTAL_BALANCE_KEY, &(total_balance - balance).max(0)); + env.storage().persistent().remove(&DataKey::Balance(user.clone(), asset.clone())); + env.storage().persistent().remove(&DataKey::Shares(user.clone(), asset.clone())); + return balance; + } + + // Partial withdrawal — burn proportional shares + let shares_to_burn = (user_shares * amount) / balance; + env.storage().persistent().set(&DataKey::Balance(user.clone(), asset.clone()), &(balance - amount)); + env.storage().persistent().set(&DataKey::Shares(user.clone(), asset.clone()), &(user_shares - shares_to_burn)); + env.storage().instance().set(&TOTAL_SHARES_KEY, &(total_shares - shares_to_burn)); + env.storage().instance().set(&TOTAL_BALANCE_KEY, &(total_balance - amount)); - // Calculate pro-rata yields: (user_shares / total_shares) * strategy_balance - let total_payout = if is_eligible_for_yield(&env, &user) && total_shares > 0 { - // High-precision fixed-point multiplication/division prevents early truncation errors - div_fp(mul_fp(user_shares, strategy_balance), total_shares) - } else { - principal_balance - }; + amount + } - // Complete state destruction for the user's position - env.storage().persistent().remove(&DataKey::Balance(user.clone())); - env.storage().persistent().remove(&DataKey::Shares(user.clone())); - env.storage().instance().set(&TOTAL_SHARES_KEY, &(total_shares - user_shares)); + /// Flex has no lock period, so early exit is equivalent to a plain + /// withdrawal: no fee, no maturity check. Kept as a distinct entry + /// point so VaultRouter's early_exit forwarding works uniformly across + /// all four tiers. + pub fn early_exit(env: Env, user: Address, asset: Address, amount: i128) -> i128 { + Self::withdraw(env, user, asset, amount) + } - total_payout + /// Flex has no lock; always returns 0. Present for ABI parity with the + /// locked tiers so all four vaults share an identical signature set. + pub fn lock_until(_env: Env, _user: Address, _asset: Address) -> u32 { + 0 } /* --- Read-Only Query Methods --- */ - pub fn balance(env: Env, user: Address) -> i128 { - env.storage().persistent().get::(&DataKey::Balance(user)).unwrap_or(0) + pub fn balance(env: Env, user: Address, asset: Address) -> i128 { + env.storage().persistent().get::(&DataKey::Balance(user, asset)).unwrap_or(0) } - pub fn shares(env: Env, user: Address) -> i128 { - env.storage().persistent().get::(&DataKey::Shares(user)).unwrap_or(0) + pub fn shares(env: Env, user: Address, asset: Address) -> i128 { + env.storage().persistent().get::(&DataKey::Shares(user, asset)).unwrap_or(0) } pub fn total_shares(env: Env) -> i128 { env.storage().instance().get::(&TOTAL_SHARES_KEY).unwrap_or(0) } -} \ No newline at end of file + + pub fn total_balance(env: Env) -> i128 { + env.storage().instance().get::(&TOTAL_BALANCE_KEY).unwrap_or(0) + } +} + +#[cfg(test)] +mod test { + extern crate std; + + use super::VaultFlex; + use soroban_sdk::{testutils::Address as _, Address, Env}; + + fn setup() -> (Env, super::VaultFlexClient<'static>, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + let vault_id = env.register_contract(None, VaultFlex); + let client = super::VaultFlexClient::new(&env, &vault_id); + let admin = Address::generate(&env); + let strategy = Address::generate(&env); + client.initialize(&admin, &strategy); + (env, client, admin, strategy) + } + + #[test] + fn test_deposit_records_balance_and_shares() { + let (env, client, _, _) = setup(); + let user = Address::generate(&env); + let usdc = Address::generate(&env); + + client.deposit(&user, &usdc, &50_000_000i128); + + assert_eq!(client.balance(&user, &usdc), 50_000_000i128); + assert_eq!(client.shares(&user, &usdc), 50_000_000i128); + assert_eq!(client.total_balance(), 50_000_000i128); + assert_eq!(client.total_shares(), 50_000_000i128); + } + + #[test] + #[should_panic] + fn test_deposit_below_minimum_rejected() { + let (env, client, _, _) = setup(); + let user = Address::generate(&env); + let usdc = Address::generate(&env); + client.deposit(&user, &usdc, &9_999_999i128); + } + + #[test] + fn test_deposits_are_scoped_per_asset() { + let (env, client, _, _) = setup(); + let user = Address::generate(&env); + let usdc = Address::generate(&env); + let eurc = Address::generate(&env); + + client.deposit(&user, &usdc, &20_000_000i128); + client.deposit(&user, &eurc, &30_000_000i128); + + assert_eq!(client.balance(&user, &usdc), 20_000_000i128); + assert_eq!(client.balance(&user, &eurc), 30_000_000i128); + assert_eq!(client.total_balance(), 50_000_000i128); + } + + #[test] + fn test_full_withdrawal_clears_position() { + let (env, client, _, _) = setup(); + let user = Address::generate(&env); + let usdc = Address::generate(&env); + client.deposit(&user, &usdc, &50_000_000i128); + + let payout = client.withdraw(&user, &usdc, &50_000_000i128); + + assert_eq!(payout, 50_000_000i128); + assert_eq!(client.balance(&user, &usdc), 0i128); + assert_eq!(client.shares(&user, &usdc), 0i128); + assert_eq!(client.total_balance(), 0i128); + } + + #[test] + fn test_partial_withdrawal_leaves_remainder() { + let (env, client, _, _) = setup(); + let user = Address::generate(&env); + let usdc = Address::generate(&env); + client.deposit(&user, &usdc, &50_000_000i128); + + let payout = client.withdraw(&user, &usdc, &20_000_000i128); + + assert_eq!(payout, 20_000_000i128); + assert_eq!(client.balance(&user, &usdc), 30_000_000i128); + assert_eq!(client.shares(&user, &usdc), 30_000_000i128); + assert_eq!(client.total_balance(), 30_000_000i128); + } + + #[test] + #[should_panic] + fn test_withdraw_exceeding_balance_rejected() { + let (env, client, _, _) = setup(); + let user = Address::generate(&env); + let usdc = Address::generate(&env); + client.deposit(&user, &usdc, &50_000_000i128); + client.withdraw(&user, &usdc, &50_000_001i128); + } + + #[test] + #[should_panic] + fn test_double_withdraw_rejected() { + let (env, client, _, _) = setup(); + let user = Address::generate(&env); + let usdc = Address::generate(&env); + client.deposit(&user, &usdc, &50_000_000i128); + client.withdraw(&user, &usdc, &50_000_000i128); + client.withdraw(&user, &usdc, &1i128); + } + + #[test] + fn test_early_exit_matches_withdraw_no_fee() { + let (env, client, _, _) = setup(); + let user = Address::generate(&env); + let usdc = Address::generate(&env); + client.deposit(&user, &usdc, &50_000_000i128); + + let payout = client.early_exit(&user, &usdc, &20_000_000i128); + + // No lock, no fee: early_exit pays out exactly the requested amount. + assert_eq!(payout, 20_000_000i128); + assert_eq!(client.balance(&user, &usdc), 30_000_000i128); + } + + #[test] + fn test_lock_until_always_zero() { + let (env, client, _, _) = setup(); + let user = Address::generate(&env); + let usdc = Address::generate(&env); + assert_eq!(client.lock_until(&user, &usdc), 0u32); + client.deposit(&user, &usdc, &50_000_000i128); + assert_eq!(client.lock_until(&user, &usdc), 0u32); + } + + #[test] + #[should_panic] + fn test_double_initialize_panics() { + let (env, client, admin, strategy) = setup(); + let _ = env; + client.initialize(&admin, &strategy); + } +} diff --git a/contracts/vault_l12/src/lib.rs b/contracts/vault_l12/src/lib.rs index 2cbbdba..991d105 100644 --- a/contracts/vault_l12/src/lib.rs +++ b/contracts/vault_l12/src/lib.rs @@ -1,5 +1,5 @@ #![no_std] -use soroban_sdk::{contract, contracterror, contractimpl, contracttype, panic_with_error, token, Address, Env}; +use soroban_sdk::{contract, contracterror, contractimpl, contracttype, panic_with_error, Address, Env}; #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq)] @@ -23,10 +23,10 @@ pub enum DataKey { Strategy, Usdc, MaxTvl, - Balance(Address), - Shares(Address), - LockUntil(Address), - Checkpoint(Address), + Balance(Address, Address), // (user, asset) + Shares(Address, Address), // (user, asset) + LockUntil(Address, Address), // (user, asset) + Checkpoint(Address, Address), // (user, asset) } const FP_MULTIPLIER: i128 = 1_000_000_0; @@ -63,7 +63,10 @@ impl VaultL12 { env.storage().instance().set(&DataKey::MaxTvl, &cap); } - pub fn deposit(env: Env, user: Address, amount: i128) { + /// Records a deposit of `asset` for `user`. VaultRouter has already + /// moved the tokens from `user` to this contract before calling, so no + /// token transfer happens here — this is bookkeeping only. + pub fn deposit(env: Env, user: Address, asset: Address, amount: i128) { user.require_auth(); if amount < 2_500_000_000 { @@ -79,39 +82,33 @@ impl VaultL12 { let multiplier_fp = 13_000_000; let new_shares = mul_fp(amount, multiplier_fp); - let usdc_addr: Address = env.storage().instance().get(&DataKey::Usdc).unwrap(); - let strategy: Address = env.storage().instance().get(&DataKey::Strategy).unwrap(); + let current_balance: i128 = env.storage().persistent().get(&DataKey::Balance(user.clone(), asset.clone())).unwrap_or(0); + let current_shares: i128 = env.storage().persistent().get(&DataKey::Shares(user.clone(), asset.clone())).unwrap_or(0); - let token_client = token::Client::new(&env, &usdc_addr); - token_client.transfer(&user, &strategy, &amount); - - let current_balance: i128 = env.storage().persistent().get(&DataKey::Balance(user.clone())).unwrap_or(0); - let current_shares: i128 = env.storage().persistent().get(&DataKey::Shares(user.clone())).unwrap_or(0); - - env.storage().persistent().set(&DataKey::Balance(user.clone()), &(current_balance + amount)); - env.storage().persistent().set(&DataKey::Shares(user.clone()), &(current_shares + new_shares)); + env.storage().persistent().set(&DataKey::Balance(user.clone(), asset.clone()), &(current_balance + amount)); + env.storage().persistent().set(&DataKey::Shares(user.clone(), asset.clone()), &(current_shares + new_shares)); let total_shares: i128 = env.storage().instance().get(&DataKey::TotalShares).unwrap_or(0); env.storage().instance().set(&DataKey::TotalShares, &(total_shares + new_shares)); env.storage().instance().set(&DataKey::TotalBalance, &(total_balance + amount)); let lock_until = env.ledger().sequence() + LOCK_DURATION; - env.storage().persistent().set(&DataKey::LockUntil(user.clone()), &lock_until); + env.storage().persistent().set(&DataKey::LockUntil(user.clone(), asset.clone()), &lock_until); let checkpoint = env.ledger().sequence() + 1; - env.storage().persistent().set(&DataKey::Checkpoint(user.clone()), &checkpoint); + env.storage().persistent().set(&DataKey::Checkpoint(user.clone(), asset.clone()), &checkpoint); } - /// Withdraw `amount` from a matured position. - pub fn withdraw(env: Env, user: Address, amount: i128) -> i128 { + /// Withdraw `amount` from a matured position for `asset`. + pub fn withdraw(env: Env, user: Address, asset: Address, amount: i128) -> i128 { user.require_auth(); - let lock_until: u32 = env.storage().persistent().get(&DataKey::LockUntil(user.clone())).unwrap_or(0); + let lock_until: u32 = env.storage().persistent().get(&DataKey::LockUntil(user.clone(), asset.clone())).unwrap_or(0); if env.ledger().sequence() < lock_until { panic_with_error!(&env, VaultError::LockNotExpired); } - let balance: i128 = env.storage().persistent().get(&DataKey::Balance(user.clone())).unwrap_or(0); - let user_shares: i128 = env.storage().persistent().get(&DataKey::Shares(user.clone())).unwrap_or(0); + let balance: i128 = env.storage().persistent().get(&DataKey::Balance(user.clone(), asset.clone())).unwrap_or(0); + let user_shares: i128 = env.storage().persistent().get(&DataKey::Shares(user.clone(), asset.clone())).unwrap_or(0); let total_shares: i128 = env.storage().instance().get(&DataKey::TotalShares).unwrap_or(0); if amount > balance { @@ -122,29 +119,29 @@ impl VaultL12 { let total_balance: i128 = env.storage().instance().get(&DataKey::TotalBalance).unwrap_or(0); env.storage().instance().set(&DataKey::TotalShares, &(total_shares - user_shares)); env.storage().instance().set(&DataKey::TotalBalance, &(total_balance - balance).max(0)); - env.storage().persistent().remove(&DataKey::Balance(user.clone())); - env.storage().persistent().remove(&DataKey::Shares(user.clone())); - env.storage().persistent().remove(&DataKey::LockUntil(user.clone())); - env.storage().persistent().remove(&DataKey::Checkpoint(user.clone())); + env.storage().persistent().remove(&DataKey::Balance(user.clone(), asset.clone())); + env.storage().persistent().remove(&DataKey::Shares(user.clone(), asset.clone())); + env.storage().persistent().remove(&DataKey::LockUntil(user.clone(), asset.clone())); + env.storage().persistent().remove(&DataKey::Checkpoint(user.clone(), asset.clone())); return balance; } let shares_to_burn = (user_shares * amount) / balance; let total_balance: i128 = env.storage().instance().get(&DataKey::TotalBalance).unwrap_or(0); - env.storage().persistent().set(&DataKey::Balance(user.clone()), &(balance - amount)); - env.storage().persistent().set(&DataKey::Shares(user.clone()), &(user_shares - shares_to_burn)); + env.storage().persistent().set(&DataKey::Balance(user.clone(), asset.clone()), &(balance - amount)); + env.storage().persistent().set(&DataKey::Shares(user.clone(), asset.clone()), &(user_shares - shares_to_burn)); env.storage().instance().set(&DataKey::TotalShares, &(total_shares - shares_to_burn)); env.storage().instance().set(&DataKey::TotalBalance, &(total_balance - amount)); amount } - /// Early exit `amount` before maturity. - pub fn early_exit(env: Env, user: Address, amount: i128) -> i128 { + /// Early exit `amount` before maturity for `asset`. + pub fn early_exit(env: Env, user: Address, asset: Address, amount: i128) -> i128 { user.require_auth(); - let balance: i128 = env.storage().persistent().get(&DataKey::Balance(user.clone())).unwrap_or(0); - let user_shares: i128 = env.storage().persistent().get(&DataKey::Shares(user.clone())).unwrap_or(0); + let balance: i128 = env.storage().persistent().get(&DataKey::Balance(user.clone(), asset.clone())).unwrap_or(0); + let user_shares: i128 = env.storage().persistent().get(&DataKey::Shares(user.clone(), asset.clone())).unwrap_or(0); let total_shares: i128 = env.storage().instance().get(&DataKey::TotalShares).unwrap_or(0); if amount > balance { @@ -160,17 +157,17 @@ impl VaultL12 { let total_balance: i128 = env.storage().instance().get(&DataKey::TotalBalance).unwrap_or(0); env.storage().instance().set(&DataKey::TotalShares, &(total_shares - user_shares)); env.storage().instance().set(&DataKey::TotalBalance, &(total_balance - balance).max(0)); - env.storage().persistent().remove(&DataKey::Balance(user.clone())); - env.storage().persistent().remove(&DataKey::Shares(user.clone())); - env.storage().persistent().remove(&DataKey::LockUntil(user.clone())); - env.storage().persistent().remove(&DataKey::Checkpoint(user.clone())); + env.storage().persistent().remove(&DataKey::Balance(user.clone(), asset.clone())); + env.storage().persistent().remove(&DataKey::Shares(user.clone(), asset.clone())); + env.storage().persistent().remove(&DataKey::LockUntil(user.clone(), asset.clone())); + env.storage().persistent().remove(&DataKey::Checkpoint(user.clone(), asset.clone())); return net_amount; } let shares_to_burn = (user_shares * amount) / balance; let total_balance: i128 = env.storage().instance().get(&DataKey::TotalBalance).unwrap_or(0); - env.storage().persistent().set(&DataKey::Balance(user.clone()), &(balance - amount)); - env.storage().persistent().set(&DataKey::Shares(user.clone()), &(user_shares - shares_to_burn)); + env.storage().persistent().set(&DataKey::Balance(user.clone(), asset.clone()), &(balance - amount)); + env.storage().persistent().set(&DataKey::Shares(user.clone(), asset.clone()), &(user_shares - shares_to_burn)); env.storage().instance().set(&DataKey::TotalShares, &(total_shares - shares_to_burn)); env.storage().instance().set(&DataKey::TotalBalance, &(total_balance - amount)); @@ -193,13 +190,13 @@ impl VaultL12 { (max_tvl - total_balance).max(0) } - pub fn relock(env: Env, user: Address) -> u32 { + pub fn relock(env: Env, user: Address, asset: Address) -> u32 { user.require_auth(); let lock_until: u32 = env .storage() .persistent() - .get(&DataKey::LockUntil(user.clone())) + .get(&DataKey::LockUntil(user.clone(), asset.clone())) .unwrap_or(0); if env.ledger().sequence() < lock_until { @@ -209,21 +206,21 @@ impl VaultL12 { let new_lock_until = env.ledger().sequence() + LOCK_DURATION; env.storage() .persistent() - .set(&DataKey::LockUntil(user.clone()), &new_lock_until); + .set(&DataKey::LockUntil(user.clone(), asset.clone()), &new_lock_until); new_lock_until } - pub fn lock_until(env: Env, user: Address) -> u32 { - env.storage().persistent().get(&DataKey::LockUntil(user)).unwrap_or(0) + pub fn lock_until(env: Env, user: Address, asset: Address) -> u32 { + env.storage().persistent().get(&DataKey::LockUntil(user, asset)).unwrap_or(0) } - pub fn balance(env: Env, user: Address) -> i128 { - env.storage().persistent().get(&DataKey::Balance(user)).unwrap_or(0) + pub fn balance(env: Env, user: Address, asset: Address) -> i128 { + env.storage().persistent().get(&DataKey::Balance(user, asset)).unwrap_or(0) } - pub fn shares(env: Env, user: Address) -> i128 { - env.storage().persistent().get(&DataKey::Shares(user)).unwrap_or(0) + pub fn shares(env: Env, user: Address, asset: Address) -> i128 { + env.storage().persistent().get(&DataKey::Shares(user, asset)).unwrap_or(0) } pub fn total_shares(env: Env) -> i128 { @@ -234,3 +231,100 @@ impl VaultL12 { env.storage().instance().get(&DataKey::TotalBalance).unwrap_or(0) } } + +#[cfg(test)] +mod test { + extern crate std; + + use super::VaultL12; + use soroban_sdk::{testutils::Address as _, Address, Env}; + + fn setup() -> (Env, super::VaultL12Client<'static>, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + let vault_id = env.register_contract(None, VaultL12); + let client = super::VaultL12Client::new(&env, &vault_id); + let admin = Address::generate(&env); + let governance = Address::generate(&env); + let strategy = Address::generate(&env); + let usdc = Address::generate(&env); + client.initialize(&admin, &governance, &strategy, &usdc, &0i128); + (env, client, governance, usdc) + } + + #[test] + fn test_deposit_records_balance_and_lock() { + let (env, client, _, usdc) = setup(); + let user = Address::generate(&env); + client.deposit(&user, &usdc, &2_500_000_000i128); + + assert_eq!(client.balance(&user, &usdc), 2_500_000_000i128); + assert!(client.lock_until(&user, &usdc) > 0); + } + + #[test] + fn test_positions_are_scoped_per_asset() { + let (env, client, _, usdc) = setup(); + let eurc = Address::generate(&env); + let user = Address::generate(&env); + + client.deposit(&user, &usdc, &2_500_000_000i128); + client.deposit(&user, &eurc, &3_000_000_000i128); + + assert_eq!(client.balance(&user, &usdc), 2_500_000_000i128); + assert_eq!(client.balance(&user, &eurc), 3_000_000_000i128); + } + + #[test] + #[should_panic] + fn test_withdraw_before_maturity_rejected() { + let (env, client, _, usdc) = setup(); + let user = Address::generate(&env); + client.deposit(&user, &usdc, &2_500_000_000i128); + client.withdraw(&user, &usdc, &2_500_000_000i128); + } + + #[test] + fn test_withdraw_after_maturity_succeeds() { + let (env, client, _, usdc) = setup(); + let user = Address::generate(&env); + client.deposit(&user, &usdc, &2_500_000_000i128); + + env.ledger().set_sequence(env.ledger().sequence() + super::LOCK_DURATION + 1); + let payout = client.withdraw(&user, &usdc, &2_500_000_000i128); + + assert_eq!(payout, 2_500_000_000i128); + } + + #[test] + fn test_early_exit_applies_fee() { + let (env, client, _, usdc) = setup(); + let user = Address::generate(&env); + client.deposit(&user, &usdc, &2_500_000_000i128); + + let net = client.early_exit(&user, &usdc, &2_500_000_000i128); + + // 2.50% fee on 2_500_000_000 = 62_500_000 + assert_eq!(net, 2_437_500_000i128); + } + + #[test] + fn test_relock_extends_lock_after_maturity() { + let (env, client, _, usdc) = setup(); + let user = Address::generate(&env); + client.deposit(&user, &usdc, &2_500_000_000i128); + env.ledger().set_sequence(env.ledger().sequence() + super::LOCK_DURATION + 1); + + let new_lock = client.relock(&user, &usdc); + assert!(new_lock > env.ledger().sequence()); + } + + #[test] + #[should_panic] + fn test_relock_before_maturity_rejected() { + let (env, client, _, usdc) = setup(); + let user = Address::generate(&env); + client.deposit(&user, &usdc, &2_500_000_000i128); + client.relock(&user, &usdc); + } +} diff --git a/contracts/vault_l3/src/lib.rs b/contracts/vault_l3/src/lib.rs index 1dd6969..b272e8e 100644 --- a/contracts/vault_l3/src/lib.rs +++ b/contracts/vault_l3/src/lib.rs @@ -1,5 +1,5 @@ #![no_std] -use soroban_sdk::{contract, contracterror, contractimpl, contracttype, panic_with_error, token, Address, Env}; +use soroban_sdk::{contract, contracterror, contractimpl, contracttype, panic_with_error, Address, Env}; #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq)] @@ -24,13 +24,15 @@ pub enum DataKey { Strategy, Usdc, MaxTvl, - Balance(Address), - Shares(Address), - LockUntil(Address), - Checkpoint(Address), - /// Emergency unlock flag — when true, early_exit and withdraw skip - /// lock and fee enforcement so depositors can exit safely. - EmergencyUnlock, + Balance(Address, Address), // (user, asset) + Shares(Address, Address), // (user, asset) + LockUntil(Address, Address), // (user, asset) + Checkpoint(Address, Address), // (user, asset) + /// Emergency unlock flag, scoped per asset — when true for a given + /// asset, early_exit and withdraw for THAT asset skip lock and fee + /// enforcement so depositors can exit safely, without freezing/ + /// unfreezing every other asset in the tier (issue #138). + EmergencyUnlock(Address), } const FP_MULTIPLIER: i128 = 1_000_000_0; @@ -67,33 +69,35 @@ impl VaultL3 { env.storage().instance().set(&DataKey::Usdc, &usdc); env.storage().instance().set(&DataKey::TotalShares, &0i128); env.storage().instance().set(&DataKey::TotalBalance, &0i128); - env.storage().instance().set(&DataKey::EmergencyUnlock, &false); let cap = if max_tvl > 0 { max_tvl } else { DEFAULT_MAX_TVL }; env.storage().instance().set(&DataKey::MaxTvl, &cap); } // ── Emergency Unlock ───────────────────────────────────────────────────── - pub fn set_emergency_unlock(env: Env, active: bool) { + pub fn set_emergency_unlock(env: Env, asset: Address, active: bool) { let guardian: Address = env .storage() .instance() .get(&DataKey::Guardian) .expect("not initialized"); guardian.require_auth(); - env.storage().instance().set(&DataKey::EmergencyUnlock, &active); + env.storage().persistent().set(&DataKey::EmergencyUnlock(asset), &active); } - pub fn emergency_unlock(env: Env) -> bool { + pub fn emergency_unlock(env: Env, asset: Address) -> bool { env.storage() - .instance() - .get(&DataKey::EmergencyUnlock) + .persistent() + .get(&DataKey::EmergencyUnlock(asset)) .unwrap_or(false) } // ── Core vault operations ───────────────────────────────────────────────── - pub fn deposit(env: Env, user: Address, amount: i128) { + /// Records a deposit of `asset` for `user`. VaultRouter has already + /// moved the tokens from `user` to this contract before calling, so no + /// token transfer happens here — this is bookkeeping only. + pub fn deposit(env: Env, user: Address, asset: Address, amount: i128) { user.require_auth(); if amount < 500_000_000 { @@ -109,53 +113,47 @@ impl VaultL3 { let multiplier_fp = 10_500_000; let new_shares = mul_fp(amount, multiplier_fp); - let usdc_addr: Address = env.storage().instance().get(&DataKey::Usdc).unwrap(); - let strategy: Address = env.storage().instance().get(&DataKey::Strategy).unwrap(); - - let token_client = token::Client::new(&env, &usdc_addr); - token_client.transfer(&user, &strategy, &amount); - - let current_balance: i128 = env.storage().persistent().get(&DataKey::Balance(user.clone())).unwrap_or(0); - let current_shares: i128 = env.storage().persistent().get(&DataKey::Shares(user.clone())).unwrap_or(0); + let current_balance: i128 = env.storage().persistent().get(&DataKey::Balance(user.clone(), asset.clone())).unwrap_or(0); + let current_shares: i128 = env.storage().persistent().get(&DataKey::Shares(user.clone(), asset.clone())).unwrap_or(0); - env.storage().persistent().set(&DataKey::Balance(user.clone()), &(current_balance + amount)); - env.storage().persistent().set(&DataKey::Shares(user.clone()), &(current_shares + new_shares)); + env.storage().persistent().set(&DataKey::Balance(user.clone(), asset.clone()), &(current_balance + amount)); + env.storage().persistent().set(&DataKey::Shares(user.clone(), asset.clone()), &(current_shares + new_shares)); let total_shares: i128 = env.storage().instance().get(&DataKey::TotalShares).unwrap_or(0); env.storage().instance().set(&DataKey::TotalShares, &(total_shares + new_shares)); env.storage().instance().set(&DataKey::TotalBalance, &(total_balance + amount)); let lock_until = env.ledger().sequence() + LOCK_DURATION; - env.storage().persistent().set(&DataKey::LockUntil(user.clone()), &lock_until); + env.storage().persistent().set(&DataKey::LockUntil(user.clone(), asset.clone()), &lock_until); let checkpoint = env.ledger().sequence() + 1; - env.storage().persistent().set(&DataKey::Checkpoint(user.clone()), &checkpoint); + env.storage().persistent().set(&DataKey::Checkpoint(user.clone(), asset.clone()), &checkpoint); } - /// Withdraw `amount` from a matured position. + /// Withdraw `amount` from a matured position for `asset`. /// /// - If `amount >= balance`: full withdrawal — storage entries removed. /// - If `amount < balance`: partial withdrawal — burns proportional shares, /// reduces Balance/Shares, leaves LockUntil/Checkpoint untouched. /// - If `amount > balance`: rejected with `AmountExceedsBalance`. - pub fn withdraw(env: Env, user: Address, amount: i128) -> i128 { + pub fn withdraw(env: Env, user: Address, asset: Address, amount: i128) -> i128 { user.require_auth(); let emergency: bool = env .storage() - .instance() - .get(&DataKey::EmergencyUnlock) + .persistent() + .get(&DataKey::EmergencyUnlock(asset.clone())) .unwrap_or(false); if !emergency { - let lock_until: u32 = env.storage().persistent().get(&DataKey::LockUntil(user.clone())).unwrap_or(0); + let lock_until: u32 = env.storage().persistent().get(&DataKey::LockUntil(user.clone(), asset.clone())).unwrap_or(0); if env.ledger().sequence() < lock_until { panic_with_error!(&env, VaultError::LockNotExpired); } } - let balance: i128 = env.storage().persistent().get(&DataKey::Balance(user.clone())).unwrap_or(0); - let user_shares: i128 = env.storage().persistent().get(&DataKey::Shares(user.clone())).unwrap_or(0); + let balance: i128 = env.storage().persistent().get(&DataKey::Balance(user.clone(), asset.clone())).unwrap_or(0); + let user_shares: i128 = env.storage().persistent().get(&DataKey::Shares(user.clone(), asset.clone())).unwrap_or(0); let total_shares: i128 = env.storage().instance().get(&DataKey::TotalShares).unwrap_or(0); if amount > balance { @@ -167,35 +165,35 @@ impl VaultL3 { let total_balance: i128 = env.storage().instance().get(&DataKey::TotalBalance).unwrap_or(0); env.storage().instance().set(&DataKey::TotalShares, &(total_shares - user_shares)); env.storage().instance().set(&DataKey::TotalBalance, &(total_balance - balance).max(0)); - env.storage().persistent().remove(&DataKey::Balance(user.clone())); - env.storage().persistent().remove(&DataKey::Shares(user.clone())); - env.storage().persistent().remove(&DataKey::LockUntil(user.clone())); - env.storage().persistent().remove(&DataKey::Checkpoint(user.clone())); + env.storage().persistent().remove(&DataKey::Balance(user.clone(), asset.clone())); + env.storage().persistent().remove(&DataKey::Shares(user.clone(), asset.clone())); + env.storage().persistent().remove(&DataKey::LockUntil(user.clone(), asset.clone())); + env.storage().persistent().remove(&DataKey::Checkpoint(user.clone(), asset.clone())); return balance; } // Partial withdrawal — burn proportional shares let shares_to_burn = (user_shares * amount) / balance; let total_balance: i128 = env.storage().instance().get(&DataKey::TotalBalance).unwrap_or(0); - env.storage().persistent().set(&DataKey::Balance(user.clone()), &(balance - amount)); - env.storage().persistent().set(&DataKey::Shares(user.clone()), &(user_shares - shares_to_burn)); + env.storage().persistent().set(&DataKey::Balance(user.clone(), asset.clone()), &(balance - amount)); + env.storage().persistent().set(&DataKey::Shares(user.clone(), asset.clone()), &(user_shares - shares_to_burn)); env.storage().instance().set(&DataKey::TotalShares, &(total_shares - shares_to_burn)); env.storage().instance().set(&DataKey::TotalBalance, &(total_balance - amount)); amount } - /// Early exit `amount` before maturity, applying the exit fee only to the - /// withdrawn amount. + /// Early exit `amount` before maturity for `asset`, applying the exit + /// fee only to the withdrawn amount. /// /// - If `amount >= balance`: full early exit. /// - If `amount < balance`: partial early exit, remainder stays. /// - If `amount > balance`: rejected with `AmountExceedsBalance`. - pub fn early_exit(env: Env, user: Address, amount: i128) -> i128 { + pub fn early_exit(env: Env, user: Address, asset: Address, amount: i128) -> i128 { user.require_auth(); - let balance: i128 = env.storage().persistent().get(&DataKey::Balance(user.clone())).unwrap_or(0); - let user_shares: i128 = env.storage().persistent().get(&DataKey::Shares(user.clone())).unwrap_or(0); + let balance: i128 = env.storage().persistent().get(&DataKey::Balance(user.clone(), asset.clone())).unwrap_or(0); + let user_shares: i128 = env.storage().persistent().get(&DataKey::Shares(user.clone(), asset.clone())).unwrap_or(0); let total_shares: i128 = env.storage().instance().get(&DataKey::TotalShares).unwrap_or(0); if amount > balance { @@ -204,8 +202,8 @@ impl VaultL3 { let emergency: bool = env .storage() - .instance() - .get(&DataKey::EmergencyUnlock) + .persistent() + .get(&DataKey::EmergencyUnlock(asset.clone())) .unwrap_or(false); // Exit fee: 0.50% on the withdrawn amount only @@ -222,18 +220,18 @@ impl VaultL3 { let total_balance: i128 = env.storage().instance().get(&DataKey::TotalBalance).unwrap_or(0); env.storage().instance().set(&DataKey::TotalShares, &(total_shares - user_shares)); env.storage().instance().set(&DataKey::TotalBalance, &(total_balance - balance).max(0)); - env.storage().persistent().remove(&DataKey::Balance(user.clone())); - env.storage().persistent().remove(&DataKey::Shares(user.clone())); - env.storage().persistent().remove(&DataKey::LockUntil(user.clone())); - env.storage().persistent().remove(&DataKey::Checkpoint(user.clone())); + env.storage().persistent().remove(&DataKey::Balance(user.clone(), asset.clone())); + env.storage().persistent().remove(&DataKey::Shares(user.clone(), asset.clone())); + env.storage().persistent().remove(&DataKey::LockUntil(user.clone(), asset.clone())); + env.storage().persistent().remove(&DataKey::Checkpoint(user.clone(), asset.clone())); return net_amount; } // Partial early exit let shares_to_burn = (user_shares * amount) / balance; let total_balance: i128 = env.storage().instance().get(&DataKey::TotalBalance).unwrap_or(0); - env.storage().persistent().set(&DataKey::Balance(user.clone()), &(balance - amount)); - env.storage().persistent().set(&DataKey::Shares(user.clone()), &(user_shares - shares_to_burn)); + env.storage().persistent().set(&DataKey::Balance(user.clone(), asset.clone()), &(balance - amount)); + env.storage().persistent().set(&DataKey::Shares(user.clone(), asset.clone()), &(user_shares - shares_to_burn)); env.storage().instance().set(&DataKey::TotalShares, &(total_shares - shares_to_burn)); env.storage().instance().set(&DataKey::TotalBalance, &(total_balance - amount)); @@ -257,13 +255,13 @@ impl VaultL3 { (max_tvl - total_balance).max(0) } - pub fn relock(env: Env, user: Address) -> u32 { + pub fn relock(env: Env, user: Address, asset: Address) -> u32 { user.require_auth(); let lock_until: u32 = env .storage() .persistent() - .get(&DataKey::LockUntil(user.clone())) + .get(&DataKey::LockUntil(user.clone(), asset.clone())) .unwrap_or(0); if env.ledger().sequence() < lock_until { @@ -273,21 +271,21 @@ impl VaultL3 { let new_lock_until = env.ledger().sequence() + LOCK_DURATION; env.storage() .persistent() - .set(&DataKey::LockUntil(user.clone()), &new_lock_until); + .set(&DataKey::LockUntil(user.clone(), asset.clone()), &new_lock_until); new_lock_until } - pub fn lock_until(env: Env, user: Address) -> u32 { - env.storage().persistent().get(&DataKey::LockUntil(user)).unwrap_or(0) + pub fn lock_until(env: Env, user: Address, asset: Address) -> u32 { + env.storage().persistent().get(&DataKey::LockUntil(user, asset)).unwrap_or(0) } - pub fn balance(env: Env, user: Address) -> i128 { - env.storage().persistent().get(&DataKey::Balance(user)).unwrap_or(0) + pub fn balance(env: Env, user: Address, asset: Address) -> i128 { + env.storage().persistent().get(&DataKey::Balance(user, asset)).unwrap_or(0) } - pub fn shares(env: Env, user: Address) -> i128 { - env.storage().persistent().get(&DataKey::Shares(user)).unwrap_or(0) + pub fn shares(env: Env, user: Address, asset: Address) -> i128 { + env.storage().persistent().get(&DataKey::Shares(user, asset)).unwrap_or(0) } pub fn total_shares(env: Env) -> i128 { @@ -298,3 +296,162 @@ impl VaultL3 { env.storage().instance().get(&DataKey::TotalBalance).unwrap_or(0) } } + +#[cfg(test)] +mod test { + extern crate std; + + use super::VaultL3; + use soroban_sdk::{testutils::Address as _, Address, Env}; + + fn setup() -> (Env, super::VaultL3Client<'static>, Address, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + let vault_id = env.register_contract(None, VaultL3); + let client = super::VaultL3Client::new(&env, &vault_id); + let admin = Address::generate(&env); + let governance = Address::generate(&env); + let guardian = Address::generate(&env); + let strategy = Address::generate(&env); + let usdc = Address::generate(&env); + client.initialize(&admin, &governance, &guardian, &strategy, &usdc, &0i128); + (env, client, governance, guardian, usdc) + } + + #[test] + fn test_deposit_records_balance_and_lock() { + let (env, client, _, _, usdc) = setup(); + let user = Address::generate(&env); + client.deposit(&user, &usdc, &500_000_000i128); + + assert_eq!(client.balance(&user, &usdc), 500_000_000i128); + assert!(client.lock_until(&user, &usdc) > 0); + } + + #[test] + #[should_panic] + fn test_deposit_below_minimum_rejected() { + let (env, client, _, _, usdc) = setup(); + let user = Address::generate(&env); + client.deposit(&user, &usdc, &499_999_999i128); + } + + #[test] + #[should_panic] + fn test_deposit_cap_exceeded_rejected() { + let (env, client, governance, _, usdc) = setup(); + client.set_max_tvl(&governance, &600_000_000i128); + let user = Address::generate(&env); + client.deposit(&user, &usdc, &700_000_000i128); + } + + #[test] + fn test_positions_are_scoped_per_asset() { + let (env, client, _, _, usdc) = setup(); + let eurc = Address::generate(&env); + let user = Address::generate(&env); + + client.deposit(&user, &usdc, &500_000_000i128); + client.deposit(&user, &eurc, &600_000_000i128); + + assert_eq!(client.balance(&user, &usdc), 500_000_000i128); + assert_eq!(client.balance(&user, &eurc), 600_000_000i128); + } + + #[test] + #[should_panic] + fn test_withdraw_before_maturity_rejected() { + let (env, client, _, _, usdc) = setup(); + let user = Address::generate(&env); + client.deposit(&user, &usdc, &500_000_000i128); + client.withdraw(&user, &usdc, &500_000_000i128); + } + + #[test] + fn test_withdraw_after_maturity_succeeds() { + let (env, client, _, _, usdc) = setup(); + let user = Address::generate(&env); + client.deposit(&user, &usdc, &500_000_000i128); + + env.ledger().set_sequence(env.ledger().sequence() + super::LOCK_DURATION + 1); + let payout = client.withdraw(&user, &usdc, &500_000_000i128); + + assert_eq!(payout, 500_000_000i128); + assert_eq!(client.balance(&user, &usdc), 0i128); + } + + #[test] + fn test_partial_withdraw_leaves_remainder_and_lock() { + let (env, client, _, _, usdc) = setup(); + let user = Address::generate(&env); + client.deposit(&user, &usdc, &500_000_000i128); + env.ledger().set_sequence(env.ledger().sequence() + super::LOCK_DURATION + 1); + + let payout = client.withdraw(&user, &usdc, &200_000_000i128); + + assert_eq!(payout, 200_000_000i128); + assert_eq!(client.balance(&user, &usdc), 300_000_000i128); + assert!(client.lock_until(&user, &usdc) > 0); + } + + #[test] + fn test_early_exit_applies_fee_before_maturity() { + let (env, client, _, _, usdc) = setup(); + let user = Address::generate(&env); + client.deposit(&user, &usdc, &500_000_000i128); + + let net = client.early_exit(&user, &usdc, &500_000_000i128); + + // 0.50% fee on 500_000_000 = 2_500_000 + assert_eq!(net, 497_500_000i128); + } + + #[test] + fn test_emergency_unlock_skips_lock_for_scoped_asset() { + let (env, client, _, _guardian, usdc) = setup(); + let user = Address::generate(&env); + + client.deposit(&user, &usdc, &500_000_000i128); + client.set_emergency_unlock(&usdc, &true); + + // usdc: emergency unlock active, no maturity enforcement. + let payout = client.withdraw(&user, &usdc, &500_000_000i128); + assert_eq!(payout, 500_000_000i128); + } + + #[test] + #[should_panic] + fn test_emergency_unlock_does_not_leak_across_assets() { + let (env, client, _, _guardian, usdc) = setup(); + let eurc = Address::generate(&env); + let user = Address::generate(&env); + + client.deposit(&user, &usdc, &500_000_000i128); + client.deposit(&user, &eurc, &500_000_000i128); + client.set_emergency_unlock(&usdc, &true); + + // eurc: emergency unlock was only set for usdc, so eurc's lock must + // still apply — must not have been globally lifted. + client.withdraw(&user, &eurc, &500_000_000i128); + } + + #[test] + fn test_relock_extends_lock_after_maturity() { + let (env, client, _, _, usdc) = setup(); + let user = Address::generate(&env); + client.deposit(&user, &usdc, &500_000_000i128); + env.ledger().set_sequence(env.ledger().sequence() + super::LOCK_DURATION + 1); + + let new_lock = client.relock(&user, &usdc); + assert!(new_lock > env.ledger().sequence()); + } + + #[test] + #[should_panic] + fn test_relock_before_maturity_rejected() { + let (env, client, _, _, usdc) = setup(); + let user = Address::generate(&env); + client.deposit(&user, &usdc, &500_000_000i128); + client.relock(&user, &usdc); + } +} diff --git a/contracts/vault_l6/src/lib.rs b/contracts/vault_l6/src/lib.rs index 624bf8d..d8b3afe 100644 --- a/contracts/vault_l6/src/lib.rs +++ b/contracts/vault_l6/src/lib.rs @@ -1,5 +1,5 @@ #![no_std] -use soroban_sdk::{contract, contracterror, contractimpl, contracttype, panic_with_error, token, Address, Env}; +use soroban_sdk::{contract, contracterror, contractimpl, contracttype, panic_with_error, Address, Env}; #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq)] @@ -23,10 +23,10 @@ pub enum DataKey { Strategy, Usdc, MaxTvl, - Balance(Address), - Shares(Address), - LockUntil(Address), - Checkpoint(Address), + Balance(Address, Address), // (user, asset) + Shares(Address, Address), // (user, asset) + LockUntil(Address, Address), // (user, asset) + Checkpoint(Address, Address), // (user, asset) } const FP_MULTIPLIER: i128 = 1_000_000_0; @@ -63,7 +63,10 @@ impl VaultL6 { env.storage().instance().set(&DataKey::MaxTvl, &cap); } - pub fn deposit(env: Env, user: Address, amount: i128) { + /// Records a deposit of `asset` for `user`. VaultRouter has already + /// moved the tokens from `user` to this contract before calling, so no + /// token transfer happens here — this is bookkeeping only. + pub fn deposit(env: Env, user: Address, asset: Address, amount: i128) { user.require_auth(); if amount < 1_000_000_000 { @@ -79,39 +82,33 @@ impl VaultL6 { let multiplier_fp = 11_500_000; let new_shares = mul_fp(amount, multiplier_fp); - let usdc_addr: Address = env.storage().instance().get(&DataKey::Usdc).unwrap(); - let strategy: Address = env.storage().instance().get(&DataKey::Strategy).unwrap(); + let current_balance: i128 = env.storage().persistent().get(&DataKey::Balance(user.clone(), asset.clone())).unwrap_or(0); + let current_shares: i128 = env.storage().persistent().get(&DataKey::Shares(user.clone(), asset.clone())).unwrap_or(0); - let token_client = token::Client::new(&env, &usdc_addr); - token_client.transfer(&user, &strategy, &amount); - - let current_balance: i128 = env.storage().persistent().get(&DataKey::Balance(user.clone())).unwrap_or(0); - let current_shares: i128 = env.storage().persistent().get(&DataKey::Shares(user.clone())).unwrap_or(0); - - env.storage().persistent().set(&DataKey::Balance(user.clone()), &(current_balance + amount)); - env.storage().persistent().set(&DataKey::Shares(user.clone()), &(current_shares + new_shares)); + env.storage().persistent().set(&DataKey::Balance(user.clone(), asset.clone()), &(current_balance + amount)); + env.storage().persistent().set(&DataKey::Shares(user.clone(), asset.clone()), &(current_shares + new_shares)); let total_shares: i128 = env.storage().instance().get(&DataKey::TotalShares).unwrap_or(0); env.storage().instance().set(&DataKey::TotalShares, &(total_shares + new_shares)); env.storage().instance().set(&DataKey::TotalBalance, &(total_balance + amount)); let lock_until = env.ledger().sequence() + LOCK_DURATION; - env.storage().persistent().set(&DataKey::LockUntil(user.clone()), &lock_until); + env.storage().persistent().set(&DataKey::LockUntil(user.clone(), asset.clone()), &lock_until); let checkpoint = env.ledger().sequence() + 1; - env.storage().persistent().set(&DataKey::Checkpoint(user.clone()), &checkpoint); + env.storage().persistent().set(&DataKey::Checkpoint(user.clone(), asset.clone()), &checkpoint); } - /// Withdraw `amount` from a matured position. - pub fn withdraw(env: Env, user: Address, amount: i128) -> i128 { + /// Withdraw `amount` from a matured position for `asset`. + pub fn withdraw(env: Env, user: Address, asset: Address, amount: i128) -> i128 { user.require_auth(); - let lock_until: u32 = env.storage().persistent().get(&DataKey::LockUntil(user.clone())).unwrap_or(0); + let lock_until: u32 = env.storage().persistent().get(&DataKey::LockUntil(user.clone(), asset.clone())).unwrap_or(0); if env.ledger().sequence() < lock_until { panic_with_error!(&env, VaultError::LockNotExpired); } - let balance: i128 = env.storage().persistent().get(&DataKey::Balance(user.clone())).unwrap_or(0); - let user_shares: i128 = env.storage().persistent().get(&DataKey::Shares(user.clone())).unwrap_or(0); + let balance: i128 = env.storage().persistent().get(&DataKey::Balance(user.clone(), asset.clone())).unwrap_or(0); + let user_shares: i128 = env.storage().persistent().get(&DataKey::Shares(user.clone(), asset.clone())).unwrap_or(0); let total_shares: i128 = env.storage().instance().get(&DataKey::TotalShares).unwrap_or(0); if amount > balance { @@ -122,29 +119,29 @@ impl VaultL6 { let total_balance: i128 = env.storage().instance().get(&DataKey::TotalBalance).unwrap_or(0); env.storage().instance().set(&DataKey::TotalShares, &(total_shares - user_shares)); env.storage().instance().set(&DataKey::TotalBalance, &(total_balance - balance).max(0)); - env.storage().persistent().remove(&DataKey::Balance(user.clone())); - env.storage().persistent().remove(&DataKey::Shares(user.clone())); - env.storage().persistent().remove(&DataKey::LockUntil(user.clone())); - env.storage().persistent().remove(&DataKey::Checkpoint(user.clone())); + env.storage().persistent().remove(&DataKey::Balance(user.clone(), asset.clone())); + env.storage().persistent().remove(&DataKey::Shares(user.clone(), asset.clone())); + env.storage().persistent().remove(&DataKey::LockUntil(user.clone(), asset.clone())); + env.storage().persistent().remove(&DataKey::Checkpoint(user.clone(), asset.clone())); return balance; } let shares_to_burn = (user_shares * amount) / balance; let total_balance: i128 = env.storage().instance().get(&DataKey::TotalBalance).unwrap_or(0); - env.storage().persistent().set(&DataKey::Balance(user.clone()), &(balance - amount)); - env.storage().persistent().set(&DataKey::Shares(user.clone()), &(user_shares - shares_to_burn)); + env.storage().persistent().set(&DataKey::Balance(user.clone(), asset.clone()), &(balance - amount)); + env.storage().persistent().set(&DataKey::Shares(user.clone(), asset.clone()), &(user_shares - shares_to_burn)); env.storage().instance().set(&DataKey::TotalShares, &(total_shares - shares_to_burn)); env.storage().instance().set(&DataKey::TotalBalance, &(total_balance - amount)); amount } - /// Early exit `amount` before maturity. - pub fn early_exit(env: Env, user: Address, amount: i128) -> i128 { + /// Early exit `amount` before maturity for `asset`. + pub fn early_exit(env: Env, user: Address, asset: Address, amount: i128) -> i128 { user.require_auth(); - let balance: i128 = env.storage().persistent().get(&DataKey::Balance(user.clone())).unwrap_or(0); - let user_shares: i128 = env.storage().persistent().get(&DataKey::Shares(user.clone())).unwrap_or(0); + let balance: i128 = env.storage().persistent().get(&DataKey::Balance(user.clone(), asset.clone())).unwrap_or(0); + let user_shares: i128 = env.storage().persistent().get(&DataKey::Shares(user.clone(), asset.clone())).unwrap_or(0); let total_shares: i128 = env.storage().instance().get(&DataKey::TotalShares).unwrap_or(0); if amount > balance { @@ -160,17 +157,17 @@ impl VaultL6 { let total_balance: i128 = env.storage().instance().get(&DataKey::TotalBalance).unwrap_or(0); env.storage().instance().set(&DataKey::TotalShares, &(total_shares - user_shares)); env.storage().instance().set(&DataKey::TotalBalance, &(total_balance - balance).max(0)); - env.storage().persistent().remove(&DataKey::Balance(user.clone())); - env.storage().persistent().remove(&DataKey::Shares(user.clone())); - env.storage().persistent().remove(&DataKey::LockUntil(user.clone())); - env.storage().persistent().remove(&DataKey::Checkpoint(user.clone())); + env.storage().persistent().remove(&DataKey::Balance(user.clone(), asset.clone())); + env.storage().persistent().remove(&DataKey::Shares(user.clone(), asset.clone())); + env.storage().persistent().remove(&DataKey::LockUntil(user.clone(), asset.clone())); + env.storage().persistent().remove(&DataKey::Checkpoint(user.clone(), asset.clone())); return net_amount; } let shares_to_burn = (user_shares * amount) / balance; let total_balance: i128 = env.storage().instance().get(&DataKey::TotalBalance).unwrap_or(0); - env.storage().persistent().set(&DataKey::Balance(user.clone()), &(balance - amount)); - env.storage().persistent().set(&DataKey::Shares(user.clone()), &(user_shares - shares_to_burn)); + env.storage().persistent().set(&DataKey::Balance(user.clone(), asset.clone()), &(balance - amount)); + env.storage().persistent().set(&DataKey::Shares(user.clone(), asset.clone()), &(user_shares - shares_to_burn)); env.storage().instance().set(&DataKey::TotalShares, &(total_shares - shares_to_burn)); env.storage().instance().set(&DataKey::TotalBalance, &(total_balance - amount)); @@ -193,13 +190,13 @@ impl VaultL6 { (max_tvl - total_balance).max(0) } - pub fn relock(env: Env, user: Address) -> u32 { + pub fn relock(env: Env, user: Address, asset: Address) -> u32 { user.require_auth(); let lock_until: u32 = env .storage() .persistent() - .get(&DataKey::LockUntil(user.clone())) + .get(&DataKey::LockUntil(user.clone(), asset.clone())) .unwrap_or(0); if env.ledger().sequence() < lock_until { @@ -209,21 +206,21 @@ impl VaultL6 { let new_lock_until = env.ledger().sequence() + LOCK_DURATION; env.storage() .persistent() - .set(&DataKey::LockUntil(user.clone()), &new_lock_until); + .set(&DataKey::LockUntil(user.clone(), asset.clone()), &new_lock_until); new_lock_until } - pub fn lock_until(env: Env, user: Address) -> u32 { - env.storage().persistent().get(&DataKey::LockUntil(user)).unwrap_or(0) + pub fn lock_until(env: Env, user: Address, asset: Address) -> u32 { + env.storage().persistent().get(&DataKey::LockUntil(user, asset)).unwrap_or(0) } - pub fn balance(env: Env, user: Address) -> i128 { - env.storage().persistent().get(&DataKey::Balance(user)).unwrap_or(0) + pub fn balance(env: Env, user: Address, asset: Address) -> i128 { + env.storage().persistent().get(&DataKey::Balance(user, asset)).unwrap_or(0) } - pub fn shares(env: Env, user: Address) -> i128 { - env.storage().persistent().get(&DataKey::Shares(user)).unwrap_or(0) + pub fn shares(env: Env, user: Address, asset: Address) -> i128 { + env.storage().persistent().get(&DataKey::Shares(user, asset)).unwrap_or(0) } pub fn total_shares(env: Env) -> i128 { @@ -234,3 +231,100 @@ impl VaultL6 { env.storage().instance().get(&DataKey::TotalBalance).unwrap_or(0) } } + +#[cfg(test)] +mod test { + extern crate std; + + use super::VaultL6; + use soroban_sdk::{testutils::Address as _, Address, Env}; + + fn setup() -> (Env, super::VaultL6Client<'static>, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + let vault_id = env.register_contract(None, VaultL6); + let client = super::VaultL6Client::new(&env, &vault_id); + let admin = Address::generate(&env); + let governance = Address::generate(&env); + let strategy = Address::generate(&env); + let usdc = Address::generate(&env); + client.initialize(&admin, &governance, &strategy, &usdc, &0i128); + (env, client, governance, usdc) + } + + #[test] + fn test_deposit_records_balance_and_lock() { + let (env, client, _, usdc) = setup(); + let user = Address::generate(&env); + client.deposit(&user, &usdc, &1_000_000_000i128); + + assert_eq!(client.balance(&user, &usdc), 1_000_000_000i128); + assert!(client.lock_until(&user, &usdc) > 0); + } + + #[test] + fn test_positions_are_scoped_per_asset() { + let (env, client, _, usdc) = setup(); + let eurc = Address::generate(&env); + let user = Address::generate(&env); + + client.deposit(&user, &usdc, &1_000_000_000i128); + client.deposit(&user, &eurc, &1_200_000_000i128); + + assert_eq!(client.balance(&user, &usdc), 1_000_000_000i128); + assert_eq!(client.balance(&user, &eurc), 1_200_000_000i128); + } + + #[test] + #[should_panic] + fn test_withdraw_before_maturity_rejected() { + let (env, client, _, usdc) = setup(); + let user = Address::generate(&env); + client.deposit(&user, &usdc, &1_000_000_000i128); + client.withdraw(&user, &usdc, &1_000_000_000i128); + } + + #[test] + fn test_withdraw_after_maturity_succeeds() { + let (env, client, _, usdc) = setup(); + let user = Address::generate(&env); + client.deposit(&user, &usdc, &1_000_000_000i128); + + env.ledger().set_sequence(env.ledger().sequence() + super::LOCK_DURATION + 1); + let payout = client.withdraw(&user, &usdc, &1_000_000_000i128); + + assert_eq!(payout, 1_000_000_000i128); + } + + #[test] + fn test_early_exit_applies_fee() { + let (env, client, _, usdc) = setup(); + let user = Address::generate(&env); + client.deposit(&user, &usdc, &1_000_000_000i128); + + let net = client.early_exit(&user, &usdc, &1_000_000_000i128); + + // 1.25% fee on 1_000_000_000 = 12_500_000 + assert_eq!(net, 987_500_000i128); + } + + #[test] + fn test_relock_extends_lock_after_maturity() { + let (env, client, _, usdc) = setup(); + let user = Address::generate(&env); + client.deposit(&user, &usdc, &1_000_000_000i128); + env.ledger().set_sequence(env.ledger().sequence() + super::LOCK_DURATION + 1); + + let new_lock = client.relock(&user, &usdc); + assert!(new_lock > env.ledger().sequence()); + } + + #[test] + #[should_panic] + fn test_relock_before_maturity_rejected() { + let (env, client, _, usdc) = setup(); + let user = Address::generate(&env); + client.deposit(&user, &usdc, &1_000_000_000i128); + client.relock(&user, &usdc); + } +} diff --git a/contracts/vault_router/Cargo.toml b/contracts/vault_router/Cargo.toml index 4080221..0dbaf09 100644 --- a/contracts/vault_router/Cargo.toml +++ b/contracts/vault_router/Cargo.toml @@ -12,4 +12,8 @@ shared = { path = "../shared" } [dev-dependencies] soroban-sdk = { version = "20", features = ["testutils"] } -shared = { path = "../shared", features = ["testutils"] } \ No newline at end of file +shared = { path = "../shared", features = ["testutils"] } +vault_l3 = { path = "../vault_l3" } +vault_l6 = { path = "../vault_l6" } +vault_l12 = { path = "../vault_l12" } +vault_flex = { path = "../vault_flex" } \ No newline at end of file diff --git a/contracts/vault_router/src/lib.rs b/contracts/vault_router/src/lib.rs index f4091a1..367db69 100644 --- a/contracts/vault_router/src/lib.rs +++ b/contracts/vault_router/src/lib.rs @@ -235,13 +235,14 @@ impl VaultRouter { VaultCapacity { max_tvl, remaining } } - /// Renew the lock on a matured position in the given locked tier vault. + /// Renew the lock on a matured position in the given locked tier vault, + /// for a specific asset position. /// - /// Passes through to the tier vault's `relock(user)` function. + /// Passes through to the tier vault's `relock(user, asset)` function. /// Only valid for locked tiers (L3, L6, L12). Flex has no lock period. /// /// Returns the new `lock_until` ledger sequence number. - pub fn relock(env: Env, user: Address, tier: Tier) -> u32 { + pub fn relock(env: Env, user: Address, tier: Tier, asset: Address) -> u32 { user.require_auth(); // Flex vault has no lock; relock is not applicable @@ -250,7 +251,7 @@ impl VaultRouter { } let vault = vault_addr(&env, &tier); - let args: Vec = (user,).into_val(&env); + let args: Vec = (user, asset).into_val(&env); env.invoke_contract(&vault, &Symbol::new(&env, "relock"), args) } @@ -362,7 +363,7 @@ mod test { pub fn balance(_env: Env, _user: Address, _asset: Address) -> i128 { 500_000_000_i128 } pub fn shares(_env: Env, _user: Address, _asset: Address) -> i128 { 525_000_000_i128 } pub fn lock_until(_env: Env, _user: Address, _asset: Address) -> u32 { 1_000_000_u32 } - pub fn relock(_env: Env, _user: Address) -> u32 { 2_000_000_u32 } + pub fn relock(_env: Env, _user: Address, _asset: Address) -> u32 { 2_000_000_u32 } pub fn set_max_tvl(_env: Env, _new_cap: i128) {} pub fn max_tvl(_env: Env) -> i128 { 10_000_000_000_i128 } pub fn remaining_capacity(_env: Env) -> i128 { 9_500_000_000_i128 } @@ -566,34 +567,34 @@ mod test { #[test] fn test_relock_routes_to_vault_l3() { - let (env, client, _, _, _) = setup(); + let (env, client, _, _, usdc) = setup(); let user = Address::generate(&env); - let new_lock = client.relock(&user, &Tier::L3); + let new_lock = client.relock(&user, &Tier::L3, &usdc); assert_eq!(new_lock, 2_000_000_u32); } #[test] fn test_relock_routes_to_vault_l6() { - let (env, client, _, _, _) = setup(); + let (env, client, _, _, usdc) = setup(); let user = Address::generate(&env); - let new_lock = client.relock(&user, &Tier::L6); + let new_lock = client.relock(&user, &Tier::L6, &usdc); assert_eq!(new_lock, 2_000_000_u32); } #[test] fn test_relock_routes_to_vault_l12() { - let (env, client, _, _, _) = setup(); + let (env, client, _, _, usdc) = setup(); let user = Address::generate(&env); - let new_lock = client.relock(&user, &Tier::L12); + let new_lock = client.relock(&user, &Tier::L12, &usdc); assert_eq!(new_lock, 2_000_000_u32); } #[test] #[should_panic(expected = "relock not supported for Flex tier")] fn test_relock_flex_panics() { - let (env, client, _, _, _) = setup(); + let (env, client, _, _, usdc) = setup(); let user = Address::generate(&env); - client.relock(&user, &Tier::Flex); + client.relock(&user, &Tier::Flex, &usdc); } // ── TVL cap management ────────────────────────────────────────────────── diff --git a/contracts/vault_router/tests/real_vaults_integration.rs b/contracts/vault_router/tests/real_vaults_integration.rs new file mode 100644 index 0000000..1c321f3 --- /dev/null +++ b/contracts/vault_router/tests/real_vaults_integration.rs @@ -0,0 +1,188 @@ +//! Integration tests that exercise VaultRouter against the *real* tier vault +//! contracts (VaultFlex, VaultL3, VaultL6, VaultL12) — not the hand-written +//! MockVault used by vault_router's own unit tests — across two allowlisted +//! assets. This is what would have caught the ABI mismatch fixed in +//! issue #138 before it reached a real deployment. + +use soroban_sdk::{ + testutils::Address as _, + token::{Client as TokenClient, StellarAssetClient}, + vec, Address, Env, +}; +use vault_router::{Tier, VaultRouter, VaultRouterClient}; + +struct Harness<'a> { + env: Env, + router: VaultRouterClient<'a>, + usdc: Address, + eurc: Address, +} + +fn setup() -> Harness<'static> { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let governance = Address::generate(&env); + let guardian = Address::generate(&env); + let strategy = Address::generate(&env); + + let usdc = env + .register_stellar_asset_contract_v2(Address::generate(&env)) + .address(); + let eurc = env + .register_stellar_asset_contract_v2(Address::generate(&env)) + .address(); + + let router_id = env.register_contract(None, VaultRouter); + let router = VaultRouterClient::new(&env, &router_id); + + let flex_id = env.register_contract(None, vault_flex::VaultFlex); + let l3_id = env.register_contract(None, vault_l3::VaultL3); + let l6_id = env.register_contract(None, vault_l6::VaultL6); + let l12_id = env.register_contract(None, vault_l12::VaultL12); + + // Every tier vault's Admin is the router: deposit/withdraw/early_exit + // are only callable by whichever address invokes them, which in + // production is always the router. + vault_flex::VaultFlexClient::new(&env, &flex_id).initialize(&router_id, &strategy); + vault_l3::VaultL3Client::new(&env, &l3_id).initialize( + &router_id, &governance, &guardian, &strategy, &usdc, &0i128, + ); + vault_l6::VaultL6Client::new(&env, &l6_id).initialize( + &router_id, &governance, &strategy, &usdc, &0i128, + ); + vault_l12::VaultL12Client::new(&env, &l12_id).initialize( + &router_id, &governance, &strategy, &usdc, &0i128, + ); + + router.initialize( + &admin, + &governance, + &guardian, + &flex_id, + &l3_id, + &l6_id, + &l12_id, + &vec![&env, usdc.clone(), eurc.clone()], + ); + + Harness { env, router, usdc, eurc } +} + +fn fund(env: &Env, asset: &Address, user: &Address, amount: i128) { + StellarAssetClient::new(env, asset).mint(user, &amount); +} + +/// Acceptance criterion: VaultRouter.deposit/withdraw/early_exit succeed +/// end-to-end against real (non-mock) instances of all four tier vaults, +/// for at least two allowlisted assets. +#[test] +fn test_deposit_withdraw_early_exit_across_all_tiers_and_two_assets() { + let h = setup(); + let env = &h.env; + let user = Address::generate(env); + + let starting_balance = 20_000_000_000i128; + fund(env, &h.usdc, &user, starting_balance); + fund(env, &h.eurc, &user, starting_balance); + + // Deposit into every tier, for both assets. + h.router.deposit(&user, &Tier::Flex, &h.usdc, &10_000_000i128); + h.router.deposit(&user, &Tier::Flex, &h.eurc, &10_000_000i128); + h.router.deposit(&user, &Tier::L3, &h.usdc, &500_000_000i128); + h.router.deposit(&user, &Tier::L3, &h.eurc, &500_000_000i128); + h.router.deposit(&user, &Tier::L6, &h.usdc, &1_000_000_000i128); + h.router.deposit(&user, &Tier::L6, &h.eurc, &1_000_000_000i128); + h.router.deposit(&user, &Tier::L12, &h.usdc, &2_500_000_000i128); + h.router.deposit(&user, &Tier::L12, &h.eurc, &2_500_000_000i128); + + // Positions are recorded correctly, independently per asset per tier. + assert_eq!(h.router.position(&user, &Tier::Flex, &h.usdc).principal, 10_000_000i128); + assert_eq!(h.router.position(&user, &Tier::Flex, &h.eurc).principal, 10_000_000i128); + assert_eq!(h.router.position(&user, &Tier::L3, &h.usdc).principal, 500_000_000i128); + assert_eq!(h.router.position(&user, &Tier::L3, &h.eurc).principal, 500_000_000i128); + assert_eq!(h.router.position(&user, &Tier::L12, &h.eurc).principal, 2_500_000_000i128); + + // A single deposit must move funds exactly once, not twice. + let usdc_token = TokenClient::new(env, &h.usdc); + let eurc_token = TokenClient::new(env, &h.eurc); + let deposited_per_asset = 10_000_000i128 + 500_000_000i128 + 1_000_000_000i128 + 2_500_000_000i128; + assert_eq!(usdc_token.balance(&user), starting_balance - deposited_per_asset); + assert_eq!(eurc_token.balance(&user), starting_balance - deposited_per_asset); + + // Flex has no lock: withdraw succeeds immediately, for both assets, and + // pays the user back in full. + h.router.withdraw(&user, &Tier::Flex, &h.usdc, &10_000_000i128); + h.router.withdraw(&user, &Tier::Flex, &h.eurc, &10_000_000i128); + assert_eq!(h.router.position(&user, &Tier::Flex, &h.usdc).principal, 0i128); + assert_eq!(usdc_token.balance(&user), starting_balance - (500_000_000i128 + 1_000_000_000i128 + 2_500_000_000i128)); + + // Locked tiers: early_exit succeeds before maturity (fee applied), for + // both assets, across all three locked tiers. + h.router.early_exit(&user, &Tier::L3, &h.usdc, &500_000_000i128); + h.router.early_exit(&user, &Tier::L3, &h.eurc, &500_000_000i128); + h.router.early_exit(&user, &Tier::L6, &h.usdc, &1_000_000_000i128); + h.router.early_exit(&user, &Tier::L6, &h.eurc, &1_000_000_000i128); + h.router.early_exit(&user, &Tier::L12, &h.usdc, &2_500_000_000i128); + h.router.early_exit(&user, &Tier::L12, &h.eurc, &2_500_000_000i128); + + assert_eq!(h.router.position(&user, &Tier::L3, &h.usdc).principal, 0i128); + assert_eq!(h.router.position(&user, &Tier::L6, &h.eurc).principal, 0i128); + assert_eq!(h.router.position(&user, &Tier::L12, &h.usdc).principal, 0i128); +} + +/// Regression test for the double-transfer bug: before this fix, each tier +/// vault's deposit() moved funds a second time (user -> strategy) on top of +/// the router's own user -> vault transfer, using a hardcoded token address +/// that ignored the `asset` parameter entirely. A single deposit must debit +/// the user exactly once, in the asset actually deposited. +#[test] +fn test_deposit_moves_funds_exactly_once_per_asset() { + let h = setup(); + let env = &h.env; + let user = Address::generate(env); + + fund(env, &h.usdc, &user, 1_000_000_000i128); + fund(env, &h.eurc, &user, 1_000_000_000i128); + + h.router.deposit(&user, &Tier::L3, &h.eurc, &500_000_000i128); + + let usdc_token = TokenClient::new(env, &h.usdc); + let eurc_token = TokenClient::new(env, &h.eurc); + + // eurc debited by exactly the deposited amount... + assert_eq!(eurc_token.balance(&user), 500_000_000i128); + // ...and usdc untouched, proving the vault no longer moves the wrong + // (hardcoded) token regardless of which asset was actually deposited. + assert_eq!(usdc_token.balance(&user), 1_000_000_000i128); +} + +/// Two allowlisted assets held simultaneously in the same tier must not +/// cross-contaminate share/lock accounting (issue #138 edge case). +#[test] +fn test_two_assets_in_same_tier_are_independent() { + let h = setup(); + let env = &h.env; + let user = Address::generate(env); + + fund(env, &h.usdc, &user, 2_000_000_000i128); + fund(env, &h.eurc, &user, 2_000_000_000i128); + + h.router.deposit(&user, &Tier::L6, &h.usdc, &1_000_000_000i128); + h.router.deposit(&user, &Tier::L6, &h.eurc, &1_500_000_000i128); + + let pos_usdc = h.router.position(&user, &Tier::L6, &h.usdc); + let pos_eurc = h.router.position(&user, &Tier::L6, &h.eurc); + + assert_eq!(pos_usdc.principal, 1_000_000_000i128); + assert_eq!(pos_eurc.principal, 1_500_000_000i128); + // VaultL6's multiplier is 1.15x (11_500_000 fp): shares = principal * 1.15 + assert_eq!(pos_usdc.shares, 1_150_000_000i128); + assert_eq!(pos_eurc.shares, 1_725_000_000i128); + + // Fully exiting the usdc position must not touch the eurc position. + h.router.early_exit(&user, &Tier::L6, &h.usdc, &1_000_000_000i128); + assert_eq!(h.router.position(&user, &Tier::L6, &h.usdc).principal, 0i128); + assert_eq!(h.router.position(&user, &Tier::L6, &h.eurc).principal, 1_500_000_000i128); +}