Skip to main content

tycho_simulation/evm/protocol/sky/
state.rs

1use std::{any::Any, collections::HashMap};
2
3use alloy::primitives::U256;
4use num_bigint::BigUint;
5use num_traits::ToPrimitive;
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, Price, ProtocolSim, QueryPoolSwapParams,
14            SwapConstraint,
15        },
16    },
17    Bytes,
18};
19
20use crate::evm::{
21    protocol::{
22        safe_math::{safe_add_u256, safe_div_u256, safe_mul_u256, safe_sub_u256},
23        u256_num::{biguint_to_u256, u256_to_biguint, u256_to_f64},
24    },
25    query_pool_swap::is_within_tolerance,
26};
27
28const WAD: u128 = 1_000_000_000_000_000_000;
29/// Sentinel for `tin`/`tout` disabling the respective swap direction (DssLitePsm.HALTED).
30const HALTED: U256 = U256::MAX;
31
32const PSM_SWAP_GAS: u64 = 120_000;
33const WRAPPER_SWAP_GAS: u64 = 220_000;
34const CONVERTER_SWAP_GAS: u64 = 100_000;
35
36/// Behaviour of a `sky` protocol component, mapped from its `component_type`
37/// static attribute.
38#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
39pub enum SkyComponentKind {
40    /// DssLitePsm: stable (18-dec DAI) <-> gem (USDC) at 1:1 with `tin`/`tout` fees.
41    Psm,
42    /// UsdsPsmWrapper: same PSM math with USDS as the stable side.
43    PsmWrapper,
44    /// DaiUsds: 1:1 mint/burn between two 18-dec stables, feeless and immutable.
45    Converter,
46}
47
48/// The join escrows (`vat.dai[join]`, wad) bounding the wrapper's in-flight
49/// DAI <-> USDS conversion legs: `daiToUsds`/`usdsToDai` burn through `join`, which
50/// debits the respective join's escrow and reverts beyond it.
51#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
52pub struct JoinEscrows {
53    pub dai: U256,
54    pub usds: U256,
55}
56
57/// State for Sky mint/redeem components (LitePSM, USDS PSM wrapper, DaiUsds converter).
58///
59/// All components swap 1:1 modulo decimal rescaling; the PSM legs additionally apply the
60/// governance-mutable `tin` (stable out) / `tout` (stable in) wad fees. Balances bound
61/// the swap limits: the PSM's pre-minted stable inventory and pocket gem inventory, or
62/// the join escrows (`vat.dai[join]`, the burnable amount per side) for the converter.
63/// The wrapper is additionally bounded by both join escrows, which its in-flight
64/// DAI <-> USDS conversion burns through.
65#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
66pub struct SkyState {
67    pub component_id: String,
68    pub kind: SkyComponentKind,
69    /// The side paired against the gem: DAI (psm, converter) or USDS (wrapper).
70    stable: Token,
71    /// The side named by the `gem` static attribute, which fee and call direction
72    /// are defined against: USDC (psm, wrapper) or USDS (converter).
73    gem: Token,
74    /// Fee in wad taken on gem -> stable swaps (`sellGem`); `U256::MAX` halts them.
75    tin: U256,
76    /// Fee in wad taken on stable -> gem swaps (`buyGem`); `U256::MAX` halts them.
77    tout: U256,
78    stable_balance: U256,
79    gem_balance: U256,
80    /// The join escrows the component's conversion legs burn through; wrapper only.
81    escrows: Option<JoinEscrows>,
82}
83
84impl SkyState {
85    #[allow(clippy::too_many_arguments)]
86    pub fn new(
87        component_id: String,
88        kind: SkyComponentKind,
89        stable: Token,
90        gem: Token,
91        tin: U256,
92        tout: U256,
93        stable_balance: U256,
94        gem_balance: U256,
95        escrows: Option<JoinEscrows>,
96    ) -> Self {
97        Self { component_id, kind, stable, gem, tin, tout, stable_balance, gem_balance, escrows }
98    }
99
100    /// 10^(stable.decimals - gem.decimals); 1 for the converter's 18/18 pair.
101    /// The decoder rejects components with gem.decimals > stable.decimals, so
102    /// the subtraction cannot underflow.
103    fn conversion_factor(&self) -> U256 {
104        U256::from(10).pow(U256::from(self.stable.decimals - self.gem.decimals))
105    }
106
107    fn swap_gas(&self) -> u64 {
108        match self.kind {
109            SkyComponentKind::Psm => PSM_SWAP_GAS,
110            SkyComponentKind::PsmWrapper => WRAPPER_SWAP_GAS,
111            SkyComponentKind::Converter => CONVERTER_SWAP_GAS,
112        }
113    }
114
115    fn is_gem_to_stable(
116        &self,
117        token_in: &Bytes,
118        token_out: &Bytes,
119    ) -> Result<bool, SimulationError> {
120        if *token_in == self.gem.address && *token_out == self.stable.address {
121            Ok(true)
122        } else if *token_in == self.stable.address && *token_out == self.gem.address {
123            Ok(false)
124        } else {
125            Err(SimulationError::InvalidInput(
126                format!("invalid token pair: {token_in}, {token_out}"),
127                None,
128            ))
129        }
130    }
131
132    /// `sellGem` output: stable amount received for `gem_in`, after `tin`.
133    fn stable_out(&self, gem_in: U256) -> Result<U256, SimulationError> {
134        if self.tin == HALTED {
135            return Err(SimulationError::RecoverableError("sell gem is halted".to_string()));
136        }
137        let stable_wad = safe_mul_u256(gem_in, self.conversion_factor())?;
138        let fee = safe_mul_u256(stable_wad, self.tin)? / U256::from(WAD);
139        safe_sub_u256(stable_wad, fee)
140    }
141
142    /// `buyGem` output: the largest gem amount whose cost (incl. `tout`) fits in
143    /// `stable_in`. Rounds down, so execution never requires more than `stable_in`.
144    fn gem_out(&self, stable_in: U256) -> Result<U256, SimulationError> {
145        if self.tout == HALTED {
146            return Err(SimulationError::RecoverableError("buy gem is halted".to_string()));
147        }
148        let wad = U256::from(WAD);
149        // The divisor cannot be zero: a power of ten times at least `wad`.
150        Ok(safe_mul_u256(stable_in, wad)? /
151            safe_mul_u256(self.conversion_factor(), safe_add_u256(wad, self.tout)?)?)
152    }
153
154    fn apply_component_balance_updates(&mut self, balances: &Balances) {
155        let Some(component_balances) = balances
156            .component_balances
157            .get(&self.component_id)
158        else {
159            return;
160        };
161        if let Some(balance) = component_balances.get(&self.stable.address) {
162            self.stable_balance = U256::from_be_slice(balance);
163        }
164        if let Some(balance) = component_balances.get(&self.gem.address) {
165            self.gem_balance = U256::from_be_slice(balance);
166        }
167    }
168
169    fn fee_f64(fee: U256) -> f64 {
170        if fee == HALTED {
171            return 1.0;
172        }
173        u256_to_f64(fee).unwrap_or(f64::MAX) / WAD as f64
174    }
175}
176
177#[typetag::serde]
178impl ProtocolSim for SkyState {
179    fn fee(&self) -> f64 {
180        f64::max(Self::fee_f64(self.tin), Self::fee_f64(self.tout))
181    }
182
183    /// Buy price of `base` in `quote` (trait convention): acquiring gem goes through
184    /// `buyGem` and costs `1 + tout` stable per unit; acquiring stable goes through
185    /// `sellGem`, where receiving 1 stable costs `1 / (1 - tin)` gem.
186    fn spot_price(&self, base: &Token, quote: &Token) -> Result<f64, SimulationError> {
187        if self.is_gem_to_stable(&base.address, &quote.address)? {
188            if self.tout == HALTED {
189                return Err(SimulationError::RecoverableError("buy gem is halted".to_string()));
190            }
191            Ok(1.0 + Self::fee_f64(self.tout))
192        } else {
193            if self.tin == HALTED {
194                return Err(SimulationError::RecoverableError("sell gem is halted".to_string()));
195            }
196            Ok(1.0 / (1.0 - Self::fee_f64(self.tin)))
197        }
198    }
199
200    fn get_amount_out(
201        &self,
202        amount_in: BigUint,
203        token_in: &Token,
204        token_out: &Token,
205    ) -> Result<GetAmountOutResult, SimulationError> {
206        let amount_in = biguint_to_u256(&amount_in);
207        let mut new_state = self.clone();
208        let gem_to_stable = self.is_gem_to_stable(&token_in.address, &token_out.address)?;
209
210        // The converter mints its output and burns its input, so its balances (the
211        // join escrows) move opposite to the PSM's inventories: burning shrinks the
212        // input side's escrow, minting grows the output side's. The output never
213        // bounds a swap — only the input (a burn cannot exceed the escrow backing
214        // the circulating supply).
215        let amount_out = if self.kind == SkyComponentKind::Converter {
216            let (sell_escrow, out) = if gem_to_stable {
217                (self.gem_balance, self.stable_out(amount_in)?)
218            } else {
219                (self.stable_balance, self.gem_out(amount_in)?)
220            };
221            if amount_in > sell_escrow {
222                return Err(SimulationError::RecoverableError(format!(
223                    "amount in {amount_in} exceeds sell token escrow {sell_escrow}"
224                )));
225            }
226            if gem_to_stable {
227                new_state.gem_balance -= amount_in;
228                new_state.stable_balance += out;
229            } else {
230                new_state.stable_balance -= amount_in;
231                new_state.gem_balance += out;
232            }
233            out
234        } else if gem_to_stable {
235            let out = self.stable_out(amount_in)?;
236            if out > self.stable_balance {
237                return Err(SimulationError::RecoverableError(format!(
238                    "amount out {out} exceeds stable inventory {}",
239                    self.stable_balance
240                )));
241            }
242            // The wrapper converts the PSM's DAI payout to USDS in-flight, burning it
243            // through DaiJoin: the escrow bounds the payout, and the burnt DAI backs
244            // the minted USDS on the other join.
245            if let Some(escrows) = &mut new_state.escrows {
246                if out > escrows.dai {
247                    return Err(SimulationError::RecoverableError(format!(
248                        "amount out {out} exceeds DAI join escrow {}",
249                        escrows.dai
250                    )));
251                }
252                escrows.dai -= out;
253                escrows.usds += out;
254            }
255            new_state.stable_balance -= out;
256            new_state.gem_balance += amount_in;
257            out
258        } else {
259            let out = self.gem_out(amount_in)?;
260            if out > self.gem_balance {
261                return Err(SimulationError::RecoverableError(format!(
262                    "amount out {out} exceeds gem inventory {}",
263                    self.gem_balance
264                )));
265            }
266            // The wrapper burns the full USDS input through UsdsJoin before buying gem
267            // from the PSM; the escrow bounds the input.
268            if let Some(escrows) = &mut new_state.escrows {
269                if amount_in > escrows.usds {
270                    return Err(SimulationError::RecoverableError(format!(
271                        "amount in {amount_in} exceeds USDS join escrow {}",
272                        escrows.usds
273                    )));
274                }
275                escrows.usds -= amount_in;
276                escrows.dai += amount_in;
277            }
278            new_state.gem_balance -= out;
279            new_state.stable_balance += amount_in;
280            out
281        };
282
283        Ok(GetAmountOutResult {
284            amount: u256_to_biguint(amount_out),
285            gas: BigUint::from(self.swap_gas()),
286            new_state: Box::new(new_state),
287        })
288    }
289
290    fn get_limits(
291        &self,
292        sell_token: Bytes,
293        buy_token: Bytes,
294    ) -> Result<(BigUint, BigUint), SimulationError> {
295        let wad = U256::from(WAD);
296        if self.is_gem_to_stable(&sell_token, &buy_token)? {
297            // Bounded by the pre-minted stable inventory (for the converter: by the
298            // sell token's join escrow, tracked as the gem balance).
299            if self.tin == HALTED {
300                return Ok((BigUint::ZERO, BigUint::ZERO));
301            }
302            if self.kind == SkyComponentKind::Converter {
303                return Ok((u256_to_biguint(self.gem_balance), u256_to_biguint(self.gem_balance)));
304            }
305            // The wrapper's stable payout is additionally bounded by the DAI join
306            // escrow its in-flight conversion burns through.
307            let max_out = match &self.escrows {
308                Some(escrows) => self.stable_balance.min(escrows.dai),
309                None => self.stable_balance,
310            };
311            let max_in = safe_div_u256(
312                safe_mul_u256(max_out, wad)?,
313                safe_mul_u256(self.conversion_factor(), safe_sub_u256(wad, self.tin)?)?,
314            )?;
315            Ok((u256_to_biguint(max_in), u256_to_biguint(self.stable_out(max_in)?)))
316        } else {
317            if self.tout == HALTED {
318                return Ok((BigUint::ZERO, BigUint::ZERO));
319            }
320            if self.kind == SkyComponentKind::Converter {
321                return Ok((
322                    u256_to_biguint(self.stable_balance),
323                    u256_to_biguint(self.stable_balance),
324                ));
325            }
326            // Bounded by the pocket's gem inventory; for the wrapper additionally by
327            // the USDS join escrow the full stable input is burned through.
328            let max_out = self.gem_balance;
329            let max_in = safe_mul_u256(
330                safe_mul_u256(max_out, self.conversion_factor())?,
331                safe_add_u256(wad, self.tout)?,
332            )? / wad;
333            if let Some(escrows) = &self.escrows {
334                if escrows.usds < max_in {
335                    return Ok((
336                        u256_to_biguint(escrows.usds),
337                        u256_to_biguint(self.gem_out(escrows.usds)?),
338                    ));
339                }
340            }
341            Ok((u256_to_biguint(max_in), u256_to_biguint(max_out)))
342        }
343    }
344
345    fn delta_transition(
346        &mut self,
347        delta: ProtocolStateDelta,
348        _tokens: &HashMap<Bytes, Token>,
349        balances: &Balances,
350    ) -> Result<(), TransitionError> {
351        if let Some(tin) = delta.updated_attributes.get("tin") {
352            self.tin = U256::from_be_slice(tin);
353        }
354        if let Some(tout) = delta.updated_attributes.get("tout") {
355            self.tout = U256::from_be_slice(tout);
356        }
357        if let Some(escrows) = &mut self.escrows {
358            if let Some(dai) = delta
359                .updated_attributes
360                .get("dai_escrow")
361            {
362                escrows.dai = U256::from_be_slice(dai);
363            }
364            if let Some(usds) = delta
365                .updated_attributes
366                .get("usds_escrow")
367            {
368                escrows.usds = U256::from_be_slice(usds);
369            }
370        }
371        self.apply_component_balance_updates(balances);
372        Ok(())
373    }
374
375    fn clone_box(&self) -> Box<dyn ProtocolSim> {
376        Box::new(self.clone())
377    }
378
379    fn as_any(&self) -> &dyn Any {
380        self
381    }
382
383    fn as_any_mut(&mut self) -> &mut dyn Any {
384        self
385    }
386
387    fn eq(&self, other: &dyn ProtocolSim) -> bool {
388        if let Some(other_state) = other
389            .as_any()
390            .downcast_ref::<SkyState>()
391        {
392            self == other_state
393        } else {
394            false
395        }
396    }
397
398    /// Closed-form implementation: prices are size-independent up to the hard
399    /// capacity bound, so the crate's generic root-search (built for falling AMM
400    /// price curves, where it brackets and converges on an interior amount) would
401    /// return an arbitrary probe instead of the true optimum on a flat curve.
402    ///
403    /// - `TradeLimitPrice` is all-or-nothing: the full `get_limits` capacity if the flat execution
404    ///   price clears the limit, a zero-amount swap otherwise.
405    /// - `PoolTargetPrice` is only satisfiable where the pool already is: a zero-amount swap for a
406    ///   target within tolerance of spot, an error otherwise.
407    ///
408    /// `min_amount_in`/`max_amount_in` are ignored, matching the generic helper.
409    fn query_pool_swap(&self, params: &QueryPoolSwapParams) -> Result<PoolSwap, SimulationError> {
410        let token_in = params.token_in();
411        let token_out = params.token_out();
412        let zero_swap =
413            || PoolSwap::new(BigUint::from(0u8), BigUint::from(0u8), self.clone_box(), None);
414
415        match params.swap_constraint() {
416            SwapConstraint::TradeLimitPrice { limit, .. } => {
417                // Zero limits cover both empty inventory and a HALTED direction.
418                let (max_in, _) =
419                    self.get_limits(token_in.address.clone(), token_out.address.clone())?;
420                if max_in == BigUint::from(0u8) {
421                    return Ok(zero_swap());
422                }
423                // The flat execution price of the trade (out per in, decimal
424                // adjusted). Not `spot_price`: that is the buy price of `token_in`,
425                // i.e. the opposite trade with the opposite fee.
426                let execution = if self.is_gem_to_stable(&token_in.address, &token_out.address)? {
427                    1.0 - Self::fee_f64(self.tin)
428                } else {
429                    1.0 / (1.0 + Self::fee_f64(self.tout))
430                };
431                let limit = price_f64(limit, token_in.decimals, token_out.decimals);
432                if execution < limit {
433                    return Ok(zero_swap());
434                }
435                // `get_amount_out` is only needed for the post-swap state; its
436                // amount matches the `get_limits` max_out by construction.
437                let result = self.get_amount_out(max_in.clone(), token_in, token_out)?;
438                Ok(PoolSwap::new(max_in, result.amount, result.new_state, None))
439            }
440            SwapConstraint::PoolTargetPrice { target, tolerance, .. } => {
441                let target = price_f64(target, token_in.decimals, token_out.decimals);
442                let spot = self.spot_price(token_in, token_out)?;
443                if is_within_tolerance(spot, target, *tolerance) {
444                    return Ok(zero_swap());
445                }
446                Err(SimulationError::InvalidInput(
447                    format!("spot price {spot} is size-independent; cannot reach target {target}"),
448                    None,
449                ))
450            }
451        }
452    }
453}
454
455/// Decimal-adjusted f64 of a `Price` (`token_out` per `token_in`).
456fn price_f64(price: &Price, in_decimals: u32, out_decimals: u32) -> f64 {
457    (to_f64(&price.numerator, out_decimals)) / (to_f64(&price.denominator, in_decimals))
458}
459
460fn to_f64(amount: &BigUint, decimals: u32) -> f64 {
461    amount.to_f64().unwrap_or(f64::MAX) / 10f64.powi(decimals as i32)
462}
463
464#[cfg(test)]
465mod tests {
466    use std::str::FromStr;
467
468    use rstest::rstest;
469    use tycho_common::models::Chain;
470
471    use super::*;
472
473    fn dai() -> Token {
474        Token::new(
475            &Bytes::from_str("0x6b175474e89094c44da98b954eedeac495271d0f").unwrap(),
476            "DAI",
477            18,
478            0,
479            &[Some(50_000)],
480            Chain::Ethereum,
481            100,
482        )
483    }
484
485    fn usdc() -> Token {
486        Token::new(
487            &Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap(),
488            "USDC",
489            6,
490            0,
491            &[Some(50_000)],
492            Chain::Ethereum,
493            100,
494        )
495    }
496
497    fn usds() -> Token {
498        Token::new(
499            &Bytes::from_str("0xdc035d45d973e3ec169d2276ddab16f1e407384f").unwrap(),
500            "USDS",
501            18,
502            0,
503            &[Some(50_000)],
504            Chain::Ethereum,
505            100,
506        )
507    }
508
509    fn wad(amount: u64) -> U256 {
510        U256::from(amount) * U256::from(WAD)
511    }
512
513    fn psm_state(tin: U256, tout: U256) -> SkyState {
514        SkyState::new(
515            "0xf6e72db5454dd049d0788e411b06cfaf16853042".to_string(),
516            SkyComponentKind::Psm,
517            dai(),
518            usdc(),
519            tin,
520            tout,
521            wad(1_000_000),                   // 1M DAI buffer
522            U256::from(2_000_000_000_000u64), // 2M USDC in the pocket
523            None,
524        )
525    }
526
527    fn wrapper_state(escrows: JoinEscrows) -> SkyState {
528        SkyState::new(
529            "0xa188eec8f81263234da3622a406892f3d630f98c".to_string(),
530            SkyComponentKind::PsmWrapper,
531            usds(),
532            usdc(),
533            U256::ZERO,
534            U256::ZERO,
535            wad(1_000_000),                   // mirrored 1M DAI buffer
536            U256::from(2_000_000_000_000u64), // mirrored 2M USDC pocket
537            Some(escrows),
538        )
539    }
540
541    fn converter_state() -> SkyState {
542        SkyState::new(
543            "0x3225737a9bbb6473cb4a45b7244aca2befdb276a".to_string(),
544            SkyComponentKind::Converter,
545            dai(),
546            usds(),
547            U256::ZERO,
548            U256::ZERO,
549            wad(3_000_000), // DaiJoin escrow
550            wad(9_000_000), // UsdsJoin escrow
551            None,
552        )
553    }
554
555    #[test]
556    fn sell_gem_fee_free_rescales_decimals() {
557        let state = psm_state(U256::ZERO, U256::ZERO);
558        let res = state
559            .get_amount_out(BigUint::from(1_000_000u64), &usdc(), &dai())
560            .unwrap();
561        // 1 USDC -> 1 DAI
562        assert_eq!(res.amount, BigUint::from(WAD));
563    }
564
565    #[test]
566    fn buy_gem_fee_free_rescales_decimals() {
567        let state = psm_state(U256::ZERO, U256::ZERO);
568        let res = state
569            .get_amount_out(BigUint::from(WAD), &dai(), &usdc())
570            .unwrap();
571        // 1 DAI -> 1 USDC
572        assert_eq!(res.amount, BigUint::from(1_000_000u64));
573    }
574
575    #[test]
576    fn sell_gem_applies_tin() {
577        // tin = 0.1% = 1e15
578        let state = psm_state(U256::from(WAD / 1000), U256::ZERO);
579        let res = state
580            .get_amount_out(BigUint::from(1_000_000u64), &usdc(), &dai())
581            .unwrap();
582        assert_eq!(res.amount, BigUint::from(WAD - WAD / 1000));
583    }
584
585    #[test]
586    fn buy_gem_applies_tout_and_rounds_down() {
587        // tout = 0.1%: 1 DAI buys floor(1e18 * 1e18 / (1e12 * 1.001e18)) = 999000 (dust) USDC
588        let state = psm_state(U256::ZERO, U256::from(WAD / 1000));
589        let res = state
590            .get_amount_out(BigUint::from(WAD), &dai(), &usdc())
591            .unwrap();
592        assert_eq!(res.amount, BigUint::from(999_000u64));
593        // The implied cost of the returned amount never exceeds the input.
594        let cost = U256::from(999_000u64) * U256::from(1_000_000_000_000u64) * // ->wad
595            (U256::from(WAD) + U256::from(WAD / 1000)) /
596            U256::from(WAD);
597        assert!(cost <= wad(1));
598    }
599
600    #[test]
601    fn swap_updates_inventory_in_new_state() {
602        let state = psm_state(U256::ZERO, U256::ZERO);
603        let res = state
604            .get_amount_out(BigUint::from(1_000_000u64), &usdc(), &dai())
605            .unwrap();
606        let new_state = res
607            .new_state
608            .as_any()
609            .downcast_ref::<SkyState>()
610            .unwrap()
611            .clone();
612        assert_eq!(new_state.stable_balance, state.stable_balance - U256::from(WAD));
613        assert_eq!(new_state.gem_balance, state.gem_balance + U256::from(1_000_000u64));
614    }
615
616    #[test]
617    fn sell_gem_bounded_by_stable_inventory() {
618        let state = psm_state(U256::ZERO, U256::ZERO);
619        // 2M USDC in would need 2M DAI out but only 1M is pre-minted.
620        let res = state.get_amount_out(BigUint::from(2_000_000_000_000u64), &usdc(), &dai());
621        assert!(matches!(res, Err(SimulationError::RecoverableError(_))));
622    }
623
624    #[rstest]
625    #[case::sell_gem_halted(HALTED, U256::ZERO, true)]
626    #[case::buy_gem_halted(U256::ZERO, HALTED, false)]
627    fn halted_direction_has_zero_limits(
628        #[case] tin: U256,
629        #[case] tout: U256,
630        #[case] gem_to_stable: bool,
631    ) {
632        let state = psm_state(tin, tout);
633        let (sell, buy) = if gem_to_stable {
634            state
635                .get_limits(usdc().address, dai().address)
636                .unwrap()
637        } else {
638            state
639                .get_limits(dai().address, usdc().address)
640                .unwrap()
641        };
642        assert_eq!(sell, BigUint::ZERO);
643        assert_eq!(buy, BigUint::ZERO);
644    }
645
646    #[test]
647    fn limits_match_inventory() {
648        let state = psm_state(U256::ZERO, U256::ZERO);
649        let (max_in, max_out) = state
650            .get_limits(usdc().address, dai().address)
651            .unwrap();
652        // Sell side capped by the 1M DAI buffer.
653        assert_eq!(max_out, u256_to_biguint(wad(1_000_000)));
654        assert_eq!(max_in, BigUint::from(1_000_000_000_000u64));
655
656        let (max_in, max_out) = state
657            .get_limits(dai().address, usdc().address)
658            .unwrap();
659        // Buy side capped by the 2M USDC pocket.
660        assert_eq!(max_out, BigUint::from(2_000_000_000_000u64));
661        assert_eq!(max_in, u256_to_biguint(wad(2_000_000)));
662    }
663
664    #[test]
665    fn spot_prices_include_fees_and_round_trip() {
666        let tin = U256::from(WAD / 1000);
667        let tout = U256::from(2 * (WAD / 1000));
668        let state = psm_state(tin, tout);
669        let usdc_in_dai = state
670            .spot_price(&usdc(), &dai())
671            .unwrap();
672        let dai_in_usdc = state
673            .spot_price(&dai(), &usdc())
674            .unwrap();
675        // Buy prices: acquiring USDC costs 1 + tout DAI; acquiring DAI costs
676        // 1 / (1 - tin) USDC. The round trip is >= 1 under the buy-price convention.
677        assert!((usdc_in_dai - 1.002).abs() < 1e-12);
678        assert!((dai_in_usdc - 1.0 / 0.999).abs() < 1e-12);
679        assert!(usdc_in_dai * dai_in_usdc >= 1.0);
680    }
681
682    #[test]
683    fn converter_is_symmetric_one_to_one() {
684        let state = converter_state();
685        let res = state
686            .get_amount_out(BigUint::from(WAD), &usds(), &dai())
687            .unwrap();
688        assert_eq!(res.amount, BigUint::from(WAD));
689        let res = state
690            .get_amount_out(BigUint::from(WAD), &dai(), &usds())
691            .unwrap();
692        assert_eq!(res.amount, BigUint::from(WAD));
693        assert_eq!(
694            state
695                .spot_price(&dai(), &usds())
696                .unwrap(),
697            1.0
698        );
699    }
700
701    #[test]
702    fn converter_mints_output_regardless_of_target_escrow() {
703        // USDS's escrow was zero at converter creation: DAI -> USDS must still work,
704        // and the escrows move burn-in / mint-out.
705        let state = SkyState::new(
706            "0x3225737a9bbb6473cb4a45b7244aca2befdb276a".to_string(),
707            SkyComponentKind::Converter,
708            dai(),
709            usds(),
710            U256::ZERO,
711            U256::ZERO,
712            wad(3_000_000),
713            U256::ZERO,
714            None,
715        );
716        let res = state
717            .get_amount_out(BigUint::from(WAD), &dai(), &usds())
718            .unwrap();
719        assert_eq!(res.amount, BigUint::from(WAD));
720        let new_state = res
721            .new_state
722            .as_any()
723            .downcast_ref::<SkyState>()
724            .unwrap();
725        assert_eq!(new_state.stable_balance, wad(3_000_000) - U256::from(WAD));
726        assert_eq!(new_state.gem_balance, U256::from(WAD));
727    }
728
729    #[test]
730    fn converter_burn_bounded_by_sell_escrow() {
731        let state = converter_state();
732        // More USDS than the escrow backs cannot be burned.
733        let res = state.get_amount_out(u256_to_biguint(wad(9_000_001)), &usds(), &dai());
734        assert!(matches!(res, Err(SimulationError::RecoverableError(_))));
735    }
736
737    #[test]
738    fn converter_limits_use_sell_token_escrow() {
739        let state = converter_state();
740        let (max_in, _) = state
741            .get_limits(dai().address, usds().address)
742            .unwrap();
743        assert_eq!(max_in, u256_to_biguint(wad(3_000_000)));
744        let (max_in, _) = state
745            .get_limits(usds().address, dai().address)
746            .unwrap();
747        assert_eq!(max_in, u256_to_biguint(wad(9_000_000)));
748    }
749
750    #[test]
751    fn wrapper_stable_payout_bounded_by_dai_escrow() {
752        // DAI escrow smaller than the mirrored 1M buffer binds the USDC -> USDS side.
753        let state = wrapper_state(JoinEscrows { dai: wad(400_000), usds: wad(9_000_000) });
754        let (max_in, max_out) = state
755            .get_limits(usdc().address, usds().address)
756            .unwrap();
757        assert_eq!(max_out, u256_to_biguint(wad(400_000)));
758        assert_eq!(max_in, BigUint::from(400_000_000_000u64));
759
760        let res = state.get_amount_out(BigUint::from(500_000_000_000u64), &usdc(), &usds());
761        assert!(matches!(res, Err(SimulationError::RecoverableError(_))));
762    }
763
764    #[test]
765    fn wrapper_stable_input_bounded_by_usds_escrow() {
766        // USDS escrow smaller than the pocket-implied input binds the USDS -> USDC side.
767        let state = wrapper_state(JoinEscrows { dai: wad(3_000_000), usds: wad(500_000) });
768        let (max_in, max_out) = state
769            .get_limits(usds().address, usdc().address)
770            .unwrap();
771        assert_eq!(max_in, u256_to_biguint(wad(500_000)));
772        assert_eq!(max_out, BigUint::from(500_000_000_000u64));
773
774        let res = state.get_amount_out(u256_to_biguint(wad(500_001)), &usds(), &usdc());
775        assert!(matches!(res, Err(SimulationError::RecoverableError(_))));
776    }
777
778    #[test]
779    fn wrapper_unbinding_escrows_leave_mirror_limits() {
780        let state = wrapper_state(JoinEscrows { dai: wad(3_000_000), usds: wad(9_000_000) });
781        let (max_in, max_out) = state
782            .get_limits(usds().address, usdc().address)
783            .unwrap();
784        // Pocket-bounded, as without escrow tracking.
785        assert_eq!(max_out, BigUint::from(2_000_000_000_000u64));
786        assert_eq!(max_in, u256_to_biguint(wad(2_000_000)));
787        let (_, max_out) = state
788            .get_limits(usdc().address, usds().address)
789            .unwrap();
790        // Buffer-bounded, as without escrow tracking.
791        assert_eq!(max_out, u256_to_biguint(wad(1_000_000)));
792    }
793
794    #[test]
795    fn wrapper_zero_usds_escrow_zeroes_buy_gem_limits() {
796        // The pre-launch window: no USDS exists, so nothing can be burned.
797        let state = wrapper_state(JoinEscrows { dai: wad(3_000_000), usds: U256::ZERO });
798        let (max_in, max_out) = state
799            .get_limits(usds().address, usdc().address)
800            .unwrap();
801        assert_eq!(max_in, BigUint::ZERO);
802        assert_eq!(max_out, BigUint::ZERO);
803    }
804
805    #[test]
806    fn wrapper_swap_moves_escrows_in_new_state() {
807        let escrows = JoinEscrows { dai: wad(3_000_000), usds: wad(9_000_000) };
808        let state = wrapper_state(escrows);
809
810        // sellGem: the DAI payout is burned into freshly minted USDS.
811        let res = state
812            .get_amount_out(BigUint::from(1_000_000u64), &usdc(), &usds())
813            .unwrap();
814        let new_state = res
815            .new_state
816            .as_any()
817            .downcast_ref::<SkyState>()
818            .unwrap();
819        let new_escrows = new_state.escrows.unwrap();
820        assert_eq!(new_escrows.dai, escrows.dai - U256::from(WAD));
821        assert_eq!(new_escrows.usds, escrows.usds + U256::from(WAD));
822
823        // buyGem: the full USDS input is burned back into DAI.
824        let res = state
825            .get_amount_out(BigUint::from(WAD), &usds(), &usdc())
826            .unwrap();
827        let new_state = res
828            .new_state
829            .as_any()
830            .downcast_ref::<SkyState>()
831            .unwrap();
832        let new_escrows = new_state.escrows.unwrap();
833        assert_eq!(new_escrows.usds, escrows.usds - U256::from(WAD));
834        assert_eq!(new_escrows.dai, escrows.dai + U256::from(WAD));
835    }
836
837    #[test]
838    fn delta_transition_updates_escrows() {
839        let mut state = wrapper_state(JoinEscrows { dai: wad(3_000_000), usds: wad(9_000_000) });
840        let delta = ProtocolStateDelta {
841            component_id: state.component_id.clone(),
842            updated_attributes: HashMap::from([
843                ("dai_escrow".to_string(), Bytes::from(wad(5).to_be_bytes_vec())),
844                ("usds_escrow".to_string(), Bytes::from(wad(6).to_be_bytes_vec())),
845            ]),
846            deleted_attributes: Default::default(),
847        };
848        let balances =
849            Balances { component_balances: HashMap::new(), account_balances: HashMap::new() };
850        state
851            .delta_transition(delta, &HashMap::new(), &balances)
852            .unwrap();
853        assert_eq!(state.escrows.unwrap(), JoinEscrows { dai: wad(5), usds: wad(6) });
854    }
855
856    /// Price of `price` DAI-wei per 1 USDC (1e6 raw).
857    fn usdc_dai_price(dai_wei: u128) -> Price {
858        Price::new(BigUint::from(dai_wei), BigUint::from(1_000_000u32))
859    }
860
861    #[test]
862    fn query_pool_swap_trade_limit_is_all_or_nothing() {
863        // tin = 0.1%: flat USDC -> DAI execution price of 0.999.
864        let state = psm_state(U256::from(WAD / 1000), U256::ZERO);
865
866        // A limit below the flat price buys the full capacity.
867        let swap = state
868            .query_pool_swap(&QueryPoolSwapParams::new(
869                usdc(),
870                dai(),
871                SwapConstraint::TradeLimitPrice {
872                    limit: usdc_dai_price(990_000_000_000_000_000), // 0.99
873                    tolerance: 0.0,
874                    min_amount_in: None,
875                    max_amount_in: None,
876                },
877            ))
878            .unwrap();
879        let (max_in, max_out) = state
880            .get_limits(usdc().address, dai().address)
881            .unwrap();
882        assert_eq!(swap.amount_in(), &max_in);
883        assert_eq!(swap.amount_out(), &max_out);
884
885        // A limit above the flat price cannot be met at any size.
886        let swap = state
887            .query_pool_swap(&QueryPoolSwapParams::new(
888                usdc(),
889                dai(),
890                SwapConstraint::TradeLimitPrice {
891                    limit: usdc_dai_price(999_500_000_000_000_000), // 0.9995
892                    tolerance: 0.0001,
893                    min_amount_in: None,
894                    max_amount_in: None,
895                },
896            ))
897            .unwrap();
898        assert_eq!(swap.amount_in(), &BigUint::from(0u8));
899        assert_eq!(swap.amount_out(), &BigUint::from(0u8));
900
901        // The limit is a hard floor: even a generous tolerance must not admit
902        // an execution price below it.
903        let swap = state
904            .query_pool_swap(&QueryPoolSwapParams::new(
905                usdc(),
906                dai(),
907                SwapConstraint::TradeLimitPrice {
908                    limit: usdc_dai_price(999_500_000_000_000_000), // 0.9995
909                    tolerance: 0.01,
910                    min_amount_in: None,
911                    max_amount_in: None,
912                },
913            ))
914            .unwrap();
915        assert_eq!(swap.amount_in(), &BigUint::from(0u8));
916        assert_eq!(swap.amount_out(), &BigUint::from(0u8));
917    }
918
919    #[test]
920    fn query_pool_swap_target_price_only_satisfiable_at_spot() {
921        // tout = 0.2%: spot buy price of USDC is 1.002 DAI.
922        let state = psm_state(U256::ZERO, U256::from(2 * (WAD / 1000)));
923
924        let swap = state
925            .query_pool_swap(&QueryPoolSwapParams::new(
926                usdc(),
927                dai(),
928                SwapConstraint::PoolTargetPrice {
929                    target: usdc_dai_price(1_002_000_000_000_000_000), // 1.002 == spot
930                    tolerance: 1e-9,
931                    min_amount_in: None,
932                    max_amount_in: None,
933                },
934            ))
935            .unwrap();
936        assert_eq!(swap.amount_in(), &BigUint::from(0u8));
937
938        let res = state.query_pool_swap(&QueryPoolSwapParams::new(
939            usdc(),
940            dai(),
941            SwapConstraint::PoolTargetPrice {
942                target: usdc_dai_price(1_010_000_000_000_000_000), // 1.01 != spot
943                tolerance: 1e-9,
944                min_amount_in: None,
945                max_amount_in: None,
946            },
947        ));
948        assert!(matches!(res, Err(SimulationError::InvalidInput(_, _))));
949    }
950
951    #[test]
952    fn delta_transition_updates_fees_and_balances() {
953        let mut state = psm_state(U256::ZERO, U256::ZERO);
954        let delta = ProtocolStateDelta {
955            component_id: state.component_id.clone(),
956            updated_attributes: HashMap::from([(
957                "tin".to_string(),
958                Bytes::from(U256::from(WAD / 100).to_be_bytes_vec()),
959            )]),
960            deleted_attributes: Default::default(),
961        };
962        let balances = Balances {
963            component_balances: HashMap::from([(
964                state.component_id.clone(),
965                HashMap::from([(dai().address, Bytes::from(wad(500_000).to_be_bytes_vec()))]),
966            )]),
967            account_balances: HashMap::new(),
968        };
969        state
970            .delta_transition(delta, &HashMap::new(), &balances)
971            .unwrap();
972        assert_eq!(state.tin, U256::from(WAD / 100));
973        assert_eq!(state.stable_balance, wad(500_000));
974    }
975
976    #[test]
977    fn invalid_pair_errors() {
978        let state = psm_state(U256::ZERO, U256::ZERO);
979        assert!(state
980            .get_amount_out(BigUint::from(1u64), &usds(), &dai())
981            .is_err());
982    }
983}