Skip to main content

tycho_simulation/rfq/protocols/
component.rs

1use std::{
2    collections::{BTreeSet, HashMap},
3    time::SystemTime,
4};
5
6use alloy::primitives::utils::keccak256;
7use serde::{de::DeserializeOwned, Serialize};
8use tracing::{debug, warn};
9use tycho_client::feed::synchronizer::{ComponentWithState, Snapshot, StateSyncMessage};
10use tycho_common::{
11    models::{
12        protocol::{ProtocolComponent, ProtocolComponentState},
13        token::Token,
14        Chain,
15    },
16    Bytes,
17};
18
19use crate::{
20    protocol::errors::InvalidSnapshotError,
21    rfq::{
22        errors::RFQError,
23        models::{QuoteRule, TimestampHeader},
24    },
25};
26
27/// State attribute: the venue's books as JSON.
28pub const BOOKS_ATTRIBUTE: &str = "books";
29/// Static attribute: the directions the venue quotes, 40 bytes each (token in, then token out).
30pub const SWAP_DIRECTIONS_ATTRIBUTE: &str = "swap_directions";
31
32/// The id of a venue's component on `chain`. One per chain, the same across polls.
33pub fn component_id(protocol_system: &str, chain: Chain) -> String {
34    keccak256(format!("{protocol_system}_{}", chain.id()).as_bytes()).to_string()
35}
36
37pub fn encode_swap_directions(directions: &BTreeSet<(Bytes, Bytes)>) -> Bytes {
38    let mut encoded = Vec::with_capacity(directions.len() * 40);
39    for (token_in, token_out) in directions {
40        encoded.extend_from_slice(token_in);
41        encoded.extend_from_slice(token_out);
42    }
43    encoded.into()
44}
45
46pub fn decode_swap_directions(attribute: &[u8]) -> Result<Vec<(Bytes, Bytes)>, String> {
47    let (directions, rest) = attribute.as_chunks::<40>();
48    if !rest.is_empty() {
49        return Err(format!(
50            "Swap directions attribute holds {} bytes, not a multiple of 40",
51            attribute.len()
52        ));
53    }
54    Ok(directions
55        .iter()
56        .map(|direction| (Bytes::from(&direction[..20]), Bytes::from(&direction[20..])))
57        .collect())
58}
59
60/// The all-pairs component for one poll. Its `tokens` are every token a direction names.
61#[allow(clippy::too_many_arguments)]
62pub fn all_pairs_component<B: Serialize>(
63    protocol_system: &str,
64    protocol_type_name: &str,
65    chain: Chain,
66    swap_directions: &BTreeSet<(Bytes, Bytes)>,
67    books: &[B],
68    tvl: f64,
69    quote_rule: QuoteRule,
70) -> Result<ComponentWithState, RFQError> {
71    let mut tokens = BTreeSet::new();
72    for (token_in, token_out) in swap_directions {
73        tokens.insert(token_in.clone());
74        tokens.insert(token_out.clone());
75    }
76    let books = serde_json::to_vec(books)
77        .map_err(|e| RFQError::ParsingError(format!("Failed to serialize books: {e}")))?;
78    let id = component_id(protocol_system, chain);
79    let component = ProtocolComponent {
80        id: id.clone(),
81        protocol_system: protocol_system.to_string(),
82        protocol_type_name: protocol_type_name.to_string(),
83        chain,
84        tokens: tokens.into_iter().collect(),
85        contract_addresses: vec![],
86        static_attributes: HashMap::from([
87            (SWAP_DIRECTIONS_ATTRIBUTE.to_string(), encode_swap_directions(swap_directions)),
88            (QuoteRule::ATTRIBUTE.to_string(), quote_rule.as_str().as_bytes().into()),
89        ]),
90        ..Default::default()
91    };
92    let attributes = HashMap::from([(BOOKS_ATTRIBUTE.to_string(), books.into())]);
93    Ok(ComponentWithState {
94        state: ProtocolComponentState::new(&id, attributes, HashMap::new()),
95        component,
96        component_tvl: Some(tvl),
97        entrypoints: vec![],
98    })
99}
100
101/// What an all-pairs component carries.
102pub struct DecodedAllPairs<B> {
103    pub books: Vec<B>,
104    pub tokens: HashMap<Bytes, Token>,
105    /// `None` when the component carries no `quote_rule` attribute; the builder default applies.
106    pub quote_rule: Option<QuoteRule>,
107}
108
109/// Decodes an all-pairs component's tokens, books and quote rule.
110///
111/// A missing `books` attribute is a venue with no books. Fails on a per-pair component, which has
112/// no `swap_directions` attribute, and on a `books` attribute that is not a JSON list. Drops a
113/// token missing from `all_tokens` and a book that fails to parse: a failed component would stay
114/// failed, as its id never changes.
115pub fn decode_all_pairs_component<B: DeserializeOwned>(
116    snapshot: &ComponentWithState,
117    all_tokens: &HashMap<Bytes, Token>,
118) -> Result<DecodedAllPairs<B>, InvalidSnapshotError> {
119    let component = &snapshot.component;
120    if !component
121        .static_attributes
122        .contains_key(SWAP_DIRECTIONS_ATTRIBUTE)
123    {
124        return Err(InvalidSnapshotError::MissingAttribute(format!(
125            "Component {} of {} has no {SWAP_DIRECTIONS_ATTRIBUTE} attribute, so its client \
126             streams per pair. Build the client with ComponentLayout::AllPairs, or register the \
127             per-pair state for it.",
128            component.id, component.protocol_system
129        )));
130    }
131    let mut tokens = HashMap::new();
132    for address in &component.tokens {
133        let Some(token) = all_tokens.get(address) else {
134            debug!(
135                "Component {} of {} names token {address}, which the decoder does not know. \
136                 Dropping the token and the books that name it.",
137                component.id, component.protocol_system
138            );
139            continue;
140        };
141        tokens.insert(address.clone(), token.clone());
142    }
143    let books = match snapshot
144        .state
145        .attributes
146        .get(BOOKS_ATTRIBUTE)
147    {
148        Some(books) => decode_books(books, component)?,
149        None => Vec::new(),
150    };
151    let quote_rule = QuoteRule::from_attributes(&component.static_attributes)
152        .map_err(InvalidSnapshotError::ValueError)?;
153    Ok(DecodedAllPairs { books, tokens, quote_rule })
154}
155
156fn decode_books<B: DeserializeOwned>(
157    books: &[u8],
158    component: &ProtocolComponent,
159) -> Result<Vec<B>, InvalidSnapshotError> {
160    let books: Vec<serde_json::Value> = serde_json::from_slice(books)
161        .map_err(|e| InvalidSnapshotError::ValueError(format!("Invalid books JSON: {e}")))?;
162    let mut decoded = Vec::with_capacity(books.len());
163    for (index, book) in books.into_iter().enumerate() {
164        match serde_json::from_value(book) {
165            Ok(book) => decoded.push(book),
166            Err(e) => warn!(
167                "Component {} of {} has an invalid book at index {index}: {e}. Skipping it.",
168                component.id, component.protocol_system
169            ),
170        }
171    }
172    Ok(decoded)
173}
174
175/// Seconds since the UNIX epoch.
176pub fn unix_timestamp() -> Result<u64, RFQError> {
177    SystemTime::now()
178        .duration_since(SystemTime::UNIX_EPOCH)
179        .map(|elapsed| elapsed.as_secs())
180        .map_err(|_| RFQError::ParsingError("SystemTime before UNIX EPOCH!".into()))
181}
182
183/// The stream message for one poll: every component in `components`, and the removal of every
184/// component the stream emitted before that `components` lacks.
185pub fn poll_message(
186    current: &mut HashMap<String, ComponentWithState>,
187    components: HashMap<String, ComponentWithState>,
188    timestamp: u64,
189) -> StateSyncMessage<TimestampHeader> {
190    let mut removed_components = HashMap::new();
191    for (id, component) in current.iter() {
192        if !components.contains_key(id) {
193            removed_components.insert(id.clone(), component.component.clone());
194        }
195    }
196    *current = components.clone();
197    sync_message(components, removed_components, timestamp)
198}
199
200fn sync_message(
201    states: HashMap<String, ComponentWithState>,
202    removed_components: HashMap<String, ProtocolComponent>,
203    timestamp: u64,
204) -> StateSyncMessage<TimestampHeader> {
205    StateSyncMessage {
206        header: TimestampHeader { timestamp },
207        snapshots: Snapshot { states, vm_storage: HashMap::new() },
208        deltas: None,
209        removed_components,
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use crate::rfq::protocols::test_utils::{all_pairs_snapshot, usdc, weth};
217
218    #[test]
219    fn swap_directions_round_trip() {
220        let directions =
221            BTreeSet::from([(weth().address, usdc().address), (usdc().address, weth().address)]);
222        let encoded = encode_swap_directions(&directions);
223        assert_eq!(encoded.len(), 80);
224        let decoded: BTreeSet<_> = decode_swap_directions(&encoded)
225            .unwrap()
226            .into_iter()
227            .collect();
228        assert_eq!(decoded, directions);
229    }
230
231    #[test]
232    fn swap_directions_length_not_a_multiple_of_40() {
233        let mut attribute =
234            encode_swap_directions(&BTreeSet::from([(weth().address, usdc().address)])).to_vec();
235        attribute.push(0);
236        let result = decode_swap_directions(&attribute);
237        assert!(matches!(result, Err(message) if message.contains("not a multiple of 40")));
238    }
239
240    #[test]
241    fn component_tokens_are_the_directions_tokens() {
242        let directions = BTreeSet::from([(weth().address, usdc().address)]);
243        let component = all_pairs_component(
244            "rfq:test",
245            "test_pool",
246            Chain::Ethereum,
247            &directions,
248            &["book"],
249            100.0,
250            QuoteRule::OncePerVenue,
251        )
252        .unwrap();
253
254        let mut expected_tokens = vec![weth().address, usdc().address];
255        expected_tokens.sort();
256        assert_eq!(component.component.tokens, expected_tokens);
257        assert_eq!(component.component.id, component_id("rfq:test", Chain::Ethereum));
258        assert_eq!(component.state.component_id, component.component.id);
259        assert_eq!(component.component_tvl, Some(100.0));
260        assert_eq!(
261            component.component.static_attributes[QuoteRule::ATTRIBUTE].as_ref(),
262            b"once_per_venue"
263        );
264        assert_eq!(
265            component.component.static_attributes[SWAP_DIRECTIONS_ATTRIBUTE],
266            encode_swap_directions(&directions)
267        );
268        let books: Vec<String> =
269            serde_json::from_slice(&component.state.attributes[BOOKS_ATTRIBUTE]).unwrap();
270        assert_eq!(books, ["book"]);
271    }
272
273    #[test]
274    fn decode_all_pairs_component_skips_invalid_book() {
275        let (snapshot, tokens) =
276            all_pairs_snapshot("rfq:test", &[weth(), usdc()], &serde_json::json!(["a", 7, "b"]));
277        let decoded = decode_all_pairs_component::<String>(&snapshot, &tokens).unwrap();
278        assert_eq!(decoded.books, ["a", "b"]);
279    }
280
281    #[test]
282    fn decode_all_pairs_component_invalid_books_json() {
283        let (mut snapshot, tokens) = all_pairs_snapshot("rfq:test", &[weth(), usdc()], &["book"]);
284        snapshot
285            .state
286            .attributes
287            .insert(BOOKS_ATTRIBUTE.to_string(), b"invalid json".into());
288        let result = decode_all_pairs_component::<String>(&snapshot, &tokens);
289        assert!(
290            matches!(result, Err(InvalidSnapshotError::ValueError(msg)) if msg.contains("Invalid books JSON"))
291        );
292    }
293
294    #[test]
295    fn decode_all_pairs_component_rejects_per_pair_component() {
296        let (mut snapshot, tokens) = all_pairs_snapshot("rfq:test", &[weth(), usdc()], &["book"]);
297        snapshot
298            .component
299            .static_attributes
300            .remove(SWAP_DIRECTIONS_ATTRIBUTE);
301        let result = decode_all_pairs_component::<String>(&snapshot, &tokens);
302        assert!(
303            matches!(result, Err(InvalidSnapshotError::MissingAttribute(msg)) if msg.contains("AllPairs"))
304        );
305    }
306
307    #[test]
308    fn poll_message_removes_dropped_components() {
309        let pair = |id: &str| {
310            let mut component = all_pairs_component(
311                "rfq:test",
312                "test_pool",
313                Chain::Ethereum,
314                &BTreeSet::from([(weth().address, usdc().address)]),
315                &["book"],
316                100.0,
317                QuoteRule::OncePerVenue,
318            )
319            .unwrap();
320            component.component.id = id.to_string();
321            (id.to_string(), component)
322        };
323        let mut current = HashMap::new();
324
325        let first = poll_message(&mut current, HashMap::from([pair("a"), pair("b")]), 1);
326        assert_eq!(first.snapshots.states.len(), 2);
327        assert!(first.removed_components.is_empty());
328
329        let second = poll_message(&mut current, HashMap::from([pair("b")]), 2);
330        assert_eq!(
331            second
332                .snapshots
333                .states
334                .keys()
335                .collect::<Vec<_>>(),
336            ["b"]
337        );
338        assert_eq!(
339            second
340                .removed_components
341                .keys()
342                .collect::<Vec<_>>(),
343            ["a"]
344        );
345    }
346}