diff --git a/contracts/bridge-compat/src/lib.rs b/contracts/bridge-compat/src/lib.rs index be6f9091f..be995acf2 100644 --- a/contracts/bridge-compat/src/lib.rs +++ b/contracts/bridge-compat/src/lib.rs @@ -50,6 +50,28 @@ pub enum BridgeError { NonceAlreadyUsed = 10, NoFallbackAvailable = 11, ProviderDisabled = 12, + TimelockNotReady = 13, + NoPendingRemoval = 14, +} + +// ── Bridge endpoint interface ────────────────────────────────────────────── + +/// Interface every bridge provider endpoint contract must expose. +/// +/// The bridge layer keeps custody of the locked tokens (so failed transfers +/// can be refunded) and notifies the endpoint to relay the message. Any +/// error or panic from `send` is treated as a provider failure and the +/// transfer is routed to the next eligible provider. +#[contractclient(name = "BridgeEndpointClient")] +pub trait BridgeEndpoint { + fn send( + env: Env, + transfer_id: u64, + token: Address, + amount: i128, + dest_chain: u32, + recipient: Bytes, + ); ArithmeticOverflow = 13, InvalidLimits = 14, } @@ -164,6 +186,7 @@ enum DataKey { Limits, DefaultProvider, TransferNonceFor(Address, u32), + PendingRemoval(u32), } #[contracttype] @@ -179,6 +202,8 @@ const STELLAR_CHAIN_ID: u32 = 0; const BPS_DENOMINATOR: i128 = 10_000; const STORAGE_TTL_THRESHOLD: u32 = 10_000; const STORAGE_TTL_EXTENSION: u32 = 100_000; +/// Delay between queueing and executing a provider removal. +pub const PROVIDER_REMOVAL_DELAY: u64 = 86_400; // ── Contract ─────────────────────────────────────────────────────────────── @@ -324,6 +349,70 @@ impl BridgeCompat { Ok(()) } + /// Queue removal of a provider. Admin-only; executable after + /// [`PROVIDER_REMOVAL_DELAY`]. Returns the earliest execution time. + pub fn queue_unregister_provider(env: Env, provider_id: u32) -> Result { + Self::require_admin(&env)?; + let _ = Self::get_provider(&env, provider_id)?; // validate exists + let eta = env + .ledger() + .timestamp() + .checked_add(PROVIDER_REMOVAL_DELAY) + .ok_or(BridgeError::InvalidAmount)?; + env.storage() + .instance() + .set(&DataKey::PendingRemoval(provider_id), &eta); + env.events() + .publish((symbol_short!("brgrmq"),), (provider_id, eta)); + Ok(eta) + } + + /// Cancel a queued provider removal. Admin-only. + pub fn cancel_unregister_provider(env: Env, provider_id: u32) -> Result<(), BridgeError> { + Self::require_admin(&env)?; + let key = DataKey::PendingRemoval(provider_id); + if !env.storage().instance().has(&key) { + return Err(BridgeError::NoPendingRemoval); + } + env.storage().instance().remove(&key); + env.events() + .publish((symbol_short!("brgrmc"),), (provider_id,)); + Ok(()) + } + + /// Remove a provider once its queued removal timelock has elapsed. + /// Admin-only. Clears the default provider if it pointed at this one. + pub fn unregister_provider(env: Env, provider_id: u32) -> Result<(), BridgeError> { + Self::require_admin(&env)?; + let pending_key = DataKey::PendingRemoval(provider_id); + let eta: u64 = env + .storage() + .instance() + .get(&pending_key) + .ok_or(BridgeError::NoPendingRemoval)?; + if env.ledger().timestamp() < eta { + return Err(BridgeError::TimelockNotReady); + } + let _ = Self::get_provider(&env, provider_id)?; + + let key = DataKey::Provider(provider_id); + env.storage().persistent().remove(&key); + env.storage().instance().remove(&pending_key); + if Self::default_provider(env.clone()) == Some(provider_id) { + env.storage().instance().remove(&DataKey::DefaultProvider); + } + env.events() + .publish((symbol_short!("brgrm"),), (provider_id,)); + Ok(()) + } + + /// Returns the earliest execution time of a queued provider removal. + pub fn pending_unregister(env: Env, provider_id: u32) -> Option { + env.storage() + .instance() + .get(&DataKey::PendingRemoval(provider_id)) + } + /// Returns a provider by ID. pub fn provider(env: Env, provider_id: u32) -> Option { Self::get_provider(&env, provider_id).ok() @@ -412,10 +501,12 @@ impl BridgeCompat { /// Initiate a cross-chain USDC transfer. /// - /// Pulls USDC from the sender, records the transfer, and (in production) - /// would call the bridge endpoint contract. This implementation handles - /// the vault-side accounting; the actual bridge call is abstracted for - /// testnet compatibility. + /// Pulls USDC from the sender into this contract's custody, then invokes + /// the selected provider's [`BridgeEndpoint::send`]. If the primary + /// endpoint fails, the transfer is routed to the next enabled provider + /// (ascending ID) that supports `dest_chain`; if none succeeds the call + /// reverts with [`BridgeError::NoFallbackAvailable`]. The recorded + /// transfer carries the provider that actually accepted it. pub fn transfer_out( env: Env, sender: Address, @@ -466,23 +557,49 @@ impl BridgeCompat { return Err(BridgeError::InsufficientBalance); } - // Nonces are monotonic within a sender/provider domain. Transfer IDs - // additionally bind that nonce to both identities so they cannot be - // confused with replay nonces or reused across providers. - let nonce_key = DataKey::TransferNonceFor(sender.clone(), pid); - let previous_nonce = env - .storage() - .instance() - .get::<_, u64>(&nonce_key) - .unwrap_or(0); - let nonce = previous_nonce - .checked_add(1) - .ok_or(BridgeError::NonceAlreadyUsed)?; - let transfer_id = Self::derive_transfer_id(&env, &sender, nonce, pid); - let transfer_key = DataKey::Transfer(transfer_id); - if env.storage().instance().has(&transfer_key) { - return Err(BridgeError::NonceAlreadyUsed); + // Lock the sender's tokens before routing; any failure below reverts it. + token_client.transfer(&sender, &env.current_contract_address(), &amount); + + // Primary first, then every other eligible provider as fallback. + let mut routed: Option<(u32, u64, u64)> = None; + let (nonce, transfer_id) = Self::next_transfer_id(&env, &sender, pid)?; + if Self::call_endpoint( + &env, + &provider, + transfer_id, + &token_addr, + amount, + dest_chain, + &recipient, + ) { + routed = Some((pid, nonce, transfer_id)); + } else { + for fallback_id in 1..=Self::provider_count(env.clone()) { + if fallback_id == pid { + continue; + } + let fallback = match Self::get_provider(&env, fallback_id) { + Ok(p) if p.enabled && p.supported_chains.contains(&dest_chain) => p, + _ => continue, + }; + let (nonce, transfer_id) = Self::next_transfer_id(&env, &sender, fallback_id)?; + if Self::call_endpoint( + &env, + &fallback, + transfer_id, + &token_addr, + amount, + dest_chain, + &recipient, + ) { + env.events() + .publish((symbol_short!("brgfbk"),), (pid, fallback_id, transfer_id)); + routed = Some((fallback_id, nonce, transfer_id)); + break; + } + } } + let (used_pid, nonce, transfer_id) = routed.ok_or(BridgeError::NoFallbackAvailable)?; let transfer = BridgeTransfer { transfer_id, @@ -491,7 +608,7 @@ impl BridgeCompat { amount, source_chain: STELLAR_CHAIN_ID, dest_chain, - provider_id: pid, + provider_id: used_pid, status: TransferStatus::InFlight, created_at: env.ledger().timestamp(), completed_at: 0, @@ -500,10 +617,10 @@ impl BridgeCompat { env.storage() .instance() - .set(&transfer_key, &transfer); + .set(&DataKey::Transfer(transfer_id), &transfer); env.storage() .instance() - .set(&nonce_key, &nonce); + .set(&DataKey::TransferNonceFor(sender.clone(), used_pid), &nonce); // Update user transfer count let user_count: i128 = env @@ -521,13 +638,9 @@ impl BridgeCompat { env.events().publish( (symbol_short!("brgout"), sender.clone()), - (transfer_id, amount, dest_chain, pid), + (transfer_id, amount, dest_chain, used_pid), ); - // Persist the nonce and transfer record before the token interaction. - // A callback therefore cannot observe or reuse an uncommitted nonce. - token_client.transfer(&sender, &env.current_contract_address(), &amount); - Ok(transfer_id) } @@ -661,6 +774,48 @@ impl BridgeCompat { ); } + /// Next unused (nonce, transfer_id) for the sender/provider domain. + fn next_transfer_id( + env: &Env, + sender: &Address, + provider_id: u32, + ) -> Result<(u64, u64), BridgeError> { + // Nonces are monotonic within a sender/provider domain. Transfer IDs + // additionally bind that nonce to both identities so they cannot be + // confused with replay nonces or reused across providers. + let nonce = env + .storage() + .instance() + .get::<_, u64>(&DataKey::TransferNonceFor(sender.clone(), provider_id)) + .unwrap_or(0) + .checked_add(1) + .ok_or(BridgeError::NonceAlreadyUsed)?; + let transfer_id = Self::derive_transfer_id(env, sender, nonce, provider_id); + if env + .storage() + .instance() + .has(&DataKey::Transfer(transfer_id)) + { + return Err(BridgeError::NonceAlreadyUsed); + } + Ok((nonce, transfer_id)) + } + + /// Invoke the provider endpoint; `false` on any error or panic. + fn call_endpoint( + env: &Env, + provider: &BridgeProvider, + transfer_id: u64, + token: &Address, + amount: i128, + dest_chain: u32, + recipient: &Bytes, + ) -> bool { + BridgeEndpointClient::new(env, &provider.endpoint) + .try_send(&transfer_id, token, &amount, &dest_chain, recipient) + .is_ok() + } + fn derive_transfer_id(env: &Env, sender: &Address, nonce: u64, provider_id: u32) -> u64 { let mut payload = sender.to_xdr(env); payload.append(&nonce.to_xdr(env)); @@ -733,6 +888,53 @@ impl BridgeCompat { mod tests { use super::*; use soroban_sdk::testutils::{Address as _, Ledger}; + use soroban_sdk::{contract, symbol_short}; + + /// Test endpoint: counts `send` calls, or panics when configured to fail. + #[contract] + pub struct MockEndpoint; + + #[contractimpl] + impl MockEndpoint { + pub fn set_fail(env: Env, fail: bool) { + env.storage().instance().set(&symbol_short!("fail"), &fail); + } + + pub fn calls(env: Env) -> u32 { + env.storage() + .instance() + .get(&symbol_short!("calls")) + .unwrap_or(0) + } + + pub fn send( + env: Env, + _transfer_id: u64, + _token: Address, + _amount: i128, + _dest_chain: u32, + _recipient: Bytes, + ) { + if env + .storage() + .instance() + .get(&symbol_short!("fail")) + .unwrap_or(false) + { + panic!("endpoint down"); + } + let calls = Self::calls(env.clone()) + 1; + env.storage() + .instance() + .set(&symbol_short!("calls"), &calls); + } + } + + fn mock_endpoint(env: &Env, fail: bool) -> Address { + let id = env.register(MockEndpoint, ()); + MockEndpointClient::new(env, &id).set_fail(&fail); + id + } /// Register the contract and return a client for it. fn setup() -> (Env, BridgeCompatClient<'static>) { @@ -867,6 +1069,8 @@ mod tests { token_admin_client.mint(&sender_a, &300); token_admin_client.mint(&sender_b, &300); + let endpoint_a = mock_endpoint(&env, false); + let endpoint_b = mock_endpoint(&env, false); let endpoint_a = Address::generate(&env); let endpoint_b = Address::generate(&env); let chains = Vec::from_array(&env, [1u32]); @@ -927,7 +1131,7 @@ mod tests { #[test] fn test_epoch_limit_exceeded() { let (env, client, _admin, token_addr, contract_id) = setup_with_token(); - let endpoint = Address::generate(&env); + let endpoint = mock_endpoint(&env, false); let sender = Address::generate(&env); let recipient = Bytes::from_slice(&env, &[1, 2, 3, 4]); @@ -957,7 +1161,7 @@ mod tests { // First transfer: 50,000 (epoch volume becomes 50,000 <= 80,000) -> succeeds let tx1 = client.transfer_out(&sender, &recipient, &50_000, &1, &Some(provider_id)); - assert_eq!(tx1, 1); + assert_eq!(client.transfer(&tx1).unwrap().nonce, 1); // Second transfer: 40,000 // Epoch volume would be 50,000 + 40,000 = 90,000 > 80,000 (epoch_volume_limit) @@ -970,13 +1174,13 @@ mod tests { // Once epoch has reset, 40,000 transfer succeeds let tx2 = client.transfer_out(&sender, &recipient, &40_000, &1, &Some(provider_id)); - assert_eq!(tx2, 2); + assert_eq!(client.transfer(&tx2).unwrap().nonce, 2); } #[test] fn test_fail_transfer_refund() { let (env, client, _admin, token_addr, contract_id) = setup_with_token(); - let endpoint = Address::generate(&env); + let endpoint = mock_endpoint(&env, false); let sender = Address::generate(&env); let recipient = Bytes::from_slice(&env, &[9, 8, 7]); @@ -1000,7 +1204,10 @@ mod tests { // 1. Initiate transfer_out let transfer_id = client.transfer_out(&sender, &recipient, &transfer_amount, &1, &Some(provider_id)); - assert_eq!(transfer_id, 1); + assert_eq!( + client.transfer(&transfer_id).unwrap().provider_id, + provider_id + ); // Verify sender debited by transfer_amount assert_eq!( @@ -1040,7 +1247,7 @@ mod tests { #[test] fn test_disabled_provider_rejection() { let (env, client, _admin, token_addr, contract_id) = setup_with_token(); - let endpoint = Address::generate(&env); + let endpoint = mock_endpoint(&env, false); let sender = Address::generate(&env); let recipient = Bytes::from_slice(&env, &[4, 5, 6]); @@ -1093,7 +1300,173 @@ mod tests { assert_eq!(estimate_ok.provider_id, provider_id); let tx_id = client.transfer_out(&sender, &recipient, &10_000, &1, &Some(provider_id)); - assert_eq!(tx_id, 1); + assert_eq!(client.transfer(&tx_id).unwrap().provider_id, provider_id); + } + + // ── Provider removal / fallback routing / endpoint invocation ─────────── + + fn register(client: &BridgeCompatClient, env: &Env, endpoint: &Address) -> u32 { + client.register_provider( + &String::from_str(env, "p"), + &BridgeProviderKind::Custom, + endpoint, + &0, + &1_000_000, + &Vec::from_array(env, [1u32]), + ) + } + + #[test] + fn test_transfer_out_invokes_provider_endpoint() { + let (env, client, _admin, token_addr, contract_id) = setup_with_token(); + let endpoint = mock_endpoint(&env, false); + let pid = register(&client, &env, &endpoint); + let sender = Address::generate(&env); + let token_admin = soroban_sdk::token::StellarAssetClient::new(&env, &token_addr); + token_admin.mint(&sender, &1_000); + token_admin.mint(&contract_id, &1_000); + + let id = client.transfer_out(&sender, &Bytes::new(&env), &500, &1, &None); + + assert_eq!(MockEndpointClient::new(&env, &endpoint).calls(), 1); + assert_eq!(client.transfer(&id).unwrap().provider_id, pid); + let token = soroban_sdk::token::Client::new(&env, &token_addr); + assert_eq!(token.balance(&sender), 500); + assert_eq!(token.balance(&contract_id), 1_500); + } + + #[test] + fn test_transfer_out_falls_back_when_primary_fails() { + let (env, client, _admin, token_addr, contract_id) = setup_with_token(); + let primary = mock_endpoint(&env, true); + let disabled = mock_endpoint(&env, false); + let fallback = mock_endpoint(&env, false); + let primary_id = register(&client, &env, &primary); + let disabled_id = register(&client, &env, &disabled); + let fallback_id = register(&client, &env, &fallback); + client.set_provider_enabled(&disabled_id, &false); + let sender = Address::generate(&env); + let token_admin = soroban_sdk::token::StellarAssetClient::new(&env, &token_addr); + token_admin.mint(&sender, &1_000); + token_admin.mint(&contract_id, &1_000); + + let id = client.transfer_out(&sender, &Bytes::new(&env), &500, &1, &Some(primary_id)); + + let record = client.transfer(&id).unwrap(); + assert_eq!(record.provider_id, fallback_id); + assert_eq!(record.nonce, 1); + assert_eq!(MockEndpointClient::new(&env, &disabled).calls(), 0); + assert_eq!(MockEndpointClient::new(&env, &fallback).calls(), 1); + } + + #[test] + fn test_transfer_out_reverts_when_no_provider_succeeds() { + let (env, client, _admin, token_addr, contract_id) = setup_with_token(); + let primary = mock_endpoint(&env, true); + let backup = mock_endpoint(&env, true); + register(&client, &env, &primary); + register(&client, &env, &backup); + let sender = Address::generate(&env); + let token_admin = soroban_sdk::token::StellarAssetClient::new(&env, &token_addr); + token_admin.mint(&sender, &1_000); + token_admin.mint(&contract_id, &1_000); + + let res = client.try_transfer_out(&sender, &Bytes::new(&env), &500, &1, &None); + + assert_eq!(res, Err(Ok(BridgeError::NoFallbackAvailable))); + let token = soroban_sdk::token::Client::new(&env, &token_addr); + assert_eq!(token.balance(&sender), 1_000); + assert_eq!(client.user_transfer_count(&sender), 0); + } + + #[test] + fn test_unregister_provider_requires_timelock() { + let (env, client, _admin, _token, _id) = setup_with_token(); + let pid = register(&client, &env, &mock_endpoint(&env, false)); + + assert_eq!( + client.try_unregister_provider(&pid), + Err(Ok(BridgeError::NoPendingRemoval)) + ); + let eta = client.queue_unregister_provider(&pid); + assert_eq!(eta, env.ledger().timestamp() + PROVIDER_REMOVAL_DELAY); + assert_eq!(client.pending_unregister(&pid), Some(eta)); + assert_eq!( + client.try_unregister_provider(&pid), + Err(Ok(BridgeError::TimelockNotReady)) + ); + + env.ledger().set_timestamp(eta); + client.unregister_provider(&pid); + + assert_eq!(client.provider(&pid), None); + assert_eq!(client.pending_unregister(&pid), None); + assert_eq!(client.default_provider(), None); + assert_eq!( + client.try_queue_unregister_provider(&pid), + Err(Ok(BridgeError::ProviderNotFound)) + ); + } + + #[test] + fn test_cancel_unregister_provider() { + let (env, client, _admin, _token, _id) = setup_with_token(); + let pid = register(&client, &env, &mock_endpoint(&env, false)); + + assert_eq!( + client.try_cancel_unregister_provider(&pid), + Err(Ok(BridgeError::NoPendingRemoval)) + ); + let eta = client.queue_unregister_provider(&pid); + client.cancel_unregister_provider(&pid); + env.ledger().set_timestamp(eta); + + assert_eq!( + client.try_unregister_provider(&pid), + Err(Ok(BridgeError::NoPendingRemoval)) + ); + assert!(client.provider(&pid).is_some()); + } + + #[test] + fn test_unregister_provider_requires_admin_auth() { + let env = Env::default(); + let id = env.register(BridgeCompat, ()); + let client = BridgeCompatClient::new(&env, &id); + let admin = Address::generate(&env); + client.initialize(&admin, &Address::generate(&env)); + + env.mock_all_auths(); + let pid = register(&client, &env, &Address::generate(&env)); + env.set_auths(&[]); + + assert!(client.try_queue_unregister_provider(&pid).is_err()); + assert!(client.try_cancel_unregister_provider(&pid).is_err()); + assert!(client.try_unregister_provider(&pid).is_err()); + } + + #[test] + fn test_removed_provider_skipped_by_fallback() { + let (env, client, _admin, token_addr, contract_id) = setup_with_token(); + let primary = mock_endpoint(&env, true); + let removed = mock_endpoint(&env, false); + let backup = mock_endpoint(&env, false); + let primary_id = register(&client, &env, &primary); + let removed_id = register(&client, &env, &removed); + let backup_id = register(&client, &env, &backup); + let eta = client.queue_unregister_provider(&removed_id); + env.ledger().set_timestamp(eta); + client.unregister_provider(&removed_id); + + let sender = Address::generate(&env); + let token_admin = soroban_sdk::token::StellarAssetClient::new(&env, &token_addr); + token_admin.mint(&sender, &1_000); + token_admin.mint(&contract_id, &1_000); + + let id = client.transfer_out(&sender, &Bytes::new(&env), &500, &1, &Some(primary_id)); + + assert_eq!(client.transfer(&id).unwrap().provider_id, backup_id); + assert_eq!(MockEndpointClient::new(&env, &removed).calls(), 0); } #[test] diff --git a/contracts/share-price-math/src/rounding.rs b/contracts/share-price-math/src/rounding.rs index ebe8f5750..1ad3230e9 100644 --- a/contracts/share-price-math/src/rounding.rs +++ b/contracts/share-price-math/src/rounding.rs @@ -3,8 +3,6 @@ //! The vault must never round in the user's favour: rounding always leaves the //! residual dust with the vault, so `deposit -> withdraw` round trips can never //! mint value out of nothing. - -use crate::MathError; //! //! | Operation | Direction | Rounding | //! |------------|-----------|----------| @@ -18,6 +16,8 @@ use crate::MathError; //! TypeScript SDK and the backend quote service can assert byte-identical //! results against this implementation. +use crate::MathError; + /// Rounding direction applied to the residual of an integer division. #[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum Rounding { @@ -295,6 +295,23 @@ mod tests { } } + #[test] + fn dust_deposit_below_one_share_rounds_to_zero() { + // Issue #1536: at 1_000 assets per share every deposit below 1_000 + // mints zero shares; the vault rejects these instead of keeping them. + let (total_shares, total_assets) = (1_000, 1_000_000); + for assets in 1..1_000i128 { + assert_eq!( + assets_to_shares_rounded(assets, total_shares, total_assets, Op::Deposit), + Ok(0) + ); + } + assert_eq!( + assets_to_shares_rounded(1_000, total_shares, total_assets, Op::Deposit), + Ok(1) + ); + } + #[test] fn policy_directions_are_stable() { assert_eq!(Op::Deposit.rounding(), Rounding::Down); diff --git a/contracts/vault/src/errors.rs b/contracts/vault/src/errors.rs index f5b079a67..24a4a7c41 100644 --- a/contracts/vault/src/errors.rs +++ b/contracts/vault/src/errors.rs @@ -22,7 +22,8 @@ pub enum VaultError { ContractPaused = 4, /// Deposit would exceed per-user cap. ExceedsUserCap = 5, - /// Deposit is below minimum deposit threshold. + /// Deposit is below minimum deposit threshold, or is dust that would + /// mint zero shares at the current share price. MinDepositNotMet = 6, /// Large withdrawal timelock has not expired yet. TimelockNotExpired = 7, diff --git a/contracts/vault/src/fuzz_math.rs b/contracts/vault/src/fuzz_math.rs index a4275e86c..4b2048ec5 100644 --- a/contracts/vault/src/fuzz_math.rs +++ b/contracts/vault/src/fuzz_math.rs @@ -43,7 +43,9 @@ fn mint(env: &Env, token_addr: &Address, _admin: &Address, recipient: &Address, // ── pure math helpers (delegates to centralized math module) ────────────────── -use crate::math::{try_assets_to_shares as shares_for, try_shares_to_assets as assets_for}; +use crate::math::{ + try_assets_to_shares as shares_for, try_shares_to_assets as assets_for, MathError, +}; // ── property strategies ─────────────────────────────────────────────────────── diff --git a/contracts/vault/src/lib.rs b/contracts/vault/src/lib.rs index c808f6cfa..a56bce7a1 100644 --- a/contracts/vault/src/lib.rs +++ b/contracts/vault/src/lib.rs @@ -239,6 +239,9 @@ pub struct UserBalanceKey { #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub enum RiskExtKey { + /// Legacy last-price slot superseded by [`DataKeyExt::LastOraclePrice`]. + /// Never written; only read once by `last_oracle_price` to migrate any + /// value left by older deployments into the canonical key. LastPx, MaxTvl, MaxConc, @@ -749,6 +752,7 @@ impl YieldVault { pub fn set_strategy(env: Env, strategy: Address) -> Result<(), VaultError> { let admin: Address = get_admin(&env).expect("Admin not set"); admin.require_auth(); + let now = env.ledger().timestamp(); // Strategy switch cooldown enforcement let cooldown: u64 = env @@ -763,7 +767,7 @@ impl YieldVault { .get(&DataKey::LastStrategySwitchTime) .unwrap_or(0); let earliest_switch = last_switch.checked_add(cooldown).expect("overflow"); - if env.ledger().timestamp() < earliest_switch { + if now < earliest_switch { return Err(VaultError::AdminParamChangeTooSoon); } } @@ -811,7 +815,6 @@ impl YieldVault { env.storage().instance().set(&DataKey::Strategy, &strategy); // Record switch timestamp and emit events - let now = env.ledger().timestamp(); env.storage() .instance() .set(&DataKey::LastStrategySwitchTime, &now); @@ -822,13 +825,7 @@ impl YieldVault { (previous_strategy.clone(), strategy.clone()), ); - crate::audit_events::emit_strategy_switch( - &env, - &admin, - &previous_strategy, - &strategy, - env.ledger().timestamp(), - ); + crate::audit_events::emit_strategy_switch(&env, &admin, &previous_strategy, &strategy, now); Ok(()) } @@ -1372,31 +1369,26 @@ impl YieldVault { let state = Self::get_state(&env); let idle_assets = state.total_assets; + // Each config key is read at most once per call (issue #1538). let strategy_assets = if let Some(strategy_addr) = Self::strategy(env.clone()) { + let token = Self::token(env.clone()); if Self::is_oracle_enabled(env.clone()) { if let Some(oracle_addr) = Self::price_oracle(env.clone()) { let oracle_client = OracleClient::new(&env, &oracle_addr); - let token = Self::token(env.clone()); let price_data = oracle_client.get_price(&token, &token); let max_age = Self::oracle_heartbeat(env.clone()); - let last: Option = env - .storage() - .instance() - .get(&DataKeyExt::Risk(RiskExtKey::LastPx)); let last_price = Self::last_oracle_price(&env); oracle::OracleValidator::validate_price_data( &env, &price_data, max_age, Some(oracle::MAX_PRICE_DEVIATION_BPS), - last.as_ref(), last_price.as_ref(), ) .map_err(|_| VaultError::OracleValidationFailed)?; Self::set_last_oracle_price(&env, &price_data); } } - let token = Self::token(env.clone()); Self::require_valid_strategy_response(&env, &strategy_addr, &token) } else { 0 @@ -1407,8 +1399,21 @@ impl YieldVault { .ok_or(VaultError::MathOverflow) } + /// Last validated oracle price. `DataKeyExt::LastOraclePrice` is the single + /// canonical key; a value under the legacy `Risk(LastPx)` slot is migrated + /// into it on first read and the legacy slot is removed (issue #1525). fn last_oracle_price(env: &Env) -> Option { - env.storage().instance().get(&DataKeyExt::LastOraclePrice) + let storage = env.storage().instance(); + if let Some(price) = storage.get(&DataKeyExt::LastOraclePrice) { + return Some(price); + } + let legacy_key = DataKeyExt::Risk(RiskExtKey::LastPx); + let legacy: Option = storage.get(&legacy_key); + if let Some(price) = legacy.as_ref() { + storage.set(&DataKeyExt::LastOraclePrice, price); + storage.remove(&legacy_key); + } + legacy } fn set_last_oracle_price(env: &Env, price_data: &oracle::PriceData) { @@ -1417,6 +1422,12 @@ impl YieldVault { .set(&DataKeyExt::LastOraclePrice, price_data); } + fn clear_last_oracle_price(env: &Env) { + let storage = env.storage().instance(); + storage.remove(&DataKeyExt::LastOraclePrice); + storage.remove(&DataKeyExt::Risk(RiskExtKey::LastPx)); + } + /// Returns the current vault share price scaled to 10^18. /// /// This exchange rate represents the value of one vault share in underlying @@ -2123,7 +2134,8 @@ impl YieldVault { /// /// ### Rounding /// Uses round-down conversion (see [`math::assets_to_shares`]). - /// Rejects deposits that would mint zero shares to prevent silent loss of funds. + /// Rejects deposits that would mint zero shares with + /// [`VaultError::MinDepositNotMet`] to prevent silent loss of funds. /// /// ### Events /// Publishes a `(symbol_short!("deposit"),)` event with `(amount, shares_minted)`. @@ -2161,7 +2173,7 @@ impl YieldVault { // Prevent silent loss of funds if shares round down to 0 if shares_to_mint == 0 { - return Err(VaultError::InvalidAmount); + return Err(Self::reject_dust_deposit(&env, &user, amount, &state)); } let deposit_key = DataKey::UserDeposit(user.clone()); @@ -2357,7 +2369,7 @@ impl YieldVault { .map_err(|_| VaultError::MathOverflow)?; if shares_to_mint == 0 { - return Err(VaultError::InvalidAmount); + return Err(Self::reject_dust_deposit(&env, &user, amount, &state)); } // Per-user deposit cap check @@ -2620,6 +2632,24 @@ impl YieldVault { }) } + /// Rejects a deposit that would mint zero shares (issue #1536). + /// + /// Publishes `("dep_dust", user)` with `(amount, total_shares, total_assets)` + /// and returns [`VaultError::MinDepositNotMet`]: the amount is below the + /// smallest deposit that mints one share at the current price. + fn reject_dust_deposit( + env: &Env, + user: &Address, + amount: i128, + state: &VaultState, + ) -> VaultError { + env.events().publish( + (symbol_short!("dep_dust"), user.clone()), + (amount, state.total_shares, state.total_assets), + ); + VaultError::MinDepositNotMet + } + /// Internal helper: validates and applies a single deposit within a batch. /// /// State fields `total_assets` and `total_shares` on `state` are updated @@ -2650,7 +2680,7 @@ impl YieldVault { .map_err(|_| VaultError::MathOverflow)?; if shares_to_mint == 0 { - return Err(VaultError::InvalidAmount); + return Err(Self::reject_dust_deposit(env, user, amount, state)); } // Per-user deposit cap check @@ -4288,9 +4318,7 @@ impl YieldVault { env.storage() .instance() .remove(&DataKeyExt::PendingPriceOracle); - env.storage() - .instance() - .remove(&DataKeyExt::Risk(RiskExtKey::LastPx)); + Self::clear_last_oracle_price(&env); env.events() .publish((symbol_short!("oraclech"),), pending.new_value); Ok(()) diff --git a/contracts/vault/src/oracle_tests.rs b/contracts/vault/src/oracle_tests.rs index 5e5a15cce..94676445a 100644 --- a/contracts/vault/src/oracle_tests.rs +++ b/contracts/vault/src/oracle_tests.rs @@ -1,10 +1,10 @@ use crate::benji_strategy::{BenjiStrategy, BenjiStrategyClient}; use crate::oracle::{ price_data_new, price_data_scaled_price, validate_conversion_rate, - validate_price_for_calculation, OracleValidator, DEFAULT_HEARTBEAT_SECONDS, + validate_price_for_calculation, OracleValidator, PriceData, DEFAULT_HEARTBEAT_SECONDS, MAX_ORACLE_HEARTBEAT, MAX_PRICE_DEVIATION_BPS, }; -use crate::{VaultError, YieldVault, YieldVaultClient}; +use crate::{DataKey, DataKeyExt, RiskExtKey, VaultError, YieldVault, YieldVaultClient}; use mock_strategy::mock_oracle::{MockPriceOracle, MockPriceOracleClient}; use soroban_sdk::testutils::{Address as _, Ledger as _}; use soroban_sdk::{token, Address, Env}; @@ -408,3 +408,140 @@ fn test_invest_reverts_on_stale_oracle_data() { let result = vault.try_invest(&100); assert_eq!(result, Err(Ok(VaultError::OracleValidationFailed))); } + +// ─── Canonical last-price key (issues #1525 / #1538) ─────────────────────── + +fn stored_prices(env: &Env, vault: &Address) -> (Option, Option) { + env.as_contract(vault, || { + let storage = env.storage().instance(); + ( + storage.get(&DataKeyExt::LastOraclePrice), + storage.get(&DataKeyExt::Risk(RiskExtKey::LastPx)), + ) + }) +} + +#[test] +fn test_total_assets_writes_only_canonical_last_price() { + let env = Env::default(); + env.ledger().with_mut(|li| li.timestamp = 100_000); + let (vault, _usdc, _benji_admin, _oracle_id) = setup_vault_with_oracle(&env); + + assert_eq!(stored_prices(&env, &vault.address), (None, None)); + assert!(vault.try_total_assets().is_ok()); + + let (canonical, legacy) = stored_prices(&env, &vault.address); + assert_eq!( + canonical, + Some(price_data_new(1_000_000_000i128, 100_000, 18)) + ); + assert_eq!(legacy, None); +} + +#[test] +fn test_legacy_last_price_is_migrated_to_canonical_key() { + let env = Env::default(); + env.ledger().with_mut(|li| li.timestamp = 100_000); + let (vault, _usdc, _benji_admin, oracle_id) = setup_vault_with_oracle(&env); + let now = env.ledger().timestamp(); + MockPriceOracleClient::new(&env, &oracle_id).set_price(&1_100_000_000i128, &now, &18u32); + + // Simulate a deployment that only ever wrote the legacy slot. + let legacy_price = price_data_new(1_000_000_000i128, now, 18); + env.as_contract(&vault.address, || { + env.storage() + .instance() + .set(&DataKeyExt::Risk(RiskExtKey::LastPx), &legacy_price); + }); + + assert!(vault.try_total_assets().is_ok()); + + let (canonical, legacy) = stored_prices(&env, &vault.address); + assert_eq!(canonical, Some(price_data_new(1_100_000_000i128, now, 18))); + assert_eq!(legacy, None); +} + +#[test] +fn test_legacy_last_price_still_guards_deviation_before_migration() { + let env = Env::default(); + env.ledger().with_mut(|li| li.timestamp = 100_000); + let (vault, _usdc, _benji_admin, _oracle_id) = setup_vault_with_oracle(&env); + let now = env.ledger().timestamp(); + + // Legacy reference 10x the live price: the breaker must still trip. + env.as_contract(&vault.address, || { + env.storage().instance().set( + &DataKeyExt::Risk(RiskExtKey::LastPx), + &price_data_new(10_000_000_000i128, now, 18), + ); + }); + + assert_eq!( + vault.try_total_assets(), + Err(Ok(VaultError::OracleValidationFailed)) + ); +} + +#[test] +fn test_oracle_change_clears_both_last_price_keys() { + let env = Env::default(); + env.ledger().with_mut(|li| li.timestamp = 100_000); + let (vault, _usdc, _benji_admin, oracle_id) = setup_vault_with_oracle(&env); + assert!(vault.try_total_assets().is_ok()); + env.as_contract(&vault.address, || { + env.storage().instance().set( + &DataKeyExt::Risk(RiskExtKey::LastPx), + &price_data_new(1, 0, 18), + ); + }); + + vault.queue_price_oracle_change(&oracle_id); + vault.execute_price_oracle_change(); + + assert_eq!(stored_prices(&env, &vault.address), (None, None)); +} + +/// Footprint comparison: the single canonical read/write per `total_assets` +/// call must cost less than the previous dual-key pattern (read both keys, +/// read `TokenAsset` twice, write both keys). +#[test] +fn test_total_assets_price_cache_cheaper_than_dual_key_pattern() { + let env = Env::default(); + env.ledger().with_mut(|li| li.timestamp = 100_000); + let (vault, _usdc, _benji_admin, _oracle_id) = setup_vault_with_oracle(&env); + assert!(vault.try_total_assets().is_ok()); + let price = price_data_new(1_000_000_000i128, 100_000, 18); + + let measure = |work: &dyn Fn()| -> (u64, u64) { + env.as_contract(&vault.address, || { + let mut budget = env.cost_estimate().budget(); + budget.reset_unlimited(); + work(); + (budget.cpu_instruction_cost(), budget.memory_bytes_cost()) + }) + }; + + let legacy = measure(&|| { + let storage = env.storage().instance(); + let _: Option = storage.get(&DataKeyExt::Risk(RiskExtKey::LastPx)); + let _: Option = storage.get(&DataKeyExt::LastOraclePrice); + let _: Address = storage.get(&DataKey::TokenAsset).unwrap(); + let _: Address = storage.get(&DataKey::TokenAsset).unwrap(); + storage.set(&DataKeyExt::LastOraclePrice, &price); + storage.set(&DataKeyExt::Risk(RiskExtKey::LastPx), &price); + }); + env.as_contract(&vault.address, || { + env.storage() + .instance() + .remove(&DataKeyExt::Risk(RiskExtKey::LastPx)); + }); + let cached = measure(&|| { + let storage = env.storage().instance(); + let _: Option = storage.get(&DataKeyExt::LastOraclePrice); + let _: Address = storage.get(&DataKey::TokenAsset).unwrap(); + storage.set(&DataKeyExt::LastOraclePrice, &price); + }); + + assert!(cached.0 < legacy.0, "cpu {} !< {}", cached.0, legacy.0); + assert!(cached.1 < legacy.1, "mem {} !< {}", cached.1, legacy.1); +} diff --git a/contracts/vault/src/recovery_sequence.rs b/contracts/vault/src/recovery_sequence.rs index 260d80674..940e3218e 100644 --- a/contracts/vault/src/recovery_sequence.rs +++ b/contracts/vault/src/recovery_sequence.rs @@ -135,6 +135,7 @@ pub fn assets_for_shares(state: &AccountingState, shares: i128) -> Result Result = env + .host() + .get_events() + .unwrap() + .0 + .into_iter() + .filter(|e| { + matches!(&e.event.body, ContractEventBody::V0(body) + if body.topics.first() == Some(&dust_symbol)) + }) + .collect(); + assert_eq!(dust_events.len(), 1); + assert!(dust_events[0].failed_call); + + assert_eq!(usdc.balance(&user), 1_000); + assert_eq!(vault.balance(&user), 0); + assert_eq!(vault.total_shares(), 1_000); + assert_eq!(vault.total_assets(), 1_000_000); + + // One share's worth (1_000 assets) is the smallest accepted deposit. + assert_eq!( + vault.try_deposit(&user, &999), + Err(Ok(VaultError::MinDepositNotMet)) ); + assert_eq!(vault.deposit(&user, &1_000), 1); +} + +/// Issue #1536: a dust entry in a batch fails with the typed error and the +/// `dep_dust` event persists because the batch itself succeeds. +#[test] +fn test_batch_dust_entry_rejected_with_typed_error_and_event() { + let env = Env::default(); + env.mock_all_auths_allowing_non_root_auth(); + + let seed = Address::generate(&env); + let dust_user = Address::generate(&env); + let (vault, _usdc, usdc_sa, admin, relayer) = + setup_vault_with_relayer(&env, &[(seed.clone(), 1_000), (dust_user.clone(), 10)]); + usdc_sa.mint(&admin, &999_000); + vault.deposit(&seed, &1_000); + vault.accrue_yield(&999_000); + + let mut entries = Vec::new(&env); + entries.push_back(DepositEntry { + user: dust_user.clone(), + amount: 1, + }); + let result = vault.batch_deposit(&relayer, &entries); + let dust_events = env.events().all(); + + assert_eq!(result.success_count, 0); + assert_eq!(result.failure_count, 1); + let r0 = result.results.get(0).unwrap(); + assert_eq!(r0.error_code, VaultError::MinDepositNotMet as u32); + assert_eq!(r0.shares_minted, 0); + assert!(dust_events.iter().any(|(contract, topics, data)| { + contract == vault.address + && topics == (symbol_short!("dep_dust"), dust_user.clone()).into_val(&env) + && <(i128, i128, i128)>::try_from_val(&env, &data) == Ok((1, 1_000, 1_000_000)) + })); + assert_eq!(vault.balance(&dust_user), 0); } // ─── 3. withdraw ───────────────────────────────────────────────────────────── diff --git a/contracts/vault/src/timelock.rs b/contracts/vault/src/timelock.rs index 248ced83a..17ba3e8d7 100644 --- a/contracts/vault/src/timelock.rs +++ b/contracts/vault/src/timelock.rs @@ -61,6 +61,8 @@ pub fn is_valid_delay(delay_secs: u64) -> bool { #[cfg(test)] mod tests { + extern crate std; + use super::*; use proptest::prelude::*; use soroban_sdk::testutils::{Address as _, Ledger}; diff --git a/contracts/vault/src/whitelist.rs b/contracts/vault/src/whitelist.rs index d1191da7f..bb8145f2e 100644 --- a/contracts/vault/src/whitelist.rs +++ b/contracts/vault/src/whitelist.rs @@ -86,7 +86,7 @@ mod tests { }; use crate::upgrade::set_admin; use crate::DataKey; - use soroban_sdk::{testutils::Address as _, Address, Env}; + use soroban_sdk::{testutils::{Address as _, Ledger as _}, Address, Env}; #[test] fn test_whitelist_documentation_exists() {} diff --git a/docs/api/ERROR_CODE_CATALOG.md b/docs/api/ERROR_CODE_CATALOG.md index 29ce827a3..1679cf257 100644 --- a/docs/api/ERROR_CODE_CATALOG.md +++ b/docs/api/ERROR_CODE_CATALOG.md @@ -164,10 +164,10 @@ source of truth for contract failure behavior. |------|------|-------------|-------------| | 1 | `AlreadyInitialized` | `initialize` called twice | Contract already deployed; call operational methods only | | 2 | `InsufficientShares` | `withdraw` / `execute_withdrawal` with more shares than balance | Lower share amount; refresh user balance | -| 3 | `InvalidAmount` | Zero/negative amount or shares rounded to zero on deposit | Increase deposit amount above minimum | +| 3 | `InvalidAmount` | Zero/negative amount | Pass a positive amount | | 4 | `ContractPaused` | Any user op while paused | Wait for admin `unpause`; show maintenance UI | | 5 | `ExceedsUserCap` | Deposit would exceed per-user cap | Reduce deposit or request cap increase | -| 6 | `MinDepositNotMet` | Deposit below configured minimum | Deposit at least `min_deposit` (read from contract) | +| 6 | `MinDepositNotMet` | Deposit below configured minimum, or dust that would mint zero shares (emits `dep_dust`) | Deposit at least `min_deposit` and at least one share's worth of assets | | 7 | `TimelockNotExpired` | `execute_withdrawal` before 24h timelock; also `execute_fee_bps_change` / `execute_treasury_change` / `execute_price_oracle_change` before the configured sensitive-parameter timelock elapses (Issue #969) | Wait until `unlock_timestamp` / queued `eta`; poll pending state | | 8 | `NoPendingWithdrawal` | `execute_withdrawal` with no pending record; also reused for `accept_admin`/`cancel_admin_rotation` with an unknown proposal id, and `execute_*_change`/`cancel_*_change` (sensitive-parameter timelock) with nothing queued | Call `withdraw` first for large withdrawals; call the matching `queue_*_change` first for timelocked params | | 9 | `LiquidityBufferNotMet` | `invest` would breach minimum idle buffer | Reduce invest amount or wait for deposits |