Skip to content

Commit c3ca3a8

Browse files
authored
Merge pull request #5 from solendprotocol/audit_fixes
Audit fixes
2 parents 259b00d + 41c3349 commit c3ca3a8

File tree

5 files changed

+272
-29
lines changed

5 files changed

+272
-29
lines changed

contracts/sources/fees.move

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@ module liquid_staking::fees {
22
use sui::bag::{Self, Bag};
33
use std::u64::max;
44

5+
// Errors
56
const EInvalidFeeConfig: u64 = 0;
67

8+
// Constants
9+
const MAX_BPS: u64 = 10_000;
10+
const MAX_REDEEM_FEE_BPS: u64 = 500; // 5%
11+
712
public struct FeeConfig has store {
813
sui_mint_fee_bps: u64,
914
staked_sui_mint_fee_bps: u64, // unused
@@ -127,12 +132,12 @@ module liquid_staking::fees {
127132
// This is because having a 0 fee LST is useful in certain cases where mint/redemption can only be done by
128133
// a single party. It is up to the pool creator to ensure that the fees are set correctly.
129134
fun validate_fees(fees: &FeeConfig) {
130-
assert!(fees.sui_mint_fee_bps <= 10_000, EInvalidFeeConfig);
131-
assert!(fees.staked_sui_mint_fee_bps <= 10_000, EInvalidFeeConfig);
132-
assert!(fees.redeem_fee_bps <= 10_000, EInvalidFeeConfig);
133-
assert!(fees.staked_sui_redeem_fee_bps <= 10_000, EInvalidFeeConfig);
134-
assert!(fees.spread_fee_bps <= 10_000, EInvalidFeeConfig);
135-
assert!(fees.custom_redeem_fee_bps <= 10_000, EInvalidFeeConfig);
135+
assert!(fees.sui_mint_fee_bps <= MAX_BPS, EInvalidFeeConfig);
136+
assert!(fees.staked_sui_mint_fee_bps <= MAX_BPS, EInvalidFeeConfig);
137+
assert!(fees.redeem_fee_bps <= MAX_REDEEM_FEE_BPS, EInvalidFeeConfig);
138+
assert!(fees.staked_sui_redeem_fee_bps <= MAX_BPS, EInvalidFeeConfig);
139+
assert!(fees.spread_fee_bps <= MAX_BPS, EInvalidFeeConfig);
140+
assert!(fees.custom_redeem_fee_bps <= MAX_BPS, EInvalidFeeConfig);
136141
}
137142

138143
public(package) fun calculate_mint_fee(self: &FeeConfig, sui_amount: u64): u64 {
@@ -161,7 +166,7 @@ module liquid_staking::fees {
161166
let fees = FeeConfig {
162167
sui_mint_fee_bps: 10_000,
163168
staked_sui_mint_fee_bps: 10_000,
164-
redeem_fee_bps: 10_000,
169+
redeem_fee_bps: 500,
165170
staked_sui_redeem_fee_bps: 10_000,
166171
spread_fee_bps: 10_000,
167172
custom_redeem_fee_bps: 10_000,

contracts/sources/liquid_staking.move

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ module liquid_staking::liquid_staking {
2020
const EMintInvariantViolated: u64 = 1;
2121
const ERedeemInvariantViolated: u64 = 2;
2222
const EValidatorNotFound: u64 = 3;
23+
const EZeroLstSupply: u64 = 4;
24+
const EZeroLstMinted: u64 = 5;
2325

2426
/* Constants */
2527
const CURRENT_VERSION: u16 = 1;
@@ -159,6 +161,16 @@ module liquid_staking::liquid_staking {
159161
storage.join_to_sui_pool(sui.into_balance());
160162

161163
assert!(lst_treasury_cap.total_supply() > 0 && storage.total_sui_supply() > 0, EInvalidLstCreation);
164+
165+
// make sure the lst ratio is in a sane range:
166+
let total_sui_supply = (storage.total_sui_supply() as u128);
167+
let total_lst_supply = (lst_treasury_cap.total_supply() as u128);
168+
assert!(
169+
(total_sui_supply >= total_lst_supply)
170+
&& (total_sui_supply <= 2 * total_lst_supply), // total_sui_supply / total_lst_supply <= 2
171+
EInvalidLstCreation
172+
);
173+
162174
create_lst_with_storage(
163175
fee_config,
164176
lst_treasury_cap,
@@ -215,6 +227,7 @@ module liquid_staking::liquid_staking {
215227
self.fees.join(sui_balance.split(mint_fee_amount));
216228

217229
let lst_mint_amount = self.sui_amount_to_lst_amount(sui_balance.value());
230+
assert!(lst_mint_amount > 0, EZeroLstMinted);
218231

219232
emit_event(MintEvent {
220233
typename: type_name::get<P>(),
@@ -228,7 +241,8 @@ module liquid_staking::liquid_staking {
228241
// invariant: lst_out / sui_in <= old_lst_supply / old_sui_supply
229242
// -> lst_out * old_sui_supply <= sui_in * old_lst_supply
230243
assert!(
231-
(lst.value() as u128) * old_sui_supply <= (sui_balance.value() as u128) * old_lst_supply,
244+
((lst.value() as u128) * old_sui_supply <= (sui_balance.value() as u128) * old_lst_supply)
245+
|| (old_sui_supply > 0 && old_lst_supply == 0), // special case
232246
EMintInvariantViolated
233247
);
234248

@@ -324,7 +338,7 @@ module liquid_staking::liquid_staking {
324338
_: &AdminCap<P>,
325339
system_state: &mut SuiSystemState,
326340
validator_address: address,
327-
max_sui_amount: u64,
341+
target_unstake_sui_amount: u64,
328342
ctx: &mut TxContext
329343
): u64 {
330344
self.refresh(system_state, ctx);
@@ -335,7 +349,7 @@ module liquid_staking::liquid_staking {
335349
let sui_amount = self.storage.unstake_approx_n_sui_from_validator(
336350
system_state,
337351
validator_index,
338-
max_sui_amount,
352+
target_unstake_sui_amount,
339353
ctx
340354
);
341355

@@ -449,7 +463,8 @@ module liquid_staking::liquid_staking {
449463
let total_sui_supply = self.total_sui_supply();
450464
let total_lst_supply = self.total_lst_supply();
451465

452-
// div by zero case should never happen
466+
assert!(total_lst_supply > 0, EZeroLstSupply);
467+
453468
let sui_amount = (total_sui_supply as u128)
454469
* (lst_amount as u128)
455470
/ (total_lst_supply as u128);

contracts/sources/storage.move

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,15 @@ module liquid_staking::storage {
77
use sui::bag::{Self, Bag};
88

99
/* Errors */
10+
const ENotEnoughSuiInSuiPool: u64 = 0;
11+
const ENotActiveValidator: u64 = 1;
12+
const ETooManyValidators: u64 = 2;
13+
const EValidatorAlreadyExists: u64 = 3;
1014

1115
/* Constants */
1216
const MIN_STAKE_THRESHOLD: u64 = 1_000_000_000;
17+
const MAX_SUI_SUPPLY: u64 = 10_000_000_000 * 1_000_000_000;
18+
const MAX_VALIDATORS: u64 = 50;
1319

1420
/// The Storage struct holds all stake for the LST.
1521
public struct Storage has store {
@@ -129,32 +135,41 @@ module liquid_staking::storage {
129135
return false
130136
};
131137

138+
let active_validator_addresses = system_state.active_validator_addresses();
139+
132140
let mut i = self.validator_infos.length();
133141
while (i > 0) {
134142
i = i - 1;
135143

136-
// update pool token exchange rates
137-
let validator_info = &mut self.validator_infos[i];
144+
// if validator is inactive, withdraw all stake.
145+
if (!active_validator_addresses.contains(&self.validator_infos[i].validator_address)) {
146+
// technically this is using a stale exchange rate, but it doesn't matter because we're unstaking everything.
147+
// this is done before fetching the exchange rate because i don't want the function to abort if an epoch is skipped.
148+
self.unstake_approx_n_sui_from_validator(system_state, i, MAX_SUI_SUPPLY, ctx);
149+
};
138150

139-
let exchange_rates = system_state.pool_exchange_rates(&validator_info.staking_pool_id);
151+
if (self.validator_infos[i].is_empty()) {
152+
let ValidatorInfo { active_stake, inactive_stake, extra_fields, .. } = self.validator_infos.remove(i);
153+
active_stake.destroy_none();
154+
inactive_stake.destroy_none();
155+
extra_fields.destroy_empty();
156+
157+
continue
158+
};
159+
160+
// update pool token exchange rates
161+
let exchange_rates = system_state.pool_exchange_rates(&self.validator_infos[i].staking_pool_id);
140162
let latest_exchange_rate = exchange_rates.borrow(ctx.epoch());
141163

142-
validator_info.exchange_rate = *latest_exchange_rate;
164+
self.validator_infos[i].exchange_rate = *latest_exchange_rate;
165+
self.refresh_validator_info(i);
143166

144-
if (validator_info.inactive_stake.is_some()) {
167+
if (self.validator_infos[i].inactive_stake.is_some()) {
145168
let inactive_stake = self.take_from_inactive_stake(i);
146169
let fungible_staked_sui = system_state.convert_to_fungible_staked_sui(inactive_stake, ctx);
147170
self.join_fungible_staked_sui_to_validator(i, fungible_staked_sui);
148171
};
149172

150-
refresh_validator_info(self, i);
151-
152-
if (self.validator_infos[i].is_empty()) {
153-
let ValidatorInfo { active_stake, inactive_stake, extra_fields, .. } = self.validator_infos.remove(i);
154-
active_stake.destroy_none();
155-
inactive_stake.destroy_none();
156-
extra_fields.destroy_empty();
157-
};
158173
};
159174

160175
self.last_refresh_epoch = ctx.epoch();
@@ -373,7 +388,7 @@ module liquid_staking::storage {
373388
let target_unstake_sui_amount = max(target_unstake_sui_amount, MIN_STAKE_THRESHOLD);
374389

375390
let staked_sui_amount = validator_info.inactive_stake.borrow().staked_sui_amount();
376-
let staked_sui = if (staked_sui_amount <= target_unstake_sui_amount + MIN_STAKE_THRESHOLD) {
391+
let staked_sui = if (staked_sui_amount < target_unstake_sui_amount + MIN_STAKE_THRESHOLD) {
377392
self.take_from_inactive_stake(validator_index)
378393
}
379394
else {
@@ -424,6 +439,7 @@ module liquid_staking::storage {
424439
};
425440
};
426441

442+
assert!(self.sui_pool.value() >= max_sui_amount_out, ENotEnoughSuiInSuiPool);
427443
self.split_from_sui_pool(max_sui_amount_out)
428444
}
429445

@@ -495,16 +511,31 @@ module liquid_staking::storage {
495511
staking_pool_id: ID,
496512
ctx: &mut TxContext
497513
): u64 {
514+
let mut current_validator_addresses = vector[];
515+
498516
let mut i = 0;
499517
while (i < self.validator_infos.length()) {
500518
if (self.validator_infos[i].staking_pool_id == staking_pool_id) {
501519
return i
502520
};
503521

522+
current_validator_addresses.push_back(self.validator_infos[i].validator_address);
504523
i = i + 1;
505524
};
506525

507526
let validator_address = system_state.validator_address_by_pool_id(&staking_pool_id);
527+
528+
assert!(
529+
!current_validator_addresses.contains(&validator_address),
530+
EValidatorAlreadyExists
531+
);
532+
533+
let active_validator_addresses = system_state.active_validator_addresses();
534+
assert!(
535+
active_validator_addresses.contains(&validator_address),
536+
ENotActiveValidator
537+
);
538+
508539
let exchange_rates = system_state.pool_exchange_rates(&staking_pool_id);
509540
let latest_exchange_rate = exchange_rates.borrow(ctx.epoch());
510541

@@ -518,6 +549,8 @@ module liquid_staking::storage {
518549
extra_fields: bag::new(ctx)
519550
});
520551

552+
assert!(self.validator_infos.length() <= MAX_VALIDATORS, ETooManyValidators);
553+
521554
i
522555
}
523556

contracts/tests/liquid_staking_tests.move

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ module liquid_staking::liquid_staking_tests {
9292

9393
// Create a treasury cap with non-zero coins
9494
let mut treasury_cap = coin::create_treasury_cap_for_testing<TEST>(scenario.ctx());
95-
let coins = treasury_cap.mint(1000 * MIST_PER_SUI, scenario.ctx());
95+
let coins = treasury_cap.mint(200 * MIST_PER_SUI, scenario.ctx());
9696

9797
let (admin_cap, lst_info) = create_lst_with_stake<TEST>(
9898
&mut system_state,
@@ -106,7 +106,7 @@ module liquid_staking::liquid_staking_tests {
106106
scenario.ctx()
107107
);
108108

109-
assert!(lst_info.total_lst_supply() == 1000 * MIST_PER_SUI, 0);
109+
assert!(lst_info.total_lst_supply() == 200 * MIST_PER_SUI, 0);
110110
assert!(lst_info.storage().total_sui_supply() == 200 * MIST_PER_SUI, 0);
111111

112112
test_scenario::return_shared(system_state);
@@ -217,6 +217,72 @@ module liquid_staking::liquid_staking_tests {
217217
scenario.end();
218218
}
219219

220+
#[test]
221+
#[expected_failure(abort_code = 0, location = liquid_staking::liquid_staking)]
222+
fun test_create_lst_with_stake_fail_3() {
223+
let mut scenario = test_scenario::begin(@0x0);
224+
225+
setup_sui_system(&mut scenario, vector[100, 100]);
226+
227+
let mut system_state = scenario.take_shared<SuiSystemState>();
228+
229+
let mut treasury_cap = coin::create_treasury_cap_for_testing(scenario.ctx());
230+
let coins = treasury_cap.mint(1000 * MIST_PER_SUI, scenario.ctx());
231+
232+
let (admin_cap, lst_info) = create_lst_with_stake<TEST>(
233+
&mut system_state,
234+
fees::new_builder(scenario.ctx())
235+
.set_sui_mint_fee_bps(100)
236+
.set_redeem_fee_bps(100)
237+
.to_fee_config(),
238+
treasury_cap,
239+
vector::empty(),
240+
coin::mint_for_testing(1000 * MIST_PER_SUI - 1, scenario.ctx()),
241+
scenario.ctx()
242+
);
243+
244+
test_scenario::return_shared(system_state);
245+
246+
sui::test_utils::destroy(admin_cap);
247+
sui::test_utils::destroy(lst_info);
248+
sui::test_utils::destroy(coins);
249+
250+
scenario.end();
251+
}
252+
253+
#[test]
254+
#[expected_failure(abort_code = 0, location = liquid_staking::liquid_staking)]
255+
fun test_create_lst_with_stake_fail_4() {
256+
let mut scenario = test_scenario::begin(@0x0);
257+
258+
setup_sui_system(&mut scenario, vector[100, 100]);
259+
260+
let mut system_state = scenario.take_shared<SuiSystemState>();
261+
262+
let mut treasury_cap = coin::create_treasury_cap_for_testing(scenario.ctx());
263+
let coins = treasury_cap.mint(1000 * MIST_PER_SUI, scenario.ctx());
264+
265+
let (admin_cap, lst_info) = create_lst_with_stake<TEST>(
266+
&mut system_state,
267+
fees::new_builder(scenario.ctx())
268+
.set_sui_mint_fee_bps(100)
269+
.set_redeem_fee_bps(100)
270+
.to_fee_config(),
271+
treasury_cap,
272+
vector::empty(),
273+
coin::mint_for_testing(2000 * MIST_PER_SUI + 1, scenario.ctx()),
274+
scenario.ctx()
275+
);
276+
277+
test_scenario::return_shared(system_state);
278+
279+
sui::test_utils::destroy(admin_cap);
280+
sui::test_utils::destroy(lst_info);
281+
sui::test_utils::destroy(coins);
282+
283+
scenario.end();
284+
}
285+
220286
#[test]
221287
fun test_mint_and_redeem() {
222288
let mut scenario = test_scenario::begin(@0x0);
@@ -668,7 +734,7 @@ module liquid_staking::liquid_staking_tests {
668734

669735
let staked_sui = stake_with(0, std::u64::max(mint_amount / MIST_PER_SUI, 1), &mut scenario);
670736
let mut treasury_cap = coin::create_treasury_cap_for_testing<TEST>(scenario.ctx());
671-
let lst = treasury_cap.mint(100 * MIST_PER_SUI, scenario.ctx());
737+
let lst = treasury_cap.mint(mint_amount / MIST_PER_SUI * MIST_PER_SUI, scenario.ctx());
672738

673739
advance_epoch_with_reward_amounts(0, 0, &mut scenario);
674740

0 commit comments

Comments
 (0)