Skip to main content

tycho_simulation/evm/protocol/uniswap_v2/
state.rs

1use std::{any::Any, collections::HashMap};
2
3use alloy::primitives::U256;
4use num_bigint::BigUint;
5use num_traits::Zero;
6use serde::{Deserialize, Serialize};
7use tycho_common::{
8    dto::ProtocolStateDelta,
9    models::token::Token,
10    simulation::{
11        errors::{SimulationError, TransitionError},
12        protocol_sim::{
13            Balances, GetAmountOutResult, PoolSwap, ProtocolSim, QueryPoolSwapParams,
14            SwapConstraint,
15        },
16    },
17    Bytes,
18};
19
20use crate::evm::protocol::{
21    cpmm::protocol::{
22        cpmm_delta_transition, cpmm_fee, cpmm_get_amount_out, cpmm_get_limits, cpmm_spot_price,
23        cpmm_swap_to_price, ProtocolFee,
24    },
25    safe_math::{safe_add_u256, safe_sub_u256},
26    u256_num::{biguint_to_u256, u256_to_biguint},
27    utils::add_fee_markup,
28};
29
30const SWAP_BASE_GAS: u64 = 90_000;
31const UNISWAP_V2_FEE_BPS: u32 = 30; // 0.3% fee
32const FEE_PRECISION: U256 = U256::from_limbs([10000, 0, 0, 0]);
33const FEE_NUMERATOR: U256 = U256::from_limbs([9970, 0, 0, 0]);
34
35#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
36pub struct UniswapV2State {
37    pub reserve0: U256,
38    pub reserve1: U256,
39}
40
41impl UniswapV2State {
42    /// Creates a new instance of `UniswapV2State` with the given reserves.
43    ///
44    /// # Arguments
45    ///
46    /// * `reserve0` - Reserve of token 0.
47    /// * `reserve1` - Reserve of token 1.
48    pub fn new(reserve0: U256, reserve1: U256) -> Self {
49        UniswapV2State { reserve0, reserve1 }
50    }
51}
52
53#[typetag::serde]
54impl ProtocolSim for UniswapV2State {
55    fn fee(&self) -> f64 {
56        cpmm_fee(UNISWAP_V2_FEE_BPS)
57    }
58
59    fn spot_price(&self, base: &Token, quote: &Token) -> Result<f64, SimulationError> {
60        let price = cpmm_spot_price(base, quote, self.reserve0, self.reserve1)?;
61        Ok(add_fee_markup(price, self.fee()))
62    }
63
64    fn get_amount_out(
65        &self,
66        amount_in: BigUint,
67        token_in: &Token,
68        token_out: &Token,
69    ) -> Result<GetAmountOutResult, SimulationError> {
70        let amount_in = biguint_to_u256(&amount_in);
71        let zero2one = token_in.address < token_out.address;
72        let (reserve_in, reserve_out) =
73            if zero2one { (self.reserve0, self.reserve1) } else { (self.reserve1, self.reserve0) };
74        let fee = ProtocolFee::new(FEE_NUMERATOR, FEE_PRECISION);
75        let amount_out = cpmm_get_amount_out(amount_in, reserve_in, reserve_out, fee)?;
76        let mut new_state = self.clone();
77        let (reserve0_mut, reserve1_mut) = (&mut new_state.reserve0, &mut new_state.reserve1);
78        if zero2one {
79            *reserve0_mut = safe_add_u256(self.reserve0, amount_in)?;
80            *reserve1_mut = safe_sub_u256(self.reserve1, amount_out)?;
81        } else {
82            *reserve0_mut = safe_sub_u256(self.reserve0, amount_out)?;
83            *reserve1_mut = safe_add_u256(self.reserve1, amount_in)?;
84        };
85        Ok(GetAmountOutResult::new(
86            u256_to_biguint(amount_out),
87            BigUint::from(SWAP_BASE_GAS),
88            Box::new(new_state),
89        ))
90    }
91
92    fn get_limits(
93        &self,
94        sell_token: Bytes,
95        buy_token: Bytes,
96    ) -> Result<(BigUint, BigUint), SimulationError> {
97        cpmm_get_limits(sell_token, buy_token, self.reserve0, self.reserve1, UNISWAP_V2_FEE_BPS)
98    }
99
100    fn delta_transition(
101        &mut self,
102        delta: ProtocolStateDelta,
103        _tokens: &HashMap<Bytes, Token>,
104        _balances: &Balances,
105    ) -> Result<(), TransitionError> {
106        let (reserve0_mut, reserve1_mut) = (&mut self.reserve0, &mut self.reserve1);
107        cpmm_delta_transition(delta, reserve0_mut, reserve1_mut)
108    }
109
110    fn query_pool_swap(&self, params: &QueryPoolSwapParams) -> Result<PoolSwap, SimulationError> {
111        match params.swap_constraint() {
112            SwapConstraint::PoolTargetPrice {
113                target: price,
114                tolerance: _,
115                min_amount_in: _,
116                max_amount_in: _,
117            } => {
118                let zero2one = params.token_in().address < params.token_out().address;
119                let (reserve_in, reserve_out) = if zero2one {
120                    (self.reserve0, self.reserve1)
121                } else {
122                    (self.reserve1, self.reserve0)
123                };
124
125                let fee = ProtocolFee::new(FEE_NUMERATOR, FEE_PRECISION);
126                let (amount_in, _) = cpmm_swap_to_price(reserve_in, reserve_out, price, fee)?;
127                if amount_in.is_zero() {
128                    return Ok(PoolSwap::new(
129                        BigUint::ZERO,
130                        BigUint::ZERO,
131                        Box::new(self.clone()),
132                        None,
133                    ));
134                }
135
136                let res =
137                    self.get_amount_out(amount_in.clone(), params.token_in(), params.token_out())?;
138                Ok(PoolSwap::new(amount_in, res.amount, res.new_state, None))
139            }
140            SwapConstraint::TradeLimitPrice { .. } => Err(SimulationError::InvalidInput(
141                "UniswapV2State does not support TradeLimitPrice constraint in query_pool_swap"
142                    .to_string(),
143                None,
144            )),
145        }
146    }
147
148    fn clone_box(&self) -> Box<dyn ProtocolSim> {
149        Box::new(self.clone())
150    }
151
152    fn as_any(&self) -> &dyn Any {
153        self
154    }
155
156    fn as_any_mut(&mut self) -> &mut dyn Any {
157        self
158    }
159
160    fn eq(&self, other: &dyn ProtocolSim) -> bool {
161        if let Some(other_state) = other.as_any().downcast_ref::<Self>() {
162            let (self_reserve0, self_reserve1) = (self.reserve0, self.reserve1);
163            let (other_reserve0, other_reserve1) = (other_state.reserve0, other_state.reserve1);
164            self_reserve0 == other_reserve0 &&
165                self_reserve1 == other_reserve1 &&
166                self.fee() == other_state.fee()
167        } else {
168            false
169        }
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use std::{
176        collections::{HashMap, HashSet},
177        str::FromStr,
178    };
179
180    use approx::assert_ulps_eq;
181    use num_bigint::BigUint;
182    use num_traits::One;
183    use rstest::rstest;
184    use tycho_common::{
185        dto::ProtocolStateDelta,
186        hex_bytes::Bytes,
187        models::{token::Token, Chain},
188        simulation::{
189            errors::{SimulationError, TransitionError},
190            protocol_sim::{Balances, Price, ProtocolSim},
191        },
192    };
193
194    use super::*;
195    use crate::evm::protocol::u256_num::biguint_to_u256;
196
197    fn token_0() -> Token {
198        Token::new(
199            &Bytes::from_str("0x0000000000000000000000000000000000000000").unwrap(),
200            "T0",
201            18,
202            0,
203            &[Some(10_000)],
204            Chain::Ethereum,
205            100,
206        )
207    }
208
209    fn token_1() -> Token {
210        Token::new(
211            &Bytes::from_str("0x0000000000000000000000000000000000000001").unwrap(),
212            "T1",
213            18,
214            0,
215            &[Some(10_000)],
216            Chain::Ethereum,
217            100,
218        )
219    }
220
221    #[rstest]
222    #[case::same_dec(
223        U256::from_str("6770398782322527849696614").unwrap(),
224        U256::from_str("5124813135806900540214").unwrap(),
225        18,
226        18,
227    BigUint::from_str("10000000000000000000000").unwrap(),
228    BigUint::from_str("7535635391574243447").unwrap()
229    )]
230    #[case::diff_dec(
231        U256::from_str("33372357002392258830279").unwrap(),
232        U256::from_str("43356945776493").unwrap(),
233        18,
234        6,
235    BigUint::from_str("10000000000000000000").unwrap(),
236    BigUint::from_str("12949029867").unwrap()
237    )]
238    fn test_get_amount_out(
239        #[case] r0: U256,
240        #[case] r1: U256,
241        #[case] token_0_decimals: u32,
242        #[case] token_1_decimals: u32,
243        #[case] amount_in: BigUint,
244        #[case] exp: BigUint,
245    ) {
246        let t0 = Token::new(
247            &Bytes::from_str("0x0000000000000000000000000000000000000000").unwrap(),
248            "T0",
249            token_0_decimals,
250            0,
251            &[Some(10_000)],
252            Chain::Ethereum,
253            100,
254        );
255        let t1 = Token::new(
256            &Bytes::from_str("0x0000000000000000000000000000000000000001").unwrap(),
257            "T0",
258            token_1_decimals,
259            0,
260            &[Some(10_000)],
261            Chain::Ethereum,
262            100,
263        );
264        let state = UniswapV2State::new(r0, r1);
265
266        let res = state
267            .get_amount_out(amount_in.clone(), &t0, &t1)
268            .unwrap();
269
270        assert_eq!(res.amount, exp);
271        let new_state = res
272            .new_state
273            .as_any()
274            .downcast_ref::<UniswapV2State>()
275            .unwrap();
276        assert_eq!(new_state.reserve0, r0 + biguint_to_u256(&amount_in));
277        assert_eq!(new_state.reserve1, r1 - biguint_to_u256(&exp));
278        // Assert that the old state is unchanged
279        assert_eq!(state.reserve0, r0);
280        assert_eq!(state.reserve1, r1);
281    }
282
283    #[test]
284    fn test_get_amount_out_overflow() {
285        let r0 = U256::from_str("33372357002392258830279").unwrap();
286        let r1 = U256::from_str("43356945776493").unwrap();
287        let amount_in = (BigUint::one() << 256) - BigUint::one(); // U256 max value
288        let t0d = 18;
289        let t1d = 16;
290        let t0 = Token::new(
291            &Bytes::from_str("0x0000000000000000000000000000000000000000").unwrap(),
292            "T0",
293            t0d,
294            0,
295            &[Some(10_000)],
296            Chain::Ethereum,
297            100,
298        );
299        let t1 = Token::new(
300            &Bytes::from_str("0x0000000000000000000000000000000000000001").unwrap(),
301            "T0",
302            t1d,
303            0,
304            &[Some(10_000)],
305            Chain::Ethereum,
306            100,
307        );
308        let state = UniswapV2State::new(r0, r1);
309
310        let res = state.get_amount_out(amount_in, &t0, &t1);
311        assert!(res.is_err());
312        let err = res.err().unwrap();
313        assert!(matches!(err, SimulationError::FatalError(_)));
314    }
315
316    #[rstest]
317    #[case(true, 0.000823442321727627)] // 0.0008209719947624441 / 0.997
318    #[case(false, 1221.7335469177287)] // 1218.0683462769755 / 0.997
319    fn test_spot_price(#[case] zero_to_one: bool, #[case] exp: f64) {
320        let state = UniswapV2State::new(
321            U256::from_str("36925554990922").unwrap(),
322            U256::from_str("30314846538607556521556").unwrap(),
323        );
324        let usdc = Token::new(
325            &Bytes::from_str("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48").unwrap(),
326            "USDC",
327            6,
328            0,
329            &[Some(10_000)],
330            Chain::Ethereum,
331            100,
332        );
333        let weth = Token::new(
334            &Bytes::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap(),
335            "WETH",
336            18,
337            0,
338            &[Some(10_000)],
339            Chain::Ethereum,
340            100,
341        );
342
343        let res = if zero_to_one {
344            state.spot_price(&usdc, &weth).unwrap()
345        } else {
346            state.spot_price(&weth, &usdc).unwrap()
347        };
348
349        assert_ulps_eq!(res, exp);
350    }
351
352    #[test]
353    fn test_fee() {
354        let state = UniswapV2State::new(
355            U256::from_str("36925554990922").unwrap(),
356            U256::from_str("30314846538607556521556").unwrap(),
357        );
358
359        let res = state.fee();
360
361        assert_ulps_eq!(res, 0.003);
362    }
363
364    #[test]
365    fn test_delta_transition() {
366        let mut state =
367            UniswapV2State::new(U256::from_str("1000").unwrap(), U256::from_str("1000").unwrap());
368        let attributes: HashMap<String, Bytes> = vec![
369            ("reserve0".to_string(), Bytes::from(1500_u64.to_be_bytes().to_vec())),
370            ("reserve1".to_string(), Bytes::from(2000_u64.to_be_bytes().to_vec())),
371        ]
372        .into_iter()
373        .collect();
374        let delta = ProtocolStateDelta {
375            component_id: "State1".to_owned(),
376            updated_attributes: attributes,
377            deleted_attributes: HashSet::new(), // usv2 doesn't have any deletable attributes
378        };
379
380        let res = state.delta_transition(delta, &HashMap::new(), &Balances::default());
381
382        assert!(res.is_ok());
383        assert_eq!(state.reserve0, U256::from_str("1500").unwrap());
384        assert_eq!(state.reserve1, U256::from_str("2000").unwrap());
385    }
386
387    #[test]
388    fn test_delta_transition_missing_attribute() {
389        let mut state =
390            UniswapV2State::new(U256::from_str("1000").unwrap(), U256::from_str("1000").unwrap());
391        let attributes: HashMap<String, Bytes> =
392            vec![("reserve0".to_string(), Bytes::from(1500_u64.to_be_bytes().to_vec()))]
393                .into_iter()
394                .collect();
395        let delta = ProtocolStateDelta {
396            component_id: "State1".to_owned(),
397            updated_attributes: attributes,
398            deleted_attributes: HashSet::new(),
399        };
400
401        let res = state.delta_transition(delta, &HashMap::new(), &Balances::default());
402
403        assert!(res.is_err());
404        // assert it errors for the missing reserve1 attribute delta
405        match res {
406            Err(e) => {
407                assert!(matches!(e, TransitionError::MissingAttribute(ref x) if x=="reserve1"))
408            }
409            _ => panic!("Test failed: was expecting an Err value"),
410        };
411    }
412
413    #[test]
414    fn test_get_limits_price_impact() {
415        let state =
416            UniswapV2State::new(U256::from_str("1000").unwrap(), U256::from_str("100000").unwrap());
417
418        let (amount_in, _) = state
419            .get_limits(
420                Bytes::from_str("0x0000000000000000000000000000000000000000").unwrap(),
421                Bytes::from_str("0x0000000000000000000000000000000000000001").unwrap(),
422            )
423            .unwrap();
424
425        let token_0 = token_0();
426        let token_1 = token_1();
427
428        let result = state
429            .get_amount_out(amount_in.clone(), &token_0, &token_1)
430            .unwrap();
431        let new_state = result.new_state;
432
433        let initial_price = state
434            .spot_price(&token_0, &token_1)
435            .unwrap();
436        let new_price = new_state
437            .spot_price(&token_0, &token_1)
438            .unwrap();
439
440        // Price impact should be approximately 90% (new_price ≈ initial_price / 10)
441        // Due to fees being added to pool liquidity, actual impact is slightly less than 90%
442        // (see cpmm_get_limits documentation for details)
443        let price_impact = 1.0 - new_price / initial_price;
444        assert!(
445            (0.899..=0.90).contains(&price_impact),
446            "Price impact should be approximately 90%. Actual impact: {:.2}%",
447            price_impact * 100.0
448        );
449    }
450
451    #[test]
452    fn test_swap_to_price_below_spot() {
453        // Pool with reserve0=2000000, reserve1=1000000
454        // Current price: reserve1/reserve0 = 1000000/2000000 = 0.5 token_out per token_in
455        let state = UniswapV2State::new(U256::from(2_000_000u32), U256::from(1_000_000u32));
456
457        let token_in = token_0();
458        let token_out = token_1();
459
460        // Target price: 2/5 = 0.4 token_out per token_in (lower than current 0.5)
461        // Selling token_in decreases price from 0.5 down to 0.4
462        let target_price = Price::new(BigUint::from(2u32), BigUint::from(5u32));
463        let params = &QueryPoolSwapParams::new(
464            token_in.clone(),
465            token_out.clone(),
466            SwapConstraint::PoolTargetPrice {
467                target: target_price,
468                tolerance: 0f64,
469                min_amount_in: None,
470                max_amount_in: None,
471            },
472        );
473        let pool_swap = state.query_pool_swap(params).unwrap();
474
475        assert_eq!(
476            *pool_swap.amount_in(),
477            BigUint::from(232711u32),
478            "Should require some input amount"
479        );
480        assert_eq!(*pool_swap.amount_out(), BigUint::from(103947u32));
481
482        // Verify that swapping this amount brings us close to the target price
483        let new_state = pool_swap
484            .new_state()
485            .as_any()
486            .downcast_ref::<UniswapV2State>()
487            .unwrap();
488
489        // The new reserves should reflect the target price
490        // Price = reserve1/reserve0, so if price = 0.4, then reserve0/reserve1 = 2.5
491        let new_reserve_ratio =
492            new_state.reserve0.to::<u128>() as f64 / new_state.reserve1.to::<u128>() as f64;
493        let expected_ratio = 2.5;
494
495        // Allow for some difference due to fees and rounding
496        assert!(
497            (new_reserve_ratio - expected_ratio).abs() < 0.01,
498            "New reserve ratio {new_reserve_ratio} should be close to expected {expected_ratio}"
499        );
500    }
501
502    #[test]
503    fn test_swap_to_price_unreachable() {
504        // Pool with 2:1 ratio
505        let state = UniswapV2State::new(U256::from(2_000_000u32), U256::from(1_000_000u32));
506
507        let token_in = token_0();
508        let token_out = token_1();
509        // Target price is unreachable (should return error)
510        // Current pool price: reserve_out/reserve_in = 1000000/2000000 = 0.5 token_out per
511        // token_in Target: 1/1 = 1.0 token_out per token_in
512        // Selling token_in decreases pool price, so we can't reach 1.0 from 0.5
513        let target_price = Price::new(BigUint::from(1u32), BigUint::from(1u32));
514
515        let result = state.query_pool_swap(&QueryPoolSwapParams::new(
516            token_in,
517            token_out,
518            SwapConstraint::PoolTargetPrice {
519                target: target_price,
520                tolerance: 0f64,
521                min_amount_in: None,
522                max_amount_in: None,
523            },
524        ));
525
526        assert!(result.is_err(), "Should return error when target price is unreachable");
527    }
528
529    #[test]
530    fn test_swap_to_price_at_spot_price() {
531        let state = UniswapV2State::new(U256::from(2_000_000u32), U256::from(1_000_000u32));
532
533        let token_in = token_0();
534        let token_out = token_1();
535
536        // Calculate spot price with fee (token_out/token_in):
537        // Marginal price = (FEE_NUMERATOR * reserve_out) / (FEE_PRECISION * reserve_in)
538        let spot_price_num = U256::from(1_000_000u32) * FEE_NUMERATOR;
539        let spot_price_den = U256::from(2_000_000u32) * FEE_PRECISION;
540
541        let target_price =
542            Price::new(u256_to_biguint(spot_price_num), u256_to_biguint(spot_price_den));
543
544        let pool_swap = state
545            .query_pool_swap(&QueryPoolSwapParams::new(
546                token_in.clone(),
547                token_out.clone(),
548                SwapConstraint::PoolTargetPrice {
549                    target: target_price,
550                    tolerance: 0f64,
551                    min_amount_in: None,
552                    max_amount_in: None,
553                },
554            ))
555            .unwrap();
556
557        // At exact spot price, we should return zero amount
558        assert_eq!(
559            *pool_swap.amount_in(),
560            BigUint::ZERO,
561            "At spot price should require zero input amount"
562        );
563        assert_eq!(
564            *pool_swap.amount_out(),
565            BigUint::ZERO,
566            "At spot price should return zero output amount"
567        );
568    }
569
570    #[test]
571    fn test_swap_to_price_slightly_below_spot() {
572        let state = UniswapV2State::new(U256::from(2_000_000u32), U256::from(1_000_000u32));
573
574        let token_in = token_0();
575        let token_out = token_1();
576
577        // Calculate spot price with fee and subtract small amount to move slightly below
578        // Current spot (token_out/token_in with fees): (FEE_NUMERATOR * reserve_out) /
579        // (FEE_PRECISION * reserve_in) Target: slightly below current spot (multiply numerator by
580        // 99999/100000)
581        let spot_price_num = U256::from(1_000_000u32) * FEE_NUMERATOR * U256::from(99_999u32);
582        let spot_price_den = U256::from(2_000_000u32) * FEE_PRECISION * U256::from(100_000u32);
583
584        let target_price =
585            Price::new(u256_to_biguint(spot_price_num), u256_to_biguint(spot_price_den));
586
587        let pool_swap = state
588            .query_pool_swap(&QueryPoolSwapParams::new(
589                token_in,
590                token_out,
591                SwapConstraint::PoolTargetPrice {
592                    target: target_price,
593                    tolerance: 0f64,
594                    min_amount_in: None,
595                    max_amount_in: None,
596                },
597            ))
598            .unwrap();
599
600        assert!(
601            *pool_swap.amount_in() > BigUint::ZERO,
602            "Should return non-zero amount for target slightly below spot"
603        );
604    }
605
606    #[test]
607    fn test_swap_to_price_large_pool() {
608        // Test with realistic large reserves
609        let state = UniswapV2State::new(
610            U256::from_str("6770398782322527849696614").unwrap(),
611            U256::from_str("5124813135806900540214").unwrap(),
612        );
613
614        let token_in = token_0();
615        let token_out = token_1();
616
617        // Current price (token_out/token_in) = reserve1/reserve0
618        // To target a slightly lower price (move price down 10%), we can use:
619        // target_price = (reserve1 * 9) / (reserve0 * 10)
620        // This avoids floating point precision issues with large numbers
621        let price_numerator = u256_to_biguint(state.reserve1) * BigUint::from(9u32);
622        let price_denominator = u256_to_biguint(state.reserve0) * BigUint::from(10u32);
623
624        let target_price = Price::new(price_numerator, price_denominator);
625
626        let pool_swap = state
627            .query_pool_swap(&QueryPoolSwapParams::new(
628                token_in,
629                token_out,
630                SwapConstraint::PoolTargetPrice {
631                    target: target_price,
632                    tolerance: 0f64,
633                    min_amount_in: None,
634                    max_amount_in: None,
635                },
636            ))
637            .unwrap();
638
639        assert!(pool_swap.amount_in().clone() > BigUint::ZERO, "Should require some input amount");
640        assert!(pool_swap.amount_out().clone() > BigUint::ZERO, "Should get some output");
641    }
642
643    #[test]
644    fn test_swap_to_price_basic() {
645        let state = UniswapV2State::new(U256::from(1_000_000u32), U256::from(2_000_000u32));
646
647        let token_in = token_0();
648        let token_out = token_1();
649
650        let target_price = Price::new(BigUint::from(2u32), BigUint::from(3u32));
651
652        let pool_swap = state
653            .query_pool_swap(&QueryPoolSwapParams::new(
654                token_in,
655                token_out,
656                SwapConstraint::PoolTargetPrice {
657                    target: target_price,
658                    tolerance: 0f64,
659                    min_amount_in: None,
660                    max_amount_in: None,
661                },
662            ))
663            .unwrap();
664        assert!(*pool_swap.amount_in() > BigUint::ZERO, "Amount in should be non-zero");
665        assert!(*pool_swap.amount_out() > BigUint::ZERO, "Amount out should be non-zero");
666    }
667
668    #[test]
669    fn test_swap_to_price_validates_actual_output() {
670        // Test that query_supply validates actual_output >= expected_output
671        let state = UniswapV2State::new(
672            U256::from(1_000_000u128) * U256::from(1_000_000_000_000_000_000u128),
673            U256::from(2_000_000u128) * U256::from(1_000_000_000_000_000_000u128),
674        );
675
676        let token_in = token_0();
677        let token_out = token_1();
678
679        // Current pool price: 2M/1M = 2.0 token_out per token_in
680        // Target: slightly lower (e.g., 1.95:1 = 1_950_000/1_000_000)
681        // This is reachable by selling token_in
682        let target_price = Price::new(BigUint::from(1_950_000u128), BigUint::from(1_000_000u128));
683
684        let pool_swap = state
685            .query_pool_swap(&QueryPoolSwapParams::new(
686                token_in,
687                token_out,
688                SwapConstraint::PoolTargetPrice {
689                    target: target_price,
690                    tolerance: 0f64,
691                    min_amount_in: None,
692                    max_amount_in: None,
693                },
694            ))
695            .unwrap();
696        assert!(
697            *pool_swap.amount_out() > BigUint::ZERO,
698            "Should return amount out for valid price"
699        );
700        assert!(*pool_swap.amount_in() > BigUint::ZERO, "Should return amount in for valid price");
701    }
702
703    #[test]
704    fn test_swap_around_spot_price() {
705        let usdc = Token::new(
706            &Bytes::from_str("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48").unwrap(),
707            "USDC",
708            6,
709            0,
710            &[Some(10_000)],
711            Chain::Ethereum,
712            100,
713        );
714        let dai = Token::new(
715            &Bytes::from_str("0x6b175474e89094c44da98b954eedeac495271d0f").unwrap(),
716            "DAI",
717            18,
718            0,
719            &[Some(10_000)],
720            Chain::Ethereum,
721            100,
722        );
723
724        let reserve_0 = U256::from_str("735952457913070155214197").unwrap();
725        let reserve_1 = U256::from_str("735997725943000000000000").unwrap();
726
727        let pool = UniswapV2State::new(reserve_0, reserve_1);
728
729        // Reserves: reserve_0 = DAI, reserve_1 = USDC (DAI address < USDC address)
730        let reserve_usdc = reserve_1;
731        let reserve_dai = reserve_0;
732
733        // Calculate spot price (USDC/DAI with fee)
734        let spot_price_dai_per_usdc_num = reserve_dai
735            .checked_mul(U256::from(1000u32))
736            .unwrap();
737        let spot_price_dai_per_usdc_den = reserve_usdc
738            .checked_mul(U256::from(1003u32))
739            .unwrap();
740
741        // Test 1: Price above reachable limit (more DAI per USDC than pool can provide) -should
742        // return error Multiply by 1001/1000 to go above reachable limit
743        let above_limit_num = spot_price_dai_per_usdc_num
744            .checked_mul(U256::from(1001u32))
745            .unwrap();
746        let above_limit_den = spot_price_dai_per_usdc_den
747            .checked_mul(U256::from(1000u32))
748            .unwrap();
749        let target_price =
750            Price::new(u256_to_biguint(above_limit_num), u256_to_biguint(above_limit_den));
751
752        let swap_above_limit = pool.query_pool_swap(&QueryPoolSwapParams::new(
753            usdc.clone(),
754            dai.clone(),
755            SwapConstraint::PoolTargetPrice {
756                target: target_price,
757                tolerance: 0f64,
758                min_amount_in: None,
759                max_amount_in: None,
760            },
761        ));
762        assert!(swap_above_limit.is_err(), "Should return error for price above reachable limit");
763
764        // Test 2: Price just below reachable limit - should return non-zero
765        // Multiply by 100_000/100_001 to go slightly below (more reachable)
766        let below_limit_num = spot_price_dai_per_usdc_num
767            .checked_mul(U256::from(100_000u32))
768            .unwrap();
769        let below_limit_den = spot_price_dai_per_usdc_den
770            .checked_mul(U256::from(100_001u32))
771            .unwrap();
772        let target_price =
773            Price::new(u256_to_biguint(below_limit_num), u256_to_biguint(below_limit_den));
774
775        let swap_below_limit = pool
776            .query_pool_swap(&QueryPoolSwapParams::new(
777                usdc.clone(),
778                dai.clone(),
779                SwapConstraint::PoolTargetPrice {
780                    target: target_price,
781                    tolerance: 0f64,
782                    min_amount_in: None,
783                    max_amount_in: None,
784                },
785            ))
786            .unwrap();
787
788        assert!(
789            swap_below_limit.amount_out().clone() > BigUint::ZERO,
790            "Should return non-zero for reachable price"
791        );
792
793        // Verify with actual swap
794        let actual_result = pool
795            .get_amount_out(swap_below_limit.amount_in().clone(), &usdc, &dai)
796            .unwrap();
797
798        assert_eq!(
799            biguint_to_u256(&actual_result.amount),
800            U256::from(366839007208379339u128),
801            "Should return non-zero amount"
802        );
803        assert!(
804            actual_result.amount >= swap_below_limit.amount_out().clone(),
805            "Actual swap should give at least predicted amount"
806        );
807    }
808}