From 6f5ccfcbeff9120c06e82b99a4ee5f2ab2f6fa09 Mon Sep 17 00:00:00 2001 From: mikwansa <308800479+mikwansa@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:11:26 +0200 Subject: [PATCH 1/4] test(vaults): add zero-balance deposit/withdraw revert tests (#737) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the 0-amount deposit and 0-amount withdrawal revert paths, including on an empty vault before any shares exist, and asserts the failed calls leave vault state untouched. Closes #737 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- contracts/wrapper/src/test.rs | 44 +++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/contracts/wrapper/src/test.rs b/contracts/wrapper/src/test.rs index 5c66b485..9ccfa3c3 100644 --- a/contracts/wrapper/src/test.rs +++ b/contracts/wrapper/src/test.rs @@ -2148,3 +2148,47 @@ fn test_withdrawal_math_reverts_on_insufficient_shares() { Err(Ok(WrapperError::InsufficientBalance)) ); } + +// ─── Zero-Balance Deposit Reverts Tests (#737) ─────────────────────────────── + +#[test] +fn test_zero_balance_deposit_reverts() { + let env = Env::default(); + env.mock_all_auths(); + let (wrapper, _underlying, _admin, user) = setup_and_fund(&env); + + // 0-amount deposit reverts (prevents spam / divide-by-zero). + assert_eq!( + wrapper.try_deposit(&user, &0), + Err(Ok(WrapperError::InvalidAmount)) + ); + + // A real deposit so the withdrawal path is exercised with shares held. + wrapper.deposit(&user, &1_000_000); + + // 0-amount withdrawal must also revert. + assert_eq!( + wrapper.try_withdraw(&user, &0), + Err(Ok(WrapperError::InvalidAmount)) + ); + + // The failed calls left vault state untouched. + assert_eq!(wrapper.supply(), 1_000_000); + assert_eq!(wrapper.balance(&user), 1_000_000); +} + +#[test] +fn test_zero_balance_deposit_reverts_before_any_shares() { + let env = Env::default(); + env.mock_all_auths(); + let (wrapper, _underlying, _admin, user) = setup_and_fund(&env); + + // A 0-amount deposit on an empty vault must revert instead of minting + // shares at a 1:1 bootstrap rate (divide-by-zero / spam protection). + assert_eq!( + wrapper.try_deposit(&user, &0), + Err(Ok(WrapperError::InvalidAmount)) + ); + assert_eq!(wrapper.supply(), 0); + assert_eq!(wrapper.balance(&user), 0); +} From 81fcb3945c22a0bd59593fff63abb4d09f0d9fb6 Mon Sep 17 00:00:00 2001 From: mikwansa <308800479+mikwansa@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:11:26 +0200 Subject: [PATCH 2/4] test(vaults): add reward distribution rounding tests (#738) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deposits prime-number amounts, distributes a reward that does not divide evenly, and asserts each payout never exceeds the user's pro-rata entitlement and the vault pays out no more than it received. Closes #738 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- contracts/wrapper/src/test.rs | 67 +++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/contracts/wrapper/src/test.rs b/contracts/wrapper/src/test.rs index 9ccfa3c3..6722a445 100644 --- a/contracts/wrapper/src/test.rs +++ b/contracts/wrapper/src/test.rs @@ -2192,3 +2192,70 @@ fn test_zero_balance_deposit_reverts_before_any_shares() { assert_eq!(wrapper.supply(), 0); assert_eq!(wrapper.balance(&user), 0); } + +// ─── Reward Distribution Rounding Tests (#738) ─────────────────────────────── + +#[test] +fn test_reward_distribution_rounding_prime_deposits_never_insolvent() { + let env = Env::default(); + env.mock_all_auths(); + let (wrapper, underlying, admin, _user) = setup_and_fund(&env); + let wrapper_id = wrapper.address.clone(); + let rewarder = Address::generate(&env); + + let user_a = Address::generate(&env); + let user_b = Address::generate(&env); + let user_c = Address::generate(&env); + + // Prime-number deposits: 97, 101, 103. + let deposit_a: i128 = 97; + let deposit_b: i128 = 101; + let deposit_c: i128 = 103; + + underlying.mint(&admin, &user_a, &deposit_a); + underlying.mint(&admin, &user_b, &deposit_b); + underlying.mint(&admin, &user_c, &deposit_c); + underlying.approve(&user_a, &wrapper_id, &deposit_a, &u32::MAX); + underlying.approve(&user_b, &wrapper_id, &deposit_b, &u32::MAX); + underlying.approve(&user_c, &wrapper_id, &deposit_c, &u32::MAX); + + wrapper.deposit(&user_a, &deposit_a); + wrapper.deposit(&user_b, &deposit_b); + wrapper.deposit(&user_c, &deposit_c); + + // Reward that does not divide evenly across the share pool. + let reward: i128 = 10_000; + underlying.mint(&admin, &rewarder, &reward); + underlying.approve(&rewarder, &wrapper_id, &reward, &u32::MAX); + wrapper.distribute_rewards(&rewarder, &reward); + + let total_deposits = deposit_a + deposit_b + deposit_c; + assert_eq!(wrapper.total_assets(), total_deposits + reward); + + // Each payout is at most the user's pro-rata entitlement: rounding is + // always down (in favor of the protocol), so the vault is never insolvent. + let shares_a = wrapper.balance(&user_a); + let entitlement_a = shares_a * wrapper.total_assets() / wrapper.supply(); + let payout_a = wrapper.withdraw(&user_a, &shares_a); + assert!(payout_a <= entitlement_a); + + let shares_b = wrapper.balance(&user_b); + let entitlement_b = shares_b * wrapper.total_assets() / wrapper.supply(); + let payout_b = wrapper.withdraw(&user_b, &shares_b); + assert!(payout_b <= entitlement_b); + + let shares_c = wrapper.balance(&user_c); + let entitlement_c = shares_c * wrapper.total_assets() / wrapper.supply(); + let payout_c = wrapper.withdraw(&user_c, &shares_c); + assert!(payout_c <= entitlement_c); + + // Conservation: the vault paid out no more than it received; any rounding + // dust stays in the vault rather than being created out of thin air. + let total_paid = payout_a + payout_b + payout_c; + assert!(total_paid <= total_deposits + reward); + assert_eq!(wrapper.supply(), 0); + assert_eq!( + underlying.balance(&wrapper_id), + total_deposits + reward - total_paid + ); +} From 78fd246cb36ca03a9896bf34e7630a89195544a9 Mon Sep 17 00:00:00 2001 From: mikwansa <308800479+mikwansa@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:11:26 +0200 Subject: [PATCH 3/4] fix(vaults): enforce lockup on unwrap and add lockup cycle tests (#739) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit unwrap() bypassed the deposit time lockup because only withdraw() ran the require_unlocked guard, letting a time-locked depositor exit early. Adds the guard to unwrap() and tests the full deposit -> locked withdraw -> advance time -> withdraw cycle, plus the unwrap bypass path. Closes #739 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- contracts/wrapper/src/lib.rs | 7 ++++++- contracts/wrapper/src/test.rs | 39 +++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/contracts/wrapper/src/lib.rs b/contracts/wrapper/src/lib.rs index 8985c80a..52c44cc1 100644 --- a/contracts/wrapper/src/lib.rs +++ b/contracts/wrapper/src/lib.rs @@ -604,7 +604,8 @@ impl WrapperContract { /// in favor of the protocol. /// /// # Security - /// Protected by a reentrancy guard. + /// Protected by a reentrancy guard, and enforces the deposit time lockup so + /// a locked deposit cannot be exited early via `unwrap` either. pub fn unwrap(env: Env, caller: Address, wrapped_amount: i128) -> Result<(), WrapperError> { Self::ensure_initialized(&env)?; Self::ensure_not_paused(&env)?; @@ -614,6 +615,10 @@ impl WrapperContract { return Err(WrapperError::InvalidAmount); } + // #739 – enforce the deposit time lockup like `withdraw` does; without + // this guard, `unwrap` would let a time-locked depositor exit early. + Self::require_unlocked(&env, &caller)?; + let balance = Self::read_balance(&env, &caller); if balance < wrapped_amount { return Err(WrapperError::InsufficientBalance); diff --git a/contracts/wrapper/src/test.rs b/contracts/wrapper/src/test.rs index 6722a445..a3d2f1db 100644 --- a/contracts/wrapper/src/test.rs +++ b/contracts/wrapper/src/test.rs @@ -2259,3 +2259,42 @@ fn test_reward_distribution_rounding_prime_deposits_never_insolvent() { total_deposits + reward - total_paid ); } + +// ─── Lockup Enforcement Tests (#739) ───────────────────────────────────────── + +#[test] +fn test_lockup_enforcement_full_deposit_withdraw_cycle() { + let env = Env::default(); + env.mock_all_auths(); + let (wrapper, underlying, admin, user) = setup_and_fund(&env); + + // Admin records the deposit lockup: the deposit unlocks at UNLOCK_TIME. + wrapper.set_unlock_time(&admin, &user, &UNLOCK_TIME); + + // Deposit while the ledger is well before the unlock time. + let mut ledger_info = env.ledger().get(); + ledger_info.timestamp = UNLOCK_TIME - 100; + env.ledger().set(ledger_info); + wrapper.deposit(&user, &1_000_000); + + // 1. Withdraw immediately (still locked) -> reverts. + assert_eq!( + wrapper.try_withdraw(&user, &1_000_000), + Err(Ok(WrapperError::TokensLocked)) + ); + + // 2. Unwrapping is also blocked: the lockup cannot be bypassed via unwrap. + assert_eq!( + wrapper.try_unwrap(&user, &1_000_000), + Err(Ok(WrapperError::TokensLocked)) + ); + + // 3. Advance time past the unlock timestamp -> withdrawal succeeds. + let mut ledger_info = env.ledger().get(); + ledger_info.timestamp = UNLOCK_TIME + 100; + env.ledger().set(ledger_info); + let tokens_out = wrapper.withdraw(&user, &1_000_000); + assert_eq!(tokens_out, 1_000_000); + assert_eq!(wrapper.balance(&user), 0); + assert_eq!(underlying.balance(&user), 10_000_000); +} From 7fe9984a6d677eb9104311813f5f7590be4ac915 Mon Sep 17 00:00:00 2001 From: Promise Raji Date: Mon, 31 Aug 2026 11:57:45 +0100 Subject: [PATCH 4/4] Merge origin/main and keep vault-state unauthorized checks on the role guard. Co-authored-by: Cursor --- contracts/wrapper/src/test.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/contracts/wrapper/src/test.rs b/contracts/wrapper/src/test.rs index 5a4826b0..e1966937 100644 --- a/contracts/wrapper/src/test.rs +++ b/contracts/wrapper/src/test.rs @@ -524,8 +524,6 @@ fn test_set_vault_state_unauthorized_fails() { let env = Env::default(); env.mock_all_auths(); let (wrapper, _underlying, _admin, user, _wrapper_id) = setup(&env); - // Drop blanket auth mocks so require_admin fails for a non-admin caller. - env.mock_auths(&[]); let fee_receiver = Address::generate(&env); let state = VaultState {