Skip to main content

tycho_simulation/evm/protocol/lunarbase/
state.rs

1use std::{any::Any, collections::HashMap};
2
3use lunarbase_pmm_math::{
4    curve_pmm::{quote_x_to_y_with_multiplier, quote_y_to_x_with_multiplier},
5    PoolParams, U256,
6};
7use num_bigint::BigUint;
8use tycho_common::{
9    dto::ProtocolStateDelta,
10    models::token::Token,
11    simulation::{
12        errors::{SimulationError, TransitionError},
13        protocol_sim::{Balances, GetAmountOutResult, PoolSwap, ProtocolSim, QueryPoolSwapParams},
14    },
15    Bytes,
16};
17
18use super::decoder::apply_delta;
19
20pub type Address = [u8; 20];
21const DEFAULT_GAS: u64 = 180_000;
22
23#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
24pub struct LunarBaseTychoState {
25    pub pool: Address,
26    pub token_x: Address,
27    pub token_y: Address,
28    pub anchor_price_x96: u128,
29    pub fee_ask_x24: u32,
30    pub fee_bid_x24: u32,
31    pub latest_update_block: u64,
32    pub reserve_x: u128,
33    pub reserve_y: u128,
34    pub concentration_k: u32,
35    pub block_delay: u64,
36    pub paused: bool,
37    pub head_block: u64,
38}
39
40impl LunarBaseTychoState {
41    pub fn pool_params(&self) -> PoolParams {
42        PoolParams {
43            sqrt_price_x96: self.anchor_price_x96,
44            fee_ask_x24: self.fee_ask_x24,
45            fee_bid_x24: self.fee_bid_x24,
46            reserve_x: self.reserve_x,
47            reserve_y: self.reserve_y,
48            concentration_k: self.concentration_k,
49        }
50    }
51
52    pub fn is_fresh(&self) -> bool {
53        self.head_block <
54            self.latest_update_block
55                .saturating_add(self.block_delay)
56    }
57
58    fn quote_exact_in(
59        &self,
60        token_in: Address,
61        token_out: Address,
62        amount_in: U256,
63    ) -> Result<(U256, Self), QuoteError> {
64        if self.paused {
65            return Err(QuoteError::Paused);
66        }
67
68        if !self.is_fresh() {
69            return Err(QuoteError::Stale {
70                block_number: self.head_block,
71                latest_update_block: self.latest_update_block,
72                block_delay: self.block_delay,
73            });
74        }
75
76        let params = self.pool_params();
77        if token_in == self.token_x && token_out == self.token_y {
78            let math_result = quote_x_to_y_with_multiplier(&params, amount_in, U256::from(1u64));
79            if math_result.amount_out.is_zero() {
80                return Err(QuoteError::Rejected);
81            }
82
83            let input = u256_to_u128(amount_in)?;
84            let gross_output = u256_to_u128(
85                math_result
86                    .amount_out
87                    .checked_add(math_result.fee)
88                    .ok_or(QuoteError::ReserveOverflow)?,
89            )?;
90            let mut next = self.clone();
91            next.reserve_x = next
92                .reserve_x
93                .checked_add(input)
94                .ok_or(QuoteError::ReserveOverflow)?;
95            next.reserve_y = next
96                .reserve_y
97                .checked_sub(gross_output)
98                .ok_or(QuoteError::ReserveUnderflow)?;
99            return Ok((math_result.amount_out, next));
100        }
101
102        if token_in == self.token_y && token_out == self.token_x {
103            let math_result = quote_y_to_x_with_multiplier(&params, amount_in, U256::from(1u64));
104            if math_result.amount_out.is_zero() {
105                return Err(QuoteError::Rejected);
106            }
107
108            let input = u256_to_u128(amount_in)?;
109            let gross_output = u256_to_u128(
110                math_result
111                    .amount_out
112                    .checked_add(math_result.fee)
113                    .ok_or(QuoteError::ReserveOverflow)?,
114            )?;
115            let mut next = self.clone();
116            next.reserve_y = next
117                .reserve_y
118                .checked_add(input)
119                .ok_or(QuoteError::ReserveOverflow)?;
120            next.reserve_x = next
121                .reserve_x
122                .checked_sub(gross_output)
123                .ok_or(QuoteError::ReserveUnderflow)?;
124            return Ok((math_result.amount_out, next));
125        }
126
127        Err(QuoteError::InvalidTokenPair)
128    }
129}
130
131#[typetag::serde]
132impl ProtocolSim for LunarBaseTychoState {
133    fn fee(&self) -> f64 {
134        0.0
135    }
136
137    fn spot_price(&self, base: &Token, quote: &Token) -> Result<f64, SimulationError> {
138        let token_in = address_from_bytes(base.address.as_ref())?;
139        let token_out = address_from_bytes(quote.address.as_ref())?;
140        if token_in == self.token_x && token_out == self.token_y {
141            return spot_from_reserves(self.reserve_x, self.reserve_y, base, quote);
142        }
143        if token_in == self.token_y && token_out == self.token_x {
144            return spot_from_reserves(self.reserve_y, self.reserve_x, base, quote);
145        }
146        Err(SimulationError::InvalidInput("invalid LunarBase token pair".to_owned(), None))
147    }
148
149    fn get_amount_out(
150        &self,
151        amount_in: BigUint,
152        token_in: &Token,
153        token_out: &Token,
154    ) -> Result<GetAmountOutResult, SimulationError> {
155        let (amount_out, next_state) = self
156            .quote_exact_in(
157                address_from_bytes(token_in.address.as_ref())?,
158                address_from_bytes(token_out.address.as_ref())?,
159                biguint_to_u256(&amount_in)?,
160            )
161            .map_err(map_quote_error)?;
162
163        Ok(GetAmountOutResult::new(
164            u256_to_biguint(amount_out),
165            BigUint::from(DEFAULT_GAS),
166            Box::new(next_state),
167        ))
168    }
169
170    fn get_limits(
171        &self,
172        sell_token: Bytes,
173        buy_token: Bytes,
174    ) -> Result<(BigUint, BigUint), SimulationError> {
175        let sell = address_from_bytes(sell_token.as_ref())?;
176        let buy = address_from_bytes(buy_token.as_ref())?;
177        if sell == self.token_x && buy == self.token_y {
178            return quote_limit(self, sell, buy, soft_limit(self.reserve_x));
179        }
180        if sell == self.token_y && buy == self.token_x {
181            return quote_limit(self, sell, buy, soft_limit(self.reserve_y));
182        }
183        Err(SimulationError::InvalidInput("invalid LunarBase token pair".to_owned(), None))
184    }
185
186    fn delta_transition(
187        &mut self,
188        delta: ProtocolStateDelta,
189        _tokens: &HashMap<Bytes, Token>,
190        _balances: &Balances,
191    ) -> Result<(), TransitionError> {
192        if let Some(name) = delta.deleted_attributes.iter().next() {
193            return Err(TransitionError::DecodeError(format!(
194                "LunarBase does not support deleted attributes: {name}"
195            )));
196        }
197
198        let head_block = delta
199            .updated_attributes
200            .get("block_number")
201            .map(|value| u64::from(value.clone()));
202
203        let updated_attributes = delta
204            .updated_attributes
205            .into_iter()
206            .filter(|(key, _)| key != "block_number" && key != "block_timestamp")
207            .collect();
208        apply_delta(self, updated_attributes)
209            .map_err(|err| TransitionError::DecodeError(format!("{err:?}")))?;
210        if let Some(head_block) = head_block {
211            self.head_block = head_block;
212        }
213        Ok(())
214    }
215
216    fn query_pool_swap(&self, params: &QueryPoolSwapParams) -> Result<PoolSwap, SimulationError> {
217        crate::evm::query_pool_swap::query_pool_swap(self, params)
218    }
219
220    fn clone_box(&self) -> Box<dyn ProtocolSim> {
221        Box::new(self.clone())
222    }
223
224    fn as_any(&self) -> &dyn Any {
225        self
226    }
227
228    fn as_any_mut(&mut self) -> &mut dyn Any {
229        self
230    }
231
232    fn eq(&self, other: &dyn ProtocolSim) -> bool {
233        other.as_any().downcast_ref::<Self>() == Some(self)
234    }
235}
236
237#[derive(Clone, Debug, PartialEq, Eq)]
238enum QuoteError {
239    Paused,
240    Stale { block_number: u64, latest_update_block: u64, block_delay: u64 },
241    InvalidTokenPair,
242    Rejected,
243    ReserveOverflow,
244    ReserveUnderflow,
245}
246
247fn u256_to_u128(value: U256) -> Result<u128, QuoteError> {
248    if value.bit_len() > 128 {
249        return Err(QuoteError::ReserveOverflow);
250    }
251    let limbs = value.as_limbs();
252    Ok(((limbs[1] as u128) << 64) | limbs[0] as u128)
253}
254
255fn spot_from_reserves(
256    reserve_in: u128,
257    reserve_out: u128,
258    token_in: &Token,
259    token_out: &Token,
260) -> Result<f64, SimulationError> {
261    if reserve_in == 0 || reserve_out == 0 {
262        return Err(SimulationError::RecoverableError("zero LunarBase reserve".to_owned()));
263    }
264    let decimals_adjustment = 10f64.powi(token_in.decimals as i32 - token_out.decimals as i32);
265    Ok((reserve_out as f64 / reserve_in as f64) * decimals_adjustment)
266}
267
268// This soft bound mirrors Tycho's CPMM `get_limits` convention:
269// https://github.com/propeller-heads/tycho/blob/main/crates/tycho-simulation/src/evm/protocol/cpmm/protocol.rs/#L113
270//
271// CPMM uses `(sqrt(10) - 1) * reserve_in ~= 2.162 * reserve_in` as the
272// amount-in that would produce roughly 90% price impact in a fee-less
273// constant-product pool. LunarBase does not treat this as a protocol limit;
274// it is only the initial probe for `quote_limit`, which halves the amount
275// until the LunarBase quote math accepts it.
276fn soft_limit(reserve_in: u128) -> BigUint {
277    BigUint::from(reserve_in) * 2162u32 / 1000u32
278}
279
280fn quote_limit(
281    state: &LunarBaseTychoState,
282    token_in: Address,
283    token_out: Address,
284    mut amount_in: BigUint,
285) -> Result<(BigUint, BigUint), SimulationError> {
286    if amount_in == BigUint::ZERO {
287        return Ok((BigUint::ZERO, BigUint::ZERO));
288    }
289
290    loop {
291        match state.quote_exact_in(token_in, token_out, biguint_to_u256(&amount_in)?) {
292            Ok((amount_out, _)) => return Ok((amount_in, u256_to_biguint(amount_out))),
293            Err(
294                QuoteError::Rejected | QuoteError::ReserveOverflow | QuoteError::ReserveUnderflow,
295            ) => {
296                amount_in >>= 1;
297                if amount_in == BigUint::ZERO {
298                    return Ok((BigUint::ZERO, BigUint::ZERO));
299                }
300            }
301            Err(err) => return Err(map_quote_error(err)),
302        }
303    }
304}
305
306fn address_from_bytes(value: &[u8]) -> Result<Address, SimulationError> {
307    value.try_into().map_err(|_| {
308        SimulationError::InvalidInput(
309            format!("expected 20-byte address, got {}", value.len()),
310            None,
311        )
312    })
313}
314
315fn biguint_to_u256(value: &BigUint) -> Result<U256, SimulationError> {
316    let bytes = value.to_bytes_be();
317    if bytes.len() > 32 {
318        return Err(SimulationError::InvalidInput("amount_in exceeds uint256".to_owned(), None));
319    }
320    Ok(U256::from_be_slice(&bytes))
321}
322
323fn u256_to_biguint(value: U256) -> BigUint {
324    BigUint::from_bytes_be(&value.to_be_bytes::<32>())
325}
326
327fn map_quote_error(err: QuoteError) -> SimulationError {
328    SimulationError::InvalidInput(format!("LunarBase quote rejected: {err:?}"), None)
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    fn addr(byte: u8) -> [u8; 20] {
336        [byte; 20]
337    }
338
339    fn state() -> LunarBaseTychoState {
340        LunarBaseTychoState {
341            pool: addr(9),
342            token_x: addr(1),
343            token_y: addr(2),
344            anchor_price_x96: 1u128 << 96,
345            fee_ask_x24: 0,
346            fee_bid_x24: 0,
347            latest_update_block: 100,
348            reserve_x: 1_000_000,
349            reserve_y: 1_000_000,
350            concentration_k: 0,
351            block_delay: 2,
352            paused: false,
353            head_block: 100,
354        }
355    }
356
357    #[test]
358    fn quotes_x_to_y_and_transitions_reserves() {
359        let state = state();
360        let (amount_out, next_state) = state
361            .quote_exact_in(state.token_x, state.token_y, U256::from(1_000u64))
362            .unwrap();
363
364        assert_eq!(amount_out, U256::from(1_000u64));
365        assert_eq!(next_state.reserve_x, 1_001_000);
366        assert_eq!(next_state.reserve_y, 999_000);
367        assert_eq!(next_state.anchor_price_x96, state.anchor_price_x96);
368        assert_eq!(next_state.head_block, state.head_block);
369    }
370
371    #[test]
372    fn rejects_stale_state() {
373        let mut state = state();
374        state.head_block = 102;
375
376        let err = state
377            .quote_exact_in(state.token_x, state.token_y, U256::from(1_000u64))
378            .unwrap_err();
379
380        assert_eq!(
381            err,
382            QuoteError::Stale { block_number: 102, latest_update_block: 100, block_delay: 2 }
383        );
384    }
385}