Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Audit fixes #5

Merged
merged 10 commits into from
Oct 25, 2024
19 changes: 12 additions & 7 deletions contracts/sources/fees.move
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@ module liquid_staking::fees {
use sui::bag::{Self, Bag};
use std::u64::max;

// Errors
const EInvalidFeeConfig: u64 = 0;

// Constants
const MAX_BPS: u64 = 10_000;
const MAX_REDEEM_FEE_BPS: u64 = 500; // 5%

public struct FeeConfig has store {
sui_mint_fee_bps: u64,
staked_sui_mint_fee_bps: u64, // unused
Expand Down Expand Up @@ -127,12 +132,12 @@ module liquid_staking::fees {
// This is because having a 0 fee LST is useful in certain cases where mint/redemption can only be done by
// a single party. It is up to the pool creator to ensure that the fees are set correctly.
fun validate_fees(fees: &FeeConfig) {
assert!(fees.sui_mint_fee_bps <= 10_000, EInvalidFeeConfig);
assert!(fees.staked_sui_mint_fee_bps <= 10_000, EInvalidFeeConfig);
assert!(fees.redeem_fee_bps <= 10_000, EInvalidFeeConfig);
assert!(fees.staked_sui_redeem_fee_bps <= 10_000, EInvalidFeeConfig);
assert!(fees.spread_fee_bps <= 10_000, EInvalidFeeConfig);
assert!(fees.custom_redeem_fee_bps <= 10_000, EInvalidFeeConfig);
assert!(fees.sui_mint_fee_bps <= MAX_BPS, EInvalidFeeConfig);
assert!(fees.staked_sui_mint_fee_bps <= MAX_BPS, EInvalidFeeConfig);
assert!(fees.redeem_fee_bps <= MAX_REDEEM_FEE_BPS, EInvalidFeeConfig);
assert!(fees.staked_sui_redeem_fee_bps <= MAX_BPS, EInvalidFeeConfig);
assert!(fees.spread_fee_bps <= MAX_BPS, EInvalidFeeConfig);
assert!(fees.custom_redeem_fee_bps <= MAX_BPS, EInvalidFeeConfig);
}

public(package) fun calculate_mint_fee(self: &FeeConfig, sui_amount: u64): u64 {
Expand Down Expand Up @@ -161,7 +166,7 @@ module liquid_staking::fees {
let fees = FeeConfig {
sui_mint_fee_bps: 10_000,
staked_sui_mint_fee_bps: 10_000,
redeem_fee_bps: 10_000,
redeem_fee_bps: 500,
staked_sui_redeem_fee_bps: 10_000,
spread_fee_bps: 10_000,
custom_redeem_fee_bps: 10_000,
Expand Down
23 changes: 19 additions & 4 deletions contracts/sources/liquid_staking.move
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ module liquid_staking::liquid_staking {
const EMintInvariantViolated: u64 = 1;
const ERedeemInvariantViolated: u64 = 2;
const EValidatorNotFound: u64 = 3;
const EZeroLstSupply: u64 = 4;
const EZeroLstMinted: u64 = 5;

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

assert!(lst_treasury_cap.total_supply() > 0 && storage.total_sui_supply() > 0, EInvalidLstCreation);

// make sure the lst ratio is in a sane range:
let total_sui_supply = (storage.total_sui_supply() as u128);
let total_lst_supply = (lst_treasury_cap.total_supply() as u128);
assert!(
(total_sui_supply >= total_lst_supply)
&& (total_sui_supply <= 2 * total_lst_supply), // total_sui_supply / total_lst_supply <= 2
EInvalidLstCreation
);

create_lst_with_storage(
fee_config,
lst_treasury_cap,
Expand Down Expand Up @@ -215,6 +227,7 @@ module liquid_staking::liquid_staking {
self.fees.join(sui_balance.split(mint_fee_amount));

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

emit_event(MintEvent {
typename: type_name::get<P>(),
Expand All @@ -228,7 +241,8 @@ module liquid_staking::liquid_staking {
// invariant: lst_out / sui_in <= old_lst_supply / old_sui_supply
// -> lst_out * old_sui_supply <= sui_in * old_lst_supply
assert!(
(lst.value() as u128) * old_sui_supply <= (sui_balance.value() as u128) * old_lst_supply,
((lst.value() as u128) * old_sui_supply <= (sui_balance.value() as u128) * old_lst_supply)
|| (old_sui_supply > 0 && old_lst_supply == 0), // special case
EMintInvariantViolated
);

Expand Down Expand Up @@ -324,7 +338,7 @@ module liquid_staking::liquid_staking {
_: &AdminCap<P>,
system_state: &mut SuiSystemState,
validator_address: address,
max_sui_amount: u64,
target_unstake_sui_amount: u64,
ctx: &mut TxContext
): u64 {
self.refresh(system_state, ctx);
Expand All @@ -335,7 +349,7 @@ module liquid_staking::liquid_staking {
let sui_amount = self.storage.unstake_approx_n_sui_from_validator(
system_state,
validator_index,
max_sui_amount,
target_unstake_sui_amount,
ctx
);

Expand Down Expand Up @@ -449,7 +463,8 @@ module liquid_staking::liquid_staking {
let total_sui_supply = self.total_sui_supply();
let total_lst_supply = self.total_lst_supply();

// div by zero case should never happen
assert!(total_lst_supply > 0, EZeroLstSupply);

let sui_amount = (total_sui_supply as u128)
* (lst_amount as u128)
/ (total_lst_supply as u128);
Expand Down
61 changes: 47 additions & 14 deletions contracts/sources/storage.move
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,15 @@ module liquid_staking::storage {
use sui::bag::{Self, Bag};

/* Errors */
const ENotEnoughSuiInSuiPool: u64 = 0;
const ENotActiveValidator: u64 = 1;
const ETooManyValidators: u64 = 2;
const EValidatorAlreadyExists: u64 = 3;

/* Constants */
const MIN_STAKE_THRESHOLD: u64 = 1_000_000_000;
const MAX_SUI_SUPPLY: u64 = 10_000_000_000 * 1_000_000_000;
const MAX_VALIDATORS: u64 = 50;

/// The Storage struct holds all stake for the LST.
public struct Storage has store {
Expand Down Expand Up @@ -129,32 +135,41 @@ module liquid_staking::storage {
return false
};

let active_validator_addresses = system_state.active_validator_addresses();

let mut i = self.validator_infos.length();
while (i > 0) {
i = i - 1;

// update pool token exchange rates
let validator_info = &mut self.validator_infos[i];
// if validator is inactive, withdraw all stake.
if (!active_validator_addresses.contains(&self.validator_infos[i].validator_address)) {
// 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);
};

let exchange_rates = system_state.pool_exchange_rates(&validator_info.staking_pool_id);
if (self.validator_infos[i].is_empty()) {
let ValidatorInfo { active_stake, inactive_stake, extra_fields, .. } = self.validator_infos.remove(i);
active_stake.destroy_none();
inactive_stake.destroy_none();
extra_fields.destroy_empty();

continue
};

// update pool token exchange rates
let exchange_rates = system_state.pool_exchange_rates(&self.validator_infos[i].staking_pool_id);
let latest_exchange_rate = exchange_rates.borrow(ctx.epoch());

validator_info.exchange_rate = *latest_exchange_rate;
self.validator_infos[i].exchange_rate = *latest_exchange_rate;
self.refresh_validator_info(i);

if (validator_info.inactive_stake.is_some()) {
if (self.validator_infos[i].inactive_stake.is_some()) {
let inactive_stake = self.take_from_inactive_stake(i);
let fungible_staked_sui = system_state.convert_to_fungible_staked_sui(inactive_stake, ctx);
self.join_fungible_staked_sui_to_validator(i, fungible_staked_sui);
};

refresh_validator_info(self, i);

if (self.validator_infos[i].is_empty()) {
let ValidatorInfo { active_stake, inactive_stake, extra_fields, .. } = self.validator_infos.remove(i);
active_stake.destroy_none();
inactive_stake.destroy_none();
extra_fields.destroy_empty();
};
};

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

let staked_sui_amount = validator_info.inactive_stake.borrow().staked_sui_amount();
let staked_sui = if (staked_sui_amount <= target_unstake_sui_amount + MIN_STAKE_THRESHOLD) {
let staked_sui = if (staked_sui_amount < target_unstake_sui_amount + MIN_STAKE_THRESHOLD) {
self.take_from_inactive_stake(validator_index)
}
else {
Expand Down Expand Up @@ -424,6 +439,7 @@ module liquid_staking::storage {
};
};

assert!(self.sui_pool.value() >= max_sui_amount_out, ENotEnoughSuiInSuiPool);
self.split_from_sui_pool(max_sui_amount_out)
}

Expand Down Expand Up @@ -495,16 +511,31 @@ module liquid_staking::storage {
staking_pool_id: ID,
ctx: &mut TxContext
): u64 {
let mut current_validator_addresses = vector[];

let mut i = 0;
while (i < self.validator_infos.length()) {
if (self.validator_infos[i].staking_pool_id == staking_pool_id) {
return i
};

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

let validator_address = system_state.validator_address_by_pool_id(&staking_pool_id);

assert!(
!current_validator_addresses.contains(&validator_address),
EValidatorAlreadyExists
);

let active_validator_addresses = system_state.active_validator_addresses();
assert!(
active_validator_addresses.contains(&validator_address),
ENotActiveValidator
);

let exchange_rates = system_state.pool_exchange_rates(&staking_pool_id);
let latest_exchange_rate = exchange_rates.borrow(ctx.epoch());

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

assert!(self.validator_infos.length() <= MAX_VALIDATORS, ETooManyValidators);

i
}

Expand Down
72 changes: 69 additions & 3 deletions contracts/tests/liquid_staking_tests.move
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ module liquid_staking::liquid_staking_tests {

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

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

assert!(lst_info.total_lst_supply() == 1000 * MIST_PER_SUI, 0);
assert!(lst_info.total_lst_supply() == 200 * MIST_PER_SUI, 0);
assert!(lst_info.storage().total_sui_supply() == 200 * MIST_PER_SUI, 0);

test_scenario::return_shared(system_state);
Expand Down Expand Up @@ -217,6 +217,72 @@ module liquid_staking::liquid_staking_tests {
scenario.end();
}

#[test]
#[expected_failure(abort_code = 0, location = liquid_staking::liquid_staking)]
fun test_create_lst_with_stake_fail_3() {
let mut scenario = test_scenario::begin(@0x0);

setup_sui_system(&mut scenario, vector[100, 100]);

let mut system_state = scenario.take_shared<SuiSystemState>();

let mut treasury_cap = coin::create_treasury_cap_for_testing(scenario.ctx());
let coins = treasury_cap.mint(1000 * MIST_PER_SUI, scenario.ctx());

let (admin_cap, lst_info) = create_lst_with_stake<TEST>(
&mut system_state,
fees::new_builder(scenario.ctx())
.set_sui_mint_fee_bps(100)
.set_redeem_fee_bps(100)
.to_fee_config(),
treasury_cap,
vector::empty(),
coin::mint_for_testing(1000 * MIST_PER_SUI - 1, scenario.ctx()),
scenario.ctx()
);

test_scenario::return_shared(system_state);

sui::test_utils::destroy(admin_cap);
sui::test_utils::destroy(lst_info);
sui::test_utils::destroy(coins);

scenario.end();
}

#[test]
#[expected_failure(abort_code = 0, location = liquid_staking::liquid_staking)]
fun test_create_lst_with_stake_fail_4() {
let mut scenario = test_scenario::begin(@0x0);

setup_sui_system(&mut scenario, vector[100, 100]);

let mut system_state = scenario.take_shared<SuiSystemState>();

let mut treasury_cap = coin::create_treasury_cap_for_testing(scenario.ctx());
let coins = treasury_cap.mint(1000 * MIST_PER_SUI, scenario.ctx());

let (admin_cap, lst_info) = create_lst_with_stake<TEST>(
&mut system_state,
fees::new_builder(scenario.ctx())
.set_sui_mint_fee_bps(100)
.set_redeem_fee_bps(100)
.to_fee_config(),
treasury_cap,
vector::empty(),
coin::mint_for_testing(2000 * MIST_PER_SUI + 1, scenario.ctx()),
scenario.ctx()
);

test_scenario::return_shared(system_state);

sui::test_utils::destroy(admin_cap);
sui::test_utils::destroy(lst_info);
sui::test_utils::destroy(coins);

scenario.end();
}

#[test]
fun test_mint_and_redeem() {
let mut scenario = test_scenario::begin(@0x0);
Expand Down Expand Up @@ -668,7 +734,7 @@ module liquid_staking::liquid_staking_tests {

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

advance_epoch_with_reward_amounts(0, 0, &mut scenario);

Expand Down
Loading
Loading