Skip to main content

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