Skip to main content

tycho_simulation/rfq/protocols/metric/
client.rs

1use std::{
2    collections::{HashMap, HashSet},
3    str::FromStr,
4    sync::LazyLock,
5    time::SystemTime,
6};
7
8use alloy::{
9    primitives::{Address, Bytes as AlloyBytes, U256},
10    sol_types::SolValue,
11};
12use async_trait::async_trait;
13use futures::stream::BoxStream;
14use num_bigint::BigUint;
15use num_traits::ToPrimitive;
16use reqwest::Client;
17use tokio::time::{interval, timeout, Duration};
18use tracing::{error, info, warn};
19use tycho_common::{
20    models::{
21        protocol::{GetAmountOutParams, ProtocolComponent, ProtocolComponentState},
22        token::Token,
23        Chain,
24    },
25    simulation::indicatively_priced::SignedQuote,
26    Bytes,
27};
28
29use crate::{
30    evm::protocol::u256_num::biguint_to_u256,
31    rfq::{
32        client::RFQClient,
33        errors::RFQError,
34        models::TimestampHeader,
35        protocols::metric::models::{
36            MetricBidAskResponse, MetricMetadata, MetricOracleUpdatePolicy,
37            MetricSignedOracleUpdateResponse, MetricSignedOracleUpdateSlot,
38            ORACLE_UPDATE_POLICY_ATTR,
39        },
40    },
41    tycho_client::feed::synchronizer::{ComponentWithState, Snapshot, StateSyncMessage},
42};
43
44static METRIC_HTTP_CLIENT: LazyLock<Client> = LazyLock::new(Client::new);
45
46#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
47pub struct MetricClient {
48    chain: Chain,
49    metadata_endpoint: String,
50    // Prefix ending at /{chain}; pool-specific endpoints are derived from it.
51    chain_endpoint: String,
52    tokens: HashSet<Bytes>,
53    #[serde(default)]
54    token_metadata: HashMap<Bytes, Token>,
55    tvl: f64,
56    quote_tokens: HashSet<Bytes>,
57    #[serde(skip_serializing, default)]
58    secret_key: Option<String>,
59    poll_time: Duration,
60    quote_timeout: Duration,
61    oracle_update_policy: MetricOracleUpdatePolicy,
62}
63
64impl MetricClient {
65    pub const PROTOCOL_SYSTEM: &'static str = "rfq:metric";
66
67    #[allow(clippy::too_many_arguments)]
68    pub fn new(
69        chain: Chain,
70        tokens: HashSet<Bytes>,
71        tvl: f64,
72        quote_tokens: HashSet<Bytes>,
73        base_url: String,
74        secret_key: Option<String>,
75        poll_time: Duration,
76        quote_timeout: Duration,
77    ) -> Result<Self, RFQError> {
78        Self::new_with_token_metadata(
79            chain,
80            tokens,
81            HashMap::new(),
82            tvl,
83            quote_tokens,
84            base_url,
85            secret_key,
86            poll_time,
87            quote_timeout,
88            MetricOracleUpdatePolicy::default_for_chain(chain),
89        )
90    }
91
92    #[allow(clippy::too_many_arguments)]
93    pub(super) fn new_with_token_metadata(
94        chain: Chain,
95        tokens: HashSet<Bytes>,
96        token_metadata: HashMap<Bytes, Token>,
97        tvl: f64,
98        quote_tokens: HashSet<Bytes>,
99        base_url: String,
100        secret_key: Option<String>,
101        poll_time: Duration,
102        quote_timeout: Duration,
103        oracle_update_policy: MetricOracleUpdatePolicy,
104    ) -> Result<Self, RFQError> {
105        let chain_path = chain_to_metric_path(chain)?;
106        let base_url = base_url.trim_end_matches('/');
107        let chain_endpoint = format!("{base_url}/{chain_path}");
108        Ok(Self {
109            chain,
110            metadata_endpoint: format!("{chain_endpoint}/metadata"),
111            chain_endpoint,
112            tokens,
113            token_metadata,
114            tvl,
115            quote_tokens,
116            secret_key,
117            poll_time,
118            quote_timeout,
119            oracle_update_policy,
120        })
121    }
122
123    fn http_client(&self) -> &Client {
124        &METRIC_HTTP_CLIENT
125    }
126
127    pub fn create_component_with_state(
128        &self,
129        component_id: String,
130        metadata: &MetricMetadata,
131        bid_ask: &MetricBidAskResponse,
132        tvl: f64,
133    ) -> ComponentWithState {
134        let mut static_attributes = HashMap::new();
135        static_attributes.insert(
136            ORACLE_UPDATE_POLICY_ATTR.to_string(),
137            self.oracle_update_policy
138                .as_attribute_value(),
139        );
140
141        let protocol_component = ProtocolComponent {
142            id: component_id.clone(),
143            protocol_system: Self::PROTOCOL_SYSTEM.to_string(),
144            protocol_type_name: "metric_pool".to_string(),
145            chain: self.chain,
146            tokens: vec![metadata.token0.clone(), metadata.token1.clone()],
147            contract_addresses: Vec::new(),
148            static_attributes,
149            ..Default::default()
150        };
151
152        let mut attributes = HashMap::new();
153
154        let entries: [(&str, Vec<u8>); 6] = [
155            ("bid_adj", bid_ask.bid_adj.as_bytes().to_vec()),
156            ("ask_adj", bid_ask.ask_adj.as_bytes().to_vec()),
157            (
158                "total_token0_available",
159                bid_ask
160                    .total_token0_available
161                    .as_bytes()
162                    .to_vec(),
163            ),
164            (
165                "total_token1_available",
166                bid_ask
167                    .total_token1_available
168                    .as_bytes()
169                    .to_vec(),
170            ),
171            (
172                "latest_block",
173                bid_ask
174                    .latest_block
175                    .to_string()
176                    .into_bytes(),
177            ),
178            ("depth", serde_json::to_vec(&bid_ask.depth).unwrap_or_default()),
179        ];
180
181        for (key, bytes) in entries {
182            attributes.insert(key.to_string(), bytes.into());
183        }
184
185        ComponentWithState {
186            state: ProtocolComponentState::new(&component_id, attributes, HashMap::new()),
187            component: protocol_component,
188            component_tvl: Some(tvl),
189            entrypoints: vec![],
190        }
191    }
192
193    fn normalize_tvl(
194        &self,
195        pool: &MetricMetadata,
196        bid_ask: &MetricBidAskResponse,
197        pool_quotes: &[(MetricMetadata, MetricBidAskResponse)],
198    ) -> Option<f64> {
199        // Metric availability is raw ERC20 units. Token metadata comes from Tycho so customers can
200        // add new quote tokens without a source-code change.
201        let token1_amount = metric_available_human(
202            bid_ask.total_token1_available(),
203            self.token_metadata
204                .get(&pool.token1)?
205                .decimals,
206        )?;
207
208        if self.quote_tokens.contains(&pool.token1) {
209            return Some(token1_amount);
210        }
211
212        // For non-configured token1 values, normalize through one Metric pool that prices token1
213        // in a configured quote token.
214        let price = metric_price_in_quote_token(
215            &pool.token1,
216            pool_quotes,
217            &self.quote_tokens,
218            &self.token_metadata,
219        );
220        if price.is_none() {
221            warn!(
222                pool = ?pool.pool_address,
223                token1 = ?pool.token1,
224                "Metric one-hop TVL price not found for non-quote token"
225            );
226        }
227
228        price
229            .map(|price| token1_amount * price)
230            .filter(|tvl| tvl.is_finite() && *tvl >= 0.0)
231    }
232
233    async fn fetch_metadata(&self) -> Result<Vec<MetricMetadata>, RFQError> {
234        let response = self
235            .http_client()
236            .get(&self.metadata_endpoint)
237            .header("accept", "application/json")
238            .send()
239            .await
240            .map_err(|e| {
241                RFQError::ConnectionError(format!("Failed to fetch Metric metadata: {e}"))
242            })?;
243
244        if !response.status().is_success() {
245            return Err(RFQError::ConnectionError(format!(
246                "Metric metadata HTTP error {}: {}",
247                response.status(),
248                response
249                    .text()
250                    .await
251                    .unwrap_or_default()
252            )));
253        }
254
255        response.json().await.map_err(|e| {
256            RFQError::ParsingError(format!("Failed to parse Metric metadata response: {e}"))
257        })
258    }
259
260    async fn fetch_bid_ask(&self, pool: &Bytes) -> Result<MetricBidAskResponse, RFQError> {
261        let endpoint =
262            format!("{}/{}/bid_ask", self.chain_endpoint, bytes_to_address_string(pool)?);
263        let mut request = self
264            .http_client()
265            .get(endpoint)
266            .header("accept", "application/json");
267
268        if let Some(secret_key) = &self.secret_key {
269            request = request.query(&[("secretKey", secret_key.as_str())]);
270        }
271
272        let response = request.send().await.map_err(|e| {
273            RFQError::ConnectionError(format!("Failed to fetch Metric bid/ask: {e}"))
274        })?;
275
276        if !response.status().is_success() {
277            return Err(RFQError::ConnectionError(format!(
278                "Metric bid/ask HTTP error {}: {}",
279                response.status(),
280                response
281                    .text()
282                    .await
283                    .unwrap_or_default()
284            )));
285        }
286
287        response.json().await.map_err(|e| {
288            RFQError::ParsingError(format!("Failed to parse Metric bid/ask response: {e}"))
289        })
290    }
291
292    async fn fetch_signed_oracle_update(
293        &self,
294        pool: &Bytes,
295    ) -> Result<MetricSignedOracleUpdateResponse, RFQError> {
296        let endpoint =
297            format!("{}/{}/get_signed_data", self.chain_endpoint, bytes_to_address_string(pool)?);
298        let mut request = self
299            .http_client()
300            .get(endpoint)
301            .header("accept", "application/json");
302
303        if let Some(secret_key) = &self.secret_key {
304            request = request.query(&[("secretKey", secret_key.as_str())]);
305        }
306
307        let response = timeout(self.quote_timeout, request.send())
308            .await
309            .map_err(|_| {
310                RFQError::ConnectionError(format!(
311                    "Metric oracle update request timed out after {} seconds",
312                    self.quote_timeout.as_secs()
313                ))
314            })?
315            .map_err(|e| {
316                RFQError::ConnectionError(format!("Failed to fetch Metric oracle update: {e}"))
317            })?;
318
319        if !response.status().is_success() {
320            return Err(RFQError::QuoteNotFound(format!(
321                "Metric oracle update HTTP error {}: {}",
322                response.status(),
323                response
324                    .text()
325                    .await
326                    .unwrap_or_default()
327            )));
328        }
329
330        response.json().await.map_err(|e| {
331            RFQError::ParsingError(format!("Failed to parse Metric oracle update response: {e}"))
332        })
333    }
334
335    pub async fn request_oracle_update_for_pool(
336        &self,
337        metadata: &MetricMetadata,
338        params: &GetAmountOutParams,
339        amount_out: BigUint,
340    ) -> Result<SignedQuote, RFQError> {
341        if !((params.token_in == metadata.token0 && params.token_out == metadata.token1) ||
342            (params.token_in == metadata.token1 && params.token_out == metadata.token0))
343        {
344            return Err(RFQError::InvalidInput(format!(
345                "Metric token pair mismatch: {} -> {} is not {} / {}",
346                params.token_in, params.token_out, metadata.token0, metadata.token1
347            )));
348        }
349
350        let oracle_update = self
351            .fetch_signed_oracle_update(&metadata.pool_address)
352            .await?;
353        // Metric may return multiple slots, but the API does not expose a reliable
354        // pool-to-slot mapping yet. Use the first signed slot until that rule is clarified.
355        let slot = oracle_update
356            .slots
357            .first()
358            .ok_or_else(|| {
359                RFQError::QuoteNotFound(format!(
360                    "Metric oracle update returned no signed slots for pool {}",
361                    metadata.pool_address
362                ))
363            })?;
364
365        let mut quote_attributes = HashMap::new();
366        quote_attributes.insert(
367            "oracle_update_0_args".to_string(),
368            encode_oracle_update_args(&oracle_update.feed_creator, slot)?,
369        );
370
371        Ok(SignedQuote {
372            base_token: params.token_in.clone(),
373            quote_token: params.token_out.clone(),
374            amount_in: params.amount_in.clone(),
375            amount_out,
376            quote_attributes,
377        })
378    }
379}
380
381#[async_trait]
382impl RFQClient for MetricClient {
383    fn stream(
384        &self,
385    ) -> BoxStream<'static, Result<(String, StateSyncMessage<TimestampHeader>), RFQError>> {
386        let client = self.clone();
387
388        Box::pin(async_stream::stream! {
389            let mut current_components: HashMap<String, ComponentWithState> = HashMap::new();
390            let mut ticker = interval(client.poll_time);
391
392            info!("Starting Metric polling every {} seconds", client.poll_time.as_secs());
393            loop {
394                ticker.tick().await;
395
396                let metadata = match client.fetch_metadata().await {
397                    Ok(metadata) => metadata,
398                    Err(e) => {
399                        error!("Failed to fetch Metric metadata: {}", e);
400                        continue;
401                    }
402                };
403
404                // Fetch every Metric pool first, even when `tokens` later filters emitted
405                // components. Non-emitted pools can still provide one-hop quote-token prices for
406                // TVL normalization, e.g. rETH/WETH using WETH/USDC.
407                let mut pool_quotes = Vec::new();
408                for pool in &metadata {
409                    let bid_ask = match client.fetch_bid_ask(&pool.pool_address).await {
410                        Ok(bid_ask) => bid_ask,
411                        Err(e) => {
412                            warn!(
413                                "Failed to fetch Metric bid/ask for pool {}: {}",
414                                pool.pool_address, e
415                            );
416                            continue;
417                        }
418                    };
419                    if !bid_ask.quote_available {
420                        continue;
421                    }
422
423                    pool_quotes.push((pool.clone(), bid_ask));
424                }
425
426                let mut new_components = HashMap::new();
427                for (pool, bid_ask) in &pool_quotes {
428                    if !client.tokens.is_empty() &&
429                        (!client.tokens.contains(&pool.token0) ||
430                            !client.tokens.contains(&pool.token1))
431                    {
432                        continue;
433                    }
434
435                    let tvl = client
436                        .normalize_tvl(pool, bid_ask, &pool_quotes)
437                        .unwrap_or(0.0);
438                    if tvl < client.tvl {
439                        continue;
440                    }
441
442                    let component_id = pool.pool_address.to_string();
443                    new_components.insert(
444                        component_id.clone(),
445                        client.create_component_with_state(component_id, pool, bid_ask, tvl),
446                    );
447                }
448
449                let removed_components: HashMap<String, ProtocolComponent> = current_components
450                    .iter()
451                    .filter(|(id, _)| !new_components.contains_key(*id))
452                    .map(|(id, component)| (id.clone(), component.component.clone()))
453                    .collect();
454
455                current_components = new_components.clone();
456                let timestamp = SystemTime::now()
457                    .duration_since(SystemTime::UNIX_EPOCH)
458                    .map_err(|_| RFQError::ParsingError("SystemTime before UNIX EPOCH".to_string()))?
459                    .as_secs();
460
461                yield Ok(("metric".to_string(), StateSyncMessage {
462                    header: TimestampHeader { timestamp },
463                    snapshots: Snapshot { states: new_components, vm_storage: HashMap::new() },
464                    deltas: None,
465                    removed_components,
466                }));
467            }
468        })
469    }
470
471    async fn request_binding_quote(
472        &self,
473        params: &GetAmountOutParams,
474    ) -> Result<SignedQuote, RFQError> {
475        let metadata = self.fetch_metadata().await?;
476        let pool = metadata
477            .iter()
478            .find(|pool| {
479                (params.token_in == pool.token0 && params.token_out == pool.token1) ||
480                    (params.token_in == pool.token1 && params.token_out == pool.token0)
481            })
482            .ok_or_else(|| {
483                RFQError::QuoteNotFound(format!(
484                    "Metric pool not found for {} -> {}",
485                    params.token_in, params.token_out
486                ))
487            })?;
488
489        self.request_oracle_update_for_pool(pool, params, BigUint::default())
490            .await
491    }
492}
493
494fn chain_to_metric_path(chain: Chain) -> Result<&'static str, RFQError> {
495    match chain {
496        Chain::Ethereum => Ok("ethereum"),
497        Chain::Base => Ok("base"),
498        Chain::Bsc => Ok("bsc"),
499        Chain::Arbitrum => Ok("arbitrum"),
500        Chain::Polygon => Ok("polygon"),
501        unsupported => Err(RFQError::FatalError(format!(
502            "Metric does not support chain in this integration: {unsupported:?}"
503        ))),
504    }
505}
506
507fn bytes_to_address_string(address: &Bytes) -> Result<String, RFQError> {
508    if address.len() != 20 {
509        return Err(RFQError::InvalidInput(format!("Invalid EVM address length: {address}")));
510    }
511    Ok(Address::from_slice(address).to_checksum(None))
512}
513
514fn bytes_to_alloy_address(address: &Bytes) -> Result<Address, RFQError> {
515    if address.len() != 20 {
516        return Err(RFQError::InvalidInput(format!("Invalid EVM address length: {address}")));
517    }
518    Ok(Address::from_slice(address))
519}
520
521fn encode_oracle_update_args(
522    feed_creator: &Bytes,
523    slot: &MetricSignedOracleUpdateSlot,
524) -> Result<Bytes, RFQError> {
525    Ok((
526        bytes_to_alloy_address(feed_creator)?,
527        U256::from(slot.deadline),
528        biguint_decimal_to_u256(&slot.new_slot_value)?,
529        AlloyBytes::from(slot.signature.to_vec()),
530    )
531        .abi_encode()
532        .into())
533}
534
535fn biguint_decimal_to_u256(value: &str) -> Result<U256, RFQError> {
536    let value = BigUint::from_str(value)
537        .map_err(|_| RFQError::ParsingError(format!("Failed to parse uint value: {value}")))?;
538    Ok(biguint_to_u256(&value))
539}
540
541fn metric_price_in_quote_token(
542    token: &Bytes,
543    pool_quotes: &[(MetricMetadata, MetricBidAskResponse)],
544    quote_tokens: &HashSet<Bytes>,
545    token_metadata: &HashMap<Bytes, Token>,
546) -> Option<f64> {
547    let mut best: Option<(f64, f64)> = None;
548
549    for (pool, bid_ask) in pool_quotes {
550        let Some(mid_price) = metric_mid_price(bid_ask) else {
551            continue;
552        };
553        let candidate = if &pool.token0 == token && quote_tokens.contains(&pool.token1) {
554            let Some(quote_token) = token_metadata.get(&pool.token1) else {
555                continue;
556            };
557            let Some(quote_tvl) =
558                metric_available_human(bid_ask.total_token1_available(), quote_token.decimals)
559            else {
560                continue;
561            };
562            Some((mid_price, quote_tvl))
563        } else if &pool.token1 == token && quote_tokens.contains(&pool.token0) {
564            let Some(quote_token) = token_metadata.get(&pool.token0) else {
565                continue;
566            };
567            let Some(quote_tvl) =
568                metric_available_human(bid_ask.total_token0_available(), quote_token.decimals)
569            else {
570                continue;
571            };
572            Some((1.0 / mid_price, quote_tvl))
573        } else {
574            None
575        };
576
577        let Some((price, quote_tvl)) = candidate else {
578            continue;
579        };
580
581        // Multiple Metric pools can price the same token in a configured quote token. Use the
582        // pool with the largest quote-side availability as the most liquid pricing source.
583        if price.is_finite() &&
584            price > 0.0 &&
585            quote_tvl.is_finite() &&
586            quote_tvl > 0.0 &&
587            best.as_ref()
588                .is_none_or(|(_, best_quote_tvl)| quote_tvl > *best_quote_tvl)
589        {
590            best = Some((price, quote_tvl));
591        }
592    }
593
594    best.map(|(price, _)| price)
595}
596
597fn metric_mid_price(bid_ask: &MetricBidAskResponse) -> Option<f64> {
598    let bid = bid_ask.bid_price().ok()?;
599    let ask = bid_ask.ask_price().ok()?;
600    let mid = (bid + ask) / 2.0;
601    (mid.is_finite() && mid > 0.0).then_some(mid)
602}
603
604fn metric_available_human(amount: Result<BigUint, RFQError>, decimals: u32) -> Option<f64> {
605    amount
606        .ok()?
607        .to_f64()
608        .map(|raw| raw / 10_f64.powi(decimals as i32))
609        .filter(|amount| amount.is_finite() && *amount >= 0.0)
610}
611
612#[cfg(test)]
613mod tests {
614    use std::str::FromStr;
615
616    use tycho_common::models::token::Token;
617
618    use super::*;
619    use crate::rfq::protocols::metric::{
620        client_builder::MetricClientBuilder,
621        models::{MetricBidAskResponse, MetricDepth},
622    };
623
624    fn client() -> MetricClient {
625        MetricClient::new(
626            Chain::Ethereum,
627            HashSet::new(),
628            0.0,
629            HashSet::new(),
630            "http://localhost:8080".to_string(),
631            None,
632            Duration::from_secs(1),
633            Duration::from_secs(1),
634        )
635        .unwrap()
636    }
637
638    fn live_client() -> MetricClient {
639        let base_url = std::env::var("METRIC_API_URL")
640            .unwrap_or_else(|_| "http://54.199.103.16:8080".to_string());
641        MetricClient::new(
642            Chain::Ethereum,
643            HashSet::new(),
644            0.0,
645            HashSet::new(),
646            base_url,
647            None,
648            Duration::from_secs(1),
649            Duration::from_secs(5),
650        )
651        .unwrap()
652    }
653
654    fn client_with_tvl_config(
655        quote_tokens: HashSet<Bytes>,
656        token_metadata: HashMap<Bytes, Token>,
657    ) -> MetricClient {
658        MetricClient::new_with_token_metadata(
659            Chain::Ethereum,
660            HashSet::new(),
661            token_metadata,
662            0.0,
663            quote_tokens,
664            "http://localhost:8080".to_string(),
665            None,
666            Duration::from_secs(1),
667            Duration::from_secs(1),
668            MetricOracleUpdatePolicy::default_for_chain(Chain::Ethereum),
669        )
670        .unwrap()
671    }
672
673    fn metadata() -> MetricMetadata {
674        MetricMetadata {
675            pool_address: Bytes::from_str("0xbF48bCf474d57fF82A3215319229e0DE1476A557").unwrap(),
676            token0: Bytes::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap(),
677            token1: Bytes::from_str("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48").unwrap(),
678        }
679    }
680
681    fn metric_address(seed: u8) -> Bytes {
682        Bytes::from(vec![seed; 20])
683    }
684
685    fn q64_price(price: u64) -> String {
686        (BigUint::from(price) << 64usize).to_string()
687    }
688
689    fn metadata_for_pool(token0: Bytes, token1: Bytes, seed: u8) -> MetricMetadata {
690        MetricMetadata { pool_address: metric_address(seed), token0, token1 }
691    }
692
693    fn bid_ask() -> MetricBidAskResponse {
694        MetricBidAskResponse {
695            bid_adj: "55340232221128654848000".to_string(),
696            ask_adj: "55358678965202364400000".to_string(),
697            quote_available: true,
698            total_token0_available: "1000000000000000000".to_string(),
699            total_token1_available: "3000000000".to_string(),
700            latest_block: 100,
701            depth: MetricDepth::default(),
702        }
703    }
704
705    fn bid_ask_with_prices(
706        bid: u64,
707        ask: u64,
708        total_token0_available: &str,
709        total_token1_available: &str,
710    ) -> MetricBidAskResponse {
711        MetricBidAskResponse {
712            bid_adj: q64_price(bid),
713            ask_adj: q64_price(ask),
714            quote_available: true,
715            total_token0_available: total_token0_available.to_string(),
716            total_token1_available: total_token1_available.to_string(),
717            latest_block: 100,
718            depth: MetricDepth::default(),
719        }
720    }
721
722    #[test]
723    fn test_builder_uses_token_metadata_decimals() {
724        let usdc = Bytes::from_str("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48").unwrap();
725        let all_tokens = HashMap::from([(
726            usdc.clone(),
727            Token::new(&usdc, "USDC", 6, 0, &[], Chain::Ethereum, 100),
728        )]);
729
730        let client = MetricClientBuilder::new(Chain::Ethereum)
731            .token_metadata(all_tokens)
732            .build()
733            .unwrap();
734
735        assert_eq!(
736            client
737                .token_metadata
738                .get(&usdc)
739                .map(|token| token.decimals),
740            Some(6)
741        );
742    }
743
744    #[test]
745    fn test_normalize_tvl_uses_configured_quote_decimals() {
746        let pool = metadata();
747        let bid_ask = bid_ask();
748        let quote_tokens = HashSet::from([pool.token1.clone()]);
749        let token_metadata = HashMap::from([(
750            pool.token1.clone(),
751            Token::new(&pool.token1, "USDC", 6, 0, &[], Chain::Ethereum, 100),
752        )]);
753        let pool_quotes = vec![(pool.clone(), bid_ask.clone())];
754        let client = client_with_tvl_config(quote_tokens, token_metadata);
755
756        let tvl = client
757            .normalize_tvl(&pool, &bid_ask, &pool_quotes)
758            .unwrap();
759
760        assert_eq!(tvl, 3000.0);
761    }
762
763    #[test]
764    fn test_normalize_tvl_uses_best_one_hop_quote_pool() {
765        let reth = metric_address(10);
766        let weth = metric_address(20);
767        let usdc = metric_address(30);
768
769        let target = metadata_for_pool(reth, weth.clone(), 1);
770        let target_bid_ask =
771            bid_ask_with_prices(1, 1, "100000000000000000000", "2000000000000000000");
772        let low_liquidity_quote = metadata_for_pool(weth.clone(), usdc.clone(), 4);
773        let low_liquidity_bid_ask =
774            bid_ask_with_prices(3000, 3000, "1000000000000000000", "1000000000");
775        let high_liquidity_quote = metadata_for_pool(weth.clone(), usdc.clone(), 7);
776        // The higher-liquidity WETH/USDC pool has a different price. The expected TVL below proves
777        // normalization chooses it by quote-side availability, not by first match.
778        let high_liquidity_bid_ask =
779            bid_ask_with_prices(3100, 3100, "1000000000000000000", "5000000000");
780        let pool_quotes = vec![
781            (target.clone(), target_bid_ask.clone()),
782            (low_liquidity_quote, low_liquidity_bid_ask),
783            (high_liquidity_quote, high_liquidity_bid_ask),
784        ];
785        let quote_tokens = HashSet::from([usdc.clone()]);
786        let token_metadata = HashMap::from([
787            (weth.clone(), Token::new(&weth, "WETH", 18, 0, &[], Chain::Ethereum, 100)),
788            (usdc.clone(), Token::new(&usdc, "USDC", 6, 0, &[], Chain::Ethereum, 100)),
789        ]);
790        let client = client_with_tvl_config(quote_tokens, token_metadata);
791
792        let tvl = client
793            .normalize_tvl(&target, &target_bid_ask, &pool_quotes)
794            .unwrap();
795
796        assert_eq!(tvl, 6200.0);
797    }
798
799    #[test]
800    fn test_component_attributes_round_trip_values() {
801        let metadata = metadata();
802        let component = client().create_component_with_state(
803            metadata.pool_address.to_string(),
804            &metadata,
805            &bid_ask(),
806            3000.0,
807        );
808
809        assert_eq!(component.component.protocol_system, MetricClient::PROTOCOL_SYSTEM);
810        assert_eq!(
811            component.component.tokens,
812            vec![metadata.token0.clone(), metadata.token1.clone()]
813        );
814        assert_eq!(
815            component.component.static_attributes[ORACLE_UPDATE_POLICY_ATTR],
816            MetricOracleUpdatePolicy::Always.as_attribute_value()
817        );
818        assert_eq!(component.component.id, metadata.pool_address.to_string());
819        assert!(component
820            .component
821            .contract_addresses
822            .is_empty());
823        assert_eq!(
824            String::from_utf8(component.state.attributes["bid_adj"].to_vec()).unwrap(),
825            "55340232221128654848000"
826        );
827    }
828
829    #[test]
830    fn test_builder_sets_oracle_update_policy_attribute() {
831        let client = MetricClientBuilder::new(Chain::Ethereum)
832            .oracle_update_policy(MetricOracleUpdatePolicy::RetryOnRevert)
833            .build()
834            .unwrap();
835
836        let component = client.create_component_with_state(
837            "metric_ethusdc".to_string(),
838            &metadata(),
839            &bid_ask(),
840            3000.0,
841        );
842
843        assert_eq!(
844            component.component.static_attributes[ORACLE_UPDATE_POLICY_ATTR],
845            MetricOracleUpdatePolicy::RetryOnRevert.as_attribute_value()
846        );
847    }
848
849    #[tokio::test]
850    #[ignore = "hits Metric's public API"]
851    async fn test_live_metric_api_fetch_bid_ask_latest_fields() {
852        let client = live_client();
853        let metadata = client.fetch_metadata().await.unwrap();
854        assert!(!metadata.is_empty());
855
856        let mut last_error = None;
857        let mut selected = None;
858        for pool in &metadata {
859            match client
860                .fetch_bid_ask(&pool.pool_address)
861                .await
862            {
863                Ok(bid_ask) => {
864                    if bid_ask.quote_available &&
865                        !bid_ask.depth.asks.is_empty() &&
866                        !bid_ask.depth.bids.is_empty()
867                    {
868                        selected = Some((pool, bid_ask));
869                        break;
870                    }
871                }
872                Err(error) => last_error = Some(error.to_string()),
873            }
874        }
875
876        let Some((_pool, bid_ask)) = selected else {
877            panic!(
878                "Metric live API returned no quoteAvailable bid_ask response with ask and bid depth across {} pools; last error: {:?}",
879                metadata.len(),
880                last_error
881            );
882        };
883
884        let bid_price = bid_ask.bid_price().unwrap();
885        let ask_price = bid_ask.ask_price().unwrap();
886        assert!(bid_price.is_finite() && bid_price > 0.0);
887        assert!(ask_price.is_finite() && ask_price >= bid_price);
888        assert!(bid_ask.total_token0_available().is_ok());
889        assert!(bid_ask.total_token1_available().is_ok());
890        assert!(bid_ask.latest_block > 0);
891
892        for bin in bid_ask
893            .depth
894            .asks
895            .iter()
896            .chain(bid_ask.depth.bids.iter())
897            .take(6)
898        {
899            assert!(bin.price().unwrap().is_finite());
900            assert!(bin.cumulative_volume().is_ok());
901        }
902    }
903
904    #[test]
905    fn test_encode_oracle_update_args() {
906        let feed_creator = Bytes::from_str("0x0000000000000000000000000000000000000001").unwrap();
907        let slot = MetricSignedOracleUpdateSlot {
908            deadline: 1_700_000_000,
909            new_slot_value: "42".to_string(),
910            signature: Bytes::from_str(
911                "0x111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111b",
912            )
913            .unwrap(),
914        };
915
916        let args = encode_oracle_update_args(&feed_creator, &slot).unwrap();
917
918        assert!(!args.starts_with(&[0x78, 0xce, 0x3a, 0xe1]));
919        assert!(!args.is_empty());
920    }
921}