Root + Impact
Description
The liquidity operation remove_liquidity
is used to burn a choosen amount of the users LP Tokens and return a proportional amount in token_a
and token_b
. All outstanding LP Tokens need to be able to redeem their fair share of liquidity. There is a bug in removeliquidity
where amount_a_to_return
is 0
and amount_b_to_return
is 1
, allowing the user to redeem only token_b
for their lp_tokens_to_remove
leading to an imbalance in the pool's reserves due to a change in the ratio between the two tokens.
pub fn remove_liquidity(context: Context<ModifyLiquidity>, lpt_to_redeem: u64) -> Result<()> {
let user_lp_balance = context.accounts.liquidity_provider_lp_account.amount;
let total_lp_supply = context.accounts.lp_mint.supply;
let reserve_a = context.accounts.vault_a.amount;
let reserve_b = context.accounts.vault_b.amount;
let amount_a_to_return_u128 = (lpt_to_redeem as u128)
.checked_mul(reserve_a as u128)
.ok_or(AmmError::Overflow)?
.checked_div(total_lp_supply as u128)
.ok_or(AmmError::DivisionByZero)?;
let amount_a_to_return = amount_a_to_return_u128 as u64;
let amount_b_to_return_u128 = (lpt_to_redeem as u128)
.checked_mul(reserve_b as u128)
.ok_or(AmmError::Overflow)?
.checked_div(total_lp_supply as u128)
.ok_or(AmmError::DivisionByZero)?;
let amount_b_to_return = amount_b_to_return_u128 as u64;
@> if amount_a_to_return == 0 && amount_b_to_return == 0 && lpt_to_redeem > 0 {
return err!(AmmError::CalculationFailure);
}
if amount_a_to_return > 0 {
transfer_checked(cpi_context_transfer_a, amount_a_to_return, context.accounts.token_a_mint.decimals)?;
}
if amount_b_to_return > 0 {
transfer_checked(cpi_context_transfer_b, amount_b_to_return, context.accounts.token_b_mint.decimals)?;
}
Ok(())
}
Risk
Likelihood:
If reserve_a
is significantly smaller than reserve_b
, it becomes more likely that lpt_to_redeem * reserve_a
will be less than total_lp_supply
, leading to amount_a_to_return = 0
.
Impact:
When an LP removes liquidity and receives only one token (or a disproportionate amount) while burning LP tokens that represent both assets, the ratio of assets remaining in the pool changes. Say a pool has a total supply of 20. If the supply in reserve_a
is 5
and the supply in reserve_b
is 15
, when the user redeems 2
tokens, they will get 0
tokens from reserve_a
and 1 token from reserve_b
due to u64
rounding. The pool now has an imbalance that directly affects the price of tokens within the AMM which may deviate from the true market price, creating an arbitrage opportunity.
Proof of Concept
This Proof of Concept is a local Rust interpretation of the code present in liquidity_operations.rs
. It replicates the scenario described above and can be run via the command line with cargo run poc.rs
.
pub fn remove_liquidity(lpt_to_redeem: u64) -> Result<(), String> {
if lpt_to_redeem <= 0 {
return Err("NoZeroRedemption".to_string());
}
let user_lp_balance = 100;
let total_lp_supply = 20;
let reserve_a = 5;
let reserve_b = 15;
if user_lp_balance < lpt_to_redeem {
return Err("AskExceedsBalance".to_string());
}
if total_lp_supply == 0 {
return Err("PoolIsEmpty".to_string());
}
let amount_a_to_return_u128 = (lpt_to_redeem as u128)
.checked_mul(reserve_a as u128)
.ok_or("Overflow".to_string())?
.checked_div(total_lp_supply as u128)
.ok_or("DivisionByZero".to_string())?;
let amount_a_to_return = amount_a_to_return_u128 as u64;
let amount_b_to_return_u128 = (lpt_to_redeem as u128)
.checked_mul(reserve_b as u128)
.ok_or("Overflow".to_string())?
.checked_div(total_lp_supply as u128)
.ok_or("DivisionByZero".to_string())?;
let amount_b_to_return = amount_b_to_return_u128 as u64;
if amount_a_to_return == 0 && amount_b_to_return == 0 && lpt_to_redeem > 0 {
return Err("CalculationFailure".to_string());
}
println!("- amount_a_to_return = {} \n- amount_b_to_return = {} \n- lpt_to_redeem = {}", amount_a_to_return, amount_b_to_return, lpt_to_redeem);
Ok(())
}
fn main() {
let redeem = 2;
let remove = remove_liquidity(redeem);
println!("{:?}", remove);
}
Recommended Mitigation
The code corrects the check for the amount to return per vault to maintain the proper ratio and prevent arbitrage opportinities on the AMM.
- if amount_a_to_return == 0 && amount_b_to_return == 0 && lpt_to_redeem > 0 {
+ if ( amount_a_to_return == 0 || amount_b_to_return == 0 ) && lpt_to_redeem > 0 {
return err!(AmmError::CalculationFailure);
}