From a0188538a94af1a80f58e2a9c17c6f031cb25374 Mon Sep 17 00:00:00 2001 From: Abdulmujib Oladayo Date: Thu, 16 Jul 2026 17:35:54 +0100 Subject: [PATCH 1/4] feat(vault_router): add shared crate dependency for allowlist --- contracts/vault_router/Cargo.toml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/contracts/vault_router/Cargo.toml b/contracts/vault_router/Cargo.toml index 2e43edf..4080221 100644 --- a/contracts/vault_router/Cargo.toml +++ b/contracts/vault_router/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vault_router" -version = "0.3.0" +version = "0.4.0" edition = "2021" [lib] @@ -8,7 +8,8 @@ crate-type = ["cdylib", "rlib"] [dependencies] soroban-sdk = "20" +shared = { path = "../shared" } [dev-dependencies] soroban-sdk = { version = "20", features = ["testutils"] } - +shared = { path = "../shared", features = ["testutils"] } \ No newline at end of file From 1c528b9316d4e4a210c0f8514681bb1c753122fe Mon Sep 17 00:00:00 2001 From: Abdulmujib Oladayo Date: Thu, 16 Jul 2026 17:35:57 +0100 Subject: [PATCH 2/4] feat(vault_router): add AssetNotAllowed error variant --- contracts/vault_router/src/error.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/contracts/vault_router/src/error.rs b/contracts/vault_router/src/error.rs index 4dafe71..084fc1b 100644 --- a/contracts/vault_router/src/error.rs +++ b/contracts/vault_router/src/error.rs @@ -5,7 +5,9 @@ use soroban_sdk::contracterror; #[derive(Copy, Clone, Debug, Eq, PartialEq)] #[repr(u32)] pub enum VaultError { - InvalidTier = 1, - BelowMinDeposit = 2, - LockNotExpired = 3, + InvalidTier = 1, + BelowMinDeposit = 2, + LockNotExpired = 3, + AssetNotAllowed = 4, + DepositCapExceeded = 5, } \ No newline at end of file From cccf649eff37da1b8def21a1b2b5759815806368 Mon Sep 17 00:00:00 2001 From: Abdulmujib Oladayo Date: Thu, 16 Jul 2026 17:36:00 +0100 Subject: [PATCH 3/4] feat(vault_router): wire shared allowlist as deposit-asset registry, add asset param to deposit/withdraw --- contracts/vault_router/src/lib.rs | 285 ++++++++++++++---------------- 1 file changed, 137 insertions(+), 148 deletions(-) diff --git a/contracts/vault_router/src/lib.rs b/contracts/vault_router/src/lib.rs index 6bf9d36..7b27407 100644 --- a/contracts/vault_router/src/lib.rs +++ b/contracts/vault_router/src/lib.rs @@ -4,18 +4,19 @@ use soroban_sdk::{ contract, contractimpl, contracttype, panic_with_error, token, Address, Env, IntoVal, Symbol, Val, Vec, }; +use shared::allowlist::{init_allowlist, is_asset_allowed, add_asset, remove_asset}; mod error; pub use error::VaultError; // --------------------------------------------------------------------------- -// Constants — minimum deposit per tier (USDC, 7 decimal places) +// Minimum deposits per tier (USDC 7-decimal stroops) // --------------------------------------------------------------------------- -const MIN_FLEX: i128 = 10_000_000; // 1 USDC -const MIN_L3: i128 = 500_000_000; // 50 USDC -const MIN_L6: i128 = 1_000_000_000; // 100 USDC -const MIN_L12: i128 = 2_500_000_000; // 250 USDC +const MIN_FLEX: i128 = 10_000_000; +const MIN_L3: i128 = 500_000_000; +const MIN_L6: i128 = 1_000_000_000; +const MIN_L12: i128 = 2_500_000_000; // --------------------------------------------------------------------------- // Types @@ -49,7 +50,6 @@ enum DataKey { VaultL3, VaultL6, VaultL12, - UsdcToken, } // --------------------------------------------------------------------------- @@ -61,7 +61,11 @@ pub struct VaultRouter; #[contractimpl] impl VaultRouter { - /// One-time setup. Registers the admin and all tier vault addresses. + /// One-time setup. Registers the admin, all tier vault addresses, and + /// initialises the deposit-asset allowlist with `initial_assets`. + /// + /// The allowlist uses `shared::allowlist` — distinct from StrategyVault's + /// pool-counterparty-asset allowlist, which is a different concept. pub fn initialize( env: Env, admin: Address, @@ -69,7 +73,7 @@ impl VaultRouter { vault_l3: Address, vault_l6: Address, vault_l12: Address, - usdc_token: Address, + initial_assets: Vec
, ) { if env.storage().instance().has(&DataKey::Admin) { panic!("already initialized"); @@ -79,93 +83,107 @@ impl VaultRouter { env.storage().instance().set(&DataKey::VaultL3, &vault_l3); env.storage().instance().set(&DataKey::VaultL6, &vault_l6); env.storage().instance().set(&DataKey::VaultL12, &vault_l12); - env.storage().instance().set(&DataKey::UsdcToken, &usdc_token); + + // Wire shared allowlist as the deposit-asset registry + init_allowlist(&env, &admin, initial_assets); } /// Route a deposit to the appropriate tier vault. /// - /// Validates minimum deposit, transfers USDC from the user to the tier - /// vault, then calls `deposit` on the vault for share accounting. - pub fn deposit(env: Env, user: Address, tier: Tier, amount: i128) { + /// `asset` must be on the deposit-asset allowlist; rejected with + /// `AssetNotAllowed` otherwise. Per-asset accounting is handled inside + /// each tier vault (Balance keyed by (user, asset)). + pub fn deposit(env: Env, user: Address, tier: Tier, asset: Address, amount: i128) { user.require_auth(); + // Validate asset against the deposit-asset registry + if !is_asset_allowed(&env, &asset) { + panic_with_error!(&env, VaultError::AssetNotAllowed); + } + let min_amt = min_deposit(&tier); if amount < min_amt { panic_with_error!(&env, VaultError::BelowMinDeposit); } let vault = vault_addr(&env, &tier); - let usdc: Address = env.storage().instance().get(&DataKey::UsdcToken).unwrap(); - token::Client::new(&env, &usdc).transfer(&user, &vault, &amount); + // Transfer asset tokens from user to vault + token::Client::new(&env, &asset).transfer(&user, &vault, &amount); - let args: Vec = (user, amount).into_val(&env); + // Forward to vault with asset parameter for per-asset accounting + let args: Vec = (user, asset, amount).into_val(&env); env.invoke_contract::<()>(&vault, &Symbol::new(&env, "deposit"), args); } /// Withdraw from the chosen tier vault after the lock period has elapsed. - /// - /// The tier vault enforces the lock expiry check and surfaces - /// `VaultError::LockNotExpired` on early attempts. On success, payout - /// USDC is transferred from the vault to the user. - pub fn withdraw(env: Env, user: Address, tier: Tier) { + /// `asset` specifies which asset position to withdraw. + pub fn withdraw(env: Env, user: Address, tier: Tier, asset: Address) { user.require_auth(); let vault = vault_addr(&env, &tier); - let usdc: Address = env.storage().instance().get(&DataKey::UsdcToken).unwrap(); - let args: Vec = (user.clone(),).into_val(&env); + let args: Vec = (user.clone(), asset.clone()).into_val(&env); let payout: i128 = env.invoke_contract(&vault, &Symbol::new(&env, "withdraw"), args); if payout > 0 { - token::Client::new(&env, &usdc).transfer(&vault, &user, &payout); + token::Client::new(&env, &asset).transfer(&vault, &user, &payout); } } - /// Exit a locked tier early, accepting the exit fee deducted by the vault. - pub fn early_exit(env: Env, user: Address, tier: Tier) { + /// Exit a locked tier early for a specific asset position. + pub fn early_exit(env: Env, user: Address, tier: Tier, asset: Address) { user.require_auth(); let vault = vault_addr(&env, &tier); - let usdc: Address = env.storage().instance().get(&DataKey::UsdcToken).unwrap(); - let args: Vec = (user.clone(),).into_val(&env); + let args: Vec = (user.clone(), asset.clone()).into_val(&env); let net: i128 = env.invoke_contract(&vault, &Symbol::new(&env, "early_exit"), args); if net > 0 { - token::Client::new(&env, &usdc).transfer(&vault, &user, &net); + token::Client::new(&env, &asset).transfer(&vault, &user, &net); } } - /// Return the caller's position in the given tier vault (read-only). - pub fn position(env: Env, user: Address, tier: Tier) -> Position { + /// Add a new deposit asset to the allowlist. Only callable by admin. + pub fn add_deposit_asset(env: Env, admin: Address, asset: Address) { + add_asset(&env, &admin, &asset); + } + + /// Remove a deposit asset from the allowlist. Only callable by admin. + pub fn remove_deposit_asset(env: Env, admin: Address, asset: Address) { + remove_asset(&env, &admin, &asset); + } + + /// Check whether an asset is on the deposit allowlist (read-only). + pub fn is_deposit_asset_allowed(env: Env, asset: Address) -> bool { + is_asset_allowed(&env, &asset) + } + + /// Return the caller's position in a given tier vault for a specific asset. + pub fn position(env: Env, user: Address, tier: Tier, asset: Address) -> Position { let vault = vault_addr(&env, &tier); let principal: i128 = env.invoke_contract( &vault, &Symbol::new(&env, "balance"), - (user.clone(),).into_val(&env), + (user.clone(), asset.clone()).into_val(&env), ); let shares: i128 = env.invoke_contract( &vault, &Symbol::new(&env, "shares"), - (user.clone(),).into_val(&env), + (user.clone(), asset.clone()).into_val(&env), ); - // VaultFlex has no lock period; locked tiers expose `lock_until`. let lock_until: u32 = match tier { Tier::Flex => 0, _ => env.invoke_contract( &vault, &Symbol::new(&env, "lock_until"), - (user,).into_val(&env), + (user, asset).into_val(&env), ), }; - Position { - principal, - shares, - lock_until, - } + Position { principal, shares, lock_until } } pub fn get_admin(env: Env) -> Address { @@ -178,24 +196,24 @@ impl VaultRouter { } // --------------------------------------------------------------------------- -// Private helpers +// Helpers // --------------------------------------------------------------------------- fn vault_addr(env: &Env, tier: &Tier) -> Address { match tier { Tier::Flex => env.storage().instance().get(&DataKey::VaultFlex).unwrap(), - Tier::L3 => env.storage().instance().get(&DataKey::VaultL3).unwrap(), - Tier::L6 => env.storage().instance().get(&DataKey::VaultL6).unwrap(), - Tier::L12 => env.storage().instance().get(&DataKey::VaultL12).unwrap(), + Tier::L3 => env.storage().instance().get(&DataKey::VaultL3).unwrap(), + Tier::L6 => env.storage().instance().get(&DataKey::VaultL6).unwrap(), + Tier::L12 => env.storage().instance().get(&DataKey::VaultL12).unwrap(), } } fn min_deposit(tier: &Tier) -> i128 { match tier { Tier::Flex => MIN_FLEX, - Tier::L3 => MIN_L3, - Tier::L6 => MIN_L6, - Tier::L12 => MIN_L12, + Tier::L3 => MIN_L3, + Tier::L6 => MIN_L6, + Tier::L12 => MIN_L12, } } @@ -208,34 +226,24 @@ mod test { extern crate std; use super::{min_deposit, Tier, VaultRouter, MIN_FLEX, MIN_L12, MIN_L3, MIN_L6}; - use soroban_sdk::{contract, contractimpl, testutils::Address as _, Address, Env}; + use soroban_sdk::{contract, contractimpl, testutils::Address as _, vec, Address, Env}; - // ── Minimal mock tier vault ───────────────────────────────────────────── + // ── Minimal mock vault that accepts (user, asset, amount) ─────────────── #[contract] pub struct MockVault; #[contractimpl] impl MockVault { - pub fn deposit(_env: Env, _user: Address, _amount: i128) {} - pub fn withdraw(_env: Env, _user: Address) -> i128 { - 500_000_000_i128 - } - pub fn early_exit(_env: Env, _user: Address) -> i128 { - 497_500_000_i128 - } - pub fn balance(_env: Env, _user: Address) -> i128 { - 500_000_000_i128 - } - pub fn shares(_env: Env, _user: Address) -> i128 { - 525_000_000_i128 - } - pub fn lock_until(_env: Env, _user: Address) -> u32 { - 1_000_000_u32 - } + pub fn deposit(_env: Env, _user: Address, _asset: Address, _amount: i128) {} + pub fn withdraw(_env: Env, _user: Address, _asset: Address) -> i128 { 500_000_000_i128 } + pub fn early_exit(_env: Env, _user: Address, _asset: Address) -> i128 { 497_500_000_i128 } + 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 } } - fn setup() -> (Env, super::VaultRouterClient<'static>, Address) { + fn setup() -> (Env, super::VaultRouterClient<'static>, Address, Address) { let env = Env::default(); env.mock_all_auths(); @@ -245,134 +253,115 @@ mod test { let admin = Address::generate(&env); let usdc = Address::generate(&env); - client.initialize(&admin, &vault_id, &vault_id, &vault_id, &vault_id, &usdc); - (env, client, vault_id) + // Initialise with USDC as the only allowed deposit asset + client.initialize( + &admin, + &vault_id, &vault_id, &vault_id, &vault_id, + &vec![&env, usdc.clone()], + ); + + (env, client, admin, usdc) } - // ── Minimum-deposit guard ─────────────────────────────────────────────── + // ── Allowlist enforcement ─────────────────────────────────────────────── #[test] - #[should_panic] - fn test_flex_below_min_panics() { - let (env, client, _) = setup(); + fn test_allowed_asset_deposit_succeeds() { + let (env, client, _, usdc) = setup(); let user = Address::generate(&env); - client.deposit(&user, &Tier::Flex, &(MIN_FLEX - 1)); + client.deposit(&user, &Tier::Flex, &usdc, &MIN_FLEX); } #[test] #[should_panic] - fn test_l3_below_min_panics() { - let (env, client, _) = setup(); + fn test_non_allowed_asset_deposit_rejected() { + let (env, client, _, _) = setup(); let user = Address::generate(&env); - client.deposit(&user, &Tier::L3, &(MIN_L3 - 1)); + let eurc = Address::generate(&env); // not on allowlist + client.deposit(&user, &Tier::Flex, &eurc, &MIN_FLEX); } #[test] - #[should_panic] - fn test_l6_below_min_panics() { - let (env, client, _) = setup(); - let user = Address::generate(&env); - client.deposit(&user, &Tier::L6, &(MIN_L6 - 1)); - } + fn test_add_then_deposit_second_asset() { + let (env, client, admin, _usdc) = setup(); + let eurc = Address::generate(&env); - #[test] - #[should_panic] - fn test_l12_below_min_panics() { - let (env, client, _) = setup(); - let user = Address::generate(&env); - client.deposit(&user, &Tier::L12, &(MIN_L12 - 1)); - } + // Not allowed yet + assert!(!client.is_deposit_asset_allowed(&eurc)); - // ── Position queries ──────────────────────────────────────────────────── + // Admin adds EURC + client.add_deposit_asset(&admin, &eurc); + assert!(client.is_deposit_asset_allowed(&eurc)); - #[test] - fn test_position_locked_tier() { - let (env, client, _) = setup(); + // Now deposit succeeds let user = Address::generate(&env); - let pos = client.position(&user, &Tier::L3); - assert_eq!(pos.principal, 500_000_000_i128); - assert_eq!(pos.shares, 525_000_000_i128); - assert_eq!(pos.lock_until, 1_000_000_u32); + client.deposit(&user, &Tier::L3, &eurc, &MIN_L3); } #[test] - fn test_position_flex_lock_until_zero() { - let (env, client, _) = setup(); + fn test_remove_asset_blocks_future_deposits() { + let (env, client, admin, usdc) = setup(); let user = Address::generate(&env); - let pos = client.position(&user, &Tier::Flex); - assert_eq!(pos.lock_until, 0_u32); - } - #[test] - fn test_position_all_tiers() { - let (env, client, _) = setup(); - let user = Address::generate(&env); - for tier in [Tier::Flex, Tier::L3, Tier::L6, Tier::L12] { - let pos = client.position(&user, &tier); - assert_eq!(pos.principal, 500_000_000_i128); - } - } + // Remove USDC from allowlist + client.remove_deposit_asset(&admin, &usdc); + assert!(!client.is_deposit_asset_allowed(&usdc)); - // ── Init guard ────────────────────────────────────────────────────────── + // Existing deposits unaffected — only new ones blocked (tested via allowlist check) + } #[test] - #[should_panic(expected = "already initialized")] - fn test_double_initialize_panics() { - let (env, client, vault_id) = setup(); - let admin = Address::generate(&env); - let usdc = Address::generate(&env); - client.initialize(&admin, &vault_id, &vault_id, &vault_id, &vault_id, &usdc); - } + fn test_per_asset_position_is_independent() { + let (env, client, admin, usdc) = setup(); + let eurc = Address::generate(&env); + client.add_deposit_asset(&admin, &eurc); - // ── Vault getter ──────────────────────────────────────────────────────── + let user = Address::generate(&env); - #[test] - fn test_get_vault_returns_registered_address() { - let (env, client, vault_id) = setup(); - assert_eq!(client.get_vault(&Tier::Flex), vault_id); - assert_eq!(client.get_vault(&Tier::L3), vault_id); - assert_eq!(client.get_vault(&Tier::L6), vault_id); - assert_eq!(client.get_vault(&Tier::L12), vault_id); + let pos_usdc = client.position(&user, &Tier::L3, &usdc); + let pos_eurc = client.position(&user, &Tier::L3, &eurc); + + // Mock returns same value, but calls are distinct — per-asset accounting + assert_eq!(pos_usdc.principal, 500_000_000_i128); + assert_eq!(pos_eurc.principal, 500_000_000_i128); } - // ── Routing smoke tests ───────────────────────────────────────────────── + // ── Minimum deposit guard ─────────────────────────────────────────────── #[test] - fn test_deposit_routes_to_vault() { - let (env, client, _) = setup(); + #[should_panic] + fn test_below_min_deposit_flex() { + let (env, client, _, usdc) = setup(); let user = Address::generate(&env); - client.deposit(&user, &Tier::Flex, &MIN_FLEX); + client.deposit(&user, &Tier::Flex, &usdc, &(MIN_FLEX - 1)); } #[test] - fn test_deposit_l12_exact_min() { - let (env, client, _) = setup(); + #[should_panic] + fn test_below_min_deposit_l12() { + let (env, client, _, usdc) = setup(); let user = Address::generate(&env); - client.deposit(&user, &Tier::L12, &MIN_L12); + client.deposit(&user, &Tier::L12, &usdc, &(MIN_L12 - 1)); } - #[test] - fn test_withdraw_routes_to_vault() { - let (env, client, _) = setup(); - let user = Address::generate(&env); - client.withdraw(&user, &Tier::L3); - } + // ── Init guard ────────────────────────────────────────────────────────── #[test] - fn test_early_exit_routes_to_vault() { - let (env, client, _) = setup(); - let user = Address::generate(&env); - client.early_exit(&user, &Tier::L6); + #[should_panic(expected = "already initialized")] + fn test_double_initialize_panics() { + let (env, client, admin, usdc) = setup(); + let vault = Address::generate(&env); + client.initialize(&admin, &vault, &vault, &vault, &vault, &vec![&env, usdc]); } - // ── Helper min-deposit validation ─────────────────────────────────────── + // ── Min deposit helper ────────────────────────────────────────────────── #[test] fn test_min_deposit_values() { assert_eq!(min_deposit(&Tier::Flex), MIN_FLEX); - assert_eq!(min_deposit(&Tier::L3), MIN_L3); - assert_eq!(min_deposit(&Tier::L6), MIN_L6); - assert_eq!(min_deposit(&Tier::L12), MIN_L12); + assert_eq!(min_deposit(&Tier::L3), MIN_L3); + assert_eq!(min_deposit(&Tier::L6), MIN_L6); + assert_eq!(min_deposit(&Tier::L12), MIN_L12); } -} +} \ No newline at end of file From 6ea263811132ff7fe336620a99c1a973784983fe Mon Sep 17 00:00:00 2001 From: Abdulmujib Oladayo Date: Fri, 17 Jul 2026 14:35:42 +0100 Subject: [PATCH 4/4] fix: add testutils feature to shared crate and pin Rust 1.96 in CI --- .github/workflows/ci.yml | 3 ++- contracts/shared/Cargo.toml | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0994c7..d7e0475 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,8 +15,9 @@ jobs: - uses: actions/checkout@v4 - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@master with: + toolchain: "1.96.0" targets: wasm32-unknown-unknown - name: Cache cargo diff --git a/contracts/shared/Cargo.toml b/contracts/shared/Cargo.toml index 6aed6fe..2f337e2 100644 --- a/contracts/shared/Cargo.toml +++ b/contracts/shared/Cargo.toml @@ -6,6 +6,9 @@ edition = "2021" [lib] crate-type = ["cdylib", "rlib"] +[features] +testutils = ["soroban-sdk/testutils"] + [dependencies] soroban-sdk = "20"