diff --git a/contracts/sources/liquid_staking.move b/contracts/sources/liquid_staking.move index 64e7c3f..b4bc1b3 100644 --- a/contracts/sources/liquid_staking.move +++ b/contracts/sources/liquid_staking.move @@ -327,6 +327,9 @@ module liquid_staking::liquid_staking { }; self.fees.join(sui.split(redeem_fee_amount as u64)); + // Abort if fees consumed entire redemption amount + assert!(sui.value() > 0, ERedeemInvariantViolated); + emit_event(RedeemEvent { typename: type_name::get

(), lst_amount_in: lst.value(), diff --git a/contracts/sources/storage.move b/contracts/sources/storage.move index d3de10c..25f1678 100644 --- a/contracts/sources/storage.move +++ b/contracts/sources/storage.move @@ -148,6 +148,8 @@ module liquid_staking::storage { // technically this is using a stale exchange rate, but it doesn't matter because we're unstaking everything. // this is done before fetching the exchange rate because i don't want the function to abort if an epoch is skipped. self.unstake_approx_n_sui_from_validator(system_state, i, MAX_SUI_SUPPLY, ctx); + // Reconcile total_sui_amount accounting after full unstake + self.refresh_validator_info(i); }; if (self.validator_infos[i].is_empty()) { @@ -373,8 +375,6 @@ module liquid_staking::storage { return 0 }; - let target_unstake_sui_amount = max(target_unstake_sui_amount, MIN_STAKE_THRESHOLD); - let fungible_staked_sui_amount = validator_info.active_stake.borrow().value(); let total_sui_amount = get_sui_amount( &validator_info.exchange_rate, @@ -495,9 +495,7 @@ module liquid_staking::storage { .borrow_mut() .split_fungible_staked_sui(fungible_staked_sui_amount, ctx); - self.refresh_validator_info(validator_index); - - system_state.redeem_fungible_staked_sui(stake, ctx) + self.redeem_and_update_accounting(system_state, validator_index, stake, ctx) } fun take_active_stake( @@ -509,9 +507,7 @@ module liquid_staking::storage { let validator_info = &mut self.validator_infos[validator_index]; let fungible_staked_sui = validator_info.active_stake.extract(); - self.refresh_validator_info(validator_index); - - system_state.redeem_fungible_staked_sui(fungible_staked_sui, ctx) + self.redeem_and_update_accounting(system_state, validator_index, fungible_staked_sui, ctx) } fun split_from_inactive_stake( @@ -542,6 +538,30 @@ module liquid_staking::storage { stake } + /// Redeems fungible staked SUI and updates accounting based on actual redeemed amount. + fun redeem_and_update_accounting( + self: &mut Storage, + system_state: &mut SuiSystemState, + validator_index: u64, + fungible_staked_sui: FungibleStakedSui, + ctx: &TxContext + ): Balance { + let redeemed_sui = system_state.redeem_fungible_staked_sui(fungible_staked_sui, ctx); + let redeemed_amount = redeemed_sui.value(); + + // Update accounting based on actual redeemed amount. + // Use min() to handle the case where the redeemed amount exceeds the tracked + // amount due to a stale exchange rate (e.g. inactive validator cleanup). + // Any excess is untracked reward income that enters total_sui_supply via join_to_sui_pool. + // The validator's total_sui_amount will be reconciled during the next refresh. + let validator_info = &mut self.validator_infos[validator_index]; + let tracked_decrease = redeemed_amount.min(validator_info.total_sui_amount); + self.total_sui_supply = self.total_sui_supply - tracked_decrease; + validator_info.total_sui_amount = validator_info.total_sui_amount - tracked_decrease; + + redeemed_sui + } + /* Private functions */ fun get_or_add_validator_index_by_staking_pool_id_mut( self: &mut Storage, @@ -575,7 +595,13 @@ module liquid_staking::storage { ); let exchange_rates = system_state.pool_exchange_rates(&staking_pool_id); - let latest_exchange_rate = exchange_rates.borrow(ctx.epoch()); + // Search backwards to handle newly activated validators that may only + // have an exchange rate at their activation epoch, not the current epoch. + let mut cur_epoch = ctx.epoch(); + while (!exchange_rates.contains(cur_epoch)) { + cur_epoch = cur_epoch - 1; + }; + let latest_exchange_rate = exchange_rates.borrow(cur_epoch); self.validator_infos.push_back(ValidatorInfo { staking_pool_id: copy staking_pool_id, diff --git a/contracts/tests/liquid_staking_tests.move b/contracts/tests/liquid_staking_tests.move index a4bfbb9..d277045 100644 --- a/contracts/tests/liquid_staking_tests.move +++ b/contracts/tests/liquid_staking_tests.move @@ -811,4 +811,50 @@ module liquid_staking::liquid_staking_tests { scenario.end(); } + + #[test] + fun test_exchange_rate_gap() { + use sui_system::sui_system; + use std::unit_test; + + let mut scenario = test_scenario::begin(@0x0); + + // activate validator + setup_sui_system(&mut scenario, vector[100]); + + scenario.next_tx(@0x0); + let mut system_state = scenario.take_shared(); + sui_system::set_epoch_for_testing(&mut system_state, scenario.ctx().epoch() + 1); + scenario.next_epoch(@0x0); + + let (admin_cap, mut lst_info) = create_lst( + fees::new_builder(scenario.ctx()) + .set_sui_mint_fee_bps(100) + .set_redeem_fee_bps(100) + .to_fee_config(), + coin::create_treasury_cap_for_testing(scenario.ctx()), + scenario.ctx() + ); + + assert!(lst_info.total_lst_supply() == 0, 0); + assert!(lst_info.storage().total_sui_supply() == 0, 0); + + let sui = coin::mint_for_testing(200 * MIST_PER_SUI, scenario.ctx()); + let lst = lst_info.mint(&mut system_state, sui, scenario.ctx()); + + lst_info.increase_validator_stake( + &admin_cap, + &mut system_state, + @0x0, + 200 * MIST_PER_SUI, + scenario.ctx() + ); + + test_scenario::return_shared(system_state); + unit_test::destroy(admin_cap); + unit_test::destroy(lst); + unit_test::destroy(lst_info); + + scenario.end(); + } } diff --git a/contracts/tests/storage_tests.move b/contracts/tests/storage_tests.move index 5eb4251..26eab6b 100644 --- a/contracts/tests/storage_tests.move +++ b/contracts/tests/storage_tests.move @@ -943,12 +943,12 @@ module liquid_staking::storage_tests { scenario.ctx() ); - assert!(amount == MIST_PER_SUI, 0); + assert!(amount == 2, 0); assert!(storage.validators().length() == 1, 0); assert!(storage.total_sui_supply() == 200 * MIST_PER_SUI, 0); - assert!(storage.sui_pool().value() == MIST_PER_SUI, 0); - assert!(storage.validators()[0].total_sui_amount() == 199 * MIST_PER_SUI, 0); - assert!(storage.validators()[0].active_stake().borrow().value() == 99_500_000_000, 0); + assert!(storage.sui_pool().value() == 2, 0); + assert!(storage.validators()[0].total_sui_amount() == 200 * MIST_PER_SUI - 2, 0); + assert!(storage.validators()[0].active_stake().borrow().value() == 100 * MIST_PER_SUI - 1, 0); assert!(storage.validators()[0].inactive_stake().is_none(), 0); sui::test_utils::destroy(storage); @@ -1456,4 +1456,103 @@ module liquid_staking::storage_tests { sui::test_utils::destroy(storage); scenario.end(); } -} + + #[test] + fun test_unstake_rounding_loss_regression() { + let mut scenario = test_scenario::begin(@0x0); + + // Single validator with 500 SUI + setup_sui_system(&mut scenario, vector[500]); + + // User stakes 200 SUI → validator has 700 tokens total + let staked_sui = stake_with(0, 200, &mut scenario); + advance_epoch_with_reward_amounts(0, 0, &mut scenario); + // Add 400 SUI rewards → rate becomes 1100:700 = 11:7 + advance_epoch_with_reward_amounts(0, 400, &mut scenario); + + scenario.next_tx(@0x0); + + let mut system_state = scenario.take_shared(); + let mut storage = new(scenario.ctx()); + + storage.refresh(&mut system_state, scenario.ctx()); + storage.join_stake(&mut system_state, staked_sui, scenario.ctx()); + + let initial_total = storage.total_sui_supply(); + let initial_validator_total = storage.validators()[0].total_sui_amount(); + let initial_sui_pool = storage.sui_pool().value(); + + // Unstake minimum (1 SUI due to MIN_STAKE_THRESHOLD) + let redeemed = storage.unstake_approx_n_sui_from_active_stake( + &mut system_state, + 0, + MIST_PER_SUI, + scenario.ctx() + ); + + let final_total = storage.total_sui_supply(); + let final_validator_total = storage.validators()[0].total_sui_amount(); + let final_sui_pool = storage.sui_pool().value(); + + // total_sui_supply should not change during unstaking. + // We're just moving SUI from staked form to liquid form. + assert!(final_total == initial_total); + + // The redeemed amount should equal the increase in sui_pool + assert!(final_sui_pool == initial_sui_pool + redeemed); + + // validator_total decreases by exactly the redeemed amount + assert!(final_validator_total == initial_validator_total - redeemed); + + // total_sui_supply = sui_pool + validator totals + assert!(final_total == final_sui_pool + final_validator_total); + + test_scenario::return_shared(system_state); + sui::test_utils::destroy(storage); + scenario.end(); + } + + #[test] + fun test_refresh_inactive_validator_with_rewards() { + let mut scenario = test_scenario::begin(@0x0); + + setup_sui_system(&mut scenario, vector[100, 100]); + + let staked_sui = stake_with(1, 100, &mut scenario); + + // Activate the stake + advance_epoch_with_reward_amounts(0, 0, &mut scenario); + + let mut system_state = scenario.take_shared(); + let mut storage = new(scenario.ctx()); + storage.join_stake(&mut system_state, staked_sui, scenario.ctx()); + storage.refresh(&mut system_state, scenario.ctx()); + test_scenario::return_shared(system_state); + + // Earn rewards, then remove validator. + // Storage's exchange rate is now stale — it doesn't include these rewards. + advance_epoch_with_reward_amounts(0, 400, &mut scenario); + + scenario.next_tx(@0x1); + let mut system_state = scenario.take_shared(); + system_state.request_remove_validator(scenario.ctx()); + test_scenario::return_shared(system_state); + + advance_epoch_with_reward_amounts(0, 0, &mut scenario); + + // refresh() detects the inactive validator and unstakes everything. + // redeem_and_update_accounting subtracts the actual redeemed amount + // (at the current rate, which includes rewards) from + // total_sui_amount (which was computed at the stale rate) + scenario.next_tx(@0x0); + let mut system_state = scenario.take_shared(); + assert!(!system_state.active_validator_addresses().contains(&@0x1)); + + storage.refresh(&mut system_state, scenario.ctx()); + assert!(storage.validators().length() == 0); + + test_scenario::return_shared(system_state); + sui::test_utils::destroy(storage); + scenario.end(); + } +}