Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion contracts/wrapper/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?;
Expand All @@ -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);
Expand Down
152 changes: 150 additions & 2 deletions contracts/wrapper/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -2150,3 +2148,153 @@ 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);
}

// ─── 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
);
}

// ─── 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);
}
Loading