diff --git a/math/src/omnipool/math.rs b/math/src/omnipool/math.rs index 6eda04143..e0fd92548 100644 --- a/math/src/omnipool/math.rs +++ b/math/src/omnipool/math.rs @@ -277,14 +277,58 @@ pub fn calculate_buy_state_changes( // Step 2: Invert buy-side slip to find D_gross from D_net let d_gross = if let Some(slip) = slip { - invert_buy_side_slip(d_net, slip.asset_out_hub_reserve, slip.asset_out_delta)? + let d_gross_uncapped = invert_buy_side_slip(d_net, slip.asset_out_hub_reserve, slip.asset_out_delta)?; + let max_parts = slip.max_slip_fee.deconstruct() as u128; + let one_minus_max = 1_000_000u128.checked_sub(max_parts)?; + let (d_net_hp, d_gross_uncapped_hp) = to_u256!(d_net, d_gross_uncapped); + let rate_hp = d_net_hp + .checked_mul(U256::from(1_000_000u128))? + .checked_div(d_gross_uncapped_hp)?; + if rate_hp < U256::from(one_minus_max) { + // Cap fired: d_gross = floor(D_net * 1_000_000 / (1_000_000 - max_parts)) + // -1 compensates for the +1 ceiling added by invert_sell_side_fees + let d_gross_capped_hp = d_net_hp + .checked_mul(U256::from(1_000_000u128))? + .checked_div(U256::from(one_minus_max))?; + let d_gross_capped = to_balance!(d_gross_capped_hp).ok()?; + d_gross_capped.checked_sub(Balance::one())? + } else { + d_gross_uncapped + } } else { d_net }; // Step 3: Invert sell-side fees (protocol_fee + sell slip) to find delta_hub_reserve_in let delta_hub_reserve_in = if let Some(slip) = slip { - invert_sell_side_fees(d_gross, protocol_fee, slip.asset_in_hub_reserve, slip.asset_in_delta)? + let u_raw = invert_sell_side_fees(d_gross, protocol_fee, slip.asset_in_hub_reserve, slip.asset_in_delta)?; + let max_parts = slip.max_slip_fee.deconstruct() as u128; + let cumulative_sell = slip.asset_in_delta.checked_add(SignedBalance::Negative(u_raw))?; + let denom_sell = cumulative_sell.add_to_unsigned(slip.asset_in_hub_reserve)?; + if denom_sell > 0 && !cumulative_sell.is_zero() { + let abs_cum = cumulative_sell.abs(); + let (abs_cum_hp, denom_hp) = to_u256!(abs_cum, denom_sell); + let rate_hp = abs_cum_hp + .checked_mul(U256::from(1_000_000u128))? + .checked_div(denom_hp)?; + if rate_hp > U256::from(max_parts) { + // Cap fired: delta_hub_reserve_in = floor((d_gross - 1) * 1_000_000 / (k_parts - max_parts)) + // -1 compensates for the +1 ceiling added by invert_buy_side_slip + let d_gross_adj = d_gross.checked_sub(Balance::one())?; + let pf_parts = protocol_fee.deconstruct() as u128; + let k_parts = 1_000_000u128.checked_sub(pf_parts)?; + let den_parts = k_parts.checked_sub(max_parts)?; + let (d_gross_adj_hp, den_parts_hp) = to_u256!(d_gross_adj, den_parts); + let u_cap_hp = d_gross_adj_hp + .checked_mul(U256::from(1_000_000u128))? + .checked_div(den_parts_hp)?; + to_balance!(u_cap_hp).ok()? + } else { + u_raw + } + } else { + u_raw + } } else { // No slip — original inversion FixedU128::from_inner(d_net) diff --git a/math/src/omnipool/tests.rs b/math/src/omnipool/tests.rs index 60d584f5d..13f351cfc 100644 --- a/math/src/omnipool/tests.rs +++ b/math/src/omnipool/tests.rs @@ -3350,3 +3350,127 @@ fn cross_validate_buy_dot_with_lrna_with_prior_delta() { *r_fresh.asset.delta_hub_reserve ); } + +#[test] +fn calculate_buy_should_respect_max_slip_fee_cap_on_buy_side() { + // large pool: ~0.02% hub flow -> cap does not fire + let asset_in_state = AssetReserveState { + reserve: 100_000_000 * UNIT, + hub_reserve: 100_000_000 * UNIT, + shares: 100_000_000 * UNIT, + protocol_shares: 0u128, + }; + // small pool: ~2% hub flow -> cap fires + let asset_out_state = AssetReserveState { + reserve: 1_000_000 * UNIT, + hub_reserve: 1_000_000 * UNIT, + shares: 1_000_000 * UNIT, + protocol_shares: 0u128, + }; + + let amount = 20_000 * UNIT; + let asset_fee = Permill::zero(); + let protocol_fee = Permill::zero(); + let burn_fee = Permill::zero(); + let max_slip_fee = Permill::from_percent(1); + + let slip = TradeSlipFees { + asset_in_hub_reserve: asset_in_state.hub_reserve, + asset_in_delta: SignedBalance::zero(), + asset_out_hub_reserve: asset_out_state.hub_reserve, + asset_out_delta: SignedBalance::zero(), + max_slip_fee, + }; + + let state_changes = calculate_buy_state_changes( + &asset_in_state, + &asset_out_state, + amount, + asset_fee, + protocol_fee, + burn_fee, + Some(&slip), + ) + .expect("calculate_buy_state_changes should succeed"); + + let delta_hub_reserve_in = match state_changes.asset_in.delta_hub_reserve { + BalanceUpdate::Decrease(v) => v, + _ => panic!("expected Decrease for asset_in hub reserve"), + }; + let total_protocol_fee = state_changes.fee.protocol_fee; + + let d_net_expected: Balance = asset_out_state.hub_reserve * amount / (asset_out_state.reserve - amount) + 1; + + let d_net_actual = delta_hub_reserve_in + .checked_sub(total_protocol_fee) + .expect("delta_hub_reserve_in must be >= total_protocol_fee"); + + assert_eq!( + d_net_actual, d_net_expected, + "buy-side slip fee cap not applied during inversion: \ + buyer pays {delta_hub_reserve_in} hub, fees = {total_protocol_fee}, \ + net to pool = {d_net_actual} but pool needs {d_net_expected} hub for the trade", + ); +} + +#[test] +fn calculate_buy_should_respect_max_slip_fee_cap_on_sell_side() { + // small pool: ~2% hub flow → cap fires + let asset_in_state = AssetReserveState { + reserve: 1_000_000 * UNIT, + hub_reserve: 1_000_000 * UNIT, + shares: 1_000_000 * UNIT, + protocol_shares: 0u128, + }; + // large pool: ~0.02% hub flow → cap does not fire + let asset_out_state = AssetReserveState { + reserve: 100_000_000 * UNIT, + hub_reserve: 100_000_000 * UNIT, + shares: 100_000_000 * UNIT, + protocol_shares: 0u128, + }; + + let amount = 20_000 * UNIT; + let asset_fee = Permill::zero(); + let protocol_fee = Permill::zero(); + let burn_fee = Permill::zero(); + let max_slip_fee = Permill::from_percent(1); + + let slip = TradeSlipFees { + asset_in_hub_reserve: asset_in_state.hub_reserve, + asset_in_delta: SignedBalance::zero(), + asset_out_hub_reserve: asset_out_state.hub_reserve, + asset_out_delta: SignedBalance::zero(), + max_slip_fee, + }; + + let state_changes = calculate_buy_state_changes( + &asset_in_state, + &asset_out_state, + amount, + asset_fee, + protocol_fee, + burn_fee, + Some(&slip), + ) + .expect("calculate_buy_state_changes should succeed"); + + let delta_hub_reserve_in = match state_changes.asset_in.delta_hub_reserve { + BalanceUpdate::Decrease(v) => v, + _ => panic!("expected Decrease for asset_in hub reserve"), + }; + let total_protocol_fee = state_changes.fee.protocol_fee; + + let d_net_expected: Balance = asset_out_state.hub_reserve * amount / (asset_out_state.reserve - amount) + 1; + + let d_net_actual = delta_hub_reserve_in + .checked_sub(total_protocol_fee) + .expect("delta_hub_reserve_in must be >= total_protocol_fee"); + + assert_eq!( + d_net_actual, d_net_expected, + "sell-side slip fee cap not applied during inversion: \ + buyer pays {delta_hub_reserve_in} hub, fees = {total_protocol_fee}, \ + net to pool = {d_net_actual} but pool needs {d_net_expected} hub for the trade", + ); +}