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    sqrt_price_x96_to_price, 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 LunarBaseState {
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 LunarBaseState {
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 LunarBaseState {
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 Ok(apply_fee_discount(
142                anchor_price(self.anchor_price_x96, base, quote),
143                self.fee_bid_x24,
144            ));
145        }
146        if token_in == self.token_y && token_out == self.token_x {
147            return Ok(apply_fee_discount(
148                1.0 / anchor_price(self.anchor_price_x96, quote, base),
149                self.fee_ask_x24,
150            ));
151        }
152        Err(SimulationError::InvalidInput("invalid LunarBase token pair".to_owned(), None))
153    }
154
155    fn get_amount_out(
156        &self,
157        amount_in: BigUint,
158        token_in: &Token,
159        token_out: &Token,
160    ) -> Result<GetAmountOutResult, SimulationError> {
161        if amount_in == BigUint::ZERO {
162            return Ok(GetAmountOutResult::new(
163                BigUint::ZERO,
164                BigUint::from(DEFAULT_GAS),
165                Box::new(self.clone()),
166            ));
167        }
168
169        let (amount_out, next_state) = self
170            .quote_exact_in(
171                address_from_bytes(token_in.address.as_ref())?,
172                address_from_bytes(token_out.address.as_ref())?,
173                biguint_to_u256(&amount_in)?,
174            )
175            .map_err(map_quote_error)?;
176
177        Ok(GetAmountOutResult::new(
178            u256_to_biguint(amount_out),
179            BigUint::from(DEFAULT_GAS),
180            Box::new(next_state),
181        ))
182    }
183
184    fn get_limits(
185        &self,
186        sell_token: Bytes,
187        buy_token: Bytes,
188    ) -> Result<(BigUint, BigUint), SimulationError> {
189        let sell = address_from_bytes(sell_token.as_ref())?;
190        let buy = address_from_bytes(buy_token.as_ref())?;
191        if sell == self.token_x && buy == self.token_y {
192            return quote_limit(self, sell, buy, soft_limit(self.reserve_x));
193        }
194        if sell == self.token_y && buy == self.token_x {
195            return quote_limit(self, sell, buy, soft_limit(self.reserve_y));
196        }
197        Err(SimulationError::InvalidInput("invalid LunarBase token pair".to_owned(), None))
198    }
199
200    fn delta_transition(
201        &mut self,
202        delta: ProtocolStateDelta,
203        _tokens: &HashMap<Bytes, Token>,
204        _balances: &Balances,
205    ) -> Result<(), TransitionError> {
206        if let Some(name) = delta.deleted_attributes.iter().next() {
207            return Err(TransitionError::DecodeError(format!(
208                "LunarBase does not support deleted attributes: {name}"
209            )));
210        }
211
212        let head_block = delta
213            .updated_attributes
214            .get("block_number")
215            .map(|value| u64::from(value.clone()));
216
217        let updated_attributes = delta
218            .updated_attributes
219            .into_iter()
220            .filter(|(key, _)| key != "block_number" && key != "block_timestamp")
221            .collect();
222        apply_delta(self, updated_attributes)
223            .map_err(|err| TransitionError::DecodeError(format!("{err:?}")))?;
224        if let Some(head_block) = head_block {
225            self.head_block = head_block;
226        }
227        Ok(())
228    }
229
230    fn query_pool_swap(&self, params: &QueryPoolSwapParams) -> Result<PoolSwap, SimulationError> {
231        crate::evm::query_pool_swap::query_pool_swap(self, params)
232    }
233
234    fn clone_box(&self) -> Box<dyn ProtocolSim> {
235        Box::new(self.clone())
236    }
237
238    fn as_any(&self) -> &dyn Any {
239        self
240    }
241
242    fn as_any_mut(&mut self) -> &mut dyn Any {
243        self
244    }
245
246    fn eq(&self, other: &dyn ProtocolSim) -> bool {
247        other.as_any().downcast_ref::<Self>() == Some(self)
248    }
249}
250
251#[derive(Clone, Debug, PartialEq, Eq)]
252enum QuoteError {
253    Paused,
254    Stale { block_number: u64, latest_update_block: u64, block_delay: u64 },
255    InvalidTokenPair,
256    Rejected,
257    ReserveOverflow,
258    ReserveUnderflow,
259}
260
261fn u256_to_u128(value: U256) -> Result<u128, QuoteError> {
262    if value.bit_len() > 128 {
263        return Err(QuoteError::ReserveOverflow);
264    }
265    let limbs = value.as_limbs();
266    Ok(((limbs[1] as u128) << 64) | limbs[0] as u128)
267}
268
269fn anchor_price(anchor_price_x96: u128, token_in: &Token, token_out: &Token) -> f64 {
270    let decimals_adjustment = 10f64.powi(token_in.decimals as i32 - token_out.decimals as i32);
271    sqrt_price_x96_to_price(anchor_price_x96) * decimals_adjustment
272}
273
274fn apply_fee_discount(price: f64, fee_x24: u32) -> f64 {
275    price * (1.0 - fee_x24 as f64 / (1u64 << 24) as f64)
276}
277
278// This soft bound mirrors Tycho's CPMM `get_limits` convention:
279// https://github.com/propeller-heads/tycho/blob/main/crates/tycho-simulation/src/evm/protocol/cpmm/protocol.rs/#L113
280//
281// CPMM uses `(sqrt(10) - 1) * reserve_in ~= 2.162 * reserve_in` as the
282// amount-in that would produce roughly 90% price impact in a fee-less
283// constant-product pool. LunarBase does not treat this as a protocol limit;
284// it is only the initial probe for `quote_limit`, which halves the amount
285// until the LunarBase quote math accepts it.
286fn soft_limit(reserve_in: u128) -> BigUint {
287    BigUint::from(reserve_in) * 2162u32 / 1000u32
288}
289
290fn quote_limit(
291    state: &LunarBaseState,
292    token_in: Address,
293    token_out: Address,
294    mut amount_in: BigUint,
295) -> Result<(BigUint, BigUint), SimulationError> {
296    if amount_in == BigUint::ZERO {
297        return Ok((BigUint::ZERO, BigUint::ZERO));
298    }
299
300    loop {
301        match state.quote_exact_in(token_in, token_out, biguint_to_u256(&amount_in)?) {
302            Ok((amount_out, _)) => return Ok((amount_in, u256_to_biguint(amount_out))),
303            Err(
304                QuoteError::Rejected | QuoteError::ReserveOverflow | QuoteError::ReserveUnderflow,
305            ) => {
306                amount_in >>= 1;
307                if amount_in == BigUint::ZERO {
308                    return Ok((BigUint::ZERO, BigUint::ZERO));
309                }
310            }
311            Err(err) => return Err(map_quote_error(err)),
312        }
313    }
314}
315
316fn address_from_bytes(value: &[u8]) -> Result<Address, SimulationError> {
317    value.try_into().map_err(|_| {
318        SimulationError::InvalidInput(
319            format!("expected 20-byte address, got {}", value.len()),
320            None,
321        )
322    })
323}
324
325fn biguint_to_u256(value: &BigUint) -> Result<U256, SimulationError> {
326    let bytes = value.to_bytes_be();
327    if bytes.len() > 32 {
328        return Err(SimulationError::InvalidInput("amount_in exceeds uint256".to_owned(), None));
329    }
330    Ok(U256::from_be_slice(&bytes))
331}
332
333fn u256_to_biguint(value: U256) -> BigUint {
334    BigUint::from_bytes_be(&value.to_be_bytes::<32>())
335}
336
337fn map_quote_error(err: QuoteError) -> SimulationError {
338    SimulationError::InvalidInput(format!("LunarBase quote rejected: {err:?}"), None)
339}
340
341#[cfg(test)]
342mod tests {
343    use tycho_common::models::Chain;
344
345    use super::*;
346
347    fn addr(byte: u8) -> [u8; 20] {
348        [byte; 20]
349    }
350
351    fn token(address: Address, symbol: &str, decimals: u32) -> Token {
352        Token::new(
353            &Bytes::from(address.to_vec()),
354            symbol,
355            decimals,
356            100,
357            &[Some(100_000)],
358            Chain::Base,
359            100,
360        )
361    }
362
363    fn state() -> LunarBaseState {
364        LunarBaseState {
365            pool: addr(9),
366            token_x: addr(1),
367            token_y: addr(2),
368            anchor_price_x96: 1u128 << 96,
369            fee_ask_x24: 0,
370            fee_bid_x24: 0,
371            latest_update_block: 100,
372            reserve_x: 1_000_000,
373            reserve_y: 1_000_000,
374            concentration_k: 0,
375            block_delay: 2,
376            paused: false,
377            head_block: 100,
378        }
379    }
380
381    #[test]
382    fn quotes_x_to_y_and_transitions_reserves() {
383        let state = state();
384        let (amount_out, next_state) = state
385            .quote_exact_in(state.token_x, state.token_y, U256::from(1_000u64))
386            .unwrap();
387
388        assert_eq!(amount_out, U256::from(1_000u64));
389        assert_eq!(next_state.reserve_x, 1_001_000);
390        assert_eq!(next_state.reserve_y, 999_000);
391        assert_eq!(next_state.anchor_price_x96, state.anchor_price_x96);
392        assert_eq!(next_state.head_block, state.head_block);
393    }
394
395    #[test]
396    fn zero_amount_out_returns_zero_without_transition() {
397        let state = state();
398        let token_x = token(state.token_x, "ETH", 18);
399        let token_y = token(state.token_y, "USDC", 6);
400
401        let quote = state
402            .get_amount_out(BigUint::ZERO, &token_x, &token_y)
403            .unwrap();
404        let next_state = quote
405            .new_state
406            .as_any()
407            .downcast_ref::<LunarBaseState>()
408            .unwrap();
409
410        assert_eq!(quote.amount, BigUint::ZERO);
411        assert_eq!(next_state, &state);
412    }
413
414    #[test]
415    fn spot_price_uses_anchor_price() {
416        let mut state = state();
417        state.anchor_price_x96 = lunarbase_pmm_math::price_to_sqrt_price_x96(2_000.0 / 1e12);
418        state.reserve_x = 1_000_000_000_000_000_000;
419        state.reserve_y = 1_000_000;
420
421        let token_x = token(state.token_x, "ETH", 18);
422        let token_y = token(state.token_y, "USDC", 6);
423
424        let price = state
425            .spot_price(&token_x, &token_y)
426            .unwrap();
427        let inverse = state
428            .spot_price(&token_y, &token_x)
429            .unwrap();
430
431        assert!((price - 2_000.0).abs() < 1e-6);
432        assert!((inverse - 0.0005).abs() < 1e-12);
433    }
434
435    #[test]
436    fn spot_price_applies_directional_base_fee() {
437        let mut state = state();
438        state.anchor_price_x96 = lunarbase_pmm_math::price_to_sqrt_price_x96(2_000.0 / 1e12);
439        state.fee_ask_x24 = (1u32 << 24) / 100;
440        state.fee_bid_x24 = (2 * (1u32 << 24)) / 100;
441
442        let token_x = token(state.token_x, "ETH", 18);
443        let token_y = token(state.token_y, "USDC", 6);
444
445        let price = state
446            .spot_price(&token_x, &token_y)
447            .unwrap();
448        let inverse = state
449            .spot_price(&token_y, &token_x)
450            .unwrap();
451
452        assert!((price - (2_000.0 * 0.98)).abs() < 1e-3);
453        assert!((inverse - (0.0005 * 0.99)).abs() < 1e-9);
454    }
455
456    #[test]
457    fn rejects_stale_state() {
458        let mut state = state();
459        state.head_block = 102;
460
461        let err = state
462            .quote_exact_in(state.token_x, state.token_y, U256::from(1_000u64))
463            .unwrap_err();
464
465        assert_eq!(
466            err,
467            QuoteError::Stale { block_number: 102, latest_update_block: 100, block_delay: 2 }
468        );
469    }
470}