Skip to main content

equanetwork_math/
swap.rs

1use super::{
2    a_to_b, b_to_a, fee_from_post_fee_amount, fee_from_pre_fee_amount, next_band, prev_band,
3    skew_band, CoreError, SkewBand, SkewExponent, SkewVault, ARITHMETIC_OVERFLOW,
4    PARTIAL_FILL_NOT_ALLOWED, PER_M_DENOMINATOR, U128,
5};
6
7#[cfg(feature = "wasm")]
8use equanetwork_macros::wasm_expose;
9
10use ethnum::U256;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13#[cfg_attr(feature = "wasm", wasm_expose)]
14pub enum SwapMode {
15    ExactIn,
16    ExactOut,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20#[cfg_attr(feature = "wasm", wasm_expose)]
21pub struct SwapQuote {
22    pub amount_in: u64,
23    pub fee_in: u64,
24    pub amount_out: u64,
25    pub fee_out: u64,
26}
27
28#[derive(Debug, Clone, Copy)]
29#[cfg_attr(feature = "wasm", wasm_expose)]
30pub struct SwapVault {
31    pub swap_fee_per_m: u32,
32    pub inventory_limit_upper: u64,
33    pub inventory_limit_lower: u64,
34    pub max_swap_amount: u64,
35    pub positive_skew_per_m: u32,
36    pub negative_skew_per_m: u32,
37    pub lower_skew_offset: u64,
38    pub upper_skew_offset: u64,
39    pub skew_exponent: SkewExponent,
40}
41
42#[allow(clippy::too_many_arguments)]
43#[cfg_attr(feature = "wasm", wasm_expose)]
44pub fn swap_quote(
45    amount: u64,
46    swap_mode: SwapMode,
47    base_price: U128,
48    allow_partial_fill: bool,
49    input_vault: SwapVault,
50    input_vault_balance: u64,
51    output_vault: SwapVault,
52    output_vault_balance: u64,
53) -> Result<SwapQuote, CoreError> {
54    let base_price: u128 = base_price.into();
55    let max_amount_in = input_vault
56        .inventory_limit_upper
57        .checked_sub(input_vault_balance)
58        .ok_or(ARITHMETIC_OVERFLOW)?
59        .min(input_vault.max_swap_amount);
60
61    let max_amount_out = output_vault_balance
62        .checked_sub(output_vault.inventory_limit_lower)
63        .ok_or(ARITHMETIC_OVERFLOW)?
64        .min(output_vault.max_swap_amount);
65
66    match swap_mode {
67        SwapMode::ExactIn => {
68            let amount_in = amount.min(max_amount_in);
69            let mut quote = raw_quote(
70                amount_in,
71                SwapMode::ExactIn,
72                base_price.into(),
73                &input_vault,
74                input_vault_balance,
75                &output_vault,
76                output_vault_balance,
77            )?;
78            if quote.amount_out > max_amount_out {
79                quote = raw_quote(
80                    max_amount_out,
81                    SwapMode::ExactOut,
82                    base_price.into(),
83                    &input_vault,
84                    input_vault_balance,
85                    &output_vault,
86                    output_vault_balance,
87                )?;
88            }
89            if allow_partial_fill || quote.amount_in == amount {
90                Ok(quote)
91            } else {
92                Err(PARTIAL_FILL_NOT_ALLOWED)
93            }
94        }
95        SwapMode::ExactOut => {
96            let amount_out = amount.min(max_amount_out);
97            let mut quote = raw_quote(
98                amount_out,
99                SwapMode::ExactOut,
100                base_price.into(),
101                &input_vault,
102                input_vault_balance,
103                &output_vault,
104                output_vault_balance,
105            )?;
106            if quote.amount_in > max_amount_in {
107                quote = raw_quote(
108                    max_amount_in,
109                    SwapMode::ExactIn,
110                    base_price.into(),
111                    &input_vault,
112                    input_vault_balance,
113                    &output_vault,
114                    output_vault_balance,
115                )?;
116            }
117            if allow_partial_fill || quote.amount_out == amount {
118                Ok(quote)
119            } else {
120                Err(PARTIAL_FILL_NOT_ALLOWED)
121            }
122        }
123    }
124}
125
126fn raw_quote(
127    amount: u64,
128    swap_mode: SwapMode,
129    base_price: u128,
130    input_vault: &SwapVault,
131    input_vault_balance: u64,
132    output_vault: &SwapVault,
133    output_vault_balance: u64,
134) -> Result<SwapQuote, CoreError> {
135    let prelim_fee = match swap_mode {
136        SwapMode::ExactIn => fee_from_pre_fee_amount(amount, input_vault.swap_fee_per_m)?,
137        SwapMode::ExactOut => fee_from_post_fee_amount(amount, output_vault.swap_fee_per_m)?,
138    };
139
140    let consumable_amount = match swap_mode {
141        SwapMode::ExactIn => amount.checked_sub(prelim_fee).ok_or(ARITHMETIC_OVERFLOW)?,
142        SwapMode::ExactOut => amount.checked_add(prelim_fee).ok_or(ARITHMETIC_OVERFLOW)?,
143    };
144
145    let mut raw_amount_in = 0u64;
146    let mut raw_amount_out = 0u64;
147    let mut remaining_amount = consumable_amount;
148
149    let mut current_input_balance = input_vault_balance;
150    let mut current_output_balance = output_vault_balance;
151
152    let mut input_band = Some(skew_band(
153        input_vault_balance,
154        true,
155        SkewVault::from(*input_vault),
156    )?);
157    let mut output_band = Some(skew_band(
158        output_vault_balance,
159        false,
160        SkewVault::from(*output_vault),
161    )?);
162
163    while remaining_amount > 0 {
164        let (Some(current_input_band), Some(current_output_band)) = (&input_band, &output_band)
165        else {
166            break;
167        };
168
169        let (a_to_b_price, b_to_a_price) =
170            band_prices(base_price, current_input_band, current_output_band)?;
171        if a_to_b_price == 0 || b_to_a_price == 0 {
172            break;
173        }
174
175        let (input_amount, output_amount, move_input_vault, move_output_vault) = match swap_mode {
176            SwapMode::ExactIn => {
177                let input_amount_input_band = current_input_band
178                    .upper_limit
179                    .abs_diff(current_input_balance)
180                    .min(remaining_amount);
181                let output_amount_output_band = current_output_band
182                    .lower_limit
183                    .abs_diff(current_output_balance);
184                let input_amount_output_band =
185                    b_to_a(output_amount_output_band, b_to_a_price.into(), true)?
186                        .min(remaining_amount);
187
188                let input_amount = input_amount_input_band.min(input_amount_output_band);
189                let output_amount = a_to_b(input_amount, a_to_b_price.into(), false)?;
190
191                (
192                    input_amount,
193                    output_amount,
194                    input_amount_input_band < input_amount_output_band,
195                    input_amount_input_band > input_amount_output_band,
196                )
197            }
198            SwapMode::ExactOut => {
199                let output_amount_output_band = current_output_band
200                    .lower_limit
201                    .abs_diff(current_output_balance)
202                    .min(remaining_amount);
203                let input_amount_input_band = current_input_band
204                    .upper_limit
205                    .abs_diff(current_input_balance);
206                let output_amount_input_band =
207                    a_to_b(input_amount_input_band, a_to_b_price.into(), false)?
208                        .min(remaining_amount);
209
210                let output_amount = output_amount_input_band.min(output_amount_output_band);
211                let input_amount = b_to_a(output_amount, b_to_a_price.into(), true)?;
212
213                (
214                    input_amount,
215                    output_amount,
216                    output_amount_input_band < output_amount_output_band,
217                    output_amount_input_band > output_amount_output_band,
218                )
219            }
220        };
221
222        raw_amount_in = raw_amount_in
223            .checked_add(input_amount)
224            .ok_or(ARITHMETIC_OVERFLOW)?;
225        raw_amount_out = raw_amount_out
226            .checked_add(output_amount)
227            .ok_or(ARITHMETIC_OVERFLOW)?;
228
229        remaining_amount = match swap_mode {
230            SwapMode::ExactIn => remaining_amount
231                .checked_sub(input_amount)
232                .ok_or(ARITHMETIC_OVERFLOW)?,
233            SwapMode::ExactOut => remaining_amount
234                .checked_sub(output_amount)
235                .ok_or(ARITHMETIC_OVERFLOW)?,
236        };
237
238        current_input_balance = current_input_balance
239            .checked_add(input_amount)
240            .ok_or(ARITHMETIC_OVERFLOW)?;
241        current_output_balance = current_output_balance
242            .checked_sub(output_amount)
243            .ok_or(ARITHMETIC_OVERFLOW)?;
244
245        if move_input_vault {
246            input_band = next_band(current_input_band, &SkewVault::from(*input_vault))?;
247        }
248
249        if move_output_vault {
250            output_band = prev_band(current_output_band, &SkewVault::from(*output_vault))?;
251        }
252    }
253
254    let (fee_in, amount_in) =
255        if matches!(swap_mode, SwapMode::ExactIn) && consumable_amount == raw_amount_in {
256            (prelim_fee, amount)
257        } else {
258            let fee_in = fee_from_post_fee_amount(raw_amount_in, input_vault.swap_fee_per_m)?;
259            let amount_in = raw_amount_in
260                .checked_add(fee_in)
261                .ok_or(ARITHMETIC_OVERFLOW)?;
262            (fee_in, amount_in)
263        };
264
265    let (fee_out, amount_out) =
266        if matches!(swap_mode, SwapMode::ExactOut) && consumable_amount == raw_amount_out {
267            (prelim_fee, amount)
268        } else {
269            let fee_out = fee_from_pre_fee_amount(raw_amount_out, output_vault.swap_fee_per_m)?;
270            let amount_out = raw_amount_out
271                .checked_sub(fee_out)
272                .ok_or(ARITHMETIC_OVERFLOW)?;
273            (fee_out, amount_out)
274        };
275
276    Ok(SwapQuote {
277        amount_in,
278        fee_in,
279        amount_out,
280        fee_out,
281    })
282}
283
284fn band_prices(
285    base_price: u128,
286    input_band: &SkewBand,
287    output_band: &SkewBand,
288) -> Result<(u128, u128), CoreError> {
289    let price_skew_per_m = PER_M_DENOMINATOR
290        .checked_add(input_band.skew_per_m)
291        .ok_or(ARITHMETIC_OVERFLOW)?
292        .checked_add(output_band.skew_per_m)
293        .ok_or(ARITHMETIC_OVERFLOW)?
294        .clamp(0, PER_M_DENOMINATOR * 2);
295
296    let product = U256::from(base_price)
297        .checked_mul(U256::from(price_skew_per_m as u32))
298        .ok_or(ARITHMETIC_OVERFLOW)?;
299
300    let quotient = product
301        .checked_div(U256::from(PER_M_DENOMINATOR as u32))
302        .ok_or(ARITHMETIC_OVERFLOW)?;
303
304    let remainder = product
305        .checked_rem(U256::from(PER_M_DENOMINATOR as u32))
306        .ok_or(ARITHMETIC_OVERFLOW)?;
307
308    let result = if remainder > 0 {
309        quotient + 1
310    } else {
311        quotient
312    };
313
314    Ok((
315        quotient.try_into().map_err(|_| ARITHMETIC_OVERFLOW)?,
316        result.try_into().map_err(|_| ARITHMETIC_OVERFLOW)?,
317    ))
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323    use rstest::rstest;
324
325    fn stub_band(skew_per_m: i32) -> SkewBand {
326        SkewBand {
327            index: 0,
328            lower_limit: 0,
329            upper_limit: 0,
330            skew_per_m,
331        }
332    }
333
334    fn dead_zone_vault(swap_fee_per_m: u32, max_swap_amount: u64) -> SwapVault {
335        SwapVault {
336            swap_fee_per_m,
337            inventory_limit_upper: 100_000,
338            inventory_limit_lower: 0,
339            max_swap_amount,
340            positive_skew_per_m: 0,
341            negative_skew_per_m: 0,
342            lower_skew_offset: 0,
343            upper_skew_offset: 100_000,
344            skew_exponent: SkewExponent::Linear,
345        }
346    }
347
348    fn skew_vault(
349        positive_skew_per_m: u32,
350        negative_skew_per_m: u32,
351        skew_exponent: SkewExponent,
352    ) -> SwapVault {
353        SwapVault {
354            swap_fee_per_m: 0,
355            inventory_limit_upper: 64_000,
356            inventory_limit_lower: 0,
357            max_swap_amount: u64::MAX,
358            positive_skew_per_m,
359            negative_skew_per_m,
360            lower_skew_offset: 16_000,
361            upper_skew_offset: 48_000,
362            skew_exponent,
363        }
364    }
365
366    #[rstest]
367    // base = PER_M so factor math is exact (no Q64.64 remainder)
368    #[case(0, 0, 1_000_000u128, 1_000_000u128)]
369    #[case(100_000, 0, 1_100_000, 1_100_000)]
370    #[case(0, -100_000, 900_000, 900_000)]
371    #[case(-600_000, -600_000, 0, 0)]
372    #[case(600_000, 600_000, 2_000_000, 2_000_000)]
373    fn test_band_prices(
374        #[case] input_skew_per_m: i32,
375        #[case] output_skew_per_m: i32,
376        #[case] expected_a_to_b: u128,
377        #[case] expected_b_to_a: u128,
378    ) {
379        let (a_to_b_price, b_to_a_price) = band_prices(
380            PER_M_DENOMINATOR as u128,
381            &stub_band(input_skew_per_m),
382            &stub_band(output_skew_per_m),
383        )
384        .unwrap();
385        assert_eq!(a_to_b_price, expected_a_to_b);
386        assert_eq!(b_to_a_price, expected_b_to_a);
387    }
388
389    #[rstest]
390    // (1<<64)*(PER_M+1) is not divisible by PER_M → floor vs ceil split
391    #[case(1, 0, 18_446_762_520_453_625_325, 18_446_762_520_453_625_326)]
392    fn test_band_prices_rounding(
393        #[case] input_skew_per_m: i32,
394        #[case] output_skew_per_m: i32,
395        #[case] expected_a_to_b: u128,
396        #[case] expected_b_to_a: u128,
397    ) {
398        let (a_to_b_price, b_to_a_price) = band_prices(
399            1u128 << 64,
400            &stub_band(input_skew_per_m),
401            &stub_band(output_skew_per_m),
402        )
403        .unwrap();
404        assert_eq!(a_to_b_price, expected_a_to_b);
405        assert_eq!(b_to_a_price, expected_b_to_a);
406        assert!(b_to_a_price > a_to_b_price);
407    }
408
409    #[rstest]
410    #[case(1_000, SwapMode::ExactIn)]
411    #[case(1_000, SwapMode::ExactOut)]
412    #[case(0, SwapMode::ExactIn)]
413    #[case(0, SwapMode::ExactOut)]
414    fn test_swap_quote_identity(#[case] amount: u64, #[case] swap_mode: SwapMode) {
415        let vault = dead_zone_vault(0, u64::MAX);
416        let quote = swap_quote(
417            amount,
418            swap_mode,
419            U128::from(1u128 << 64),
420            true,
421            vault,
422            50_000,
423            vault,
424            50_000,
425        )
426        .unwrap();
427        assert_eq!(
428            quote,
429            SwapQuote {
430                amount_in: amount,
431                fee_in: 0,
432                amount_out: amount,
433                fee_out: 0,
434            }
435        );
436    }
437
438    #[rstest]
439    // ExactIn 10% input fee: fee_in=100, consumable 900 → out 900
440    #[case(SwapMode::ExactIn, 100_000, 0, 1_000, 1_000, 100, 900, 0)]
441    // ExactOut 10% output fee: amount_out=900, fee_out=100, in=1000
442    #[case(SwapMode::ExactOut, 0, 100_000, 900, 1_000, 0, 900, 100)]
443    // Both sides 10%: ExactIn 1000 → fee_in=100, raw 900, fee_out=90, out=810
444    #[case(SwapMode::ExactIn, 100_000, 100_000, 1_000, 1_000, 100, 810, 90)]
445    fn test_swap_quote_fees(
446        #[case] swap_mode: SwapMode,
447        #[case] input_fee_per_m: u32,
448        #[case] output_fee_per_m: u32,
449        #[case] amount: u64,
450        #[case] expected_in: u64,
451        #[case] expected_fee_in: u64,
452        #[case] expected_out: u64,
453        #[case] expected_fee_out: u64,
454    ) {
455        let quote = swap_quote(
456            amount,
457            swap_mode,
458            U128::from(1u128 << 64),
459            true,
460            dead_zone_vault(input_fee_per_m, u64::MAX),
461            50_000,
462            dead_zone_vault(output_fee_per_m, u64::MAX),
463            50_000,
464        )
465        .unwrap();
466        assert_eq!(quote.amount_in, expected_in);
467        assert_eq!(quote.fee_in, expected_fee_in);
468        assert_eq!(quote.amount_out, expected_out);
469        assert_eq!(quote.fee_out, expected_fee_out);
470    }
471
472    #[rstest]
473    #[case(9, 100, 9, 1, 8, 0)]
474    fn test_swap_quote_fees_rounding(
475        #[case] amount: u64,
476        #[case] input_fee_per_m: u32,
477        #[case] expected_in: u64,
478        #[case] expected_fee_in: u64,
479        #[case] expected_out: u64,
480        #[case] expected_fee_out: u64,
481    ) {
482        let quote = swap_quote(
483            amount,
484            SwapMode::ExactIn,
485            U128::from(1u128 << 64),
486            true,
487            dead_zone_vault(input_fee_per_m, u64::MAX),
488            50_000,
489            dead_zone_vault(0, u64::MAX),
490            50_000,
491        )
492        .unwrap();
493        assert_eq!(quote.amount_in, expected_in);
494        assert_eq!(quote.fee_in, expected_fee_in);
495        assert_eq!(quote.amount_out, expected_out);
496        assert_eq!(quote.fee_out, expected_fee_out);
497    }
498
499    #[rstest]
500    // Input at band +16 (balance 56000), increasing away → skew +16000 → out 406 for 400 in
501    #[case(55_501, 32_000, true, 400, 400, 406)]
502    // Output at band -16 (balance 8000), decreasing away → skew +16000 → out 406
503    #[case(50_000, 8_000, false, 400, 400, 406)]
504    // Dead zone control
505    #[case(32_000, 32_000, true, 400, 400, 400)]
506    fn test_swap_quote_skew(
507        #[case] input_balance: u64,
508        #[case] output_balance: u64,
509        #[case] skew_on_input: bool,
510        #[case] amount: u64,
511        #[case] expected_in: u64,
512        #[case] expected_out: u64,
513    ) {
514        let (input_vault, output_vault) = if skew_on_input {
515            (
516                skew_vault(32_000, 0, SkewExponent::Linear),
517                dead_zone_vault(0, u64::MAX),
518            )
519        } else if input_balance == 32_000 && output_balance == 32_000 {
520            (dead_zone_vault(0, u64::MAX), dead_zone_vault(0, u64::MAX))
521        } else {
522            (
523                dead_zone_vault(0, u64::MAX),
524                skew_vault(32_000, 0, SkewExponent::Linear),
525            )
526        };
527        let quote = swap_quote(
528            amount,
529            SwapMode::ExactIn,
530            U128::from(1u128 << 64),
531            true,
532            input_vault,
533            input_balance,
534            output_vault,
535            output_balance,
536        )
537        .unwrap();
538        assert_eq!(quote.amount_in, expected_in);
539        assert_eq!(quote.fee_in, 0);
540        assert_eq!(quote.amount_out, expected_out);
541        assert_eq!(quote.fee_out, 0);
542    }
543
544    #[rstest]
545    // Band +1 skew ceil(10000/32)=313; 10000 crosses bands → path-averaged out
546    #[case(48_001, 50_000, 10_000, 10_000, 10_023)]
547    fn test_swap_quote_skew_rounding(
548        #[case] input_balance: u64,
549        #[case] output_balance: u64,
550        #[case] positive_skew_per_m: u32,
551        #[case] amount: u64,
552        #[case] expected_out: u64,
553    ) {
554        let quote = swap_quote(
555            amount,
556            SwapMode::ExactIn,
557            U128::from(1u128 << 64),
558            true,
559            skew_vault(positive_skew_per_m, 0, SkewExponent::Linear),
560            input_balance,
561            dead_zone_vault(0, u64::MAX),
562            output_balance,
563        )
564        .unwrap();
565        assert_eq!(quote.amount_in, amount);
566        assert_eq!(quote.amount_out, expected_out);
567    }
568
569    #[rstest]
570    // ExactIn: input upper 10_000, balance 9_000 → room 1_000
571    #[case(
572        SwapMode::ExactIn,
573        5_000,
574        9_000,
575        10_000,
576        50_000,
577        100_000,
578        u64::MAX,
579        1_000,
580        1_000
581    )]
582    // ExactOut: output lower 0, balance 1_000 → room 1_000
583    #[case(
584        SwapMode::ExactOut,
585        5_000,
586        50_000,
587        100_000,
588        1_000,
589        100_000,
590        u64::MAX,
591        1_000,
592        1_000
593    )]
594    // max_swap_amount 500 caps ExactIn
595    #[case(
596        SwapMode::ExactIn,
597        5_000,
598        50_000,
599        100_000,
600        50_000,
601        100_000,
602        500,
603        500,
604        500
605    )]
606    fn test_swap_quote_caps(
607        #[case] swap_mode: SwapMode,
608        #[case] amount: u64,
609        #[case] input_balance: u64,
610        #[case] input_upper: u64,
611        #[case] output_balance: u64,
612        #[case] output_upper: u64,
613        #[case] max_swap_amount: u64,
614        #[case] expected_in: u64,
615        #[case] expected_out: u64,
616    ) {
617        let input = SwapVault {
618            swap_fee_per_m: 0,
619            inventory_limit_upper: input_upper,
620            inventory_limit_lower: 0,
621            max_swap_amount,
622            positive_skew_per_m: 0,
623            negative_skew_per_m: 0,
624            lower_skew_offset: 0,
625            upper_skew_offset: input_upper,
626            skew_exponent: SkewExponent::Linear,
627        };
628        let output = SwapVault {
629            swap_fee_per_m: 0,
630            inventory_limit_upper: output_upper,
631            inventory_limit_lower: 0,
632            max_swap_amount,
633            positive_skew_per_m: 0,
634            negative_skew_per_m: 0,
635            lower_skew_offset: 0,
636            upper_skew_offset: output_upper,
637            skew_exponent: SkewExponent::Linear,
638        };
639        let quote = swap_quote(
640            amount,
641            swap_mode,
642            U128::from(1u128 << 64),
643            true,
644            input,
645            input_balance,
646            output,
647            output_balance,
648        )
649        .unwrap();
650        assert_eq!(quote.amount_in, expected_in);
651        assert_eq!(quote.amount_out, expected_out);
652    }
653
654    #[test]
655    fn test_swap_quote_caps_requote_on_output_room() {
656        // Input room large; output only has 500 to lower bound → ExactIn requotes via ExactOut
657        let quote = swap_quote(
658            5_000,
659            SwapMode::ExactIn,
660            U128::from(1u128 << 64),
661            true,
662            dead_zone_vault(0, u64::MAX),
663            50_000,
664            dead_zone_vault(0, u64::MAX),
665            500,
666        )
667        .unwrap();
668        assert!(quote.amount_out <= 500);
669        assert_eq!(quote.amount_out, 500);
670        assert_eq!(quote.amount_in, 500);
671    }
672
673    #[rstest]
674    #[case(SwapMode::ExactIn, false, Err(PARTIAL_FILL_NOT_ALLOWED))]
675    #[case(SwapMode::ExactIn, true, Ok(()))]
676    #[case(SwapMode::ExactOut, false, Err(PARTIAL_FILL_NOT_ALLOWED))]
677    #[case(SwapMode::ExactOut, true, Ok(()))]
678    fn test_swap_quote_partial_fill(
679        #[case] swap_mode: SwapMode,
680        #[case] allow_partial_fill: bool,
681        #[case] expected: Result<(), CoreError>,
682    ) {
683        // Input upper 10_000, balance 9_000 → room 1_000; request 5_000
684        let input = SwapVault {
685            swap_fee_per_m: 0,
686            inventory_limit_upper: 10_000,
687            inventory_limit_lower: 0,
688            max_swap_amount: u64::MAX,
689            positive_skew_per_m: 0,
690            negative_skew_per_m: 0,
691            lower_skew_offset: 0,
692            upper_skew_offset: 10_000,
693            skew_exponent: SkewExponent::Linear,
694        };
695        let output = SwapVault {
696            swap_fee_per_m: 0,
697            inventory_limit_upper: 100_000,
698            inventory_limit_lower: 0,
699            max_swap_amount: u64::MAX,
700            positive_skew_per_m: 0,
701            negative_skew_per_m: 0,
702            lower_skew_offset: 0,
703            upper_skew_offset: 100_000,
704            skew_exponent: SkewExponent::Linear,
705        };
706        let result = swap_quote(
707            5_000,
708            swap_mode,
709            U128::from(1u128 << 64),
710            allow_partial_fill,
711            input,
712            9_000,
713            output,
714            1_000,
715        );
716        match expected {
717            Ok(()) => {
718                let quote = result.unwrap();
719                assert!(quote.amount_in < 5_000 || quote.amount_out < 5_000);
720            }
721            Err(e) => assert_eq!(result, Err(e)),
722        }
723    }
724
725    #[rstest]
726    #[case(SwapMode::ExactIn, 1_000)]
727    #[case(SwapMode::ExactOut, 1_000)]
728    fn test_swap_quote_multiband(#[case] swap_mode: SwapMode, #[case] amount: u64) {
729        // Start just into band +1; 1000 crosses into band +2 (width 500)
730        let input = skew_vault(32_000, 0, SkewExponent::Linear);
731        let output = dead_zone_vault(0, u64::MAX);
732        let small = swap_quote(
733            100,
734            swap_mode,
735            U128::from(1u128 << 64),
736            true,
737            input,
738            48_001,
739            output,
740            50_000,
741        )
742        .unwrap();
743        let large = swap_quote(
744            amount,
745            swap_mode,
746            U128::from(1u128 << 64),
747            true,
748            input,
749            48_001,
750            output,
751            50_000,
752        )
753        .unwrap();
754        assert!(large.amount_in > 0 && large.amount_out > 0);
755        assert!(large.amount_in >= small.amount_in);
756        // Crossed into higher positive-skew bands → average out/in rises vs single-band fill
757        let small_rate = small.amount_out as u128 * 1_000_000 / small.amount_in.max(1) as u128;
758        let large_rate = large.amount_out as u128 * 1_000_000 / large.amount_in.max(1) as u128;
759        assert!(large_rate >= small_rate);
760    }
761
762    #[test]
763    fn test_swap_quote_capacity_overflow_input_above_upper() {
764        // balance > upper → checked_sub overflows
765        let vault = dead_zone_vault(0, u64::MAX);
766        let result = swap_quote(
767            1_000,
768            SwapMode::ExactIn,
769            U128::from(1u128 << 64),
770            true,
771            vault,
772            100_001,
773            vault,
774            50_000,
775        );
776        assert_eq!(result, Err(ARITHMETIC_OVERFLOW));
777    }
778
779    #[test]
780    fn test_swap_quote_capacity_overflow_output_below_lower() {
781        let input = dead_zone_vault(0, u64::MAX);
782        let output = SwapVault {
783            swap_fee_per_m: 0,
784            inventory_limit_upper: 100_000,
785            inventory_limit_lower: 1_000,
786            max_swap_amount: u64::MAX,
787            positive_skew_per_m: 0,
788            negative_skew_per_m: 0,
789            lower_skew_offset: 1_000,
790            upper_skew_offset: 100_000,
791            skew_exponent: SkewExponent::Linear,
792        };
793        let result = swap_quote(
794            1_000,
795            SwapMode::ExactOut,
796            U128::from(1u128 << 64),
797            true,
798            input,
799            50_000,
800            output,
801            500,
802        );
803        assert_eq!(result, Err(ARITHMETIC_OVERFLOW));
804    }
805
806    #[test]
807    fn test_swap_quote_zero_prices_exit_loop() {
808        // Both-toward at the outer bands with 100% intensity clamps the factor to 0.
809        let vault = skew_vault(0, 1_000_000, SkewExponent::Linear);
810        let quote = swap_quote(
811            400,
812            SwapMode::ExactIn,
813            U128::from(1u128 << 64),
814            true,
815            vault,
816            0,
817            vault,
818            64_000,
819        )
820        .unwrap();
821        assert_eq!(quote.amount_in, 0);
822        assert_eq!(quote.amount_out, 0);
823        assert_eq!(
824            swap_quote(
825                400,
826                SwapMode::ExactIn,
827                U128::from(1u128 << 64),
828                false,
829                vault,
830                0,
831                vault,
832                64_000,
833            ),
834            Err(PARTIAL_FILL_NOT_ALLOWED)
835        );
836    }
837
838    #[test]
839    fn test_swap_quote_both_away_stacks() {
840        let input = skew_vault(32_000, 0, SkewExponent::Linear);
841        let output = skew_vault(32_000, 0, SkewExponent::Linear);
842        let one_away = swap_quote(
843            400,
844            SwapMode::ExactIn,
845            U128::from(1u128 << 64),
846            true,
847            input,
848            55_501,
849            dead_zone_vault(0, u64::MAX),
850            32_000,
851        )
852        .unwrap();
853        let both_away = swap_quote(
854            400,
855            SwapMode::ExactIn,
856            U128::from(1u128 << 64),
857            true,
858            input,
859            55_501,
860            output,
861            8_000,
862        )
863        .unwrap();
864        assert_eq!(one_away.amount_out, 406);
865        assert!(both_away.amount_out > one_away.amount_out);
866    }
867}