Skip to main content

riptide_amm_math/oracle/
skew.rs

1use borsh::{BorshDeserialize, BorshSerialize};
2#[cfg(feature = "wasm")]
3use riptide_amm_macros::wasm_expose;
4
5use super::{
6    super::{
7        error::{CoreError, AMOUNT_EXCEEDS_MAX_I32, ARITHMETIC_OVERFLOW},
8        quote::QuoteType,
9    },
10    SingleSideLiquidity, PER_M_DENOMINATOR,
11};
12use ethnum::U256;
13
14#[derive(Debug, Clone, Copy, Eq, PartialEq)]
15#[cfg_attr(true, derive(BorshDeserialize, BorshSerialize))]
16#[cfg_attr(feature = "wasm", wasm_expose)]
17pub enum SkewExponent {
18    Linear,
19    Quadratic,
20    Cubic,
21}
22
23impl SkewExponent {
24    pub fn value(&self) -> u32 {
25        match self {
26            Self::Linear => 1,
27            Self::Quadratic => 2,
28            Self::Cubic => 3,
29        }
30    }
31}
32
33#[derive(Debug, Clone, Copy, Eq, PartialEq)]
34#[cfg_attr(true, derive(BorshDeserialize, BorshSerialize))]
35#[cfg_attr(feature = "wasm", wasm_expose)]
36pub enum SkewMode {
37    None,
38    Polynomial {
39        exponent: SkewExponent,
40        positive_bid_per_m: u32,
41        negative_bid_per_m: u32,
42        positive_ask_per_m: u32,
43        negative_ask_per_m: u32,
44    },
45}
46
47/// Apply the skew-cliff shift: returns 0 when `deviation` is inside
48/// `[skew_cliff_min, skew_cliff_max]` (the dead band between the cliffs),
49/// otherwise shifts `deviation` toward zero by the relevant cliff (so the
50/// polynomial restarts from zero at the cliff instead of jumping). The
51/// caller must validate that `skew_cliff_min ≤ 0 ≤ skew_cliff_max`.
52fn apply_skew_cliff(deviation_per_m: i32, skew_cliff_min: i32, skew_cliff_max: i32) -> i32 {
53    if deviation_per_m >= skew_cliff_min && deviation_per_m <= skew_cliff_max {
54        0
55    } else if deviation_per_m > skew_cliff_max {
56        deviation_per_m - skew_cliff_max
57    } else {
58        // deviation_per_m < skew_cliff_min (which is ≤ 0), result is negative
59        deviation_per_m - skew_cliff_min
60    }
61}
62
63/// Run the polynomial skew formula on a (possibly skew-cliff-shifted)
64/// deviation.
65#[allow(clippy::too_many_arguments)]
66fn polynomial_skew(
67    deviation_per_m: i32,
68    a_to_b: bool,
69    exponent: SkewExponent,
70    positive_bid_per_m: u32,
71    negative_bid_per_m: u32,
72    positive_ask_per_m: u32,
73    negative_ask_per_m: u32,
74) -> Result<i32, CoreError> {
75    let intensity = select_intensity(
76        deviation_per_m,
77        a_to_b,
78        positive_bid_per_m,
79        negative_bid_per_m,
80        positive_ask_per_m,
81        negative_ask_per_m,
82    );
83    let sign = deviation_per_m.signum();
84    let abs_dev = deviation_per_m.unsigned_abs() as u128;
85    let exp = exponent.value();
86
87    let numerator = abs_dev
88        .checked_pow(exp)
89        .ok_or(ARITHMETIC_OVERFLOW)?
90        .checked_mul(intensity as u128)
91        .ok_or(ARITHMETIC_OVERFLOW)?;
92    let denominator = (PER_M_DENOMINATOR as u128)
93        .checked_pow(exp)
94        .ok_or(ARITHMETIC_OVERFLOW)?;
95
96    let quotient = numerator
97        .checked_div(denominator)
98        .ok_or(ARITHMETIC_OVERFLOW)?;
99    let remainder = numerator
100        .checked_rem(denominator)
101        .ok_or(ARITHMETIC_OVERFLOW)?;
102    let abs_result = if remainder > 0 {
103        quotient.checked_add(1).ok_or(ARITHMETIC_OVERFLOW)?
104    } else {
105        quotient
106    };
107
108    let result = i32::try_from(abs_result).map_err(|_| AMOUNT_EXCEEDS_MAX_I32)?;
109    sign.checked_mul(result).ok_or(ARITHMETIC_OVERFLOW)
110}
111
112fn select_intensity(
113    deviation_per_m: i32,
114    a_to_b: bool,
115    positive_bid_per_m: u32,
116    negative_bid_per_m: u32,
117    positive_ask_per_m: u32,
118    negative_ask_per_m: u32,
119) -> u32 {
120    match (deviation_per_m >= 0, a_to_b) {
121        (true, true) => positive_bid_per_m,
122        (false, true) => negative_bid_per_m,
123        (true, false) => positive_ask_per_m,
124        (false, false) => negative_ask_per_m,
125    }
126}
127
128impl SkewMode {
129    /// Computes the skew value in per_m units.
130    /// `deviation_per_m` ranges from -1_000_000 to +1_000_000.
131    /// Returns the skew adjustment to apply: bid_spread += skew, ask_spread -= skew.
132    /// Apply the skew curve to a (possibly skew-cliff-shifted) deviation.
133    ///
134    /// `skew_cliff_min_per_m` and `skew_cliff_max_per_m` come from the Market
135    /// struct (governance-controlled policy). When
136    /// `min ≤ deviation ≤ max` the result is zero (dead band between the
137    /// cliffs); outside the band, `deviation` is shifted toward zero by the
138    /// relevant cliff before the polynomial is applied so the curve restarts
139    /// from zero at the cliff instead of jumping. Operators disable the
140    /// cliffs by setting both values to zero.
141    ///
142    /// Validity: `skew_cliff_min_per_m ≤ 0 ≤ skew_cliff_max_per_m`, both in
143    /// `(-PER_M_DENOMINATOR, PER_M_DENOMINATOR)`.
144    pub(crate) fn compute_skew_per_m(
145        &self,
146        deviation_per_m: i32,
147        quote_type: QuoteType,
148        skew_cliff_min_per_m: i32,
149        skew_cliff_max_per_m: i32,
150    ) -> Result<i32, CoreError> {
151        match self {
152            Self::None => Ok(0),
153            Self::Polynomial {
154                exponent,
155                positive_bid_per_m,
156                negative_bid_per_m,
157                positive_ask_per_m,
158                negative_ask_per_m,
159            } => {
160                if skew_cliff_min_per_m > 0
161                    || skew_cliff_max_per_m < 0
162                    || skew_cliff_min_per_m <= -PER_M_DENOMINATOR
163                    || skew_cliff_max_per_m >= PER_M_DENOMINATOR
164                {
165                    return Err(super::super::error::INVALID_ORACLE_DATA);
166                }
167                let shifted =
168                    apply_skew_cliff(deviation_per_m, skew_cliff_min_per_m, skew_cliff_max_per_m);
169                if shifted == 0 {
170                    return Ok(0);
171                }
172                polynomial_skew(
173                    shifted,
174                    quote_type.a_to_b(),
175                    *exponent,
176                    *positive_bid_per_m,
177                    *negative_bid_per_m,
178                    *positive_ask_per_m,
179                    *negative_ask_per_m,
180                )
181            }
182        }
183    }
184}
185
186/// Applies skew to all prices in SingleSideLiquidity.
187/// adjusted_price = price * (PER_M - skew_per_m) / PER_M
188pub(crate) fn apply_skew_to_liquidity(
189    liquidity: SingleSideLiquidity,
190    skew_per_m: i32,
191    quote_type: QuoteType,
192) -> Result<SingleSideLiquidity, CoreError> {
193    let clamped = skew_per_m.clamp(-PER_M_DENOMINATOR, PER_M_DENOMINATOR);
194    if clamped == 0 {
195        return Ok(liquidity);
196    }
197    let a_to_b = quote_type.a_to_b();
198    let widening = a_to_b == (clamped > 0);
199    let abs_skew = U256::from(clamped.unsigned_abs());
200    let denom = U256::from(PER_M_DENOMINATOR as u64);
201    let mut result = SingleSideLiquidity::new();
202    for &(price, amount) in liquidity.as_slice() {
203        let numerator = U256::from(price)
204            .checked_mul(abs_skew)
205            .ok_or(ARITHMETIC_OVERFLOW)?;
206        let quotient = numerator.checked_div(denom).ok_or(ARITHMETIC_OVERFLOW)?;
207        let remainder = numerator.checked_rem(denom).ok_or(ARITHMETIC_OVERFLOW)?;
208        let delta: u128 = if widening && remainder > U256::ZERO {
209            quotient.checked_add(U256::ONE).ok_or(ARITHMETIC_OVERFLOW)?
210        } else {
211            quotient
212        }
213        .try_into()
214        .map_err(|_| ARITHMETIC_OVERFLOW)?;
215        let adjusted_price = if clamped > 0 {
216            price.checked_sub(delta).ok_or(ARITHMETIC_OVERFLOW)?
217        } else {
218            price.checked_add(delta).ok_or(ARITHMETIC_OVERFLOW)?
219        };
220        result.push((adjusted_price, amount));
221    }
222    Ok(result)
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use rstest::rstest;
229
230    const PRICE_ONE: u128 = 1 << 64;
231
232    #[rstest]
233    #[case(SkewMode::None, 200_000, QuoteType::TokenAExactIn, 0)]
234    // symmetric Linear
235    #[case(SkewMode::Polynomial { exponent: SkewExponent::Linear, positive_bid_per_m: 500_000, negative_bid_per_m: 500_000, positive_ask_per_m: 500_000, negative_ask_per_m: 500_000 }, 200_000, QuoteType::TokenAExactIn, 100_000)]
236    #[case(SkewMode::Polynomial { exponent: SkewExponent::Linear, positive_bid_per_m: 500_000, negative_bid_per_m: 500_000, positive_ask_per_m: 500_000, negative_ask_per_m: 500_000 }, -200_000, QuoteType::TokenAExactIn, -100_000)]
237    #[case(SkewMode::Polynomial { exponent: SkewExponent::Linear, positive_bid_per_m: 1_000_000, negative_bid_per_m: 1_000_000, positive_ask_per_m: 1_000_000, negative_ask_per_m: 1_000_000 }, 1_000_000, QuoteType::TokenAExactIn, 1_000_000)]
238    // symmetric Quadratic: 200_000^2 * 1_000_000 / 1_000_000^2 = 40_000
239    #[case(SkewMode::Polynomial { exponent: SkewExponent::Quadratic, positive_bid_per_m: 1_000_000, negative_bid_per_m: 1_000_000, positive_ask_per_m: 1_000_000, negative_ask_per_m: 1_000_000 }, 200_000, QuoteType::TokenAExactIn, 40_000)]
240    #[case(SkewMode::Polynomial { exponent: SkewExponent::Quadratic, positive_bid_per_m: 1_000_000, negative_bid_per_m: 1_000_000, positive_ask_per_m: 1_000_000, negative_ask_per_m: 1_000_000 }, -200_000, QuoteType::TokenAExactIn, -40_000)]
241    // symmetric Cubic: 200_000^3 * 1_000_000 / 1_000_000^3 = 8_000
242    #[case(SkewMode::Polynomial { exponent: SkewExponent::Cubic, positive_bid_per_m: 1_000_000, negative_bid_per_m: 1_000_000, positive_ask_per_m: 1_000_000, negative_ask_per_m: 1_000_000 }, 200_000, QuoteType::TokenAExactIn, 8_000)]
243    #[case(SkewMode::Polynomial { exponent: SkewExponent::Cubic, positive_bid_per_m: 1_000_000, negative_bid_per_m: 1_000_000, positive_ask_per_m: 1_000_000, negative_ask_per_m: 1_000_000 }, -200_000, QuoteType::TokenAExactIn, -8_000)]
244    // Cubic at max deviation: 1_000_000^3 * 1_000_000 / 1_000_000^3 = 1_000_000
245    #[case(SkewMode::Polynomial { exponent: SkewExponent::Cubic, positive_bid_per_m: 1_000_000, negative_bid_per_m: 1_000_000, positive_ask_per_m: 1_000_000, negative_ask_per_m: 1_000_000 }, 1_000_000, QuoteType::TokenAExactIn, 1_000_000)]
246    // no remainder, no rounding
247    #[case(SkewMode::Polynomial { exponent: SkewExponent::Linear, positive_bid_per_m: 1_000_000, negative_bid_per_m: 1_000_000, positive_ask_per_m: 1_000_000, negative_ask_per_m: 1_000_000 }, 1, QuoteType::TokenAExactIn, 1)]
248    #[case(SkewMode::Polynomial { exponent: SkewExponent::Linear, positive_bid_per_m: 1_000_000, negative_bid_per_m: 1_000_000, positive_ask_per_m: 1_000_000, negative_ask_per_m: 1_000_000 }, -1, QuoteType::TokenAExactOut, -1)]
249    // 700_001 * 300_000 / 1_000_000 = 210_000.3
250    // round up (a_to_b): -> 210_001
251    #[case(SkewMode::Polynomial { exponent: SkewExponent::Linear, positive_bid_per_m: 300_000, negative_bid_per_m: 300_000, positive_ask_per_m: 300_000, negative_ask_per_m: 300_000 }, 700_001, QuoteType::TokenAExactIn, 210_001)]
252    // always round away from zero: -> 210_001
253    #[case(SkewMode::Polynomial { exponent: SkewExponent::Linear, positive_bid_per_m: 300_000, negative_bid_per_m: 300_000, positive_ask_per_m: 300_000, negative_ask_per_m: 300_000 }, 700_001, QuoteType::TokenAExactOut, 210_001)]
254    // negative deviation: round up -> -210_001
255    #[case(SkewMode::Polynomial { exponent: SkewExponent::Linear, positive_bid_per_m: 300_000, negative_bid_per_m: 300_000, positive_ask_per_m: 300_000, negative_ask_per_m: 300_000 }, -700_001, QuoteType::TokenAExactIn, -210_001)]
256    // negative deviation: always round away from zero -> -210_001
257    #[case(SkewMode::Polynomial { exponent: SkewExponent::Linear, positive_bid_per_m: 300_000, negative_bid_per_m: 300_000, positive_ask_per_m: 300_000, negative_ask_per_m: 300_000 }, -700_001, QuoteType::TokenAExactOut, -210_001)]
258    // asymmetric Linear: 4 quadrants
259    #[case(SkewMode::Polynomial { exponent: SkewExponent::Linear, positive_bid_per_m: 100_000, negative_bid_per_m: 200_000, positive_ask_per_m: 300_000, negative_ask_per_m: 400_000 }, 500_000, QuoteType::TokenAExactIn, 50_000)]
260    #[case(SkewMode::Polynomial { exponent: SkewExponent::Linear, positive_bid_per_m: 100_000, negative_bid_per_m: 200_000, positive_ask_per_m: 300_000, negative_ask_per_m: 400_000 }, -500_000, QuoteType::TokenAExactIn, -100_000)]
261    #[case(SkewMode::Polynomial { exponent: SkewExponent::Linear, positive_bid_per_m: 100_000, negative_bid_per_m: 200_000, positive_ask_per_m: 300_000, negative_ask_per_m: 400_000 }, 500_000, QuoteType::TokenBExactIn, 150_000)]
262    #[case(SkewMode::Polynomial { exponent: SkewExponent::Linear, positive_bid_per_m: 100_000, negative_bid_per_m: 200_000, positive_ask_per_m: 300_000, negative_ask_per_m: 400_000 }, -500_000, QuoteType::TokenBExactIn, -200_000)]
263    // zero deviation
264    #[case(SkewMode::Polynomial { exponent: SkewExponent::Linear, positive_bid_per_m: 100_000, negative_bid_per_m: 200_000, positive_ask_per_m: 300_000, negative_ask_per_m: 400_000 }, 0, QuoteType::TokenAExactIn, 0)]
265    // rounding with asymmetric: bid ceil 700_001 * 100_000 / 1_000_000 = 70_000.1 -> 70_001
266    #[case(SkewMode::Polynomial { exponent: SkewExponent::Linear, positive_bid_per_m: 100_000, negative_bid_per_m: 200_000, positive_ask_per_m: 300_000, negative_ask_per_m: 400_000 }, 700_001, QuoteType::TokenAExactIn, 70_001)]
267    // rounding with asymmetric: always away from zero 700_001 * 300_000 / 1_000_000 = 210_000.3 ->
268    // 210_001
269    #[case(SkewMode::Polynomial { exponent: SkewExponent::Linear, positive_bid_per_m: 100_000, negative_bid_per_m: 200_000, positive_ask_per_m: 300_000, negative_ask_per_m: 400_000 }, 700_001, QuoteType::TokenBExactIn, 210_001)]
270    // one quadrant zero: positive_bid=0 -> skew=0
271    #[case(SkewMode::Polynomial { exponent: SkewExponent::Linear, positive_bid_per_m: 0, negative_bid_per_m: 500_000, positive_ask_per_m: 500_000, negative_ask_per_m: 500_000 }, 500_000, QuoteType::TokenAExactIn, 0)]
272    // asymmetric Quadratic: 500_000^2 * selected_intensity / 1_000_000^2
273    #[case(SkewMode::Polynomial { exponent: SkewExponent::Quadratic, positive_bid_per_m: 100_000, negative_bid_per_m: 200_000, positive_ask_per_m: 300_000, negative_ask_per_m: 400_000 }, 500_000, QuoteType::TokenAExactIn, 25_000)]
274    #[case(SkewMode::Polynomial { exponent: SkewExponent::Quadratic, positive_bid_per_m: 100_000, negative_bid_per_m: 200_000, positive_ask_per_m: 300_000, negative_ask_per_m: 400_000 }, -500_000, QuoteType::TokenAExactIn, -50_000)]
275    #[case(SkewMode::Polynomial { exponent: SkewExponent::Quadratic, positive_bid_per_m: 100_000, negative_bid_per_m: 200_000, positive_ask_per_m: 300_000, negative_ask_per_m: 400_000 }, 500_000, QuoteType::TokenBExactIn, 75_000)]
276    #[case(SkewMode::Polynomial { exponent: SkewExponent::Quadratic, positive_bid_per_m: 100_000, negative_bid_per_m: 200_000, positive_ask_per_m: 300_000, negative_ask_per_m: 400_000 }, -500_000, QuoteType::TokenBExactIn, -100_000)]
277    // asymmetric Cubic: 500_000^3 * selected_intensity / 1_000_000^3
278    // 500_000^3 = 125_000_000_000_000_000, * 100_000 / 10^18 = 12_500
279    #[case(SkewMode::Polynomial { exponent: SkewExponent::Cubic, positive_bid_per_m: 100_000, negative_bid_per_m: 200_000, positive_ask_per_m: 300_000, negative_ask_per_m: 400_000 }, 500_000, QuoteType::TokenAExactIn, 12_500)]
280    #[case(SkewMode::Polynomial { exponent: SkewExponent::Cubic, positive_bid_per_m: 100_000, negative_bid_per_m: 200_000, positive_ask_per_m: 300_000, negative_ask_per_m: 400_000 }, -500_000, QuoteType::TokenAExactIn, -25_000)]
281    #[case(SkewMode::Polynomial { exponent: SkewExponent::Cubic, positive_bid_per_m: 100_000, negative_bid_per_m: 200_000, positive_ask_per_m: 300_000, negative_ask_per_m: 400_000 }, 500_000, QuoteType::TokenBExactIn, 37_500)]
282    #[case(SkewMode::Polynomial { exponent: SkewExponent::Cubic, positive_bid_per_m: 100_000, negative_bid_per_m: 200_000, positive_ask_per_m: 300_000, negative_ask_per_m: 400_000 }, -500_000, QuoteType::TokenBExactIn, -50_000)]
283    fn test_compute_skew_per_m(
284        #[case] skew_mode: SkewMode,
285        #[case] deviation_per_m: i32,
286        #[case] quote_type: QuoteType,
287        #[case] expected: i32,
288    ) {
289        // Skew cliffs = (0, 0) → effectively disabled, behaves like the
290        // original Polynomial logic.
291        assert_eq!(
292            skew_mode
293                .compute_skew_per_m(deviation_per_m, quote_type, 0, 0)
294                .unwrap(),
295            expected
296        );
297    }
298
299    // Skew-cliff semantics on top of Polynomial: zero inside the band between
300    // the cliffs, then shift-from-cliff polynomial outside. Sweeps both
301    // symmetric and asymmetric bands.
302    #[rstest]
303    // Symmetric band [-50k, +50k]: zero at boundary and zero
304    #[case(0, -50_000, 50_000, 0)]
305    #[case(50_000, -50_000, 50_000, 0)]
306    #[case(-50_000, -50_000, 50_000, 0)]
307    // Outside the band: shift, linear at full intensity → result = shifted
308    #[case(200_000, -50_000, 50_000, 150_000)]
309    #[case(-200_000, -50_000, 50_000, -150_000)]
310    // Continuity at the cliff: deviation = max + 1 → tiny shift
311    #[case(50_001, -50_000, 50_000, 1)]
312    // Asymmetric band [-30k, +80k]
313    #[case(70_000, -30_000, 80_000, 0)]
314    #[case(-30_000, -30_000, 80_000, 0)]
315    #[case(130_000, -30_000, 80_000, 50_000)]
316    #[case(-130_000, -30_000, 80_000, -100_000)]
317    // Wide band acting like SkewMode::None for this deviation
318    #[case(500_000, -999_999, 999_999, 0)]
319    fn test_compute_skew_per_m_with_skew_cliff(
320        #[case] deviation_per_m: i32,
321        #[case] skew_cliff_min_per_m: i32,
322        #[case] skew_cliff_max_per_m: i32,
323        #[case] expected: i32,
324    ) {
325        let skew = SkewMode::Polynomial {
326            exponent: SkewExponent::Linear,
327            positive_bid_per_m: 1_000_000,
328            negative_bid_per_m: 1_000_000,
329            positive_ask_per_m: 1_000_000,
330            negative_ask_per_m: 1_000_000,
331        };
332        assert_eq!(
333            skew.compute_skew_per_m(
334                deviation_per_m,
335                QuoteType::TokenAExactIn,
336                skew_cliff_min_per_m,
337                skew_cliff_max_per_m,
338            )
339            .unwrap(),
340            expected
341        );
342    }
343
344    // Invalid skew-cliff configs must be rejected.
345    #[rstest]
346    // min > 0 — must contain zero
347    #[case(10_000, 50_000)]
348    // max < 0 — must contain zero
349    #[case(-50_000, -10_000)]
350    // |min| ≥ PER_M
351    #[case(-PER_M_DENOMINATOR, 50_000)]
352    // max ≥ PER_M
353    #[case(-50_000, PER_M_DENOMINATOR)]
354    fn test_compute_skew_per_m_rejects_invalid_skew_cliff(
355        #[case] skew_cliff_min_per_m: i32,
356        #[case] skew_cliff_max_per_m: i32,
357    ) {
358        let skew = SkewMode::Polynomial {
359            exponent: SkewExponent::Linear,
360            positive_bid_per_m: 0,
361            negative_bid_per_m: 0,
362            positive_ask_per_m: 0,
363            negative_ask_per_m: 0,
364        };
365        let result = skew.compute_skew_per_m(
366            100_000,
367            QuoteType::TokenAExactIn,
368            skew_cliff_min_per_m,
369            skew_cliff_max_per_m,
370        );
371        assert!(result.is_err());
372    }
373
374    // SkewMode::None must short-circuit before cliff validation: even invalid
375    // cliff configs must still return Ok(0), so disabled skew never affects
376    // swap availability.
377    #[rstest]
378    // valid cliffs
379    #[case(0, 0)]
380    #[case(-50_000, 50_000)]
381    // invalid cliffs that would fail validation for Polynomial
382    #[case(10_000, 50_000)]
383    #[case(-50_000, -10_000)]
384    #[case(-PER_M_DENOMINATOR, 50_000)]
385    #[case(-50_000, PER_M_DENOMINATOR)]
386    fn test_compute_skew_per_m_none_ignores_skew_cliff(
387        #[case] skew_cliff_min_per_m: i32,
388        #[case] skew_cliff_max_per_m: i32,
389    ) {
390        assert_eq!(
391            SkewMode::None
392                .compute_skew_per_m(
393                    500_000,
394                    QuoteType::TokenAExactIn,
395                    skew_cliff_min_per_m,
396                    skew_cliff_max_per_m,
397                )
398                .unwrap(),
399            0
400        );
401    }
402
403    #[rstest]
404    // widening: a_to_b + positive skew, or !a_to_b + negative skew -> ceil delta
405    #[case(PRICE_ONE, 0, QuoteType::TokenAExactIn, PRICE_ONE)]
406    #[case(PRICE_ONE, 500_000, QuoteType::TokenAExactIn, PRICE_ONE / 2)]
407    #[case(PRICE_ONE, -500_000, QuoteType::TokenBExactIn, PRICE_ONE + PRICE_ONE / 2)]
408    #[case(PRICE_ONE, 250_000, QuoteType::TokenAExactIn, PRICE_ONE * 3 / 4)]
409    #[case(PRICE_ONE, 1_000_000, QuoteType::TokenAExactIn, 0)]
410    #[case(PRICE_ONE, -1_000_000, QuoteType::TokenBExactIn, PRICE_ONE * 2)]
411    #[case(PRICE_ONE, 2_000_000, QuoteType::TokenAExactIn, 0)] // clamped to 1M
412    #[case(PRICE_ONE, -2_000_000, QuoteType::TokenBExactIn, PRICE_ONE * 2)] // clamped to -1M
413    // widening: delta ceiling for protocol safety
414    #[case(PRICE_ONE, 999_999, QuoteType::TokenAExactIn, PRICE_ONE - (PRICE_ONE * 999_999).div_ceil(1_000_000))]
415    #[case(PRICE_ONE, -999_999, QuoteType::TokenBExactIn, PRICE_ONE + (PRICE_ONE * 999_999).div_ceil(1_000_000))]
416    // narrowing: a_to_b + negative skew, or !a_to_b + positive skew -> floor delta
417    #[case(PRICE_ONE, -999_999, QuoteType::TokenAExactIn, PRICE_ONE + PRICE_ONE * 999_999 / 1_000_000)]
418    #[case(PRICE_ONE, 999_999, QuoteType::TokenBExactIn, PRICE_ONE - PRICE_ONE * 999_999 / 1_000_000)]
419    fn test_apply_skew_to_liquidity(
420        #[case] price: u128,
421        #[case] skew_per_m: i32,
422        #[case] quote_type: QuoteType,
423        #[case] expected_price: u128,
424    ) {
425        let liquidity = SingleSideLiquidity::from_slice(&[(price, 1000)]);
426        let result = apply_skew_to_liquidity(liquidity, skew_per_m, quote_type).unwrap();
427        let (result_price, result_amount) = result.as_slice()[0];
428        assert_eq!(result_price, expected_price);
429        assert_eq!(result_amount, 1000);
430    }
431}