Skip to main content

tycho_simulation/evm/protocol/ring_swap_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_fee, cpmm_get_amount_out, cpmm_get_limits, cpmm_spot_price, cpmm_swap_to_price,
23        ProtocolFee,
24    },
25    safe_math::{safe_add_u256, safe_div_u256, safe_mul_u256, safe_sub_u256},
26    u256_num::{biguint_to_u256, u256_to_biguint},
27    utils::add_fee_markup,
28};
29
30// A mainnet-fork gas report measures RingSwapV2Executor.swap at ~146k. The solution-level gas
31// estimator accounts for the ~60k input token transfer separately, leaving a 90k swap base.
32const SWAP_BASE_GAS: u64 = 90_000;
33const RING_SWAP_V2_FEE_BPS: u32 = 30;
34const FEE_PRECISION: U256 = U256::from_limbs([10_000, 0, 0, 0]);
35const FEE_NUMERATOR: U256 = U256::from_limbs([9_970, 0, 0, 0]);
36
37/// Ring Swap v2 uses normal CPMM reserve math, but its output FewToken must be unwrapped into
38/// the solver-facing ERC-20. `backing{0,1}` is the indexed component balance
39/// `min(pair reserve, wrapper backing)`. Since CPMM output is always below the output reserve,
40/// this value is equivalent to the wrapper backing when enforcing executable output.
41#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
42pub struct RingSwapV2State {
43    pub component_id: String,
44    pub reserve0: U256,
45    pub reserve1: U256,
46    pub backing0: U256,
47    pub backing1: U256,
48    pub token0: Bytes,
49    pub token1: Bytes,
50}
51
52impl RingSwapV2State {
53    pub fn new(
54        component_id: String,
55        reserve0: U256,
56        reserve1: U256,
57        backing0: U256,
58        backing1: U256,
59        token0: Bytes,
60        token1: Bytes,
61    ) -> Self {
62        Self { component_id, reserve0, reserve1, backing0, backing1, token0, token1 }
63    }
64
65    fn zero_to_one(&self, token_in: &Token, token_out: &Token) -> bool {
66        token_in.address < token_out.address
67    }
68
69    fn output_backing(&self, zero_to_one: bool) -> U256 {
70        if zero_to_one {
71            self.backing1
72        } else {
73            self.backing0
74        }
75    }
76
77    fn protocol_fee() -> ProtocolFee {
78        ProtocolFee::new(FEE_NUMERATOR, FEE_PRECISION)
79    }
80
81    fn max_input_for_backing(
82        reserve_in: U256,
83        reserve_out: U256,
84        output_backing: U256,
85    ) -> Result<U256, SimulationError> {
86        if output_backing >= reserve_out {
87            return Ok(U256::MAX);
88        }
89        let first_unexecutable_output = safe_add_u256(output_backing, U256::from(1))?;
90        if first_unexecutable_output == reserve_out {
91            return Ok(U256::MAX);
92        }
93
94        // floor(amount_out) <= backing iff:
95        // amount_in * fee * (reserve_out - backing - 1)
96        //     < (backing + 1) * reserve_in * fee_precision.
97        let numerator =
98            safe_mul_u256(safe_mul_u256(first_unexecutable_output, reserve_in)?, FEE_PRECISION)?;
99        let denominator =
100            safe_mul_u256(FEE_NUMERATOR, safe_sub_u256(reserve_out, first_unexecutable_output)?)?;
101
102        safe_div_u256(safe_sub_u256(numerator, U256::from(1))?, denominator)
103    }
104
105    fn capped_limits(
106        &self,
107        sell_token: Bytes,
108        buy_token: Bytes,
109    ) -> Result<(BigUint, BigUint), SimulationError> {
110        let (soft_input, _) = cpmm_get_limits(
111            sell_token.clone(),
112            buy_token.clone(),
113            self.reserve0,
114            self.reserve1,
115            RING_SWAP_V2_FEE_BPS,
116        )?;
117        let zero_to_one = sell_token < buy_token;
118        let (reserve_in, reserve_out) = if zero_to_one {
119            (self.reserve0, self.reserve1)
120        } else {
121            (self.reserve1, self.reserve0)
122        };
123        let soft_input_u256 = biguint_to_u256(&soft_input);
124        let soft_output =
125            cpmm_get_amount_out(soft_input_u256, reserve_in, reserve_out, Self::protocol_fee())?;
126        let output_backing = self.output_backing(zero_to_one);
127        if output_backing == U256::ZERO {
128            return Ok((BigUint::ZERO, BigUint::ZERO));
129        }
130        if soft_output <= output_backing {
131            return Ok((soft_input, u256_to_biguint(soft_output)));
132        }
133
134        let capped_input = Self::max_input_for_backing(reserve_in, reserve_out, output_backing)?
135            .min(soft_input_u256);
136        if capped_input == U256::ZERO {
137            return Ok((BigUint::ZERO, BigUint::ZERO));
138        }
139        let output =
140            cpmm_get_amount_out(capped_input, reserve_in, reserve_out, Self::protocol_fee())?;
141        Ok((u256_to_biguint(capped_input), u256_to_biguint(output)))
142    }
143
144    fn apply_component_balance_updates(&mut self, balances: &Balances) {
145        let Some(component_balances) = balances
146            .component_balances
147            .get(&self.component_id)
148        else {
149            return;
150        };
151        if let Some(balance) = component_balances.get(&self.token0) {
152            self.backing0 = U256::from_be_slice(balance);
153        }
154        if let Some(balance) = component_balances.get(&self.token1) {
155            self.backing1 = U256::from_be_slice(balance);
156        }
157    }
158}
159
160#[typetag::serde]
161impl ProtocolSim for RingSwapV2State {
162    fn fee(&self) -> f64 {
163        cpmm_fee(RING_SWAP_V2_FEE_BPS)
164    }
165
166    fn spot_price(&self, base: &Token, quote: &Token) -> Result<f64, SimulationError> {
167        let price = cpmm_spot_price(base, quote, self.reserve0, self.reserve1)?;
168        Ok(add_fee_markup(price, self.fee()))
169    }
170
171    fn get_amount_out(
172        &self,
173        amount_in: BigUint,
174        token_in: &Token,
175        token_out: &Token,
176    ) -> Result<GetAmountOutResult, SimulationError> {
177        let amount_in = biguint_to_u256(&amount_in);
178        let zero_to_one = self.zero_to_one(token_in, token_out);
179        let (reserve_in, reserve_out) = if zero_to_one {
180            (self.reserve0, self.reserve1)
181        } else {
182            (self.reserve1, self.reserve0)
183        };
184        let amount_out =
185            cpmm_get_amount_out(amount_in, reserve_in, reserve_out, Self::protocol_fee())?;
186        let output_backing = self.output_backing(zero_to_one);
187        if output_backing == U256::ZERO || amount_out > output_backing {
188            return Err(SimulationError::InvalidInput(
189                "RingSwapV2 output exceeds FewToken underlying backing".to_string(),
190                None,
191            ));
192        }
193
194        let mut new_state = self.clone();
195        if zero_to_one {
196            new_state.reserve0 = safe_add_u256(self.reserve0, amount_in)?;
197            new_state.reserve1 = safe_sub_u256(self.reserve1, amount_out)?;
198            new_state.backing0 = safe_add_u256(self.backing0, amount_in)?;
199            new_state.backing1 = safe_sub_u256(self.backing1, amount_out)?;
200        } else {
201            new_state.reserve0 = safe_sub_u256(self.reserve0, amount_out)?;
202            new_state.reserve1 = safe_add_u256(self.reserve1, amount_in)?;
203            new_state.backing0 = safe_sub_u256(self.backing0, amount_out)?;
204            new_state.backing1 = safe_add_u256(self.backing1, amount_in)?;
205        }
206
207        Ok(GetAmountOutResult::new(
208            u256_to_biguint(amount_out),
209            BigUint::from(SWAP_BASE_GAS),
210            Box::new(new_state),
211        ))
212    }
213
214    fn get_limits(
215        &self,
216        sell_token: Bytes,
217        buy_token: Bytes,
218    ) -> Result<(BigUint, BigUint), SimulationError> {
219        self.capped_limits(sell_token, buy_token)
220    }
221
222    fn delta_transition(
223        &mut self,
224        delta: ProtocolStateDelta,
225        _tokens: &HashMap<Bytes, Token>,
226        balances: &Balances,
227    ) -> Result<(), TransitionError> {
228        if delta
229            .updated_attributes
230            .contains_key("reserve0") ||
231            delta
232                .updated_attributes
233                .contains_key("reserve1")
234        {
235            self.reserve0 = U256::from_be_slice(
236                delta
237                    .updated_attributes
238                    .get("reserve0")
239                    .ok_or_else(|| TransitionError::MissingAttribute("reserve0".to_string()))?,
240            );
241            self.reserve1 = U256::from_be_slice(
242                delta
243                    .updated_attributes
244                    .get("reserve1")
245                    .ok_or_else(|| TransitionError::MissingAttribute("reserve1".to_string()))?,
246            );
247        }
248        self.apply_component_balance_updates(balances);
249        Ok(())
250    }
251
252    fn query_pool_swap(&self, params: &QueryPoolSwapParams) -> Result<PoolSwap, SimulationError> {
253        match params.swap_constraint() {
254            SwapConstraint::PoolTargetPrice {
255                target: price,
256                tolerance: _,
257                min_amount_in: _,
258                max_amount_in: _,
259            } => {
260                let zero_to_one = self.zero_to_one(params.token_in(), params.token_out());
261                let (reserve_in, reserve_out) = if zero_to_one {
262                    (self.reserve0, self.reserve1)
263                } else {
264                    (self.reserve1, self.reserve0)
265                };
266                let (target_input, _) =
267                    cpmm_swap_to_price(reserve_in, reserve_out, price, Self::protocol_fee())?;
268                let (max_input, _) = self.get_limits(
269                    params.token_in().address.clone(),
270                    params.token_out().address.clone(),
271                )?;
272                let amount_in = target_input.min(max_input);
273                if amount_in.is_zero() {
274                    return Ok(PoolSwap::new(
275                        BigUint::ZERO,
276                        BigUint::ZERO,
277                        Box::new(self.clone()),
278                        None,
279                    ));
280                }
281
282                let result =
283                    self.get_amount_out(amount_in.clone(), params.token_in(), params.token_out())?;
284                Ok(PoolSwap::new(amount_in, result.amount, result.new_state, None))
285            }
286            SwapConstraint::TradeLimitPrice { .. } => Err(SimulationError::InvalidInput(
287                "RingSwapV2State does not support TradeLimitPrice constraint in query_pool_swap"
288                    .to_string(),
289                None,
290            )),
291        }
292    }
293
294    fn clone_box(&self) -> Box<dyn ProtocolSim> {
295        Box::new(self.clone())
296    }
297
298    fn as_any(&self) -> &dyn Any {
299        self
300    }
301
302    fn as_any_mut(&mut self) -> &mut dyn Any {
303        self
304    }
305
306    fn eq(&self, other: &dyn ProtocolSim) -> bool {
307        other
308            .as_any()
309            .downcast_ref::<Self>()
310            .is_some_and(|other| self == other)
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use std::collections::HashMap;
317
318    use alloy::primitives::U256;
319    use num_bigint::BigUint;
320    use tycho_common::{
321        dto::ProtocolStateDelta,
322        models::{token::Token, Chain},
323        simulation::{
324            errors::SimulationError,
325            protocol_sim::{Balances, ProtocolSim},
326        },
327        Bytes,
328    };
329
330    use super::*;
331
332    fn address(value: u8) -> Bytes {
333        Bytes::from(vec![value; 20])
334    }
335
336    fn token(value: u8) -> Token {
337        Token::new(&address(value), "T", 18, 0, &[Some(10_000)], Chain::Ethereum, 100)
338    }
339
340    fn state_with_id(component_id: &str, backing1: u64) -> RingSwapV2State {
341        RingSwapV2State::new(
342            component_id.to_string(),
343            U256::from(1_000),
344            U256::from(1_000),
345            U256::from(1_000),
346            U256::from(backing1),
347            address(1),
348            address(2),
349        )
350    }
351
352    fn state(backing1: u64) -> RingSwapV2State {
353        state_with_id("ring-pool", backing1)
354    }
355
356    #[test]
357    fn rejects_quotes_that_exceed_output_wrapper_backing() {
358        let result = state(10).get_amount_out(BigUint::from(100_u64), &token(1), &token(2));
359
360        assert!(matches!(result, Err(SimulationError::InvalidInput(_, None))));
361    }
362
363    #[test]
364    fn quotes_within_backing_update_reserves_and_wrapper_balances() {
365        let result = state(10)
366            .get_amount_out(BigUint::from(10_u64), &token(1), &token(2))
367            .unwrap();
368        let updated = result
369            .new_state
370            .as_any()
371            .downcast_ref::<RingSwapV2State>()
372            .unwrap();
373
374        assert_eq!(result.amount, BigUint::from(9_u64));
375        assert_eq!(updated.reserve0, U256::from(1_010));
376        assert_eq!(updated.reserve1, U256::from(991));
377        assert_eq!(updated.backing0, U256::from(1_010));
378        assert_eq!(updated.backing1, U256::from(1));
379    }
380
381    #[test]
382    fn limits_are_capped_by_output_wrapper_backing() {
383        let state = state(10);
384        let (max_input, max_output) = state
385            .get_limits(address(1), address(2))
386            .unwrap();
387
388        assert_eq!(max_input, BigUint::from(11_u64));
389        assert_eq!(max_output, BigUint::from(10_u64));
390        assert!(state
391            .get_amount_out(max_input.clone(), &token(1), &token(2))
392            .is_ok());
393        assert!(matches!(
394            state.get_amount_out(max_input + BigUint::from(1_u64), &token(1), &token(2)),
395            Err(SimulationError::InvalidInput(_, None))
396        ));
397    }
398
399    #[test]
400    fn zero_backing_has_no_executable_limits() {
401        let state = state(0);
402        let (max_input, max_output) = state
403            .get_limits(address(1), address(2))
404            .unwrap();
405
406        assert_eq!(max_input, BigUint::ZERO);
407        assert_eq!(max_output, BigUint::ZERO);
408        assert!(matches!(
409            state.get_amount_out(BigUint::from(1_u64), &token(1), &token(2)),
410            Err(SimulationError::InvalidInput(_, None))
411        ));
412    }
413
414    #[test]
415    fn closed_form_backing_limit_is_exact_at_boundary() {
416        for (reserve_in, reserve_out, backing) in [
417            (1_000_u64, 1_000_u64, 0_u64),
418            (1_000, 1_000, 10),
419            (10_000, 25_000, 1_000),
420            (25_000, 10_000, 9_000),
421        ] {
422            let max_input = RingSwapV2State::max_input_for_backing(
423                U256::from(reserve_in),
424                U256::from(reserve_out),
425                U256::from(backing),
426            )
427            .unwrap();
428            let amount_out = cpmm_get_amount_out(
429                max_input,
430                U256::from(reserve_in),
431                U256::from(reserve_out),
432                RingSwapV2State::protocol_fee(),
433            )
434            .unwrap();
435            let next_amount_out = cpmm_get_amount_out(
436                max_input + U256::from(1),
437                U256::from(reserve_in),
438                U256::from(reserve_out),
439                RingSwapV2State::protocol_fee(),
440            )
441            .unwrap();
442
443            assert!(amount_out <= U256::from(backing));
444            assert!(next_amount_out > U256::from(backing));
445        }
446    }
447
448    #[test]
449    fn backing_at_or_above_reserve_is_uncapped_without_overflow() {
450        assert_eq!(
451            RingSwapV2State::max_input_for_backing(
452                U256::from(1_000),
453                U256::from(1_000),
454                U256::from(1_000),
455            )
456            .unwrap(),
457            U256::MAX
458        );
459        assert_eq!(
460            RingSwapV2State::max_input_for_backing(
461                U256::from(1_000),
462                U256::from(1_000),
463                U256::MAX,
464            )
465            .unwrap(),
466            U256::MAX
467        );
468    }
469
470    #[test]
471    fn uncapped_limits_report_the_exact_cpmm_output() {
472        let state = state(10_000);
473        let (max_input, max_output) = state
474            .get_limits(address(1), address(2))
475            .unwrap();
476        let expected_output = cpmm_get_amount_out(
477            biguint_to_u256(&max_input),
478            state.reserve0,
479            state.reserve1,
480            RingSwapV2State::protocol_fee(),
481        )
482        .unwrap();
483
484        assert_eq!(max_output, u256_to_biguint(expected_output));
485    }
486
487    #[test]
488    fn component_balance_only_delta_updates_without_reserve_attributes() {
489        let mut state = state(10);
490        let balances = Balances {
491            component_balances: HashMap::from([(
492                "ring-pool".to_string(),
493                HashMap::from([(address(2), Bytes::from(vec![42]))]),
494            )]),
495            account_balances: HashMap::new(),
496        };
497
498        state
499            .delta_transition(ProtocolStateDelta::default(), &HashMap::new(), &balances)
500            .unwrap();
501
502        assert_eq!(state.reserve0, U256::from(1_000));
503        assert_eq!(state.reserve1, U256::from(1_000));
504        assert_eq!(state.backing1, U256::from(42));
505    }
506
507    #[test]
508    fn shared_wrapper_updates_each_pool_from_its_component_balance() {
509        let mut first_pool = state_with_id("first-pool", 10);
510        let mut second_pool = state_with_id("second-pool", 20);
511        let balances = Balances {
512            component_balances: HashMap::from([
513                ("first-pool".to_string(), HashMap::from([(address(2), Bytes::from(vec![42]))])),
514                ("second-pool".to_string(), HashMap::from([(address(2), Bytes::from(vec![21]))])),
515            ]),
516            account_balances: HashMap::new(),
517        };
518
519        first_pool
520            .delta_transition(ProtocolStateDelta::default(), &HashMap::new(), &balances)
521            .unwrap();
522        second_pool
523            .delta_transition(ProtocolStateDelta::default(), &HashMap::new(), &balances)
524            .unwrap();
525
526        assert_eq!(first_pool.backing1, U256::from(42));
527        assert_eq!(second_pool.backing1, U256::from(21));
528    }
529
530    #[test]
531    fn unrelated_component_balance_does_not_update_state() {
532        let mut state = state(10);
533        let balances = Balances {
534            component_balances: HashMap::from([(
535                "other-pool".to_string(),
536                HashMap::from([(address(2), Bytes::from(vec![99]))]),
537            )]),
538            account_balances: HashMap::new(),
539        };
540
541        state
542            .delta_transition(ProtocolStateDelta::default(), &HashMap::new(), &balances)
543            .unwrap();
544
545        assert_eq!(state.backing1, U256::from(10));
546    }
547
548    #[test]
549    fn backing_cap_is_applied_in_both_directions() {
550        let mut state = state(1_000);
551        state.backing0 = U256::from(10);
552
553        let result = state.get_amount_out(BigUint::from(100_u64), &token(2), &token(1));
554
555        assert!(matches!(result, Err(SimulationError::InvalidInput(_, None))));
556    }
557}