1use 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
41const 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 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 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 .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 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 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 self.find_pool(&metadata, params)?;
357
358 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 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 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 assert!(bin.cumulative_input_volume > BigUint::ZERO);
576 }
577 }
578}