diff --git a/contracts/vault/src/lib.rs b/contracts/vault/src/lib.rs index 8fed151d..b6cf8bf4 100644 --- a/contracts/vault/src/lib.rs +++ b/contracts/vault/src/lib.rs @@ -192,6 +192,10 @@ pub enum DataKey { CheckpointTotalAssets(u32), UserCheckpoint(Address), UserBalanceAt(Address, u32), + // Relayer batch-deposit whitelist + RelayerWhitelist(Address), + // Maximum entries allowed in a single batch_deposit call + MaxBatchSize, } #[contracttype] @@ -212,6 +216,44 @@ pub struct PendingWithdrawal { pub unlock_timestamp: u64, } +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +/// A single entry in a batch deposit request: one user and their deposit amount. +pub struct DepositEntry { + /// The depositing user address (requires auth). + pub user: Address, + /// The amount of underlying tokens to deposit. + pub amount: i128, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +/// Per-entry result for a batch deposit operation. +pub struct DepositResult { + /// The depositing user address. + pub user: Address, + /// Shares minted on success, or 0 on failure. + pub shares_minted: i128, + /// True if this entry succeeded. + pub success: bool, + /// Error code if this entry failed; 0 means no error. + pub error_code: u32, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +/// Aggregate result returned by `batch_deposit`. +pub struct BatchDepositResult { + /// Per-entry outcomes in the same order as the input `entries` vector. + pub results: Vec, + /// Total shares minted across all successful entries. + pub total_shares_minted: i128, + /// Number of entries that succeeded. + pub success_count: u32, + /// Number of entries that failed. + pub failure_count: u32, +} + #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] #[repr(u32)] @@ -247,6 +289,10 @@ pub enum VaultError { MathOverflow = 14, /// Strategy operation exceeded maximum allowed slippage. SlippageExceeded = 15, + /// Batch deposit entries vector exceeds the maximum allowed size. + BatchTooLarge = 16, + /// Caller is not a registered relayer and cannot submit batch deposits. + RelayerNotAuthorized = 17, } #[contractclient(name = "KoreanDebtStrategyClient")] @@ -1113,6 +1159,296 @@ impl YieldVault { Ok(shares_to_mint) } + // ── Relayer management ──────────────────────────────────────────────────── + + /// Register or deregister a relayer address allowed to submit batch deposits. + /// + /// Only the Admin can call this. + pub fn set_relayer(env: Env, relayer: Address, approved: bool) { + let admin: Address = get_admin(&env).expect("Admin not set"); + admin.require_auth(); + env.storage() + .instance() + .set(&DataKey::RelayerWhitelist(relayer), &approved); + } + + /// Returns whether the given address is a registered relayer. + pub fn is_relayer(env: Env, relayer: Address) -> bool { + env.storage() + .instance() + .get(&DataKey::RelayerWhitelist(relayer)) + .unwrap_or(false) + } + + /// Set the maximum number of entries permitted in a single `batch_deposit` call. + /// + /// Defaults to 50 if not set. Only the Admin can call this. + pub fn set_max_batch_size(env: Env, size: u32) { + let admin: Address = get_admin(&env).expect("Admin not set"); + admin.require_auth(); + if size == 0 { + panic!("max_batch_size must be > 0"); + } + env.storage() + .instance() + .set(&DataKey::MaxBatchSize, &size); + } + + /// Returns the maximum batch size (default 50). + pub fn max_batch_size(env: Env) -> u32 { + env.storage() + .instance() + .get(&DataKey::MaxBatchSize) + .unwrap_or(50u32) + } + + // ── Batch deposit ───────────────────────────────────────────────────────── + + /// Processes multiple user deposits atomically in a single transaction. + /// + /// This entrypoint is reserved for whitelisted relayers that aggregate deposits + /// from multiple users and submit them in one Soroban transaction, reducing + /// per-user transaction fees and improving throughput. + /// + /// ### Atomicity + /// All state updates (total_assets, total_shares, share balances) are applied + /// together. Individual entries that fail validation (invalid amount, cap + /// exceeded, min deposit not met, etc.) are recorded with `success = false` + /// in the returned `BatchDepositResult`; other valid entries still succeed. + /// The vault pause check is performed upfront and fails the entire call. + /// + /// ### Authorization + /// * `relayer` must be a registered relayer (see `set_relayer`). + /// * Each `user` inside the entries must have pre-authorized the vault to + /// transfer their tokens (standard Soroban token auth). + /// + /// ### Parameters + /// * `relayer` — The address submitting the batch (must be whitelisted). + /// * `entries` — Vector of `DepositEntry { user, amount }` to process. + /// + /// ### Returns + /// A `BatchDepositResult` with per-entry outcomes and aggregate totals. + /// + /// ### Errors + /// * `VaultError::ContractPaused` — Vault is paused; entire call rejected. + /// * `VaultError::RelayerNotAuthorized` — Caller is not a whitelisted relayer. + /// * `VaultError::BatchTooLarge` — `entries.len()` exceeds `max_batch_size`. + /// + /// ### Events + /// Publishes `(symbol_short!("batchdep"),)` with `(total_shares_minted, success_count, failure_count)`. + pub fn batch_deposit( + env: Env, + relayer: Address, + entries: Vec, + ) -> Result { + // ── Checks ──────────────────────────────────────────────────────────── + + // 1. Vault must not be paused + let mut state = Self::get_state(&env); + if state.is_paused { + return Err(VaultError::ContractPaused); + } + + // 2. Caller must be a whitelisted relayer + relayer.require_auth(); + let is_approved: bool = env + .storage() + .instance() + .get(&DataKey::RelayerWhitelist(relayer.clone())) + .unwrap_or(false); + if !is_approved { + return Err(VaultError::RelayerNotAuthorized); + } + + // 3. Batch size guard + let max_size = env + .storage() + .instance() + .get::<_, u32>(&DataKey::MaxBatchSize) + .unwrap_or(50u32); + if entries.len() > max_size { + return Err(VaultError::BatchTooLarge); + } + + // ── Pre-load shared config once ──────────────────────────────────────── + let token_addr: Address = env.storage().instance().get(&DataKey::TokenAsset).unwrap(); + let token_client = token::Client::new(&env, &token_addr); + + let min_deposit: i128 = env + .storage() + .instance() + .get(&DataKey::MinDeposit) + .unwrap_or(0); + + let cap: i128 = env + .storage() + .instance() + .get(&DataKey::PerUserCap) + .unwrap_or(i128::MAX); + + // ── Effects: process each entry ──────────────────────────────────────── + let mut results: Vec = Vec::new(&env); + let mut total_shares_minted: i128 = 0i128; + let mut success_count: u32 = 0u32; + let mut failure_count: u32 = 0u32; + + let n = entries.len(); + let mut idx: u32 = 0; + while idx < n { + let entry = entries.get(idx).unwrap(); + let user = entry.user.clone(); + let amount = entry.amount; + + // Per-entry validation + let entry_result = Self::process_single_batch_entry( + &env, + &mut state, + &token_client, + &user, + amount, + min_deposit, + cap, + ); + + match entry_result { + Ok(shares_minted) => { + total_shares_minted = total_shares_minted + .checked_add(shares_minted) + .expect("overflow"); + success_count = success_count.checked_add(1).expect("overflow"); + results.push_back(DepositResult { + user, + shares_minted, + success: true, + error_code: 0, + }); + } + Err(e) => { + failure_count = failure_count.checked_add(1).expect("overflow"); + results.push_back(DepositResult { + user, + shares_minted: 0, + success: false, + error_code: e as u32, + }); + } + } + + idx += 1; + } + + // Persist the updated vault state once after all entries are processed + env.storage().instance().set(&DataKey::State, &state); + + env.events().publish( + (symbol_short!("batchdep"),), + (total_shares_minted, success_count, failure_count), + ); + + Ok(BatchDepositResult { + results, + total_shares_minted, + success_count, + failure_count, + }) + } + + /// Internal helper: validates and applies a single deposit within a batch. + /// + /// State fields `total_assets` and `total_shares` on `state` are updated + /// in-memory; the caller must persist `state` after the loop. + fn process_single_batch_entry( + env: &Env, + state: &mut VaultState, + token_client: &token::Client, + user: &Address, + amount: i128, + min_deposit: i128, + per_user_cap: i128, + ) -> Result { + if amount <= 0 { + return Err(VaultError::InvalidAmount); + } + + if amount < min_deposit { + return Err(VaultError::MinDepositNotMet); + } + + // Compute shares using current in-memory state (updated incrementally) + let shares_to_mint = crate::math::assets_to_shares( + amount, + state.total_shares, + state.total_assets, + ); + + if shares_to_mint == 0 { + return Err(VaultError::InvalidAmount); + } + + // Per-user deposit cap check + let deposit_key = DataKey::UserDeposit(user.clone()); + let current_deposit: i128 = env.storage().instance().get(&deposit_key).unwrap_or(0); + let new_deposit = current_deposit.checked_add(amount).expect("overflow"); + if new_deposit > per_user_cap { + return Err(VaultError::ExceedsUserCap); + } + + // ── Interaction: pull tokens from user ───────────────────────────────── + user.require_auth(); + token_client.transfer(user, &env.current_contract_address(), &amount); + + // ── Effects: update storage ──────────────────────────────────────────── + env.storage().instance().set(&deposit_key, &new_deposit); + + // Update idle TotalAssets in storage + let ta: i128 = env + .storage() + .instance() + .get::<_, i128>(&DataKey::TotalAssets) + .unwrap_or(0); + env.storage().instance().set( + &DataKey::TotalAssets, + &ta.checked_add(amount).expect("overflow"), + ); + + // Update TotalShares in storage + let ts: i128 = env + .storage() + .instance() + .get::<_, i128>(&DataKey::TotalShares) + .unwrap_or(0); + env.storage().instance().set( + &DataKey::TotalShares, + &ts.checked_add(shares_to_mint).expect("overflow"), + ); + + // Update in-memory state (used for subsequent entries in the same batch) + state.total_assets = state.total_assets.checked_add(amount).expect("overflow"); + state.total_shares = state + .total_shares + .checked_add(shares_to_mint) + .expect("overflow"); + + // Update user share balance + let user_key = DataKey::ShareBalance(user.clone()); + let user_shares: i128 = env.storage().instance().get(&user_key).unwrap_or(0); + env.storage().instance().set( + &user_key, + &user_shares.checked_add(shares_to_mint).expect("overflow"), + ); + + // Track last deposit time for withdrawal cooldown + env.storage().instance().set( + &DataKey::LastDepositTime(user.clone()), + &env.ledger().timestamp(), + ); + + env.events() + .publish((symbol_short!("deposit"),), (amount, shares_to_mint)); + + Ok(shares_to_mint) + } + /// Redeems vault shares for the proportional amount of underlying assets. /// /// For withdrawals above `LARGE_WITHDRAWAL_THRESHOLD`, a pending withdrawal diff --git a/contracts/vault/src/test.rs b/contracts/vault/src/test.rs index c79f0810..456e8082 100644 --- a/contracts/vault/src/test.rs +++ b/contracts/vault/src/test.rs @@ -1408,3 +1408,359 @@ fn test_withdrawal_cooldown_then_timelock_then_execute() { let executed = vault.execute_withdrawal(&user); assert_eq!(executed, 50_000); } + +// ─── 11. batch_deposit ──────────────────────────────────────────────────────── + +/// Helper: set up a vault with a registered relayer and mint USDC to `users`. +fn setup_vault_with_relayer( + env: &Env, + user_amounts: &[(Address, i128)], +) -> ( + YieldVaultClient<'_>, + token::Client<'_>, + token::StellarAssetClient<'_>, + Address, // admin + Address, // relayer +) { + let (vault, usdc, usdc_sa, admin) = setup_vault(env); + let relayer = Address::generate(env); + vault.set_relayer(&relayer, &true); + + for (user, amount) in user_amounts { + usdc_sa.mint(user, amount); + } + + (vault, usdc, usdc_sa, admin, relayer) +} + +#[test] +fn test_batch_deposit_happy_path_three_users() { + let env = Env::default(); + env.mock_all_auths_allowing_non_root_auth(); + + let user1 = Address::generate(&env); + let user2 = Address::generate(&env); + let user3 = Address::generate(&env); + + let (vault, usdc, _usdc_sa, _admin, relayer) = setup_vault_with_relayer( + &env, + &[ + (user1.clone(), 100), + (user2.clone(), 200), + (user3.clone(), 300), + ], + ); + + let mut entries = Vec::new(&env); + entries.push_back(DepositEntry { user: user1.clone(), amount: 100 }); + entries.push_back(DepositEntry { user: user2.clone(), amount: 200 }); + entries.push_back(DepositEntry { user: user3.clone(), amount: 300 }); + + let result = vault.batch_deposit(&relayer, &entries); + + assert_eq!(result.success_count, 3); + assert_eq!(result.failure_count, 0); + assert_eq!(result.total_shares_minted, 600); // 1:1 ratio on fresh vault + + // Vault received all tokens + let vault_id = vault.address.clone(); + assert_eq!(usdc.balance(&vault_id), 600); + + // Each user received proportional shares + assert_eq!(vault.balance(&user1), 100); + assert_eq!(vault.balance(&user2), 200); + assert_eq!(vault.balance(&user3), 300); + + assert_eq!(vault.total_assets(), 600); + assert_eq!(vault.total_shares(), 600); +} + +#[test] +fn test_batch_deposit_partial_failure_invalid_amount() { + let env = Env::default(); + env.mock_all_auths_allowing_non_root_auth(); + + let user1 = Address::generate(&env); + let user2 = Address::generate(&env); + + let (vault, _, _usdc_sa, _admin, relayer) = + setup_vault_with_relayer(&env, &[(user1.clone(), 500), (user2.clone(), 500)]); + + let mut entries = Vec::new(&env); + // entry with zero amount should fail; valid entry should still succeed + entries.push_back(DepositEntry { user: user1.clone(), amount: 0 }); + entries.push_back(DepositEntry { user: user2.clone(), amount: 100 }); + + let result = vault.batch_deposit(&relayer, &entries); + + assert_eq!(result.success_count, 1); + assert_eq!(result.failure_count, 1); + + // First entry failed + let r0 = result.results.get(0).unwrap(); + assert!(!r0.success); + assert_eq!(r0.error_code, VaultError::InvalidAmount as u32); + assert_eq!(r0.shares_minted, 0); + + // Second entry succeeded + let r1 = result.results.get(1).unwrap(); + assert!(r1.success); + assert_eq!(r1.shares_minted, 100); + + assert_eq!(vault.balance(&user2), 100); + assert_eq!(vault.total_assets(), 100); +} + +#[test] +fn test_batch_deposit_partial_failure_min_deposit_not_met() { + let env = Env::default(); + env.mock_all_auths_allowing_non_root_auth(); + + let user1 = Address::generate(&env); + let user2 = Address::generate(&env); + + let (vault, _, _usdc_sa, _admin, relayer) = + setup_vault_with_relayer(&env, &[(user1.clone(), 500), (user2.clone(), 500)]); + + vault.set_min_deposit(&50); + + let mut entries = Vec::new(&env); + entries.push_back(DepositEntry { user: user1.clone(), amount: 10 }); // below min + entries.push_back(DepositEntry { user: user2.clone(), amount: 100 }); // above min + + let result = vault.batch_deposit(&relayer, &entries); + + assert_eq!(result.success_count, 1); + assert_eq!(result.failure_count, 1); + + let r0 = result.results.get(0).unwrap(); + assert_eq!(r0.error_code, VaultError::MinDepositNotMet as u32); + + let r1 = result.results.get(1).unwrap(); + assert!(r1.success); +} + +#[test] +fn test_batch_deposit_partial_failure_exceeds_user_cap() { + let env = Env::default(); + env.mock_all_auths_allowing_non_root_auth(); + + let user1 = Address::generate(&env); + let user2 = Address::generate(&env); + + let (vault, _, _usdc_sa, _admin, relayer) = + setup_vault_with_relayer(&env, &[(user1.clone(), 500), (user2.clone(), 500)]); + + vault.set_per_user_cap(&50); + + let mut entries = Vec::new(&env); + entries.push_back(DepositEntry { user: user1.clone(), amount: 100 }); // exceeds cap + entries.push_back(DepositEntry { user: user2.clone(), amount: 30 }); // within cap + + let result = vault.batch_deposit(&relayer, &entries); + + assert_eq!(result.success_count, 1); + assert_eq!(result.failure_count, 1); + + let r0 = result.results.get(0).unwrap(); + assert_eq!(r0.error_code, VaultError::ExceedsUserCap as u32); + + let r1 = result.results.get(1).unwrap(); + assert!(r1.success); + assert_eq!(vault.balance(&user2), 30); +} + +#[test] +fn test_batch_deposit_rejects_paused_vault() { + let env = Env::default(); + env.mock_all_auths_allowing_non_root_auth(); + + let user1 = Address::generate(&env); + let (vault, _, _usdc_sa, _admin, relayer) = + setup_vault_with_relayer(&env, &[(user1.clone(), 100)]); + + vault.pause(&PauseReason::Maintenance); + + let mut entries = Vec::new(&env); + entries.push_back(DepositEntry { user: user1.clone(), amount: 100 }); + + let err = vault.try_batch_deposit(&relayer, &entries).unwrap_err(); + assert_eq!( + err.unwrap(), + VaultError::ContractPaused + ); +} + +#[test] +fn test_batch_deposit_rejects_unregistered_relayer() { + let env = Env::default(); + env.mock_all_auths_allowing_non_root_auth(); + + let user1 = Address::generate(&env); + let (vault, _, _usdc_sa, _admin, _relayer) = + setup_vault_with_relayer(&env, &[(user1.clone(), 100)]); + + let impostor = Address::generate(&env); + + let mut entries = Vec::new(&env); + entries.push_back(DepositEntry { user: user1.clone(), amount: 100 }); + + let err = vault.try_batch_deposit(&impostor, &entries).unwrap_err(); + assert_eq!( + err.unwrap(), + VaultError::RelayerNotAuthorized + ); +} + +#[test] +fn test_batch_deposit_rejects_oversized_batch() { + let env = Env::default(); + env.mock_all_auths_allowing_non_root_auth(); + + let (vault, _, _usdc_sa, _admin, relayer) = setup_vault_with_relayer(&env, &[]); + + vault.set_max_batch_size(&3); + + let mut entries = Vec::new(&env); + for _ in 0..4 { + let user = Address::generate(&env); + entries.push_back(DepositEntry { user, amount: 10 }); + } + + let err = vault.try_batch_deposit(&relayer, &entries).unwrap_err(); + assert_eq!(err.unwrap(), VaultError::BatchTooLarge); +} + +#[test] +fn test_batch_deposit_empty_entries_succeeds_with_zero_totals() { + let env = Env::default(); + env.mock_all_auths_allowing_non_root_auth(); + + let (vault, _, _, _admin, relayer) = setup_vault_with_relayer(&env, &[]); + + let entries: Vec = Vec::new(&env); + let result = vault.batch_deposit(&relayer, &entries); + + assert_eq!(result.success_count, 0); + assert_eq!(result.failure_count, 0); + assert_eq!(result.total_shares_minted, 0); + assert_eq!(vault.total_assets(), 0); +} + +#[test] +fn test_batch_deposit_share_price_consistency_after_yield() { + // Verify that mid-batch share pricing is updated correctly after yield accrual + // so entries later in the batch use the fresh price. + let env = Env::default(); + env.mock_all_auths_allowing_non_root_auth(); + + let seed_user = Address::generate(&env); + let user1 = Address::generate(&env); + let user2 = Address::generate(&env); + + let (vault, usdc, usdc_sa, admin, relayer) = setup_vault_with_relayer( + &env, + &[ + (seed_user.clone(), 1000), + (user1.clone(), 500), + (user2.clone(), 500), + ], + ); + + // Seed the vault so shares are no longer 1:1 + vault.deposit(&seed_user, &1000); + // Accrue yield: 1000 assets -> 2000 assets, 1000 shares remain => 2:1 ratio + usdc_sa.mint(&admin, &1000); + vault.accrue_yield(&1000); + assert_eq!(vault.total_assets(), 2000); + assert_eq!(vault.total_shares(), 1000); + + // Now each deposited token is worth 0.5 shares (2:1 price) + let mut entries = Vec::new(&env); + entries.push_back(DepositEntry { user: user1.clone(), amount: 200 }); + entries.push_back(DepositEntry { user: user2.clone(), amount: 400 }); + + let result = vault.batch_deposit(&relayer, &entries); + + assert_eq!(result.success_count, 2); + assert_eq!(result.failure_count, 0); + + // user1: 200 assets / (2000/1000) = 100 shares + assert_eq!(vault.balance(&user1), 100); + // user2: 400 assets / (2200/1100) = 200 shares (price re-computed after user1 entry) + assert_eq!(vault.balance(&user2), 200); + + // Total shares = 1000 (seed) + 100 + 200 = 1300 + assert_eq!(vault.total_shares(), 1300); +} + +#[test] +fn test_set_relayer_and_is_relayer() { + let env = Env::default(); + env.mock_all_auths(); + + let (vault, _, _, _admin) = setup_vault(&env); + let relayer = Address::generate(&env); + + assert!(!vault.is_relayer(&relayer)); + + vault.set_relayer(&relayer, &true); + assert!(vault.is_relayer(&relayer)); + + vault.set_relayer(&relayer, &false); + assert!(!vault.is_relayer(&relayer)); +} + +#[test] +fn test_max_batch_size_defaults_to_50_and_is_configurable() { + let env = Env::default(); + env.mock_all_auths(); + + let (vault, _, _, _admin) = setup_vault(&env); + + assert_eq!(vault.max_batch_size(), 50); + + vault.set_max_batch_size(&10); + assert_eq!(vault.max_batch_size(), 10); +} + +#[test] +fn test_batch_deposit_state_invariant_assets_eq_sum_of_deposits() { + // After a batch, total_assets must equal the sum of all successful deposit amounts. + // Entry at index 3 (amount=0) will fail; the rest succeed. + let env = Env::default(); + env.mock_all_auths_allowing_non_root_auth(); + + let u1 = Address::generate(&env); + let u2 = Address::generate(&env); + let u3 = Address::generate(&env); + let u4 = Address::generate(&env); // zero amount — will fail + let u5 = Address::generate(&env); + + let (vault, _, usdc_sa, _admin, relayer) = setup_vault_with_relayer( + &env, + &[ + (u1.clone(), 50), + (u2.clone(), 100), + (u3.clone(), 200), + (u4.clone(), 0), + (u5.clone(), 75), + ], + ); + + let mut entries = Vec::new(&env); + entries.push_back(DepositEntry { user: u1.clone(), amount: 50 }); + entries.push_back(DepositEntry { user: u2.clone(), amount: 100 }); + entries.push_back(DepositEntry { user: u3.clone(), amount: 200 }); + entries.push_back(DepositEntry { user: u4.clone(), amount: 0 }); // invalid + entries.push_back(DepositEntry { user: u5.clone(), amount: 75 }); + + let _ = usdc_sa; // already minted in setup_vault_with_relayer + + let result = vault.batch_deposit(&relayer, &entries); + + // 50 + 100 + 200 + 75 = 425 + assert_eq!(vault.total_assets(), 425); + assert_eq!(result.failure_count, 1); // zero-amount entry + assert_eq!(result.success_count, 4); +}