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: 4/27
Findings: 3
Award: $7,861.35
π Selected for report: 1
π Solo Findings: 1
π Selected for report: TheSchnilch
7592.9411 USDC - $7,592.94
https://github.com/code-423n4/2024-02-hydradx/blob/603187123a20e0cb8a7ea85c6a6d718429caad8d/HydraDX-node/pallets/omnipool/src/lib.rs#L1541-L1574 https://github.com/code-423n4/2024-02-hydradx/blob/603187123a20e0cb8a7ea85c6a6d718429caad8d/HydraDX-node/pallets/omnipool/src/traits.rs#L164-L190 https://github.com/code-423n4/2024-02-hydradx/blob/603187123a20e0cb8a7ea85c6a6d718429caad8d/HydraDX-node/pallets/ema-oracle/src/lib.rs#L558-L566
If an asset is removed from the omnipool, it is ensured that all data records in the omnipool are deleted and also all positions from liquidity providers. However, the data records in the Oracle are not reset. This means that if the asset is to be added again after some time and it then has a different price, the price in the Oracle is falsified.
Here is a poc which can be inserted into the file βintegration-tests/src/omnipool_init.rsβ and that can be started with the following command: SKIP_WASM_BUILD=1 cargo test -p runtime-integration-tests poc -- --nocapture
#[test] pub fn poc() { TestNet::reset(); Hydra::execute_with(|| { let omnipool_account = hydradx_runtime::Omnipool::protocol_account(); init_omnipool(); let position_id_init = hydradx_runtime::Omnipool::next_position_id(); assert_ok!(hydradx_runtime::Omnipool::add_token( //DOT token is added for the first time hydradx_runtime::RuntimeOrigin::root(), DOT, FixedU128::from_float(1.0), Permill::from_percent(100), AccountId::from(ALICE) )); hydradx_run_to_next_block(); assert_ok!(hydradx_runtime::Omnipool::sacrifice_position( //The shares from the initial liquidity are assigned to the protocol so that the token can be removed hydradx_runtime::RuntimeOrigin::signed(ALICE.into()), position_id_init )); hydradx_run_to_next_block(); assert_ok!(hydradx_runtime::Omnipool::set_asset_tradable_state( //The asset is frozen so that the token can be removed hydradx_runtime::RuntimeOrigin::root(), DOT, Tradability::FROZEN )); assert_ok!(hydradx_runtime::Omnipool::remove_token( //This removes the token hydradx_runtime::RuntimeOrigin::root(), DOT, AccountId::from(ALICE) )); //This is simply to skip a few blocks to show that some time has passed since the token was removed and the prices are still there hydradx_run_to_next_block(); hydradx_run_to_next_block(); hydradx_run_to_next_block(); hydradx_run_to_next_block(); hydradx_run_to_next_block(); hydradx_run_to_next_block(); hydradx_run_to_next_block(); assert_ok!(hydradx_runtime::Tokens::transfer( //The initial liquidity is sent to the pool to add DOT the second time hydradx_runtime::RuntimeOrigin::signed(ALICE.into()), omnipool_account, DOT, 500 * UNITS )); assert_ok!(hydradx_runtime::Omnipool::add_token( //DOT is added the second time hydradx_runtime::RuntimeOrigin::root(), DOT, FixedU128::from_float(10.0), //The price has increased from 1.0 to 10.0 Permill::from_percent(100), AccountId::from(ALICE) )); hydradx_run_to_next_block(); let return_value = hydradx_runtime::Omnipool::add_liquidity( //A liquidity provider tries to add liquidity hydradx_runtime::RuntimeOrigin::signed(ALICE.into()), DOT, 1 * UNITS ); println!("return_value: {:?}", return_value); //shows that adding liquidity didn't work because the price difference was too big hydradx_run_to_next_block(); let (price_short, _) = hydradx_runtime::EmaOracle::get_price(LRNA, DOT, OraclePeriod::Short, OMNIPOOL_SOURCE).unwrap(); println!("price_short: {:?}", price_short); //shows that the price for the period short is not 10, which it should actually be since the liquidity has not changed since DOT was added for the second time }); }
If an asset is removed from the Omnipool, all of its data will be deleted from the Omnipool. However, the data from the asset remains in the Oracle and is not deleted there. The Oracle then continues to store the data of the removed asset in this StorageMap:
pub type Oracles<T: Config> = StorageNMap< _, ( NMapKey<Twox64Concat, Source>, NMapKey<Twox64Concat, (AssetId, AssetId)>, NMapKey<Twox64Concat, OraclePeriod>, ), (OracleEntry<BlockNumberFor<T>>, BlockNumberFor<T>), OptionQuery, >;
The price is stored here once for the last block and once with the exponential moving average logic for a few blocks in the past. (This is the short period)
The problem now is when an asset is added again and its price has changed. As a result, the new price is calculated using the exponential moving average logic with the entries of the blocks before the asset was removed and the new entries that have been added since the asset was re-added, which leads to an incorrect price. The wrong price can also cause ensure_price in add_liquidity to fail and therefore no liquidity can be added:
T::PriceBarrier::ensure_price( &who, T::HubAssetId::get(), asset, EmaPrice::new(asset_state.hub_reserve, asset_state.reserve), ) .map_err(|_| Error::<T>::PriceDifferenceTooHigh)?;
So any protocol that uses this oracle as a price source would receive an incorrect price for the re-added asset for a short period of time.
Even if the price balances out again after some time (the length of the short period), an incorrect price is initially calculated after re-adding an asset for which the price has changed. I would recommend deleting the Oracle entries when removing an asset. This means that the price of the asset can be calculated correctly from the start when it is added again.
Oracle
#0 - c4-pre-sort
2024-03-03T08:40:00Z
0xRobocop marked the issue as primary issue
#1 - c4-pre-sort
2024-03-03T08:40:03Z
0xRobocop marked the issue as sufficient quality report
#2 - c4-sponsor
2024-03-05T16:40:58Z
enthusiastmartin (sponsor) acknowledged
#3 - c4-sponsor
2024-03-05T16:41:00Z
enthusiastmartin marked the issue as disagree with severity
#4 - enthusiastmartin
2024-03-05T16:42:48Z
It has no impact, and it is currently intended to keep it in oracle.
it might be an issue when we decided to add a token back, although the price would correct itself anyway.
#5 - c4-judge
2024-03-07T21:52:29Z
OpenCoreCH marked the issue as selected for report
#6 - OpenCoreCH
2024-03-07T21:54:47Z
The warden identified an edge case (readding a token that was previously removed) where keeping the old values can lead to problems (short DoS or wrong prices used if deviation is not too large). Medium is appropriate here because a value leak with some external requirements is possible.
π 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
150.1907 USDC - $150.19
https://github.com/code-423n4/2024-02-hydradx/blob/603187123a20e0cb8a7ea85c6a6d718429caad8d/HydraDX-node/math/src/stableswap/math.rs#L173-L230 https://github.com/code-423n4/2024-02-hydradx/blob/603187123a20e0cb8a7ea85c6a6d718429caad8d/HydraDX-node/math/src/stableswap/math.rs#L234-L314
When testing stableswap it turned out that if you use remove_liquidity_one_asset you get a little less than with withdraw_asset_amount if you use the same number of shares for both.
To prove this there is this POC that can be inserted into integration-tests/src/omnipool_init.rs.
#[test] fn poc1() { TestNet::reset(); Hydra::execute_with(|| { //Metadata is set for two tokens used in the stable pool so that the pool can use the decimals. In this case both assets have the same decimals assert_ok!(hydradx_runtime::AssetRegistry::set_metadata(hydradx_runtime::RuntimeOrigin::root(), DAI, vec![1,2], 12)); assert_ok!(hydradx_runtime::AssetRegistry::set_metadata(hydradx_runtime::RuntimeOrigin::root(), DOT, vec![3,4], 12)); //The stable pool is created with DAI and DOT as assets and ACA as share asset assert_ok!(hydradx_runtime::Stableswap::create_pool( hydradx_runtime::RuntimeOrigin::root(), ACA, vec![DAI, DOT], 12, Permill::from_percent(1) )); //Initial liquidity is added assert_ok!(hydradx_runtime::Stableswap::add_liquidity( hydradx_runtime::RuntimeOrigin::signed(ALICE.into()), ACA, vec![AssetAmount{ asset_id: DAI, amount: 1000 * UNITS }, AssetAmount{ asset_id: DOT, amount: 1000 * UNITS } ] )); println!("alice dai before: {}", hydradx_runtime::Tokens::free_balance(DAI, &AccountId::from(ALICE))); println!("alice aca before: {}", hydradx_runtime::Tokens::free_balance(ACA, &AccountId::from(ALICE))); /*assert_ok!(hydradx_runtime::Stableswap::withdraw_asset_amount( //liquidity is removed with withdraw_asset_amount hydradx_runtime::RuntimeOrigin::signed(ALICE.into()), ACA, DAI, 100 * UNITS, 10 * UNITS * UNITS ));*/ assert_ok!(hydradx_runtime::Stableswap::remove_liquidity_one_asset( //liquidity is removed with remove_liquidity_one_asset hydradx_runtime::RuntimeOrigin::signed(ALICE.into()), ACA, DAI, 100702978470355663983, //number of shares needed from withdraw_asset_amount to get 100 DAI 0 )); println!("alice dai after: {}", hydradx_runtime::Tokens::free_balance(DAI, &AccountId::from(ALICE))); println!("alice aca after: {}", hydradx_runtime::Tokens::free_balance(ACA, &AccountId::from(ALICE))); }); }
Now you can run the POC with this command SKIP_WASM_BUILD=1 cargo test -p runtime-integration-tests stableswap_test -- --nocapture
and see how many DAI Alice gets for how many shares based on the log in the terminal. To compare the two functions you can now comment out remove_liquidity_one_asset and execute withdraw_asset_amount and compare the logs in the terminal. You will notice that with the withdraw_asset_amount function, Alice gets a little more for the same number of shares as with remove_liquidity_one_asset.
This difference can be explained as follows: remove_liquidity_one_asset uses calculate_withdraw_one_asset to calculate how much of a reserve a user gets for a certain number of shares. withdraw_asset_amount uses calculate_shares_for_amount to calculate how many shares a user must pay to get a certain amount of reserve. Both functions have a part that calculates the fees:
calculate_withdraw_one_asset
for (idx, reserve) in xp_hp.iter().enumerate() { let dx_expected = if idx == asset_index { // dx_expected = xp[j] * d1 / d0 - new_y reserve.checked_mul(d1)?.checked_div(d_hp)?.checked_sub(y_hp)? } else { // dx_expected = xp[j] - xp[j] * d1 / d0 reserve.checked_sub(reserve.checked_mul(d1)?.checked_div(d_hp)?)? }; let expected = Balance::try_from(dx_expected).ok()?; let reduced = Balance::try_from(*reserve) .ok()? .checked_sub(fee.checked_mul_int(expected)?)?;
calculate_shares_for_amount
let adjusted_reserves: Vec<AssetReserve> = updated_reserves .iter() .enumerate() .map(|(idx, asset_reserve)| -> Option<AssetReserve> { let (initial_reserve, updated_reserve) = to_u256!(initial_reserves[idx].amount, asset_reserve.amount); let ideal_balance = d1.checked_mul(initial_reserve)?.checked_div(d0)?; let diff = Balance::try_from(updated_reserve.abs_diff(ideal_balance)).ok()?; let fee_amount = fee.checked_mul_int(diff)?; Some(AssetReserve::new( asset_reserve.amount.saturating_sub(fee_amount), asset_reserve.decimals, )) })
Here you can see that for both functions a difference is calculated (for calculate_withdraw_one_asset it is called expected and for calculate_shares_for_amount it is called diff) which is then used to calculate the fees. The problem is that d1 is different for the two functions.
let d1 = d_hp.checked_sub(shares_hp.checked_mul(d_hp)?.checked_div(issuance_hp)?)?;
Here we calculate how many assets the pool still has after the amount for all shares that are burned has been removed. So in this case the fees have already been deducted from d1.
With calculate_shares_for_amount for amount, d1 is calculated by deducting the amount that the user gets at the end from the reserves, but the fees are not yet deducted:
let updated_reserves: Vec<AssetReserve> = initial_reserves .iter() .enumerate() .map(|(idx, v)| -> Option<AssetReserve> { if idx == asset_idx { Some(AssetReserve::new(v.amount.checked_sub(amount)?, v.decimals)) } else { Some(*v) } }) .collect::<Option<Vec<AssetReserve>>>()?; let initial_d = calculate_d::<D>(initial_reserves, amplification)?; let updated_d = calculate_d::<D>(&updated_reserves, amplification)?; let (d1, d0) = to_u256!(updated_d, initial_d);
Since the user gets more out of one function than the other, the same d1 should be used for both functions. So for calculate_withdraw_one_asset you should also use the d1 where the fees have not yet been deducted because they are calculated in this function.
VSCode
Math
#0 - 0xRobocop
2024-03-03T09:08:19Z
No mitigation provided. Seems similar to #59
#1 - c4-pre-sort
2024-03-03T09:08:34Z
0xRobocop marked the issue as primary issue
#2 - c4-pre-sort
2024-03-03T09:08:37Z
0xRobocop marked the issue as sufficient quality report
#3 - c4-pre-sort
2024-03-03T18:54:52Z
0xRobocop marked the issue as high quality report
#4 - c4-sponsor
2024-03-05T16:07:30Z
enthusiastmartin (sponsor) disputed
#5 - enthusiastmartin
2024-03-05T16:08:02Z
This is by design, math cannot be precised enough. We deliberately implemented so using this function, users gets little bit less of shares to ensure that there is no impact for protocol.
Does not break any invariant.
What we would like to see instead is that using this function, users can get same amount if shares for less liquidity.
#6 - OpenCoreCH
2024-03-08T11:51:28Z
See #59, very small rounding difference.
#7 - c4-judge
2024-03-08T11:51:36Z
OpenCoreCH changed the severity to QA (Quality Assurance)
#8 - c4-judge
2024-03-09T11:01:34Z
OpenCoreCH marked the issue as grade-a
π Selected for report: hunter_w3b
Also found by: 0xSmartContract, Franfran, TheSchnilch, ZanyBonzy, carrotsmuggler, castle_chain, fouzantanveer, kaveyjoe, popeye, yongskiws
118.2201 USDC - $118.22
The code for this audit can be divided into two main parts: Omnipool, Stableswap, Oracle and Circuit Breaker
This is a pool into which all possible assets can be added by governance. Since it is a pool with all assets, there is a subpool in the omnipool for each asset, which consists of the asset and LRNA (hub token). Since all assets have LRNA as a second asset in their subpool, you can simply swap all assets by first swapping the asset that the user puts in into LRNA and then swapping this LRNA into the asset that should come out. This is done automatically by the sell and buy functions. After the state of the pool has been modified, a hook is called to update the oracle. In addition, the circuit breakers also check in these hooks that the amount traded does not exceed a limit that is measured per block.
Stableswap is also a pool that can hold 2-5 assets. However, these assets must be fixed when the pool is created and can no longer be changed. The pool is designed to contain only assets with the same price, e.g. DAI, USDC and USDT. However, several stable pools can be created. Hooks are also used here to update the oracle.
The circuit breaker limits how much liquidity can be traded, added and removed within a block. This is done by storing all amounts that are added, removed and traded within a block and ensuring that they are under a certain limit. After a block these values are reset.
The oracle receives the data through the hooks after liquidity has been added, removed or traded. This data is not saved directly and used by the oracle but only at the end of the block using exponential moving average logic. The oracle also stores the price for different time periods. So you should note that the price of Oracle is at least a block old.
The protocol is controlled by democratic governance (not in scope). This is mainly built with the substrate democracy module. All users that hold HDX can take part through referendums. These can be all sorts of proposals, including changes to the runtime code. Voting period of a referendum is 3 days and then an enactment period which lasts another 6 days. There is also an emergency referendum that can be used by the technical committee to make important changes to the code, such as fixing a security hole. The voting period is then only 3 hours. The technical commitee then consists of a few of the core developers of the protocol. This emergency mechanism is very good for reacting to security gaps or other events that require quick action. In a normal referedum, however, only one can be done in the voting period. All other referenda are in a queue at this time. Every 3 days, the referendum with the greatest support is moved into the voting period. This mechanism could be improved by making it possible for several referenda to be in the voting period at the same time. The protocol could be improved more quickly through the proposals. I wouldn't recommend putting all proposals into the voting period at the same time, but 3-4 should be good.
Here is a list of functions from Omnipool that can be used by governance to set new parameters for the protocol and the impact these functions can have:
Here is a list of functions from Stableswap that can be used by governance to set new parameters for the protocol and the impact these functions can have:
Even with the circuit breaker, governance has the power to restrict the Omnipool very much if the limits are very low. This can make the omnipool almost unusable.
25 hours
#0 - c4-pre-sort
2024-03-03T17:58:59Z
0xRobocop marked the issue as sufficient quality report
#1 - c4-judge
2024-03-08T19:07:21Z
OpenCoreCH marked the issue as grade-b