Platform: Code4rena
Start Date: 02/02/2024
Pot Size: $100,000 USDC
Total HM: 11
Participants: 27
Period: 28 days
Judge: Lambda
Total Solo HM: 4
Id: 327
League:
Rank: 6/27
Findings: 5
Award: $5,445.53
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: carrotsmuggler
Also found by: 3docSec, Aymen0909, DadeKuma, Franfran, J4X, QiuhaoLi, ZanyBonzy, carrotsmuggler, emerald7017, erebus, oakcobalt, zhaojie
152.7401 USDC - $152.74
LP may receive fewer shares than expected because of changed fees.
In the stable swap pallet, we apply fees on the different pools which depend on the pool.fee value and the number of coins.
/// Calculate amount of shares to be given to LP after LP provided liquidity of some assets to the pool. pub fn calculate_shares<const D: u8>( initial_reserves: &[AssetReserve], updated_reserves: &[AssetReserve], amplification: Balance, share_issuance: Balance, fee: Permill, ) -> Option<Balance> { // ... let fixed_fee = FixedU128::from(fee); let fee = fixed_fee .checked_mul(&FixedU128::from(n_coins as u128))? .checked_div(&FixedU128::from(4 * (n_coins - 1) as u128))?; // ... let amplification = Self::get_amplification(&pool); let share_issuance = T::Currency::total_issuance(pool_id); let share_amount = hydra_dx_math::stableswap::calculate_shares::<D_ITERATIONS>( &initial_reserves, &updated_reserves, amplification, share_issuance, pool.fee, )
The fee is taken into account when the shares are calculated later, so when the fee increases, the LP receives fewer shares. Also, the current amplification has impacts on the D which is the liquidity value (shares). However, there is no (slippage) protection for LP, so if an LP calls add_liquidity
and there is a AuthorityOrigin update amplification or fee, the LP may receive fewer shares than expected.
Manual Review.
Add a minimal shares parameter to add_liquidity
function.
Uniswap
#0 - c4-pre-sort
2024-03-03T09:43:02Z
0xRobocop marked the issue as duplicate of #158
#1 - c4-judge
2024-03-07T21:11:46Z
OpenCoreCH marked the issue as satisfactory
🌟 Selected for report: carrotsmuggler
1576.9955 USDC - $1,577.00
https://github.com/code-423n4/2024-02-hydradx/blob/main/HydraDX-node/pallets/stableswap/src/lib.rs#L584 https://github.com/code-423n4/2024-02-hydradx/blob/main/HydraDX-node/math/src/stableswap/math.rs#L160
The last LP in a stable swap pool can't remove all the liquidity or remove all his shares for an assest.
When an LP removes liquidity from a stable swap pool, we make sure the shares left are bigger than MinPoolLiquidity
or equal to all the issued shares:
ensure!( share_issuance == share_amount // <=== || share_issuance.saturating_sub(share_amount) >= T::MinPoolLiquidity::get(), Error::<T>::InsufficientLiquidityRemaining );
However, this is actually impossible because we will fail in math functions calculating for assets. Especially, if an LP tries to remove all the shares, the calculation of adjusted_d
in calculate_shares
will fail because not all reserves in adjusted_reserves
are zero.
Let's try it in test remove_liquidity_should_work_when_withdrawing_all_shares
by removing the Alice (the first and last LP) all shares:
fn remove_liquidity_should_work_when_withdrawing_all_shares() { let asset_a: AssetId = 1; let asset_b: AssetId = 2; let asset_c: AssetId = 3; ExtBuilder::default() .with_endowed_accounts(vec![ (BOB, asset_a, 200 * ONE), (ALICE, asset_a, 100 * ONE), (ALICE, asset_b, 200 * ONE), (ALICE, asset_c, 300 * ONE), ]) .with_registered_asset("one".as_bytes().to_vec(), asset_a, 12) .with_registered_asset("two".as_bytes().to_vec(), asset_b, 12) .with_registered_asset("three".as_bytes().to_vec(), asset_c, 12) .with_pool( ALICE, PoolInfo::<AssetId, u64> { assets: vec![asset_a, asset_b, asset_c].try_into().unwrap(), initial_amplification: NonZeroU16::new(100).unwrap(), final_amplification: NonZeroU16::new(100).unwrap(), initial_block: 0, final_block: 0, fee: Permill::from_percent(0), }, InitialLiquidity { account: ALICE, assets: vec![ AssetAmount::new(asset_a, 100 * ONE), AssetAmount::new(asset_b, 200 * ONE), AssetAmount::new(asset_c, 300 * ONE), ], }, ) .build() .execute_with(|| { let pool_id = get_pool_id_at(0); let amount_added = 200 * ONE; let pool_account = pool_account(pool_id); assert_ok!(Stableswap::add_liquidity( RuntimeOrigin::signed(BOB), pool_id, vec![AssetAmount::new(asset_a, amount_added),] )); let shares = Tokens::free_balance(pool_id, &BOB); assert_ok!(Stableswap::remove_liquidity_one_asset( RuntimeOrigin::signed(BOB), pool_id, asset_c, shares, 0, )); let amount_received = Tokens::free_balance(asset_c, &BOB); assert_balance!(BOB, asset_a, 0u128); assert_balance!(BOB, asset_c, 199999999999999u128); assert_balance!(BOB, pool_id, 0u128); assert_balance!(pool_account, asset_a, 100 * ONE + amount_added); assert_balance!(pool_account, asset_c, 300 * ONE - amount_received); let alice_shares = Tokens::free_balance(pool_id, &ALICE); assert_ok!(Stableswap::remove_liquidity_one_asset( RuntimeOrigin::signed(ALICE), pool_id, asset_b, alice_shares, 0, )); }); }
Then it will fail.
Manual Review.
When an LP remove all the issued shared, we should send all the assets out and reset the pool.
Context
#0 - c4-pre-sort
2024-03-02T03:03:09Z
0xRobocop marked the issue as primary issue
#1 - c4-pre-sort
2024-03-02T03:03:13Z
0xRobocop marked the issue as insufficient quality report
#2 - c4-pre-sort
2024-03-03T04:35:11Z
0xRobocop marked the issue as remove high or low quality report
#3 - c4-pre-sort
2024-03-03T04:35:22Z
0xRobocop marked the issue as duplicate of #86
#4 - c4-judge
2024-03-08T10:19:23Z
OpenCoreCH marked the issue as selected for report
#5 - c4-judge
2024-03-08T10:21:02Z
OpenCoreCH marked the issue as not selected for report
#6 - c4-judge
2024-03-08T10:21:05Z
OpenCoreCH marked the issue as satisfactory
#7 - c4-judge
2024-03-08T10:21:12Z
OpenCoreCH marked issue #64 as primary and marked this issue as a duplicate of 64
#8 - c4-judge
2024-03-08T10:22:05Z
OpenCoreCH marked the issue as not selected for report
#9 - c4-judge
2024-03-08T10:22:13Z
OpenCoreCH marked the issue as selected for report
#10 - c4-judge
2024-03-08T10:22:42Z
OpenCoreCH marked issue #86 as primary and marked this issue as a duplicate of 86
🌟 Selected for report: carrotsmuggler
Also found by: QiuhaoLi
2628.3258 USDC - $2,628.33
When the authority calls withdraw_protocol_liquidity
to remove protocol liquidity, it's vulnerable to sandwich attacks.
In withdraw_protocol_liquidity
, we didn't make sure the spot price was not too far from the EMA oracle price. Which can lead to:
withdraw_protocol_liquidity
.buy
or sell
, making the spot price and EMA price diff bigger than the max allowed one.Manual Review.
Like remote_liquidity
, we should ensure spot/oracle price diff is not bigger than the max allowed one:
T::PriceBarrier::ensure_price( &accountID, T::HubAssetId::get(), asset, EmaPrice::new(asset_state.hub_reserve, asset_state.reserve), ) .map_err(|_| Error::<T>::PriceDifferenceTooHigh)?;
MEV
#0 - c4-pre-sort
2024-03-03T17:13:35Z
0xRobocop marked the issue as duplicate of #158
#1 - c4-judge
2024-03-07T21:13:12Z
OpenCoreCH marked the issue as satisfactory
#2 - QiuhaoLi
2024-03-14T05:42:15Z
Hi @OpenCoreCH, thanks for the review. I think this issue is not duplicated to #93 but #84:
remove_liquidity
. This issue is about withdraw_protocol_liquidity
slippage for the admin, which is the same issue as 84.remove_liquidity
, withdraw_protocol_liquidity
lacks the ensure_price
check (check the "Proof of Concept"), which is the same root cause pointed out in this issue (check the "Recommended Mitigation Steps").safe_withdrawal
parameter, this is not the root cause (ensure_price
). And the set_asset_tradable_state
doesn't check if the current spot price is far from the oracle price (vulnerable to front-run, #174), so using ensure_price
directly would be better..According to 1 and 2, I believe this issue is duplicated to #84.
#3 - c4-judge
2024-03-15T09:07:47Z
OpenCoreCH marked the issue as not a duplicate
#4 - c4-judge
2024-03-15T09:07:59Z
OpenCoreCH marked the issue as duplicate of #84
🌟 Selected for report: castle_chain
1064.4719 USDC - $1,064.47
Users can lose all the withdrawn assets as fees when the asset is in safe withdraw mode.
In remove_liquidity
, we will skip the spot/oracle price check if it's a safe mode withdraw (i.e. sell and buy are disabled).
let safe_withdrawal = asset_state.tradable.is_safe_withdrawal(); // Skip price check if safe withdrawal - trading disabled. if !safe_withdrawal { T::PriceBarrier::ensure_price( &who, T::HubAssetId::get(), asset_id, EmaPrice::new(asset_state.hub_reserve, asset_state.reserve), ) .map_err(|_| Error::<T>::PriceDifferenceTooHigh)?; }
To prevent liquidity attacks, we apply a fee (max 100%) on the withdrawal action, which depends on the spot/oracle price diff.
let ext_asset_price = T::ExternalPriceOracle::get_price(T::HubAssetId::get(), asset_id)?; if ext_asset_price.is_zero() { return Err(Error::<T>::InvalidOraclePrice.into()); } let withdrawal_fee = hydra_dx_math::omnipool::calculate_withdrawal_fee( asset_state.price().ok_or(ArithmeticError::DivisionByZero)?, FixedU128::checked_from_rational(ext_asset_price.n, ext_asset_price.d) .defensive_ok_or(Error::<T>::InvalidOraclePrice)?, T::MinWithdrawalFee::get(), );
It seems we assume if the asset is in safe mode, spot/oracle price diff should be minimal since the trade is disabled and other operations don't change the price.
However, in set_asset_tradable_state
which can set an asset to the safe mode, we don't check if the spot/oracle diff is big (T::PriceBarrier::ensure_price
), which can lead to:
set_asset_tradable_state
.ensure_price
is not triggered this time and Alice will suffer from a big fee.Manual Review.
Call ensure_price
in set_asset_tradable_state
when we set an asset to the safe mode.
Context
#0 - c4-pre-sort
2024-03-03T17:11:56Z
0xRobocop marked the issue as duplicate of #158
#1 - c4-judge
2024-03-07T21:13:10Z
OpenCoreCH marked the issue as satisfactory
#2 - QiuhaoLi
2024-03-14T05:37:38Z
Hi @OpenCoreCH, thanks for the review. I think this issue is not duplicated to #93. #93 is about no minimum_amount_out
for the liquidity removal function. But this issue is about the problem of setting the safe withdraw mode: set_asset_tradable_state
does not ensure the slot price is not far from the Oracle price so it can be front-run by attackers. Later when the user calls the removal function, there will be no ensure_price
call because it's in safe mode, and the user will suffer from max 100% fee loss. In contrast, #93 didn't mention the safe withdrawal attack mechanism and the impact is much smaller (~1%).
#3 - c4-judge
2024-03-14T20:49:02Z
OpenCoreCH marked the issue as not a duplicate
#4 - c4-judge
2024-03-14T20:55:57Z
OpenCoreCH marked the issue as duplicate of #22
🌟 Selected for report: castle_chain
Also found by: 3docSec, Aymen0909, Franfran, J4X, Ocean_Sky, QiuhaoLi, TheSchnilch, ZanyBonzy, alix40, alkrrrrp, bin2chen, carrotsmuggler, ihtishamsudo, oakcobalt, peachtea, tsvetanovv, zhaojie
22.9928 USDC - $22.99
When a user first calls add_liquidity
, the function will fail in calculate_shares
if there are non-zero tokens in the pool (initial_reserves should be all zero or all non-zero). So an attacker can front-run the add_liquidity
after the pool is created, and then the LP will suffer a DoS.
The LP can mitigate this by sending some dust to the zero assets in the pool and then calling the add_liquidity
, but that's unexpected behavior for the users.
However, we didn't consider the fees here. For example, there are 100:100 lrna:A in the pool, the asset fee is 20%, and Alice wants to buy 30 A with lrna. In that case, the asset fee will be 7+1=8 A. And Alice needs to sell 61 lrna (10030/50 + 1). In process_trade_fee
, 8-1=7 A fees can be transferred out at most. So the subpool will be 161:63. And 100100 + 161 > 16163 > 100100, which is correct. However, suppose referrals_used is 4, so only 3 A asset fees are transferred out, the subpool will be 161:67, now 100100 + 161 is not bigger than 16167.
A malicious attacker can bypass the weight cap in a block restriction by:
There is no check if numerator <= denominator
in circuit-breaker:validate_limit(), should add one.
Otherwise, it will fail later in calculate_out_given_in.
Only callable by AuthorityOrigin, so not a big problem.
#0 - c4-pre-sort
2024-03-03T17:44:39Z
0xRobocop marked the issue as insufficient quality report
#1 - c4-judge
2024-03-08T19:29:25Z
OpenCoreCH marked the issue as grade-b
#2 - QiuhaoLi
2024-03-14T05:39:20Z
Hi @OpenCoreCH, thanks for the review. The first Low issue stableswap: Attackers can prevent the first add_liquidity by transferring one token to the pool directly
should be duplicated with #148:
#3 - OpenCoreCH
2024-03-15T09:05:24Z
No longer applicable after downgrade of issue