Skip to main content

tycho_simulation/evm/protocol/ramses_v3/
decoder.rs

1use std::collections::HashMap;
2
3use alloy::primitives::U256;
4use itertools::Itertools;
5use num_traits::Zero;
6use tycho_client::feed::{synchronizer::ComponentWithState, BlockHeader};
7use tycho_common::{models::token::Token, Bytes};
8
9use super::state::RamsesV3State;
10use crate::{
11    evm::protocol::utils::uniswap::tick_list::TickInfo,
12    protocol::{
13        errors::InvalidSnapshotError,
14        models::{DecoderContext, TryFromWithBlock},
15    },
16};
17
18impl TryFromWithBlock<ComponentWithState, BlockHeader> for RamsesV3State {
19    type Error = InvalidSnapshotError;
20
21    /// Decodes a `ComponentWithState` into a `RamsesV3State`. Errors with an `InvalidSnapshotError`
22    /// if the snapshot is missing any required attributes.
23    ///
24    /// Unlike Uniswap V3, `fee` is read from the (mutable) state attributes rather than the static
25    /// attributes, and `tick_spacing` is read directly from the static attributes instead of being
26    /// derived from the fee tier.
27    async fn try_from_with_header(
28        snapshot: ComponentWithState,
29        _block: BlockHeader,
30        _account_balances: &HashMap<Bytes, HashMap<Bytes, Bytes>>,
31        _all_tokens: &HashMap<Bytes, Token>,
32        _decoder_context: &DecoderContext,
33    ) -> Result<Self, Self::Error> {
34        let static_attrs = snapshot.component.static_attributes;
35        let state_attrs = snapshot.state.attributes;
36
37        // The fee is governance-mutable, so it lives in the (dynamic) state attributes.
38        let fee = u32::from(attribute(&state_attrs, "fee")?.clone());
39        let liquidity = u128::from(attribute(&state_attrs, "liquidity")?.clone());
40        let sqrt_price = U256::from_be_slice(attribute(&state_attrs, "sqrt_price_x96")?);
41        let tick_spacing = u16::from(attribute(&static_attrs, "tick_spacing")?.clone());
42        let tick = i32::from(attribute(&state_attrs, "tick")?.clone());
43
44        // An empty tick list is valid: a pool that is not yet initialized (or has no liquidity)
45        // simply decodes to a zero-liquidity state that gracefully returns "No liquidity" on quote.
46        let ticks = state_attrs
47            .iter()
48            .filter_map(|(key, value)| {
49                let tick_index = match key
50                    .strip_prefix("ticks/")?
51                    .parse::<i32>()
52                {
53                    Ok(tick_index) => tick_index,
54                    Err(err) => return Some(Err(InvalidSnapshotError::ValueError(err.to_string()))),
55                };
56
57                let net_liquidity = i128::from(value.clone());
58                if net_liquidity.is_zero() {
59                    return None;
60                }
61
62                Some(
63                    TickInfo::new(tick_index, net_liquidity)
64                        .map_err(|err| InvalidSnapshotError::ValueError(err.to_string())),
65                )
66            })
67            .collect::<Result<Vec<_>, _>>()?
68            .into_iter()
69            .sorted_unstable_by_key(|tick| tick.index)
70            .collect();
71
72        RamsesV3State::new(liquidity, sqrt_price, fee, tick_spacing, tick, ticks)
73            .map_err(|err| InvalidSnapshotError::ValueError(err.to_string()))
74    }
75}
76
77fn attribute<'a>(
78    map: &'a HashMap<String, Bytes>,
79    key: &str,
80) -> Result<&'a Bytes, InvalidSnapshotError> {
81    map.get(key)
82        .ok_or_else(|| InvalidSnapshotError::MissingAttribute(key.to_string()))
83}
84
85#[cfg(test)]
86mod tests {
87    use std::str::FromStr;
88
89    use chrono::DateTime;
90    use rstest::rstest;
91    use tycho_common::models::{
92        protocol::{ProtocolComponent, ProtocolComponentState},
93        Chain, ChangeType,
94    };
95
96    use super::*;
97    use crate::evm::protocol::test_utils::try_decode_snapshot_with_defaults;
98
99    fn ramses_component() -> ProtocolComponent {
100        let creation_time = DateTime::from_timestamp(1622526000, 0)
101            .unwrap()
102            .naive_utc();
103
104        // tick_spacing is the only V3-style static attribute; fee is dynamic.
105        let mut static_attributes: HashMap<String, Bytes> = HashMap::new();
106        static_attributes
107            .insert("tick_spacing".to_string(), Bytes::from(60_u16.to_be_bytes().to_vec()));
108
109        ProtocolComponent {
110            id: "State1".to_string(),
111            protocol_system: "ramses_v3".to_string(),
112            protocol_type_name: "ramses_v3_pool".to_string(),
113            chain: Chain::Polygon,
114            tokens: Vec::new(),
115            contract_addresses: Vec::new(),
116            static_attributes,
117            change: ChangeType::Creation,
118            creation_tx: Bytes::from_str("0x0000").unwrap(),
119            created_at: creation_time,
120        }
121    }
122
123    fn ramses_attributes() -> HashMap<String, Bytes> {
124        vec![
125            ("liquidity".to_string(), Bytes::from(100_u64.to_be_bytes().to_vec())),
126            ("sqrt_price_x96".to_string(), Bytes::from(200_u64.to_be_bytes().to_vec())),
127            ("tick".to_string(), Bytes::from(300_i32.to_be_bytes().to_vec())),
128            ("fee".to_string(), Bytes::from(3000_u32.to_be_bytes().to_vec())),
129            ("ticks/60".to_string(), Bytes::from(400_i128.to_be_bytes().to_vec())),
130        ]
131        .into_iter()
132        .collect::<HashMap<String, Bytes>>()
133    }
134
135    #[tokio::test]
136    async fn test_ramses_try_from() {
137        let snapshot = ComponentWithState {
138            state: ProtocolComponentState {
139                component_id: "State1".to_owned(),
140                attributes: ramses_attributes(),
141                balances: HashMap::new(),
142            },
143            component: ramses_component(),
144            component_tvl: None,
145            entrypoints: Vec::new(),
146        };
147
148        assert_eq!(
149            RamsesV3State::new(
150                100,
151                U256::from(200),
152                3000,
153                60,
154                300,
155                vec![TickInfo::new(60, 400).unwrap()],
156            )
157            .unwrap(),
158            try_decode_snapshot_with_defaults(snapshot)
159                .await
160                .unwrap(),
161        );
162    }
163
164    #[tokio::test]
165    #[rstest]
166    #[case::missing_liquidity("liquidity")]
167    #[case::missing_sqrt_price("sqrt_price_x96")]
168    #[case::missing_tick("tick")]
169    #[case::missing_fee("fee")]
170    #[case::missing_tick_spacing("tick_spacing")]
171    async fn test_ramses_try_from_invalid(#[case] missing_attribute: String) {
172        let mut attributes = ramses_attributes();
173        attributes.remove(&missing_attribute);
174
175        let mut component = ramses_component();
176        component
177            .static_attributes
178            .remove(&missing_attribute);
179
180        let snapshot = ComponentWithState {
181            state: ProtocolComponentState {
182                component_id: "State1".to_owned(),
183                attributes,
184                balances: HashMap::new(),
185            },
186            component,
187            component_tvl: None,
188            entrypoints: Vec::new(),
189        };
190
191        let result = try_decode_snapshot_with_defaults::<RamsesV3State>(snapshot).await;
192
193        assert!(matches!(
194            result.unwrap_err(),
195            InvalidSnapshotError::MissingAttribute(attr) if attr == missing_attribute
196        ));
197    }
198
199    #[tokio::test]
200    async fn test_ramses_try_from_uninitialized() {
201        // A created-but-not-initialized pool has no ticks and zero price/liquidity. It must still
202        // decode (into a zero-liquidity state) rather than error.
203        let mut attributes = ramses_attributes();
204        attributes.remove("ticks/60");
205        attributes.insert("liquidity".to_string(), Bytes::from(vec![0u8]));
206        attributes.insert("sqrt_price_x96".to_string(), Bytes::from(vec![0u8]));
207
208        let snapshot = ComponentWithState {
209            state: ProtocolComponentState {
210                component_id: "State1".to_owned(),
211                attributes,
212                balances: HashMap::new(),
213            },
214            component: ramses_component(),
215            component_tvl: None,
216            entrypoints: Vec::new(),
217        };
218
219        let result = try_decode_snapshot_with_defaults::<RamsesV3State>(snapshot).await;
220
221        assert!(result.is_ok());
222    }
223}