metrom_resolver_client/
lib.rs

1use std::{
2    collections::{HashMap, HashSet},
3    fmt::Debug,
4    num::ParseIntError,
5    time::Duration,
6};
7
8use alloy::primitives::Address;
9use chrono::NaiveDateTime;
10use metrom_commons::{
11    clients::get_retryable_http_client,
12    types::{amm::Amm, amm_pool_id::AmmPoolId, LightBlock},
13};
14use serde::{de::DeserializeOwned, Deserialize, Serialize};
15use thiserror::Error;
16
17// reexport this stuff
18pub use metrom_resolver_commons::{AmmPool, LiquityV2Collateral, Token, TokenWithAddress};
19
20#[derive(Serialize, Clone, Debug)]
21#[serde(rename_all = "camelCase")]
22pub struct ResolveTokensQuery {
23    pub with_usd_prices: bool,
24}
25
26#[derive(Serialize, Clone, Debug)]
27#[serde(rename_all = "camelCase")]
28pub struct ResolveAmmPoolsQuery {
29    pub with_usd_tvls: bool,
30}
31
32#[derive(Serialize, Deserialize, Clone, Debug)]
33#[serde(rename_all = "camelCase")]
34pub struct PricedToken {
35    pub decimals: i32,
36    pub symbol: String,
37    pub name: String,
38    pub usd_price: f64,
39}
40
41#[derive(Serialize, Deserialize, Clone, Debug)]
42#[serde(rename_all = "camelCase")]
43pub struct AmmPoolWithTvl {
44    pub dex: String,
45    pub amm: Amm,
46    pub tokens: Vec<TokenWithAddress>,
47    pub usd_tvl: f64,
48    pub fee: Option<f64>,
49}
50
51#[derive(Error, Debug)]
52pub enum ResolveError {
53    #[error("An error occurred while serializing the query string")]
54    SerializeQuery(#[source] serde_qs::Error),
55    #[error("An error occurred sending the resolve tokens request")]
56    Network(#[source] reqwest_middleware::Error),
57    #[error("Could not deserialize the given response")]
58    Deserialize(#[source] reqwest::Error),
59    #[error("Could not decode the given response")]
60    Decode(#[source] ParseIntError),
61    #[error("Could not find any searched item in response")]
62    Missing,
63}
64
65pub struct ResolverClient {
66    base_url: String,
67    client: reqwest_middleware::ClientWithMiddleware,
68}
69
70impl ResolverClient {
71    pub fn new(url: String, timeout: Duration) -> Result<Self, reqwest::Error> {
72        Ok(Self {
73            base_url: format!("{url}/v1/resolvers"),
74            client: get_retryable_http_client(timeout)?,
75        })
76    }
77
78    async fn resolve_multiple<I: Serialize + Clone, O: DeserializeOwned, Q: Serialize>(
79        &self,
80        resource: &str,
81        selector: HashMap<i32, I>,
82        query: Option<Q>,
83    ) -> Result<HashMap<i32, O>, ResolveError> {
84        if selector.is_empty() {
85            return Ok(HashMap::new());
86        }
87
88        let mut endpoint = format!("{}/{}", self.base_url, resource);
89        if let Some(query) = query {
90            endpoint.push('?');
91            endpoint.push_str(&serde_qs::to_string(&query).map_err(ResolveError::SerializeQuery)?);
92        }
93
94        self.client
95            .post(endpoint)
96            .json(&selector)
97            .send()
98            .await
99            .map_err(ResolveError::Network)?
100            .json::<HashMap<i32, O>>()
101            .await
102            .map_err(ResolveError::Deserialize)
103    }
104
105    async fn resolve_single<I: Serialize + Debug + Clone, O: DeserializeOwned, Q: Serialize>(
106        &self,
107        resource: &str,
108        chain_id: i32,
109        selector: I,
110        query: Option<Q>,
111    ) -> Result<O, ResolveError> {
112        let mut filter = HashMap::new();
113        filter.insert(chain_id, selector);
114        self.resolve_multiple(resource, filter, query)
115            .await?
116            .remove(&chain_id)
117            .ok_or(ResolveError::Missing)
118    }
119
120    pub async fn resolve_unpriced_tokens(
121        &self,
122        token_addresses_by_chain: HashMap<i32, HashSet<Address>>,
123    ) -> Result<HashMap<i32, HashMap<Address, Token>>, ResolveError> {
124        self.resolve_multiple("tokens", token_addresses_by_chain, None::<()>)
125            .await
126    }
127
128    pub async fn resolve_priced_tokens(
129        &self,
130        token_addresses_by_chain: HashMap<i32, HashSet<Address>>,
131    ) -> Result<HashMap<i32, HashMap<Address, PricedToken>>, ResolveError> {
132        self.resolve_multiple(
133            "tokens",
134            token_addresses_by_chain,
135            Some(ResolveTokensQuery {
136                with_usd_prices: true,
137            }),
138        )
139        .await
140    }
141
142    pub async fn resolve_unpriced_token(
143        &self,
144        chain_id: i32,
145        token_address: Address,
146    ) -> Result<Token, ResolveError> {
147        self.resolve_single::<_, HashMap<Address, Token>, _>(
148            "tokens",
149            chain_id,
150            vec![token_address],
151            None::<()>,
152        )
153        .await?
154        .remove(&token_address)
155        .ok_or(ResolveError::Missing)
156    }
157
158    pub async fn resolve_priced_token(
159        &self,
160        chain_id: i32,
161        token_address: Address,
162    ) -> Result<PricedToken, ResolveError> {
163        self.resolve_single::<_, HashMap<Address, PricedToken>, _>(
164            "tokens",
165            chain_id,
166            vec![token_address],
167            Some(ResolveTokensQuery {
168                with_usd_prices: true,
169            }),
170        )
171        .await?
172        .remove(&token_address)
173        .ok_or(ResolveError::Missing)
174    }
175
176    pub async fn resolve_amm_pools_without_tvl(
177        &self,
178        pool_ids_by_chain: HashMap<i32, HashSet<AmmPoolId>>,
179    ) -> Result<HashMap<i32, HashMap<AmmPoolId, AmmPool>>, ResolveError> {
180        self.resolve_multiple("amms/pools", pool_ids_by_chain, None::<()>)
181            .await
182    }
183
184    pub async fn resolve_amm_pools_with_tvl(
185        &self,
186        pool_ids_by_chain: HashMap<i32, HashSet<AmmPoolId>>,
187    ) -> Result<HashMap<i32, HashMap<AmmPoolId, AmmPoolWithTvl>>, ResolveError> {
188        self.resolve_multiple(
189            "amms/pools",
190            pool_ids_by_chain,
191            Some(ResolveAmmPoolsQuery {
192                with_usd_tvls: true,
193            }),
194        )
195        .await
196    }
197
198    pub async fn resolve_amm_pool_without_tvl(
199        &self,
200        chain_id: i32,
201        pool_id: AmmPoolId,
202    ) -> Result<AmmPool, ResolveError> {
203        self.resolve_single::<_, HashMap<AmmPoolId, AmmPool>, ()>(
204            "amms/pools",
205            chain_id,
206            vec![pool_id],
207            None,
208        )
209        .await?
210        .remove(&pool_id)
211        .ok_or(ResolveError::Missing)
212    }
213
214    pub async fn resolve_amm_pool_with_tvl(
215        &self,
216        chain_id: i32,
217        pool_id: AmmPoolId,
218    ) -> Result<AmmPoolWithTvl, ResolveError> {
219        self.resolve_single::<_, HashMap<AmmPoolId, AmmPoolWithTvl>, ResolveAmmPoolsQuery>(
220            "amms/pools",
221            chain_id,
222            vec![pool_id],
223            Some(ResolveAmmPoolsQuery {
224                with_usd_tvls: true,
225            }),
226        )
227        .await?
228        .remove(&pool_id)
229        .ok_or(ResolveError::Missing)
230    }
231
232    pub async fn resolve_prices(
233        &self,
234        token_addresses_by_chain: HashMap<i32, HashSet<Address>>,
235    ) -> Result<HashMap<i32, HashMap<Address, f64>>, ResolveError> {
236        self.resolve_multiple("prices", token_addresses_by_chain, None::<()>)
237            .await
238    }
239
240    pub async fn resolve_price(
241        &self,
242        chain_id: i32,
243        token_address: Address,
244    ) -> Result<f64, ResolveError> {
245        self.resolve_single::<_, HashMap<Address, f64>, ()>(
246            "prices",
247            chain_id,
248            vec![token_address],
249            None,
250        )
251        .await?
252        .remove(&token_address)
253        .ok_or(ResolveError::Missing)
254    }
255
256    pub async fn resolve_amm_pool_tvls(
257        &self,
258        pool_ids_by_chain: HashMap<i32, HashSet<AmmPoolId>>,
259    ) -> Result<HashMap<i32, HashMap<AmmPoolId, f64>>, ResolveError> {
260        self.resolve_multiple("amms/tvls", pool_ids_by_chain, None::<()>)
261            .await
262    }
263
264    pub async fn resolve_amm_pool_tvl(
265        &self,
266        chain_id: i32,
267        pool_id: AmmPoolId,
268    ) -> Result<f64, ResolveError> {
269        self.resolve_single::<_, HashMap<AmmPoolId, f64>, ()>(
270            "amms/tvls",
271            chain_id,
272            vec![pool_id],
273            None,
274        )
275        .await?
276        .remove(&pool_id)
277        .ok_or(ResolveError::Missing)
278    }
279
280    pub async fn get_amm_pools_with_usd_tvl(
281        &self,
282        chain_id: i32,
283        dex: String,
284    ) -> Result<HashMap<AmmPoolId, AmmPoolWithTvl>, ResolveError> {
285        self.client
286            .get(format!(
287                "{}/amms/pools-with-usd-tvls/{}/{}",
288                self.base_url, chain_id, dex
289            ))
290            .send()
291            .await
292            .map_err(ResolveError::Network)?
293            .json::<HashMap<AmmPoolId, AmmPoolWithTvl>>()
294            .await
295            .map_err(ResolveError::Deserialize)
296    }
297
298    pub async fn resolve_all_liquity_v2_collaterals_in_chain(
299        &self,
300        chain_id: i32,
301        brands: HashSet<String>,
302    ) -> Result<HashMap<String, HashMap<Address, LiquityV2Collateral>>, ResolveError> {
303        let mut selector: HashMap<String, HashSet<Address>> = HashMap::new();
304        for brand in brands.into_iter() {
305            selector.insert(brand, HashSet::new());
306        }
307
308        self.resolve_single::<_, HashMap<String, HashMap<Address, LiquityV2Collateral>>, ()>(
309            "liquity-v2/collaterals",
310            chain_id,
311            selector,
312            None::<()>,
313        )
314        .await
315    }
316
317    pub async fn resolve_all_liquity_v2_collaterals_in_chain_for_brand(
318        &self,
319        chain_id: i32,
320        brand: String,
321    ) -> Result<HashMap<Address, LiquityV2Collateral>, ResolveError> {
322        let mut selector: HashMap<String, HashSet<Address>> = HashMap::new();
323        selector.insert(brand.clone(), HashSet::new());
324
325        self.resolve_single::<_, HashMap<String, HashMap<Address, LiquityV2Collateral>>, ()>(
326            "liquity-v2/collaterals",
327            chain_id,
328            selector,
329            None::<()>,
330        )
331        .await?
332        .remove(&brand)
333        .ok_or(ResolveError::Missing)
334    }
335
336    pub async fn resolve_liquity_v2_collaterals_in_chain_for_brand(
337        &self,
338        chain_id: i32,
339        brand: String,
340        collaterals: HashSet<Address>,
341    ) -> Result<HashMap<Address, LiquityV2Collateral>, ResolveError> {
342        let mut brands: HashMap<String, HashSet<Address>> = HashMap::new();
343        brands.insert(brand.clone(), collaterals);
344
345        self.resolve_single::<_, HashMap<String, HashMap<Address, LiquityV2Collateral>>, ()>(
346            "liquity-v2/collaterals",
347            chain_id,
348            brands,
349            None::<()>,
350        )
351        .await?
352        .remove(&brand)
353        .ok_or(ResolveError::Missing)
354    }
355
356    pub async fn resolve_all_liquity_v2_collaterals(
357        &self,
358        brands_by_chain: HashMap<i32, HashSet<String>>,
359    ) -> Result<HashMap<i32, HashMap<String, HashMap<Address, LiquityV2Collateral>>>, ResolveError>
360    {
361        let mut selector: HashMap<i32, HashMap<String, HashSet<Address>>> = HashMap::new();
362        for (chain_id, brands) in brands_by_chain.into_iter() {
363            for brand in brands.into_iter() {
364                selector
365                    .entry(chain_id)
366                    .or_default()
367                    .insert(brand, HashSet::new());
368            }
369        }
370
371        self.resolve_multiple("liquity-v2/collaterals", selector, None::<()>)
372            .await
373    }
374
375    pub async fn resolve_liquity_v2_collaterals(
376        &self,
377        brands_by_chain: HashMap<i32, HashMap<String, HashSet<Address>>>,
378    ) -> Result<HashMap<i32, HashMap<String, HashMap<Address, LiquityV2Collateral>>>, ResolveError>
379    {
380        let mut selector: HashMap<i32, HashMap<String, HashSet<Address>>> = HashMap::new();
381        for (chain_id, brands) in brands_by_chain.into_iter() {
382            for (brand, collaterals) in brands.into_iter() {
383                selector
384                    .entry(chain_id)
385                    .or_default()
386                    .insert(brand, collaterals);
387            }
388        }
389
390        self.resolve_multiple("liquity-v2/collaterals", selector, None::<()>)
391            .await
392    }
393
394    pub async fn resolve_latest_safe_block(
395        &self,
396        chain_id: i32,
397    ) -> Result<LightBlock, ResolveError> {
398        self.client
399            .get(format!("{}/blocks/{}/latest-safe", self.base_url, chain_id))
400            .send()
401            .await
402            .map_err(ResolveError::Network)?
403            .json::<LightBlock>()
404            .await
405            .map_err(ResolveError::Deserialize)
406    }
407
408    pub async fn resolve_block_at(
409        &self,
410        chain_id: i32,
411        timestamp: NaiveDateTime,
412    ) -> Result<LightBlock, ResolveError> {
413        self.client
414            .get(format!(
415                "{}/blocks/{}/{}",
416                self.base_url,
417                chain_id,
418                timestamp.and_utc().timestamp()
419            ))
420            .send()
421            .await
422            .map_err(ResolveError::Network)?
423            .json::<LightBlock>()
424            .await
425            .map_err(ResolveError::Deserialize)
426    }
427}
428
429#[cfg(test)]
430mod test {
431    use serde_json::json;
432    use wiremock::{
433        matchers::{body_json, method},
434        Mock, MockServer, ResponseTemplate,
435    };
436
437    use super::*;
438
439    #[tokio::test]
440    async fn test_resolve_multiple_serde() {
441        let mock_server = MockServer::start().await;
442
443        Mock::given(method("POST"))
444            .and(body_json(json!({
445                "17000": ["0x0000000000000000000000000000000000000001"]
446            })))
447            .respond_with(ResponseTemplate::new(200).set_body_json(json!(
448                {
449                    "17000": {
450                        "0x0000000000000000000000000000000000000001": {
451                            "decimals": 18,
452                            "name": "Mocked",
453                            "symbol": "MCKD"
454                        }
455                    }
456                }
457            )))
458            .up_to_n_times(1)
459            .mount(&mock_server)
460            .await;
461
462        let client = ResolverClient::new(mock_server.uri(), Duration::from_secs(5)).unwrap();
463        let resolved_token = client
464            .resolve_unpriced_token(
465                17000,
466                "0x0000000000000000000000000000000000000001"
467                    .parse::<Address>()
468                    .unwrap(),
469            )
470            .await
471            .unwrap();
472
473        assert_eq!(resolved_token.decimals, 18);
474        assert_eq!(resolved_token.name, "Mocked");
475        assert_eq!(resolved_token.symbol, "MCKD");
476    }
477}