Skip to content

Commit 1fcbca4

Browse files
Merge pull request #1456 from galacticcouncil/fix/slip-fee-cap
fix: omnipool max slip fee cap
2 parents 67ac38b + a50d85f commit 1fcbca4

11 files changed

Lines changed: 1249 additions & 50 deletions

File tree

Cargo.lock

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

integration-tests/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "runtime-integration-tests"
3-
version = "1.84.0"
3+
version = "1.85.0"
44
description = "Integration tests"
55
authors = ["GalacticCouncil"]
66
edition = "2021"

integration-tests/src/omnipool_slip_fees.rs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,3 +449,87 @@ fn sequential_trades_accumulate_slip_within_block() {
449449
no_slip_drop
450450
);
451451
}
452+
453+
#[test]
454+
fn buy_succeeds_when_slip_cap_is_binding() {
455+
let buy_amount = 100 * UNITS;
456+
let tight_cap = Permill::from_parts(1000); // 0.1%
457+
458+
TestNet::reset();
459+
Hydra::execute_with(|| {
460+
init_omnipool();
461+
assert_ok!(Omnipool::set_slip_fee(
462+
RuntimeOrigin::root(),
463+
Some(SlipFeeConfig {
464+
max_slip_fee: tight_cap
465+
}),
466+
));
467+
468+
let trader = AccountId::from(BOB);
469+
assert_ok!(Currencies::update_balance(
470+
RuntimeOrigin::root(),
471+
trader.clone(),
472+
DAI,
473+
(10_000_000 * UNITS) as i128,
474+
));
475+
476+
let dai_before = Currencies::free_balance(DAI, &trader);
477+
let hdx_before = Currencies::free_balance(HDX, &trader);
478+
479+
assert_ok!(Omnipool::buy(
480+
RuntimeOrigin::signed(trader.clone()),
481+
HDX,
482+
DAI,
483+
buy_amount,
484+
u128::MAX,
485+
));
486+
487+
let hdx_received = Currencies::free_balance(HDX, &trader) - hdx_before;
488+
assert_eq!(hdx_received, buy_amount);
489+
490+
let dai_spent = dai_before - Currencies::free_balance(DAI, &trader);
491+
assert!(dai_spent > 0);
492+
});
493+
}
494+
495+
#[test]
496+
fn buy_with_lrna_succeeds_when_slip_cap_is_binding() {
497+
let buy_amount = 100 * UNITS;
498+
let tight_cap = Permill::from_parts(1000); // 0.1%
499+
500+
TestNet::reset();
501+
Hydra::execute_with(|| {
502+
init_omnipool();
503+
assert_ok!(Omnipool::set_slip_fee(
504+
RuntimeOrigin::root(),
505+
Some(SlipFeeConfig {
506+
max_slip_fee: tight_cap
507+
}),
508+
));
509+
510+
let trader = AccountId::from(BOB);
511+
assert_ok!(Currencies::update_balance(
512+
RuntimeOrigin::root(),
513+
trader.clone(),
514+
LRNA,
515+
(1_000_000 * UNITS) as i128,
516+
));
517+
518+
let lrna_before = Currencies::free_balance(LRNA, &trader);
519+
let dai_before = Currencies::free_balance(DAI, &trader);
520+
521+
assert_ok!(Omnipool::buy(
522+
RuntimeOrigin::signed(trader.clone()),
523+
DAI,
524+
LRNA,
525+
buy_amount,
526+
u128::MAX,
527+
));
528+
529+
let dai_received = Currencies::free_balance(DAI, &trader) - dai_before;
530+
assert_eq!(dai_received, buy_amount);
531+
532+
let lrna_spent = lrna_before - Currencies::free_balance(LRNA, &trader);
533+
assert!(lrna_spent > 0);
534+
});
535+
}

math/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ license = 'Apache-2.0'
66
name = "hydra-dx-math"
77
description = "A collection of utilities to make performing liquidity pool calculations more convenient."
88
repository = 'https://github.com/galacticcouncil/hydradx-math'
9-
version = "13.2.1"
9+
version = "13.2.2"
1010

1111
[dependencies]
1212
primitive-types = { workspace = true }

math/src/omnipool/math.rs

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ pub fn calculate_buy_for_hub_asset_state_changes(
218218

219219
// Invert buy-side slip to find how much hub asset the user must provide
220220
let slip_buy_amount = if let Some(slip) = slip {
221-
let d_gross = invert_buy_side_slip(d_net, slip.asset_hub_reserve, slip.asset_delta)?;
221+
let d_gross = invert_buy_side_slip(d_net, slip.asset_hub_reserve, slip.asset_delta, slip.max_slip_fee)?;
222222
d_gross.checked_sub(d_net)?
223223
} else {
224224
0
@@ -277,14 +277,25 @@ pub fn calculate_buy_state_changes(
277277

278278
// Step 2: Invert buy-side slip to find D_gross from D_net
279279
let d_gross = if let Some(slip) = slip {
280-
invert_buy_side_slip(d_net, slip.asset_out_hub_reserve, slip.asset_out_delta)?
280+
invert_buy_side_slip(
281+
d_net,
282+
slip.asset_out_hub_reserve,
283+
slip.asset_out_delta,
284+
slip.max_slip_fee,
285+
)?
281286
} else {
282287
d_net
283288
};
284289

285290
// Step 3: Invert sell-side fees (protocol_fee + sell slip) to find delta_hub_reserve_in
286291
let delta_hub_reserve_in = if let Some(slip) = slip {
287-
invert_sell_side_fees(d_gross, protocol_fee, slip.asset_in_hub_reserve, slip.asset_in_delta)?
292+
invert_sell_side_fees(
293+
d_gross,
294+
protocol_fee,
295+
slip.asset_in_hub_reserve,
296+
slip.asset_in_delta,
297+
slip.max_slip_fee,
298+
)?
288299
} else {
289300
// No slip — original inversion
290301
FixedU128::from_inner(d_net)

math/src/omnipool/slip_fee.rs

Lines changed: 92 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ use num_traits::One;
66
use primitive_types::U256;
77
use sp_arithmetic::Permill;
88

9+
const PERMILL_SCALE: u128 = 1_000_000;
10+
911
/// Calculate the slip fee *amount* at full U256 precision, avoiding Permill truncation.
1012
///
1113
/// Computes: `min(|cumulative| / (Q₀ + cumulative), max_slip_fee) × base_amount`
@@ -38,38 +40,73 @@ pub fn calculate_slip_fee_amount(
3840

3941
let abs_cumulative = cumulative.abs();
4042

41-
// Check if rate exceeds max_slip_fee: |cum| * 1_000_000 / denom > max_parts?
42-
// Safe: u128 * 1_000_000 fits in U256
43-
let rate_millionths = U256::from(abs_cumulative)
44-
.saturating_mul(U256::from(1_000_000u64))
45-
.checked_div(U256::from(denom))?;
46-
let max_parts = max_slip_fee.deconstruct() as u64;
47-
48-
if rate_millionths > U256::from(max_parts) {
49-
// Capped: use Permill for the capped amount
43+
if slip_rate_exceeds_cap(abs_cumulative, denom, max_slip_fee)? {
5044
Some(max_slip_fee.mul_floor(base_amount))
5145
} else {
52-
// Full precision: |cumulative| * base_amount / denom
5346
let amount_hp = U256::from(abs_cumulative)
5447
.checked_mul(U256::from(base_amount))?
5548
.checked_div(U256::from(denom))?;
5649
to_balance!(amount_hp).ok()
5750
}
5851
}
5952

60-
/// Invert buy-side slip fee: given D_net (hub asset actually entering buy pool),
61-
/// find D_gross (hub asset before buy-side slip deduction).
62-
///
63-
/// Forward formula: `D_net = D_gross - |cum| * D_gross / (L + cum)` where `cum = C + D_gross`.
53+
/// Returns `true` iff `|cumulative| * 10^6 > max_parts * denom`,
54+
/// the same threshold `calculate_slip_fee_amount` uses to switch to the capped formula.
55+
fn slip_rate_exceeds_cap(abs_cumulative: Balance, denom: Balance, max_slip_fee: Permill) -> Option<bool> {
56+
if denom == 0 || abs_cumulative == 0 {
57+
return Some(false);
58+
}
59+
let max_parts = max_slip_fee.deconstruct() as u128;
60+
let lhs = U256::from(abs_cumulative).checked_mul(U256::from(PERMILL_SCALE))?;
61+
let rhs = U256::from(max_parts).checked_mul(U256::from(denom))?;
62+
Some(lhs > rhs)
63+
}
64+
65+
fn slip_rate_exceeds_cap_from_state(
66+
hub_reserve_at_block_start: Balance,
67+
prior_delta: SignedBalance,
68+
delta_q: SignedBalance,
69+
max_slip_fee: Permill,
70+
) -> Option<bool> {
71+
let cumulative = prior_delta.checked_add(delta_q)?;
72+
let denom = cumulative.add_to_unsigned(hub_reserve_at_block_start)?;
73+
slip_rate_exceeds_cap(cumulative.abs(), denom, max_slip_fee)
74+
}
75+
76+
/// Invert buy-side slip fee: given `D_net` (hub asset entering buy pool after slip),
77+
/// find `D_gross` (before slip).
6478
///
65-
/// - `d_net` — hub asset entering the buy pool after slip deduction
66-
/// - `l` — hub reserve at block start (Q₀)
67-
/// - `c` — cumulative signed hub asset delta before this trade
79+
/// Forward: `D_net = D_gross - slip(D_gross)`, with
80+
/// `slip = min(|cum|/(L+cum), max_slip_fee) * D_gross`, `cum = C + D_gross`.
6881
///
69-
/// Two cases based on the sign of cumulative after the trade:
70-
/// - Case 1 (cum >= 0): `D_gross = D_net * (L+C) / (L - D_net)` (linear)
71-
/// - Case 2 (C < 0, D_gross < |C|): quadratic inversion
72-
pub(crate) fn invert_buy_side_slip(d_net: Balance, l: Balance, c: SignedBalance) -> Option<Balance> {
82+
/// Uncapped: linear when `cum >= 0`, quadratic when `C < 0 && D_gross < |C|`.
83+
/// Capped: `D_gross = floor(D_net * 10^6 / (10^6 - max_parts))`.
84+
pub(crate) fn invert_buy_side_slip(
85+
d_net: Balance,
86+
l: Balance,
87+
c: SignedBalance,
88+
max_slip_fee: Permill,
89+
) -> Option<Balance> {
90+
// Uncapped inverse may have no real root for very large trades — in that case
91+
// the cap is necessarily binding and we fall through to the capped formula.
92+
if let Some(d_gross_uncapped) = invert_buy_side_slip_uncapped(d_net, l, c) {
93+
if !slip_rate_exceeds_cap_from_state(l, c, SignedBalance::Positive(d_gross_uncapped), max_slip_fee)? {
94+
return Some(d_gross_uncapped);
95+
}
96+
}
97+
98+
let max_parts = max_slip_fee.deconstruct() as u128;
99+
let one_minus_max = PERMILL_SCALE.checked_sub(max_parts)?;
100+
if one_minus_max == 0 {
101+
return None;
102+
}
103+
let d_gross_hp = U256::from(d_net)
104+
.checked_mul(U256::from(PERMILL_SCALE))?
105+
.checked_div(U256::from(one_minus_max))?;
106+
to_balance!(d_gross_hp).ok()
107+
}
108+
109+
fn invert_buy_side_slip_uncapped(d_net: Balance, l: Balance, c: SignedBalance) -> Option<Balance> {
73110
let s_buy = c.add_to_unsigned(l)?; // L + C
74111
if s_buy == 0 {
75112
return None;
@@ -132,25 +169,44 @@ pub(crate) fn invert_buy_side_slip(d_net: Balance, l: Balance, c: SignedBalance)
132169
d_gross.checked_add(Balance::one())
133170
}
134171

135-
/// Invert sell-side fees (protocol_fee + sell-side slip) to find `delta_hub_reserve_in`
136-
/// given D_gross (hub asset after sell-side deductions).
172+
/// Invert sell-side fees (protocol_fee + sell-side slip) to find `u = delta_hub_reserve_in`
173+
/// given `D_gross` (after sell-side deductions).
137174
///
138-
/// Forward formula: `D_gross = u*(1-pf) - slip_fee(u)` where `u = delta_hub_reserve_in`,
139-
/// `pf = protocol_fee`, and `slip_fee = |C - u| * u / (L + C - u)`.
175+
/// Forward: `D_gross = u*(1 - pf) - slip(u)`, with
176+
/// `slip(u) = min(|C-u|/(L+C-u), max_slip_fee) * u`.
140177
///
141-
/// - `d_gross` — hub asset remaining after protocol fee and sell-side slip
142-
/// - `protocol_fee` — protocol fee rate
143-
/// - `l` — hub reserve at block start (Q₀) for the sell pool
144-
/// - `c` — cumulative signed hub asset delta before this trade (for the sell pool)
145-
///
146-
/// Two cases based on the sign of cumulative = C - u (hub asset outflow is negative):
147-
/// - Case A (u > C, cumulative < 0): `(k+1)*u² - (kS + C + D)*u + DS = 0`
148-
/// - Case B (u <= C, C > 0, opposing flow): `pf*u² + (D + kS - C)*u - DS = 0`
178+
/// Uncapped quadratic with two cases by sign of `cum = C - u`.
179+
/// Capped: `u = floor(D_gross * 10^6 / (10^6 - pf_parts - max_parts))`.
149180
pub(crate) fn invert_sell_side_fees(
150181
d_gross: Balance,
151182
protocol_fee: Permill,
152183
l: Balance,
153184
c: SignedBalance,
185+
max_slip_fee: Permill,
186+
) -> Option<Balance> {
187+
if let Some(u_uncapped) = invert_sell_side_fees_uncapped(d_gross, protocol_fee, l, c) {
188+
if !slip_rate_exceeds_cap_from_state(l, c, SignedBalance::Negative(u_uncapped), max_slip_fee)? {
189+
return Some(u_uncapped);
190+
}
191+
}
192+
193+
let pf_parts = protocol_fee.deconstruct() as u128;
194+
let max_parts = max_slip_fee.deconstruct() as u128;
195+
let denom_parts = PERMILL_SCALE.checked_sub(pf_parts)?.checked_sub(max_parts)?;
196+
if denom_parts == 0 {
197+
return None;
198+
}
199+
let u_hp = U256::from(d_gross)
200+
.checked_mul(U256::from(PERMILL_SCALE))?
201+
.checked_div(U256::from(denom_parts))?;
202+
to_balance!(u_hp).ok()
203+
}
204+
205+
fn invert_sell_side_fees_uncapped(
206+
d_gross: Balance,
207+
protocol_fee: Permill,
208+
l: Balance,
209+
c: SignedBalance,
154210
) -> Option<Balance> {
155211
let abs_c = c.abs();
156212
let c_is_positive = c.is_positive();
@@ -163,8 +219,8 @@ pub(crate) fn invert_sell_side_fees(
163219
};
164220

165221
let pf_parts = protocol_fee.deconstruct() as u128;
166-
let k_parts = 1_000_000u128 - pf_parts;
167-
let scale = U256::from(1_000_000u64);
222+
let k_parts = PERMILL_SCALE.checked_sub(pf_parts)?;
223+
let scale = U256::from(PERMILL_SCALE);
168224

169225
// Try Case B first when C > 0 (opposing flow, u <= C)
170226
if c_is_positive {
@@ -237,7 +293,7 @@ pub(crate) fn invert_sell_side_fees(
237293

238294
// Case A: (k+1)u² - (kS + C + D)u + DS = 0
239295
// Applies when C <= 0 (always) or when C > 0 but Case B yielded u > C.
240-
let a_u256 = U256::from(k_parts + 1_000_000);
296+
let a_u256 = U256::from(k_parts.checked_add(PERMILL_SCALE)?);
241297
// Safe: u128 * u128 fits in U256
242298
let ks = U256::from(k_parts).saturating_mul(U256::from(s));
243299
let d_scaled = U256::from(d_gross).saturating_mul(scale);

0 commit comments

Comments
 (0)