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