Skip to main content

wp_evm_v3_core/
quote.rs

1//! Pure quote functions for the v3 family.
2//!
3//! Inputs: a hydrated `PoolState` + parameters.
4//! Outputs: a `Quote` (amount_out, sqrt price after, price impact).
5//!
6//! No I/O. Caller hydrates state once via `hydrate::pool_state`, then can
7//! call quote functions any number of times against the in-memory snapshot.
8
9use crate::{
10    data::{ExactInParams, ExactOutParams, PoolState, Quote},
11    swap,
12};
13use alloy_primitives::{I256, U256};
14use thiserror::Error;
15use wp_evm_amm_math::AmmMathError;
16
17#[derive(Error, Debug)]
18pub enum QuoteError {
19    #[error("input token does not match pool")]
20    UnknownToken,
21    #[error("amm math error: {0}")]
22    Math(#[from] AmmMathError),
23    #[error("pool has insufficient liquidity to fulfil the requested swap")]
24    InsufficientLiquidity,
25    /// Invariant violation inside the quote/swap pipeline. Used for
26    /// conditions the surrounding code proves cannot occur (e.g. a
27    /// zero-amount or invalid-price-limit signal bubbling up from `swap`
28    /// after the quote layer has already guarded them). Kept as a distinct
29    /// variant — rather than being folded into `Math` — so diagnostics
30    /// accurately reflect the failure category.
31    #[error("quote pipeline internal invariant violated: {0}")]
32    Internal(&'static str),
33}
34
35impl From<swap::SwapError> for QuoteError {
36    fn from(e: swap::SwapError) -> Self {
37        match e {
38            swap::SwapError::Math(m) => QuoteError::Math(m),
39            swap::SwapError::InsufficientLiquidity => QuoteError::InsufficientLiquidity,
40            // ZeroAmount and InvalidPriceLimit are unreachable from quote
41            // entry points (zero amount is short-circuited above; limits are
42            // set by quote to the protocol boundary). If a future guard
43            // regression triggers them, surface the real cause via
44            // `Internal` rather than masquerading as a math overflow.
45            swap::SwapError::ZeroAmount => {
46                QuoteError::Internal("swap returned ZeroAmount after quote-level guard")
47            }
48            swap::SwapError::InvalidPriceLimit => {
49                QuoteError::Internal("swap returned InvalidPriceLimit with quote-supplied sentinel")
50            }
51            swap::SwapError::Internal(msg) => QuoteError::Internal(msg),
52        }
53    }
54}
55
56pub fn exact_in(state: &PoolState, params: &ExactInParams) -> Result<Quote, QuoteError> {
57    exact_in_with_fee_fn(state, params, |s| s.fee)
58}
59
60/// Exact-in quote with an injectable fee computation.
61///
62/// The closure is called once with the pool state and must return the effective
63/// swap fee in pips (e.g. 3000 for 0.3%). For protocols with static fees, use
64/// `exact_in` which delegates here with `|s| s.fee`. For protocols with dynamic
65/// fees (e.g. Algebra Integral), pass a closure that computes the current fee
66/// from a fresh oracle read; the computed value is then used for the swap math.
67///
68/// This function is pure. The fee closure must itself be pure (no I/O).
69pub fn exact_in_with_fee_fn<F>(
70    state: &PoolState,
71    params: &ExactInParams,
72    fee_fn: F,
73) -> Result<Quote, QuoteError>
74where
75    F: Fn(&PoolState) -> u32,
76{
77    let zero_for_one = params.token_in == state.token0;
78    if !zero_for_one && params.token_in != state.token1 {
79        return Err(QuoteError::UnknownToken);
80    }
81
82    // Preserve the SDK-era contract: zero amount_in is a no-op, not an error.
83    // Documented by `exact_in_zero_amount_returns_zero_out` test.
84    if params.amount_in.is_zero() {
85        return Ok(Quote {
86            amount_in: U256::ZERO,
87            amount_out: U256::ZERO,
88            sqrt_price_x96_after: state.sqrt_price_x96,
89            price_impact_bps: 0,
90        });
91    }
92
93    let effective_fee = fee_fn(state);
94
95    let amount_specified = I256::try_from(params.amount_in)
96        .map_err(|_| QuoteError::Math(AmmMathError::MulDivOverflow))?;
97    let sqrt_price_limit_x96 = if zero_for_one {
98        swap::min_sqrt_ratio_plus_one()
99    } else {
100        swap::max_sqrt_ratio_minus_one()
101    };
102
103    let result =
104        swap::swap(state, zero_for_one, amount_specified, sqrt_price_limit_x96, effective_fee)?;
105
106    // The quote entry point sets `sqrt_price_limit_x96` to the protocol
107    // boundary sentinel, so any partial fill at this layer means the pool
108    // cannot satisfy the requested input (not a user-chosen early exit).
109    // Without this check, a swap that walks to `MIN_SQRT_RATIO + 1` /
110    // `MAX_SQRT_RATIO - 1` with remainder would return Ok with
111    // `amount_in < params.amount_in` — silent degradation. Fail loud.
112    if result.amount_in != params.amount_in {
113        return Err(QuoteError::InsufficientLiquidity);
114    }
115
116    Ok(Quote {
117        amount_in: result.amount_in,
118        amount_out: result.amount_out,
119        sqrt_price_x96_after: result.sqrt_price_x96_after,
120        price_impact_bps: compute_price_impact_bps(
121            state.sqrt_price_x96,
122            result.sqrt_price_x96_after,
123        ),
124    })
125}
126
127pub fn exact_out(state: &PoolState, params: &ExactOutParams) -> Result<Quote, QuoteError> {
128    exact_out_with_fee_fn(state, params, |s| s.fee)
129}
130
131/// Exact-out quote with an injectable fee computation.
132///
133/// Mirrors `exact_in_with_fee_fn`. The closure returns the effective fee in
134/// pips used by the native swap loop. Default `exact_out` delegates here
135/// with `|s| s.fee`. For dynamic-fee protocols, supply a closure that returns
136/// the current fee from state.
137///
138/// This function is pure. The fee closure must itself be pure (no I/O).
139pub fn exact_out_with_fee_fn<F>(
140    state: &PoolState,
141    params: &ExactOutParams,
142    fee_fn: F,
143) -> Result<Quote, QuoteError>
144where
145    F: Fn(&PoolState) -> u32,
146{
147    let zero_for_one = params.token_in == state.token0;
148    if !zero_for_one && params.token_in != state.token1 {
149        return Err(QuoteError::UnknownToken);
150    }
151
152    // Behavioural parity with `exact_in_with_fee_fn`'s zero-amount
153    // short-circuit: zero `amount_out` is a no-op, not an error.
154    if params.amount_out.is_zero() {
155        return Ok(Quote {
156            amount_in: U256::ZERO,
157            amount_out: U256::ZERO,
158            sqrt_price_x96_after: state.sqrt_price_x96,
159            price_impact_bps: 0,
160        });
161    }
162
163    let effective_fee = fee_fn(state);
164
165    // Exact-out sign convention: `amount_specified` is the NEGATIVE of the
166    // desired output (see `UniswapV3Pool.sol::swap` + `SwapMath.sol`).
167    // `I256::try_from` can only fail for U256 >= 2^255, and `checked_neg`
168    // only fails at `I256::MIN` (whose magnitude is 2^255). Both guards
169    // surface as Math(MulDivOverflow) — shouldn't happen for any real pool.
170    let amount_out_i = I256::try_from(params.amount_out)
171        .map_err(|_| QuoteError::Math(AmmMathError::MulDivOverflow))?;
172    let amount_specified =
173        amount_out_i.checked_neg().ok_or(QuoteError::Math(AmmMathError::MulDivOverflow))?;
174
175    let sqrt_price_limit_x96 = if zero_for_one {
176        swap::min_sqrt_ratio_plus_one()
177    } else {
178        swap::max_sqrt_ratio_minus_one()
179    };
180
181    let result =
182        swap::swap(state, zero_for_one, amount_specified, sqrt_price_limit_x96, effective_fee)?;
183
184    // Mirror of `exact_in_with_fee_fn`'s partial-fill guard. The quote layer
185    // always passes the protocol-boundary sentinel as the limit, so any
186    // shortfall means the pool genuinely cannot supply the requested output.
187    // Without this check, a swap that walks to the boundary with unsatisfied
188    // demand would silently return `amount_out < params.amount_out`.
189    if result.amount_out != params.amount_out {
190        return Err(QuoteError::InsufficientLiquidity);
191    }
192
193    Ok(Quote {
194        amount_in: result.amount_in,
195        amount_out: result.amount_out,
196        sqrt_price_x96_after: result.sqrt_price_x96_after,
197        price_impact_bps: compute_price_impact_bps(
198            state.sqrt_price_x96,
199            result.sqrt_price_x96_after,
200        ),
201    })
202}
203
204/// Compute absolute price impact as basis points.
205///
206/// `|after - before| / before * 10_000`, clamped to `u16::MAX`.
207/// Returns 0 if `before == 0` (should not happen for a valid pool).
208///
209/// Note: this is computed on sqrt_price deltas. Since price ∝ sqrt_price²,
210/// the bps reading understates true price-change bps by roughly half for
211/// small moves. It is still useful as a monotone proxy for impact severity.
212fn compute_price_impact_bps(before: U256, after: U256) -> u16 {
213    if before == U256::ZERO {
214        return 0;
215    }
216    let (num, denom) =
217        if after > before { (after - before, before) } else { (before - after, before) };
218    let bps = (num * U256::from(10_000u64)) / denom;
219    bps.saturating_to::<u16>()
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use crate::data::{ExactInParams, ExactOutParams, PoolState, TickInfo};
226    use alloy_primitives::{address, U256};
227
228    /// Fixture: USDC/WETH 0.3% pool.
229    ///
230    /// `sqrt_price_x96 = 3543191142285914205922034323214` encodes a price of
231    /// roughly 2000 token1/token0 (in raw 18-decimal SDK units).
232    /// `get_tick_at_sqrt_ratio(sqrt_price_x96)` gives **76012** — the value
233    /// stored in `tick` exactly matches what the SDK derives, so the pool is
234    /// internally consistent.
235    ///
236    /// Two overlapping liquidity ranges create three initialized ticks:
237    ///
238    ///   - Wide range  [74940, 76020]: L = 1e21  (1249 × 60 to 1267 × 60)
239    ///   - Narrow range [75960, 76020]: L = 1e21  (1266 × 60 to 1267 × 60)
240    ///
241    /// At tick 76012 (in the overlap zone [75960, 76020]), active liquidity
242    /// = 2e21. Tick structure:
243    ///
244    ///   74940: net = +1e21, gross = 1e21   (wide range lower bound)
245    ///   75960: net = +1e21, gross = 1e21   (narrow range lower bound)
246    ///   76020: net = -2e21, gross = 2e21   (shared upper bound for both)
247    ///
248    /// A `zero_for_one` swap (token0 in, price DOWN) crosses tick 75960 once
249    /// ~1.2e17 tokens are consumed (SqrtPriceMath delta on 2e21 liquidity over
250    /// a 52-tick gap). After crossing, liquidity drops to 1e21 (wide range
251    /// only). The wide range holds ~6.7e18 tokens down to tick 74940, so a
252    /// 1e18-input swap crosses 75960 and stops well before 74940.
253    fn fixture_usdc_weth_03() -> PoolState {
254        // sqrt_price_x96 encodes tick 76012; the SDK re-derives this tick from
255        // the value when constructing Pool::new_with_tick_data_provider.
256        let sqrt_price_x96: U256 =
257            U256::from_str_radix("3543191142285914205922034323214", 10).unwrap();
258        PoolState {
259            token0: address!("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), // USDC
260            token1: address!("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"), // WETH
261            fee: 3000,
262            tick_spacing: 60,
263            sqrt_price_x96,
264            liquidity: 2_000_000_000_000_000_000_000u128, // 2e21 (wide + narrow range)
265            tick: 76012, // matches get_tick_at_sqrt_ratio(sqrt_price_x96)
266            ticks: vec![
267                TickInfo {
268                    // 1249 * 60 = 74940 — lower bound of wide range.
269                    // get_sqrt_ratio_at_tick(74940) = 3358146572400655475063989961326
270                    tick: 74940,
271                    liquidity_net: 1_000_000_000_000_000_000_000i128,
272                    liquidity_gross: 1_000_000_000_000_000_000_000u128,
273                },
274                TickInfo {
275                    // 1266 * 60 = 75960 — lower bound of narrow range.
276                    // A zero_for_one swap crosses this downward (~1.2e17 tokens
277                    // needed from the 2e21 active liquidity in [75960, 76020]).
278                    // get_sqrt_ratio_at_tick(75960) = 3533845506420911390540068078527
279                    tick: 75960,
280                    liquidity_net: 1_000_000_000_000_000_000_000i128,
281                    liquidity_gross: 1_000_000_000_000_000_000_000u128,
282                },
283                TickInfo {
284                    // 1267 * 60 = 76020 — shared upper bound for both ranges.
285                    tick: 76020,
286                    liquidity_net: -2_000_000_000_000_000_000_000i128,
287                    liquidity_gross: 2_000_000_000_000_000_000_000u128,
288                },
289            ],
290        }
291    }
292
293    #[test]
294    fn exact_in_one_usdc_for_weth_within_tick() {
295        let s = fixture_usdc_weth_03();
296        let p = ExactInParams {
297            token_in: s.token0,
298            token_out: s.token1,
299            amount_in: U256::from(1_000_000u64), // 1 USDC (6 decimals)
300            recipient: address!("0x0000000000000000000000000000000000000099"),
301        };
302        let q = exact_in(&s, &p).expect("quote ok");
303        assert!(q.amount_out > U256::ZERO);
304        assert!(q.amount_out < U256::from(1_000_000_000_000_000u64));
305        assert_eq!(q.amount_in, p.amount_in);
306    }
307
308    #[test]
309    fn exact_in_rejects_unknown_token() {
310        let s = fixture_usdc_weth_03();
311        let bogus_token = address!("0x000000000000000000000000000000000000dead");
312        let p = ExactInParams {
313            token_in: bogus_token,
314            token_out: s.token1,
315            amount_in: U256::from(1_000_000u64),
316            recipient: address!("0x0000000000000000000000000000000000000099"),
317        };
318        let err = exact_in(&s, &p).expect_err("should reject unknown token");
319        assert!(matches!(err, QuoteError::UnknownToken));
320    }
321
322    #[test]
323    fn exact_in_reverse_direction_weth_for_usdc() {
324        let s = fixture_usdc_weth_03();
325        let p = ExactInParams {
326            token_in: s.token1,                              // WETH
327            token_out: s.token0,                             // USDC
328            amount_in: U256::from(1_000_000_000_000_000u64), // 0.001 WETH
329            recipient: address!("0x0000000000000000000000000000000000000099"),
330        };
331        let q = exact_in(&s, &p).expect("reverse quote ok");
332        // The pool is constructed with both tokens hardcoded to 18 decimals.
333        // The fixture sqrt_price_x96 encodes token1_per_token0 ≈ 2000 in raw
334        // (both-18-decimal) units, so swapping WETH (token1) → USDC (token0)
335        // at price 1/2000 yields: 1e15 / 2000 × (1 - 0.003) ≈ 4.985e11 raw units.
336        // Allow ±3% tolerance for tick math rounding.
337        assert!(
338            q.amount_out > U256::from(480_000_000_000u64),
339            "amount_out too low: {}",
340            q.amount_out
341        );
342        assert!(
343            q.amount_out < U256::from(515_000_000_000u64),
344            "amount_out too high: {}",
345            q.amount_out
346        );
347        assert_eq!(q.amount_in, p.amount_in);
348    }
349
350    #[test]
351    fn exact_in_large_swap_crosses_tick() {
352        let s = fixture_usdc_weth_03();
353        let p = ExactInParams {
354            token_in: s.token0,  // USDC (token0) in → zero_for_one → price moves DOWN
355            token_out: s.token1, // WETH out
356            // 1e18 is ~17× the amount needed to walk from tick 76012 down through
357            // tick 75960 (SqrtPriceMath delta ≈ 6e16 at L=1e21), so this swap
358            // will cross the 75960 boundary.
359            amount_in: U256::from(1_000_000_000_000_000_000u64), // 1e18
360            recipient: address!("0x0000000000000000000000000000000000000099"),
361        };
362        let q = exact_in(&s, &p).expect("large swap ok");
363        assert!(q.amount_out > U256::ZERO, "amount_out should be > 0");
364        // sqrt_price at tick 75960 = 3533845506420911390540068078527.
365        // After crossing, sqrt_price_after must be below that value.
366        let sqrt_at_75960 = U256::from_str_radix("3533845506420911390540068078527", 10).unwrap();
367        assert!(
368            q.sqrt_price_x96_after < sqrt_at_75960,
369            "expected sqrt_price to cross below tick 75960 (sqrt {}), got {}",
370            sqrt_at_75960,
371            q.sqrt_price_x96_after
372        );
373        assert_eq!(q.amount_in, p.amount_in);
374    }
375
376    #[test]
377    fn exact_out_round_trip_against_exact_in() {
378        let s = fixture_usdc_weth_03();
379        let p_in = ExactInParams {
380            token_in: s.token0,
381            token_out: s.token1,
382            amount_in: U256::from(1_000_000u64),
383            recipient: address!("0x0000000000000000000000000000000000000099"),
384        };
385        let q_in = exact_in(&s, &p_in).unwrap();
386
387        let p_out = ExactOutParams {
388            token_in: s.token0,
389            token_out: s.token1,
390            amount_out: q_in.amount_out,
391            recipient: p_in.recipient,
392        };
393        let q_out = exact_out(&s, &p_out).unwrap();
394
395        // Round-trip should be within a few wei of the original input.
396        // Allow up to 1000 wei difference to cover integer-division rounding
397        // in the SDK's swap math without masking real bugs.
398        let diff = if q_out.amount_in > p_in.amount_in {
399            q_out.amount_in - p_in.amount_in
400        } else {
401            p_in.amount_in - q_out.amount_in
402        };
403        assert!(diff <= U256::from(1_000u64), "round-trip diff = {}", diff);
404    }
405
406    #[test]
407    fn exact_in_reports_price_impact() {
408        let s = fixture_usdc_weth_03();
409        let big = ExactInParams {
410            token_in: s.token0,
411            token_out: s.token1,
412            // 1e18 crosses tick 75960 (see exact_in_large_swap_crosses_tick).
413            // The sqrt_price moves by ~52 ticks worth (~0.26%), which is ≥ 1 bps
414            // in the sqrt-price-delta metric, so price_impact_bps must be > 0.
415            amount_in: U256::from(1_000_000_000_000_000_000u64), // 1e18
416            recipient: address!("0x0000000000000000000000000000000000000099"),
417        };
418        let q = exact_in(&s, &big).unwrap();
419        assert!(q.price_impact_bps > 0, "expected nonzero price impact, got 0");
420    }
421
422    #[test]
423    fn exact_in_small_swap_has_minimal_price_impact() {
424        let s = fixture_usdc_weth_03();
425        let p = ExactInParams {
426            token_in: s.token0,
427            token_out: s.token1,
428            amount_in: U256::from(1_000u64), // tiny
429            recipient: address!("0x0000000000000000000000000000000000000099"),
430        };
431        let q = exact_in(&s, &p).unwrap();
432        // Tiny swap: price impact should be at most a few bps (certainly < 100 = 1%).
433        assert!(q.price_impact_bps < 100, "expected tiny impact, got {}", q.price_impact_bps);
434    }
435
436    #[test]
437    fn exact_out_rejects_unknown_token() {
438        let s = fixture_usdc_weth_03();
439        let bogus = address!("0x000000000000000000000000000000000000dead");
440        let p = ExactOutParams {
441            token_in: bogus,
442            token_out: s.token1,
443            amount_out: U256::from(1_000_000_000_000_000u64),
444            recipient: address!("0x0000000000000000000000000000000000000099"),
445        };
446        let err = exact_out(&s, &p).expect_err("should reject unknown token");
447        assert!(matches!(err, QuoteError::UnknownToken));
448    }
449
450    #[test]
451    fn exact_in_with_fee_fn_uses_injected_fee() {
452        let s = fixture_usdc_weth_03();
453        let p = ExactInParams {
454            token_in: s.token0,
455            token_out: s.token1,
456            amount_in: U256::from(1_000u64),
457            recipient: address!("0x0000000000000000000000000000000000000099"),
458        };
459        // With the normal 3000-pip (0.3%) fee:
460        let q_normal = exact_in(&s, &p).expect("normal quote ok");
461        // With an injected 100-pip (0.01%) fee — smallest valid SDK tier.
462        // FeeAmount::from(0) triggers a tick-spacing invariant in the SDK, so
463        // 100 is the lowest usable value for this test. It still demonstrates
464        // that the closure result overrides state.fee.
465        let q_low_fee = exact_in_with_fee_fn(&s, &p, |_| 100).expect("low-fee quote ok");
466        // Lower-fee output must be strictly greater (less fee deducted).
467        assert!(
468            q_low_fee.amount_out > q_normal.amount_out,
469            "low-fee output should exceed normal output: {} vs {}",
470            q_low_fee.amount_out,
471            q_normal.amount_out
472        );
473    }
474
475    // ── Issue #4: edge case tests ─────────────────────────────────────────────
476
477    #[test]
478    fn exact_in_token_in_equals_token_out_returns_unknown_token() {
479        // token_in == token_out: neither branch of `zero_for_one` matches,
480        // so the guard at `!zero_for_one && token_in != token1` triggers.
481        // token_in = token0, zero_for_one = true → we bypass the guard and
482        // reach SDK pool construction. The SDK will accept token0 as input
483        // and return a quote from token0 to token1; it does NOT see the
484        // mismatch on token_out because exact_in_with_fee_fn only checks
485        // token_in against the pool. Token_out is passed through to ABI
486        // encoding but not validated by the SDK math path.
487        //
488        // Behaviour today: exact_in produces a valid Quote when token_in ==
489        // token_out == token0, because the guard only checks token_in.
490        // This test documents that known limitation rather than asserting
491        // the ideal UnknownToken error.
492        //
493        // To surface UnknownToken we use a token not in the pool at all.
494        let s = fixture_usdc_weth_03();
495        let bogus = address!("0x000000000000000000000000000000000000dead");
496        let p = ExactInParams {
497            token_in: bogus,
498            token_out: bogus, // same bogus token — token_in not in pool
499            amount_in: U256::from(1_000_000u64),
500            recipient: address!("0x0000000000000000000000000000000000000099"),
501        };
502        let err = exact_in(&s, &p).expect_err("should reject token not in pool");
503        assert!(matches!(err, QuoteError::UnknownToken));
504    }
505
506    #[test]
507    fn exact_out_token_in_equals_token_out_returns_unknown_token() {
508        // Mirror of exact_in variant: bogus token_in triggers the guard.
509        let s = fixture_usdc_weth_03();
510        let bogus = address!("0x000000000000000000000000000000000000dead");
511        let p = ExactOutParams {
512            token_in: bogus,
513            token_out: bogus,
514            amount_out: U256::from(1_000_000u64),
515            recipient: address!("0x0000000000000000000000000000000000000099"),
516        };
517        let err = exact_out(&s, &p).expect_err("should reject token not in pool");
518        assert!(matches!(err, QuoteError::UnknownToken));
519    }
520
521    #[test]
522    fn exact_in_very_large_amount_returns_insufficient_liquidity() {
523        // A swap of 1e40 exceeds the fixture's tick range entirely. The native
524        // loop walks to the lowest tick, exhausts liquidity, and errors with
525        // InsufficientLiquidity (the native loop reports this directly once
526        // it walks to the MIN_SQRT_RATIO boundary with remainder).
527
528        let s = fixture_usdc_weth_03();
529        let p = ExactInParams {
530            token_in: s.token0,
531            token_out: s.token1,
532            amount_in: U256::from_str_radix("10000000000000000000000000000000000000000", 10)
533                .unwrap(), // 1e40
534            recipient: address!("0x0000000000000000000000000000000000000099"),
535        };
536        let err = exact_in(&s, &p).expect_err("should error on oversized amount");
537        assert!(
538            matches!(err, QuoteError::InsufficientLiquidity),
539            "expected InsufficientLiquidity, got {:?}",
540            err
541        );
542    }
543
544    #[test]
545    fn exact_out_very_large_amount_returns_insufficient_liquidity() {
546        // Requesting output that exceeds the fixture's total WETH supply
547        // forces the native loop to walk to the MIN_SQRT_RATIO boundary
548        // with demand still unsatisfied, surfacing InsufficientLiquidity.
549        // The native loop walks to the MIN_SQRT_RATIO boundary with demand
550        // still unsatisfied and reports `InsufficientLiquidity` directly.
551
552        let s = fixture_usdc_weth_03();
553        let p = ExactOutParams {
554            token_in: s.token0,
555            token_out: s.token1,
556            amount_out: U256::from_str_radix("10000000000000000000000000000000000000000", 10)
557                .unwrap(), // 1e40
558            recipient: address!("0x0000000000000000000000000000000000000099"),
559        };
560        let err = exact_out(&s, &p).expect_err("should error on oversized output request");
561        assert!(
562            matches!(err, QuoteError::InsufficientLiquidity),
563            "expected InsufficientLiquidity, got {:?}",
564            err
565        );
566    }
567
568    #[test]
569    fn exact_out_zero_amount_returns_zero() {
570        // Zero `amount_out`: native path short-circuits to a zero-quote,
571        // matching the exact_in zero-amount contract. No swap invoked.
572        let s = fixture_usdc_weth_03();
573        let p = ExactOutParams {
574            token_in: s.token0,
575            token_out: s.token1,
576            amount_out: U256::ZERO,
577            recipient: address!("0x0000000000000000000000000000000000000099"),
578        };
579        let q = exact_out(&s, &p).expect("zero-amount exact_out should not error");
580        assert_eq!(q.amount_in, U256::ZERO);
581        assert_eq!(q.amount_out, U256::ZERO);
582        assert_eq!(q.sqrt_price_x96_after, s.sqrt_price_x96);
583        assert_eq!(q.price_impact_bps, 0);
584    }
585
586    #[test]
587    fn exact_in_zero_amount_returns_zero_out() {
588        // Zero amount_in: the uniswap-v3-sdk treats this as a no-op swap and
589        // returns amount_out = 0 without erroring. Document this behaviour.
590        let s = fixture_usdc_weth_03();
591        let p = ExactInParams {
592            token_in: s.token0,
593            token_out: s.token1,
594            amount_in: U256::ZERO,
595            recipient: address!("0x0000000000000000000000000000000000000099"),
596        };
597        // The SDK accepts zero input and returns zero output.
598        let q = exact_in(&s, &p).expect("zero-amount swap should not error");
599        assert_eq!(q.amount_out, U256::ZERO, "zero-in should produce zero-out");
600        assert_eq!(q.amount_in, U256::ZERO);
601    }
602
603    #[test]
604    fn exact_in_zero_amount_reverse_direction_returns_zero_out() {
605        let s = fixture_usdc_weth_03();
606        let p = ExactInParams {
607            token_in: s.token1,
608            token_out: s.token0,
609            amount_in: U256::ZERO,
610            recipient: address!("0x0000000000000000000000000000000000000099"),
611        };
612        let q = exact_in(&s, &p).expect("reverse-direction zero-input should not error");
613        assert_eq!(q.amount_in, U256::ZERO);
614        assert_eq!(q.amount_out, U256::ZERO);
615        assert_eq!(q.sqrt_price_x96_after, s.sqrt_price_x96);
616    }
617
618    #[test]
619    fn exact_out_zero_amount_reverse_direction_returns_zero() {
620        let s = fixture_usdc_weth_03();
621        let p = ExactOutParams {
622            token_in: s.token1,
623            token_out: s.token0,
624            amount_out: U256::ZERO,
625            recipient: address!("0x0000000000000000000000000000000000000099"),
626        };
627        let q = exact_out(&s, &p).expect("reverse-direction zero-output should not error");
628        assert_eq!(q.amount_in, U256::ZERO);
629        assert_eq!(q.amount_out, U256::ZERO);
630        assert_eq!(q.sqrt_price_x96_after, s.sqrt_price_x96);
631    }
632}