From 2db672b2fe13298f65f5496a6ab97ee833a7ee4c Mon Sep 17 00:00:00 2001 From: soundsng Date: Fri, 21 Aug 2026 15:30:21 +0100 Subject: [PATCH] fix(contracts): unify VaultRouter <-> tier-vault ABI and asset-scope accounting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VaultRouter already called deposit/withdraw/early_exit/balance/shares/ lock_until with an asset-scoped (user, asset, ...) signature, but none of the four tier vaults (VaultFlex, VaultL3, VaultL6, VaultL12) implemented it — they still took (user, amount) with single-asset global storage keys, so any real (non-mock) deposit/withdraw/early_exit call would fail. - Asset-scope every tier vault's storage: Balance, Shares, LockUntil, Checkpoint (and VaultL3's EmergencyUnlock) are now keyed by (user, asset) instead of user alone, so two assets held by the same user in the same tier no longer share state. shared::checkpoint is asset-scoped the same way, since it's only ever used per-position. - Standardize deposit(user, asset, amount) / withdraw(...) -> i128 / early_exit(...) -> i128 / balance / shares / lock_until across all four vaults, matching what VaultRouter already calls. - relock was still (user) on both the router and the locked vaults, which becomes ambiguous once LockUntil is asset-scoped; added an asset parameter to both sides. - Removed a duplicate token transfer inside each tier vault's deposit(): VaultRouter already moves tokens from user to vault before invoking it, so the vault's own second user -> strategy transfer (hardcoded to the tier's original single USDC address regardless of the asset parameter) double-charged the user and moved the wrong token for any non-USDC asset. Tier vaults are now bookkeeping-only; VaultRouter owns all token movement — this is also what makes real multi-asset deposits work at all rather than just fixing the argument count. - VaultFlex: added partial withdrawal (previously only full withdrawal existed), plus early_exit/lock_until for ABI parity with the locked tiers. Its previous pro-rata yield payout depended on an externally supplied strategy_balance argument the router can no longer provide under the unified signature, and StrategyVault has no way to attribute its pooled balance back to a single calling vault, so Flex now tracks its own principal balance like the locked tiers (documented on VaultFlex::withdraw). - New integration tests in vault_router/tests/ exercise VaultRouter against the real tier vault contracts (not the unit-test MockVault) across two allowlisted assets, including a regression test for the double-transfer bug above. Closes #138 --- CHANGELOG.md | 31 ++ Cargo.lock | 7 + contracts/shared/src/checkpoint.rs | 22 +- .../shared/src/tests/checkpoint_tests.rs | 30 +- contracts/vault_flex/src/lib.rs | 259 +++++++++++++--- contracts/vault_l12/src/lib.rs | 190 +++++++++--- contracts/vault_l3/src/lib.rs | 281 ++++++++++++++---- contracts/vault_l6/src/lib.rs | 190 +++++++++--- contracts/vault_router/Cargo.toml | 6 +- contracts/vault_router/src/lib.rs | 27 +- .../tests/real_vaults_integration.rs | 188 ++++++++++++ 11 files changed, 1010 insertions(+), 221 deletions(-) create mode 100644 contracts/vault_router/tests/real_vaults_integration.rs 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); +}