Skip to main content

tycho_simulation/rfq/protocols/metric/
client.rs

1//! Metric RFQ client for the `api.metric.xyz` API.
2//!
3//! Endpoint reference: <https://docs.metric.xyz/RSm94m71kqtGICv4iKRj/developers/api>
4
5use std::{
6    collections::{HashMap, HashSet},
7    sync::LazyLock,
8    time::SystemTime,
9};
10
11use alloy::primitives::Address;
12use async_trait::async_trait;
13use futures::stream::BoxStream;
14use num_bigint::BigUint;
15use reqwest::Client;
16use tokio::time::{interval, timeout, Duration};
17use tracing::{error, info, warn};
18use tycho_common::{
19    models::{
20        protocol::{GetAmountOutParams, ProtocolComponent, ProtocolComponentState},
21        Chain,
22    },
23    simulation::indicatively_priced::SignedQuote,
24    Bytes,
25};
26
27use crate::{
28    rfq::{
29        client::RFQClient,
30        errors::RFQError,
31        models::TimestampHeader,
32        protocols::metric::models::{
33            MetricBidAskResponse, MetricMetadata, PaginatedMetadataResponse,
34        },
35    },
36    tycho_client::feed::synchronizer::{ComponentWithState, Snapshot, StateSyncMessage},
37};
38
39static METRIC_HTTP_CLIENT: LazyLock<Client> = LazyLock::new(Client::new);
40
41/// Page size for the paginated metadata endpoint. The API clamps `count` to `[1, 500]`.
42const METADATA_PAGE_SIZE: u32 = 500;
43
44#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
45pub struct MetricClient {
46    chain: Chain,
47    metadata_endpoint: String,
48    // Prefix ending at /public/v1/evm/{chain_id}; pool-specific endpoints are derived from it.
49    chain_endpoint: String,
50    tokens: HashSet<Bytes>,
51    tvl: f64,
52    #[serde(skip_serializing, default)]
53    api_key: Option<String>,
54    poll_time: Duration,
55    quote_timeout: Duration,
56}
57
58impl MetricClient {
59    pub const PROTOCOL_SYSTEM: &'static str = "rfq:metric";
60
61    pub fn new(
62        chain: Chain,
63        tokens: HashSet<Bytes>,
64        tvl: f64,
65        base_url: String,
66        api_key: Option<String>,
67        poll_time: Duration,
68        quote_timeout: Duration,
69    ) -> Result<Self, RFQError> {
70        let chain_id = chain_to_chain_id(chain)?;
71        let base_url = base_url.trim_end_matches('/');
72        let chain_endpoint = format!("{base_url}/public/v1/evm/{chain_id}");
73        Ok(Self {
74            chain,
75            metadata_endpoint: format!("{chain_endpoint}/metadata"),
76            chain_endpoint,
77            tokens,
78            tvl,
79            api_key,
80            poll_time,
81            quote_timeout,
82        })
83    }
84
85    fn http_client(&self) -> &Client {
86        &METRIC_HTTP_CLIENT
87    }
88
89    pub fn create_component_with_state(
90        &self,
91        component_id: String,
92        metadata: &MetricMetadata,
93        bid_ask: &MetricBidAskResponse,
94        tvl: f64,
95    ) -> ComponentWithState {
96        let protocol_component = ProtocolComponent {
97            id: component_id.clone(),
98            protocol_system: Self::PROTOCOL_SYSTEM.to_string(),
99            protocol_type_name: "metric_pool".to_string(),
100            chain: self.chain,
101            tokens: vec![metadata.token0.clone(), metadata.token1.clone()],
102            contract_addresses: Vec::new(),
103            static_attributes: HashMap::new(),
104            ..Default::default()
105        };
106
107        let attributes = HashMap::from([
108            ("bid_adj".to_string(), Bytes::from(bid_ask.bid_adj.to_string().into_bytes())),
109            ("ask_adj".to_string(), Bytes::from(bid_ask.ask_adj.to_string().into_bytes())),
110            (
111                "total_token0_available".to_string(),
112                Bytes::from(
113                    bid_ask
114                        .total_token0_available
115                        .as_ref()
116                        .map(ToString::to_string)
117                        .unwrap_or_default()
118                        .into_bytes(),
119                ),
120            ),
121            (
122                "total_token1_available".to_string(),
123                Bytes::from(
124                    bid_ask
125                        .total_token1_available
126                        .as_ref()
127                        .map(ToString::to_string)
128                        .unwrap_or_default()
129                        .into_bytes(),
130                ),
131            ),
132            (
133                "server_ts".to_string(),
134                Bytes::from(
135                    bid_ask
136                        .server_ts
137                        .to_string()
138                        .into_bytes(),
139                ),
140            ),
141            (
142                "depth".to_string(),
143                Bytes::from(serde_json::to_vec(&bid_ask.depth).unwrap_or_default()),
144            ),
145        ]);
146
147        ComponentWithState {
148            state: ProtocolComponentState::new(&component_id, attributes, HashMap::new()),
149            component: protocol_component,
150            component_tvl: Some(tvl),
151            entrypoints: vec![],
152        }
153    }
154
155    /// Fetches every configured pool by paging through the metadata endpoint until the API reports
156    /// no next page.
157    async fn fetch_metadata(&self) -> Result<Vec<MetricMetadata>, RFQError> {
158        let mut pools = Vec::new();
159        let mut offset: u64 = 0;
160
161        loop {
162            let response = self
163                .http_client()
164                .get(&self.metadata_endpoint)
165                .header("accept", "application/json")
166                // `include24h=true` is required for the top-level `tvlFiat` field; without it the
167                // API omits TVL and every pool would fall below any non-zero threshold.
168                .query(&[
169                    ("count", METADATA_PAGE_SIZE.to_string()),
170                    ("offset", offset.to_string()),
171                    ("include24h", "true".to_string()),
172                ])
173                .send()
174                .await
175                .map_err(|e| {
176                    RFQError::ConnectionError(format!("Failed to fetch Metric metadata: {e}"))
177                })?;
178
179            if !response.status().is_success() {
180                return Err(RFQError::ConnectionError(format!(
181                    "Metric metadata HTTP error {}: {}",
182                    response.status(),
183                    response
184                        .text()
185                        .await
186                        .unwrap_or_default()
187                )));
188            }
189
190            let page: PaginatedMetadataResponse = response.json().await.map_err(|e| {
191                RFQError::ParsingError(format!("Failed to parse Metric metadata response: {e}"))
192            })?;
193
194            let page_len = page.data.len();
195            pools.extend(page.data);
196
197            // Stop when the API reports the last page, returns nothing, or fails to advance the
198            // offset (defensive guard against an infinite loop).
199            match page.next_offset {
200                Some(next) if page_len > 0 && next > offset => offset = next,
201                _ => break,
202            }
203        }
204
205        Ok(pools)
206    }
207
208    async fn fetch_bid_ask(&self, pool: &Bytes) -> Result<MetricBidAskResponse, RFQError> {
209        let endpoint =
210            format!("{}/{}/bid_ask", self.chain_endpoint, bytes_to_address_string(pool)?);
211        let mut request = self
212            .http_client()
213            .get(endpoint)
214            .header("accept", "application/json");
215
216        if let Some(api_key) = &self.api_key {
217            request = request.bearer_auth(api_key);
218        }
219
220        let response = timeout(self.quote_timeout, request.send())
221            .await
222            .map_err(|_| {
223                RFQError::ConnectionError(format!(
224                    "Metric bid/ask request timed out after {} seconds",
225                    self.quote_timeout.as_secs()
226                ))
227            })?
228            .map_err(|e| {
229                RFQError::ConnectionError(format!("Failed to fetch Metric bid/ask: {e}"))
230            })?;
231
232        if !response.status().is_success() {
233            return Err(RFQError::ConnectionError(format!(
234                "Metric bid/ask HTTP error {}: {}",
235                response.status(),
236                response
237                    .text()
238                    .await
239                    .unwrap_or_default()
240            )));
241        }
242
243        response.json().await.map_err(|e| {
244            RFQError::ParsingError(format!("Failed to parse Metric bid/ask response: {e}"))
245        })
246    }
247
248    fn find_pool<'a>(
249        &self,
250        metadata: &'a [MetricMetadata],
251        params: &GetAmountOutParams,
252    ) -> Result<&'a MetricMetadata, RFQError> {
253        metadata
254            .iter()
255            .find(|pool| {
256                (params.token_in == pool.token0 && params.token_out == pool.token1) ||
257                    (params.token_in == pool.token1 && params.token_out == pool.token0)
258            })
259            .ok_or_else(|| {
260                RFQError::QuoteNotFound(format!(
261                    "Metric pool not found for {} -> {}",
262                    params.token_in, params.token_out
263                ))
264            })
265    }
266}
267
268#[async_trait]
269impl RFQClient for MetricClient {
270    fn stream(
271        &self,
272    ) -> BoxStream<'static, Result<(String, StateSyncMessage<TimestampHeader>), RFQError>> {
273        let client = self.clone();
274
275        Box::pin(async_stream::stream! {
276            let mut current_components: HashMap<String, ComponentWithState> = HashMap::new();
277            let mut ticker = interval(client.poll_time);
278
279            info!("Starting Metric polling every {} seconds", client.poll_time.as_secs());
280            loop {
281                ticker.tick().await;
282
283                let metadata = match client.fetch_metadata().await {
284                    Ok(metadata) => metadata,
285                    Err(e) => {
286                        error!("Failed to fetch Metric metadata: {}", e);
287                        continue;
288                    }
289                };
290
291                let mut new_components = HashMap::new();
292                for pool in &metadata {
293                    if !client.tokens.is_empty() &&
294                        (!client.tokens.contains(&pool.token0) ||
295                            !client.tokens.contains(&pool.token1))
296                    {
297                        continue;
298                    }
299
300                    // v1 metadata carries the fiat TVL directly, so no cross-pool price
301                    // normalization is needed.
302                    let tvl = pool.tvl_fiat.unwrap_or(0.0);
303                    if tvl < client.tvl {
304                        continue;
305                    }
306
307                    let bid_ask = match client.fetch_bid_ask(&pool.pool_address).await {
308                        Ok(bid_ask) => bid_ask,
309                        Err(e) => {
310                            warn!(
311                                "Failed to fetch Metric bid/ask for pool {}: {}",
312                                pool.pool_address, e
313                            );
314                            continue;
315                        }
316                    };
317                    if !bid_ask.is_quotable() {
318                        continue;
319                    }
320
321                    let component_id = pool.pool_address.to_string();
322                    new_components.insert(
323                        component_id.clone(),
324                        client.create_component_with_state(component_id, pool, &bid_ask, tvl),
325                    );
326                }
327
328                let removed_components: HashMap<String, ProtocolComponent> = current_components
329                    .iter()
330                    .filter(|(id, _)| !new_components.contains_key(*id))
331                    .map(|(id, component)| (id.clone(), component.component.clone()))
332                    .collect();
333
334                current_components = new_components.clone();
335                let timestamp = SystemTime::now()
336                    .duration_since(SystemTime::UNIX_EPOCH)
337                    .map_err(|_| RFQError::ParsingError("SystemTime before UNIX EPOCH".to_string()))?
338                    .as_secs();
339
340                yield Ok(("metric".to_string(), StateSyncMessage {
341                    header: TimestampHeader { timestamp },
342                    snapshots: Snapshot { states: new_components, vm_storage: HashMap::new() },
343                    deltas: None,
344                    removed_components,
345                }));
346            }
347        })
348    }
349
350    async fn request_binding_quote(
351        &self,
352        params: &GetAmountOutParams,
353    ) -> Result<SignedQuote, RFQError> {
354        let metadata = self.fetch_metadata().await?;
355        // Validates that a pool exists for the requested pair.
356        self.find_pool(&metadata, params)?;
357
358        // The v1 heartbeat updates the oracle on-chain every block, so no signed oracle-update args
359        // are relayed with the swap. The binding quote therefore carries no quote attributes.
360        Ok(SignedQuote {
361            base_token: params.token_in.clone(),
362            quote_token: params.token_out.clone(),
363            amount_in: params.amount_in.clone(),
364            amount_out: BigUint::default(),
365            quote_attributes: HashMap::new(),
366        })
367    }
368}
369
370fn chain_to_chain_id(chain: Chain) -> Result<u64, RFQError> {
371    match chain {
372        Chain::Ethereum => Ok(1),
373        Chain::Base => Ok(8453),
374        Chain::Robinhood => Ok(4663),
375        unsupported => Err(RFQError::FatalError(format!(
376            "Metric does not support chain in this integration: {unsupported:?}"
377        ))),
378    }
379}
380
381fn bytes_to_address_string(address: &Bytes) -> Result<String, RFQError> {
382    if address.len() != 20 {
383        return Err(RFQError::InvalidInput(format!("Invalid EVM address length: {address}")));
384    }
385    Ok(Address::from_slice(address).to_checksum(None))
386}
387
388#[cfg(test)]
389mod tests {
390    use std::str::FromStr;
391
392    use super::*;
393    use crate::rfq::protocols::metric::{
394        client_builder::MetricClientBuilder,
395        models::{q64_to_f64, MetricDepth},
396    };
397
398    fn big(value: &str) -> BigUint {
399        value.parse().unwrap()
400    }
401
402    fn client() -> MetricClient {
403        MetricClient::new(
404            Chain::Ethereum,
405            HashSet::new(),
406            0.0,
407            "http://localhost:8080".to_string(),
408            None,
409            Duration::from_secs(1),
410            Duration::from_secs(1),
411        )
412        .unwrap()
413    }
414
415    // Base: the only supported chain with pools published on the live API so far.
416    fn live_client() -> MetricClient {
417        let config = crate::rfq::constants::get_metric_config();
418        MetricClient::new(
419            Chain::Base,
420            HashSet::new(),
421            0.0,
422            config.base_url,
423            config.api_key,
424            Duration::from_secs(1),
425            Duration::from_secs(5),
426        )
427        .unwrap()
428    }
429
430    fn metadata() -> MetricMetadata {
431        MetricMetadata {
432            pool_address: Bytes::from_str("0xbF48bCf474d57fF82A3215319229e0DE1476A557").unwrap(),
433            token0: Bytes::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap(),
434            token1: Bytes::from_str("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48").unwrap(),
435            tvl_fiat: Some(3000.0),
436        }
437    }
438
439    fn bid_ask() -> MetricBidAskResponse {
440        MetricBidAskResponse {
441            bid_adj: big("55340232221128654848000"),
442            ask_adj: big("55358678965202364400000"),
443            total_token0_available: Some(big("1000000000000000000")),
444            total_token1_available: Some(big("3000000000")),
445            server_ts: 1_770_053_095,
446            price_provider_status: Some("healthy".to_string()),
447            depth: MetricDepth::default(),
448        }
449    }
450
451    #[test]
452    fn test_chain_to_chain_id() {
453        assert_eq!(chain_to_chain_id(Chain::Ethereum).unwrap(), 1);
454        assert_eq!(chain_to_chain_id(Chain::Base).unwrap(), 8453);
455        assert_eq!(chain_to_chain_id(Chain::Robinhood).unwrap(), 4663);
456        // Metric lists Arbitrum, but it carries no price-provider layer yet.
457        assert!(chain_to_chain_id(Chain::Arbitrum).is_err());
458    }
459
460    #[test]
461    fn test_endpoints_use_numeric_chain_id() {
462        let client = MetricClient::new(
463            Chain::Base,
464            HashSet::new(),
465            0.0,
466            "https://api.metric.xyz".to_string(),
467            None,
468            Duration::from_secs(1),
469            Duration::from_secs(1),
470        )
471        .unwrap();
472
473        assert_eq!(client.metadata_endpoint, "https://api.metric.xyz/public/v1/evm/8453/metadata");
474        assert_eq!(client.chain_endpoint, "https://api.metric.xyz/public/v1/evm/8453");
475    }
476
477    #[test]
478    fn test_builder_defaults_to_v1_base_url() {
479        let client = MetricClientBuilder::new(Chain::Ethereum)
480            .build()
481            .unwrap();
482
483        assert_eq!(client.metadata_endpoint, "https://api.metric.xyz/public/v1/evm/1/metadata");
484    }
485
486    #[test]
487    fn test_component_attributes_round_trip_values() {
488        let metadata = metadata();
489        let component = client().create_component_with_state(
490            metadata.pool_address.to_string(),
491            &metadata,
492            &bid_ask(),
493            3000.0,
494        );
495
496        assert_eq!(component.component.protocol_system, MetricClient::PROTOCOL_SYSTEM);
497        assert_eq!(
498            component.component.tokens,
499            vec![metadata.token0.clone(), metadata.token1.clone()]
500        );
501        assert!(component
502            .component
503            .static_attributes
504            .is_empty());
505        assert_eq!(component.component.id, metadata.pool_address.to_string());
506        assert!(component
507            .component
508            .contract_addresses
509            .is_empty());
510        assert_eq!(
511            String::from_utf8(component.state.attributes["bid_adj"].to_vec()).unwrap(),
512            "55340232221128654848000"
513        );
514        assert_eq!(
515            String::from_utf8(component.state.attributes["server_ts"].to_vec()).unwrap(),
516            "1770053095"
517        );
518    }
519
520    #[tokio::test]
521    #[ignore = "hits Metric's public API"]
522    async fn test_live_metric_api_fetch_bid_ask_latest_fields() {
523        let client = live_client();
524        let metadata = client.fetch_metadata().await.unwrap();
525        assert!(!metadata.is_empty());
526
527        let mut last_error = None;
528        let mut selected = None;
529        for pool in &metadata {
530            match client
531                .fetch_bid_ask(&pool.pool_address)
532                .await
533            {
534                Ok(bid_ask) => {
535                    if bid_ask.is_quotable() &&
536                        !bid_ask.depth.asks.is_empty() &&
537                        !bid_ask.depth.bids.is_empty()
538                    {
539                        selected = Some((pool, bid_ask));
540                        break;
541                    }
542                }
543                Err(error) => last_error = Some(error.to_string()),
544            }
545        }
546
547        let Some((_pool, bid_ask)) = selected else {
548            panic!(
549                "Metric live API returned no quotable bid_ask response with ask and bid depth across {} pools; last error: {:?}",
550                metadata.len(),
551                last_error
552            );
553        };
554
555        let bid_price = bid_ask.bid_price().unwrap();
556        let ask_price = bid_ask.ask_price().unwrap();
557        assert!(bid_price.is_finite() && bid_price > 0.0);
558        assert!(ask_price.is_finite() && ask_price >= bid_price);
559        assert!(bid_ask.total_token0_available().is_ok());
560        assert!(bid_ask.total_token1_available().is_ok());
561        assert!(bid_ask.server_ts > 0);
562
563        for bin in bid_ask
564            .depth
565            .asks
566            .iter()
567            .chain(bid_ask.depth.bids.iter())
568            .take(6)
569        {
570            assert!(q64_to_f64(&bin.price)
571                .unwrap()
572                .is_finite());
573            // Deserialization already parsed the volumes; assert the input-driven depth walk's
574            // key field is populated in live responses.
575            assert!(bin.cumulative_input_volume > BigUint::ZERO);
576        }
577    }
578}