Skip to main content

tycho_simulation/rfq/protocols/metric/
state.rs

1use std::{any::Any, collections::HashMap, fmt};
2
3use async_trait::async_trait;
4use num_bigint::BigUint;
5use num_traits::{FromPrimitive, ToPrimitive, Zero};
6use serde::{Deserialize, Serialize};
7use tycho_common::{
8    dto::ProtocolStateDelta,
9    models::{protocol::GetAmountOutParams, token::Token},
10    simulation::{
11        errors::{SimulationError, TransitionError},
12        indicatively_priced::{IndicativelyPriced, SignedQuote},
13        protocol_sim::{Balances, GetAmountOutResult, ProtocolSim},
14    },
15    Bytes,
16};
17
18use crate::rfq::protocols::metric::{
19    client::MetricClient,
20    models::{MetricBidAskResponse, MetricDepthBin, MetricMetadata},
21};
22
23/// Gas estimate for one MetricExecutor swap (pool swap + callback settlement).
24const METRIC_SWAP_GAS: u64 = 170_000;
25
26#[derive(Clone, Serialize, Deserialize)]
27pub struct MetricState {
28    pub base_token: Token,
29    pub quote_token: Token,
30    pub metadata: MetricMetadata,
31    pub bid_ask: MetricBidAskResponse,
32    pub client: MetricClient,
33}
34
35impl fmt::Debug for MetricState {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        f.debug_struct("MetricState")
38            .field("base_token", &self.base_token)
39            .field("quote_token", &self.quote_token)
40            .field("pool", &self.metadata.pool_address)
41            .field("server_ts", &self.bid_ask.server_ts)
42            .finish_non_exhaustive()
43    }
44}
45
46impl MetricState {
47    pub fn new(
48        base_token: Token,
49        quote_token: Token,
50        metadata: MetricMetadata,
51        bid_ask: MetricBidAskResponse,
52        client: MetricClient,
53    ) -> Self {
54        Self { base_token, quote_token, metadata, bid_ask, client }
55    }
56
57    fn direction(
58        &self,
59        token_in: &Bytes,
60        token_out: &Bytes,
61    ) -> Result<MetricDirection, SimulationError> {
62        if token_in == &self.base_token.address && token_out == &self.quote_token.address {
63            Ok(MetricDirection::ZeroForOne)
64        } else if token_in == &self.quote_token.address && token_out == &self.base_token.address {
65            Ok(MetricDirection::OneForZero)
66        } else {
67            Err(SimulationError::InvalidInput(
68                format!(
69                    "Invalid token addresses. Got in={token_in}, out={token_out}, expected {} / {}",
70                    self.base_token.address, self.quote_token.address
71                ),
72                None,
73            ))
74        }
75    }
76
77    /// The quoted price of the book side `direction` trades against: the bid when selling
78    /// token0, the ask when buying it. `None` when the pool quotes no price on that side.
79    fn quoted_price(&self, direction: MetricDirection) -> Result<Option<f64>, SimulationError> {
80        let price = match direction {
81            MetricDirection::ZeroForOne => self.bid_ask.bid_price()?,
82            MetricDirection::OneForZero => self.bid_ask.ask_price()?,
83        };
84        Ok(price)
85    }
86
87    fn quote_with_depth(
88        &self,
89        direction: MetricDirection,
90        amount_in: &BigUint,
91        max_output: &BigUint,
92    ) -> Result<Option<DepthQuote>, SimulationError> {
93        let bins = match direction {
94            MetricDirection::ZeroForOne => &self.bid_ask.depth.bids,
95            MetricDirection::OneForZero => &self.bid_ask.depth.asks,
96        };
97
98        // `is_quotable` only requires one side of the book to be populated, so the traded side may
99        // still have no bins (one-sided depth, or a state rebuilt from a snapshot without a depth
100        // attribute). The top-of-book quote is then the best signal we have.
101        let Some(depth_max_output) = depth_max_output(bins) else {
102            return Ok(None);
103        };
104
105        let effective_max_output = depth_max_output.min(max_output.clone());
106        let depth_fill = depth_output_for_input(bins, amount_in, &effective_max_output)?;
107
108        Ok(Some(DepthQuote {
109            amount_out: depth_fill.output,
110            max_output: effective_max_output,
111            exhausted: depth_fill.exhausted,
112        }))
113    }
114}
115
116#[derive(Debug, Clone, Copy)]
117enum MetricDirection {
118    ZeroForOne,
119    OneForZero,
120}
121
122struct DepthQuote {
123    amount_out: BigUint,
124    max_output: BigUint,
125    exhausted: bool,
126}
127
128#[derive(Debug)]
129struct DepthFill {
130    output: BigUint,
131    exhausted: bool,
132}
133
134#[typetag::serde]
135impl ProtocolSim for MetricState {
136    fn fee(&self) -> f64 {
137        0.0
138    }
139
140    fn spot_price(&self, base: &Token, quote: &Token) -> Result<f64, SimulationError> {
141        let price = match (self.bid_ask.bid_price()?, self.bid_ask.ask_price()?) {
142            (Some(bid), Some(ask)) => (bid + ask) / 2.0,
143            (Some(bid), None) => bid,
144            (None, Some(ask)) => ask,
145            (None, None) => return Err(SimulationError::RecoverableError("No liquidity".into())),
146        };
147        if base.address == self.base_token.address && quote.address == self.quote_token.address {
148            Ok(price)
149        } else if base.address == self.quote_token.address &&
150            quote.address == self.base_token.address
151        {
152            Ok(1.0 / price)
153        } else {
154            Err(SimulationError::InvalidInput(
155                format!(
156                    "Invalid token addresses. Got base={}, quote={}, expected {} / {}",
157                    base.address, quote.address, self.base_token.address, self.quote_token.address
158                ),
159                None,
160            ))
161        }
162    }
163
164    fn get_amount_out(
165        &self,
166        amount_in: BigUint,
167        token_in: &Token,
168        token_out: &Token,
169    ) -> Result<GetAmountOutResult, SimulationError> {
170        let direction = self.direction(&token_in.address, &token_out.address)?;
171        let Some(price) = self.quoted_price(direction)? else {
172            return Err(SimulationError::RecoverableError("No liquidity".into()));
173        };
174        let max_output = match direction {
175            MetricDirection::ZeroForOne => self.bid_ask.total_token1_available()?,
176            MetricDirection::OneForZero => self.bid_ask.total_token0_available()?,
177        };
178
179        // Prefer size-aware depth when Metric exposes it. The depth walk runs entirely in raw
180        // integer units on Metric's own per-bin accounting, so the cap it reports when the depth
181        // is exhausted is exact (no f64 round-trip); see `depth_output_for_input` for the
182        // intra-bin rounding.
183        if let Some(quote) = self.quote_with_depth(direction, &amount_in, &max_output)? {
184            let res = GetAmountOutResult {
185                amount: quote.amount_out,
186                gas: BigUint::from(METRIC_SWAP_GAS),
187                new_state: self.clone_box(),
188            };
189            if quote.exhausted {
190                return Err(SimulationError::InvalidInput(
191                    format!(
192                        "Metric pool depth exhausted. Input {amount_in} cannot be fully filled; \
193                         tradable depth caps output at {}",
194                        quote.max_output
195                    ),
196                    Some(res),
197                ));
198            }
199            return Ok(res);
200        }
201
202        // No depth bins: flat top-of-book quote, capped only by the aggregate inventory.
203        let amount_in_human = amount_in.to_f64().ok_or_else(|| {
204            SimulationError::RecoverableError("Can't convert amount in to f64".into())
205        })? / 10_f64.powi(token_in.decimals as i32);
206        let flat_amount_out_human = match direction {
207            MetricDirection::ZeroForOne => amount_in_human * price,
208            MetricDirection::OneForZero => amount_in_human / price,
209        };
210        let amount_out =
211            BigUint::from_f64(flat_amount_out_human * 10_f64.powi(token_out.decimals as i32))
212                .ok_or_else(|| {
213                    SimulationError::RecoverableError("Can't convert amount out to BigUint".into())
214                })?;
215        let res = GetAmountOutResult {
216            amount: amount_out
217                .clone()
218                .min(max_output.clone()),
219            gas: BigUint::from(METRIC_SWAP_GAS),
220            new_state: self.clone_box(),
221        };
222        if amount_out > max_output {
223            return Err(SimulationError::InvalidInput(
224                format!(
225                    "Metric pool has not enough liquidity. Requested output {amount_out} exceeds \
226                     available {max_output}"
227                ),
228                Some(res),
229            ));
230        }
231        Ok(res)
232    }
233
234    fn get_limits(
235        &self,
236        sell_token: Bytes,
237        buy_token: Bytes,
238    ) -> Result<(BigUint, BigUint), SimulationError> {
239        let direction = self.direction(&sell_token, &buy_token)?;
240        let Some(price) = self.quoted_price(direction)? else {
241            return Ok((BigUint::zero(), BigUint::zero()));
242        };
243        // Price of one buy-token unit in sell tokens, plus the per-direction inventory cap, depth
244        // side, and token decimals.
245        let (sell_per_buy, aggregate, bins, sell_decimals, buy_decimals) = match direction {
246            MetricDirection::ZeroForOne => (
247                1.0 / price,
248                self.bid_ask.total_token1_available()?,
249                &self.bid_ask.depth.bids,
250                self.base_token.decimals,
251                self.quote_token.decimals,
252            ),
253            MetricDirection::OneForZero => (
254                price,
255                self.bid_ask.total_token0_available()?,
256                &self.bid_ask.depth.asks,
257                self.quote_token.decimals,
258                self.base_token.decimals,
259            ),
260        };
261
262        // Metric's own accounting gives the exact input required to consume the whole book: the
263        // last bin's cumulativeInputVolume. Prefer it over reconstructing the input from the
264        // top-of-book price, which understates the limit by the cumulative price impact.
265        if let Some(last_bin) = bins.last() {
266            if last_bin.cumulative_volume <= aggregate {
267                return Ok((
268                    last_bin.cumulative_input_volume.clone(),
269                    last_bin.cumulative_volume.clone(),
270                ));
271            }
272        }
273
274        // No depth bins, or the aggregate inventory truncates the walkable depth: either way the
275        // aggregate is the binding output cap, and get_amount_out rejects anything beyond it
276        // (as insufficient liquidity or depth-exhausted respectively). Estimate the matching
277        // input from the top-of-book price.
278        let buy_limit = aggregate;
279        let buy_limit_human = buy_limit.to_f64().ok_or_else(|| {
280            SimulationError::RecoverableError("Can't convert buy limit to f64".into())
281        })? / 10_f64.powi(buy_decimals as i32);
282        let sell_limit =
283            BigUint::from_f64(buy_limit_human * sell_per_buy * 10_f64.powi(sell_decimals as i32))
284                .ok_or_else(|| {
285                SimulationError::RecoverableError("Can't convert sell limit to BigUint".into())
286            })?;
287        Ok((sell_limit, buy_limit))
288    }
289
290    fn as_indicatively_priced(&self) -> Result<&dyn IndicativelyPriced, SimulationError> {
291        Ok(self)
292    }
293
294    fn delta_transition(
295        &mut self,
296        _delta: ProtocolStateDelta,
297        _tokens: &HashMap<Bytes, Token>,
298        _balances: &Balances,
299    ) -> Result<(), TransitionError> {
300        // RFQ updates arrive as full API snapshots, not block deltas.
301        Err(TransitionError::DecodeError(
302            "Metric RFQ state is snapshot-based and does not support deltas".into(),
303        ))
304    }
305
306    fn clone_box(&self) -> Box<dyn ProtocolSim> {
307        Box::new(self.clone())
308    }
309
310    fn as_any(&self) -> &dyn Any {
311        self
312    }
313
314    fn as_any_mut(&mut self) -> &mut dyn Any {
315        self
316    }
317
318    fn eq(&self, other: &dyn ProtocolSim) -> bool {
319        if let Some(other_state) = other
320            .as_any()
321            .downcast_ref::<MetricState>()
322        {
323            self.base_token == other_state.base_token &&
324                self.quote_token == other_state.quote_token &&
325                self.metadata == other_state.metadata &&
326                self.bid_ask == other_state.bid_ask
327        } else {
328            false
329        }
330    }
331}
332
333fn depth_max_output(bins: &[MetricDepthBin]) -> Option<BigUint> {
334    bins.last()
335        .map(|bin| bin.cumulative_volume.clone())
336}
337
338/// Walks the depth bins and computes the output bought by `amount_in`, entirely in raw integer
339/// units using Metric's own per-bin accounting.
340///
341/// Full bins cost exactly their `cumulativeInputVolume` difference — Metric's authoritative,
342/// fee-adjusted accounting. Partial fills are priced pro-rata at the bin's average cost: under
343/// the documented linear intra-bin model this is exact at bin boundaries and conservative inside
344/// the bin (off by at most half the bin's price width, understating the output; the division
345/// also rounds down).
346fn depth_output_for_input(
347    bins: &[MetricDepthBin],
348    amount_in: &BigUint,
349    max_output: &BigUint,
350) -> Result<DepthFill, SimulationError> {
351    if amount_in.is_zero() || max_output.is_zero() {
352        return Ok(DepthFill { output: BigUint::ZERO, exhausted: !amount_in.is_zero() });
353    }
354
355    let mut previous_output = BigUint::ZERO;
356    let mut previous_input = BigUint::ZERO;
357    let mut remaining_input = amount_in.clone();
358    let mut output = BigUint::ZERO;
359
360    for bin in bins {
361        // Metric reports both sides cumulatively to each boundary: output volume in output-token
362        // raw units and the input required to reach it in input-token raw units. Adjacent
363        // differences give the per-bin amounts.
364        let cumulative_output = &bin.cumulative_volume;
365        let cumulative_input = &bin.cumulative_input_volume;
366        if cumulative_output < &previous_output || cumulative_input < &previous_input {
367            return Err(SimulationError::RecoverableError(
368                "Metric depth cumulative volumes are not monotonic".into(),
369            ));
370        }
371        let volume_in_bin = cumulative_output - &previous_output;
372        let input_in_bin = cumulative_input - &previous_input;
373        previous_output = cumulative_output.clone();
374        previous_input = cumulative_input.clone();
375
376        // Price-grid bins without liquidity carry neither volume nor input.
377        if volume_in_bin.is_zero() && input_in_bin.is_zero() {
378            continue;
379        }
380        // A bin with volume but no input (or vice versa) would hand out output for free or charge
381        // input for nothing; refuse to price against corrupt data.
382        if volume_in_bin.is_zero() || input_in_bin.is_zero() {
383            return Err(SimulationError::RecoverableError(
384                "Metric depth bin has inconsistent volume and input".into(),
385            ));
386        }
387
388        let output_capacity = max_output - &output;
389        if output_capacity.is_zero() {
390            break;
391        }
392
393        // The aggregate inventory cap can cut the bin short; charge the fillable slice pro-rata,
394        // rounding the input up so the quote never undercharges.
395        let (fillable_volume, fillable_input) = if volume_in_bin <= output_capacity {
396            (volume_in_bin, input_in_bin)
397        } else {
398            let fillable_input = (&input_in_bin * &output_capacity + &volume_in_bin -
399                BigUint::from(1u8)) /
400                &volume_in_bin;
401            (output_capacity, fillable_input)
402        };
403
404        if remaining_input >= fillable_input {
405            output += &fillable_volume;
406            remaining_input -= &fillable_input;
407            continue;
408        }
409
410        // The input runs out inside this bin: pro-rata output, rounding down.
411        output += &fillable_volume * &remaining_input / &fillable_input;
412        remaining_input = BigUint::ZERO;
413        break;
414    }
415
416    Ok(DepthFill { output, exhausted: !remaining_input.is_zero() })
417}
418
419#[async_trait]
420impl IndicativelyPriced for MetricState {
421    async fn request_signed_quote(
422        &self,
423        params: GetAmountOutParams,
424    ) -> Result<SignedQuote, SimulationError> {
425        let direction = self.direction(&params.token_in, &params.token_out)?;
426        let (token_in, token_out) = match direction {
427            MetricDirection::ZeroForOne => (&self.base_token, &self.quote_token),
428            MetricDirection::OneForZero => (&self.quote_token, &self.base_token),
429        };
430        let amount_out = self
431            .get_amount_out(params.amount_in.clone(), token_in, token_out)?
432            .amount;
433
434        // The v1 heartbeat updates the oracle on-chain every block, so execution relays no signed
435        // oracle-update args with the swap. The quote therefore carries no quote attributes.
436        Ok(SignedQuote {
437            base_token: params.token_in.clone(),
438            quote_token: params.token_out.clone(),
439            amount_in: params.amount_in.clone(),
440            amount_out,
441            quote_attributes: HashMap::new(),
442        })
443    }
444}
445
446#[cfg(test)]
447mod tests {
448    use std::{collections::HashSet, str::FromStr};
449
450    use tokio::time::Duration;
451    use tycho_common::models::Chain;
452
453    use super::*;
454    use crate::rfq::protocols::metric::{client::MetricClient, models::MetricDepth};
455
456    fn big(value: &str) -> BigUint {
457        value.parse().unwrap()
458    }
459
460    fn weth() -> Token {
461        Token::new(
462            &Bytes::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap(),
463            "WETH",
464            18,
465            0,
466            &[Some(2300)],
467            Chain::Ethereum,
468            100,
469        )
470    }
471
472    fn usdc() -> Token {
473        Token::new(
474            &Bytes::from_str("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48").unwrap(),
475            "USDC",
476            6,
477            0,
478            &[Some(1)],
479            Chain::Ethereum,
480            100,
481        )
482    }
483
484    fn base_weth() -> Token {
485        Token::new(
486            &Bytes::from_str("0x4200000000000000000000000000000000000006").unwrap(),
487            "WETH",
488            18,
489            0,
490            &[Some(2300)],
491            Chain::Base,
492            100,
493        )
494    }
495
496    fn base_usdc() -> Token {
497        Token::new(
498            &Bytes::from_str("0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913").unwrap(),
499            "USDC",
500            6,
501            0,
502            &[Some(1)],
503            Chain::Base,
504            100,
505        )
506    }
507
508    fn state() -> MetricState {
509        let weth = weth();
510        let usdc = usdc();
511        let metadata = MetricMetadata {
512            pool_address: Bytes::from_str("0xbF48bCf474d57fF82A3215319229e0DE1476A557").unwrap(),
513            token0: weth.address.clone(),
514            token1: usdc.address.clone(),
515            tvl_fiat: Some(3000.0),
516        };
517        let bid_ask = MetricBidAskResponse {
518            // 3000 * 2^64
519            bid_adj: big("55340232221128654848000"),
520            // 3010 * 2^64
521            ask_adj: big("55524699661865750400000"),
522            total_token0_available: Some(big("10000000000000000000")),
523            total_token1_available: Some(big("30000000000")),
524            server_ts: 100,
525            price_provider_status: Some("healthy".to_string()),
526            depth: MetricDepth::default(),
527        };
528        let client = MetricClient::new(
529            Chain::Ethereum,
530            HashSet::new(),
531            0.0,
532            "http://localhost:8080".to_string(),
533            None,
534            Duration::from_secs(1),
535            Duration::from_secs(1),
536        )
537        .unwrap();
538        MetricState::new(weth, usdc, metadata, bid_ask, client)
539    }
540
541    /// A book with an ask side only: one bin selling 1 WETH for 3011 USDC.
542    fn state_without_bid() -> MetricState {
543        let mut state = state();
544        state.bid_ask.bid_adj = BigUint::zero();
545        state.bid_ask.depth.asks = vec![depth_bin("1000000000000000000", "3011000000")];
546        state
547    }
548
549    /// A book with a bid side only: one bin buying 1 WETH for 2999 USDC.
550    fn state_without_ask() -> MetricState {
551        let mut state = state();
552        state.bid_ask.ask_adj = BigUint::from(u128::MAX);
553        state.bid_ask.depth.bids = vec![depth_bin("2999000000", "1000000000000000000")];
554        state
555    }
556
557    #[test]
558    fn test_book_without_bid_trades_only_the_ask_side() {
559        let state = state_without_bid();
560        let ask = state
561            .bid_ask
562            .ask_price()
563            .unwrap()
564            .unwrap();
565
566        assert!(state.bid_ask.is_quotable());
567        assert_eq!(
568            state
569                .spot_price(&weth(), &usdc())
570                .unwrap(),
571            ask
572        );
573        assert_eq!(
574            state
575                .get_limits(weth().address, usdc().address)
576                .unwrap(),
577            (BigUint::zero(), BigUint::zero())
578        );
579        let Err(SimulationError::RecoverableError(msg)) =
580            state.get_amount_out(big("100000000000000000"), &weth(), &usdc())
581        else {
582            panic!("selling WETH into a book without a bid must fail");
583        };
584        assert_eq!(msg, "No liquidity");
585        assert_eq!(
586            state
587                .get_limits(usdc().address, weth().address)
588                .unwrap(),
589            (big("3011000000"), big("1000000000000000000"))
590        );
591    }
592
593    #[test]
594    fn test_book_without_ask_trades_only_the_bid_side() {
595        let state = state_without_ask();
596        let bid = state
597            .bid_ask
598            .bid_price()
599            .unwrap()
600            .unwrap();
601
602        assert!(state.bid_ask.is_quotable());
603        assert_eq!(
604            state
605                .spot_price(&weth(), &usdc())
606                .unwrap(),
607            bid
608        );
609        assert_eq!(
610            state
611                .get_limits(usdc().address, weth().address)
612                .unwrap(),
613            (BigUint::zero(), BigUint::zero())
614        );
615        let Err(SimulationError::RecoverableError(msg)) =
616            state.get_amount_out(big("300000000"), &usdc(), &weth())
617        else {
618            panic!("buying WETH from a book without an ask must fail");
619        };
620        assert_eq!(msg, "No liquidity");
621        assert_eq!(
622            state
623                .get_limits(weth().address, usdc().address)
624                .unwrap(),
625            (big("1000000000000000000"), big("2999000000"))
626        );
627    }
628
629    #[test]
630    fn test_book_without_quotes_has_no_spot_price() {
631        let mut state = state();
632        state.bid_ask.bid_adj = BigUint::zero();
633        state.bid_ask.ask_adj = BigUint::from(u128::MAX);
634
635        let Err(SimulationError::RecoverableError(msg)) = state.spot_price(&weth(), &usdc()) else {
636            panic!("a book without quotes has no spot price");
637        };
638        assert_eq!(msg, "No liquidity");
639    }
640
641    #[test]
642    fn test_get_amount_out_zero_for_one() {
643        let state = state();
644        let result = state
645            .get_amount_out(
646                BigUint::from(1_000_000_000_000_000_000u128),
647                &state.base_token,
648                &state.quote_token,
649            )
650            .unwrap();
651
652        assert_eq!(result.amount, BigUint::from(3_000_000_000u64));
653    }
654
655    #[test]
656    fn test_get_amount_out_one_for_zero() {
657        let state = state();
658        let result = state
659            .get_amount_out(BigUint::from(3_010_000_000u64), &state.quote_token, &state.base_token)
660            .unwrap();
661
662        assert_eq!(result.amount, BigUint::from(1_000_000_000_000_000_000u128));
663    }
664
665    #[test]
666    fn test_get_amount_out_caps_to_available_liquidity() {
667        let mut state = state();
668        state.bid_ask.total_token1_available = Some(big("1500000000"));
669        let err = state
670            .get_amount_out(
671                BigUint::from(1_000_000_000_000_000_000u128),
672                &state.base_token,
673                &state.quote_token,
674            )
675            .unwrap_err();
676
677        assert!(matches!(err, SimulationError::InvalidInput(_, Some(_))));
678    }
679
680    #[test]
681    fn test_get_amount_out_depth_exhausted_reports_depth_message() {
682        let mut state = state();
683        state.bid_ask.depth.bids = vec![MetricDepthBin {
684            bin_idx: 0,
685            // 2900 * 2^64
686            price: big("53495557813757699686400"),
687            // Only 3000 USDC of depth, far less than the 30000 USDC aggregate inventory.
688            cumulative_volume: big("3000000000"),
689            // Full-bin input at avg price 2950 (3000 USDC / 2950) ≈ 1.0169 WETH.
690            cumulative_input_volume: big("1016949152542372881"),
691        }];
692
693        let err = state
694            .get_amount_out(
695                // 2 WETH buys more output than the depth can fill.
696                BigUint::from(2_000_000_000_000_000_000u128),
697                &state.base_token,
698                &state.quote_token,
699            )
700            .unwrap_err();
701
702        match err {
703            SimulationError::InvalidInput(msg, Some(_)) => {
704                assert!(msg.contains("depth exhausted"), "unexpected message: {msg}");
705            }
706            other => panic!("expected InvalidInput, got {other:?}"),
707        }
708    }
709
710    #[test]
711    fn test_get_amount_out_exhausted_returns_exact_cap() {
712        let mut state = state();
713        // An 18-decimal cap above 2^53 that is NOT representable exactly as f64. This is the
714        // value from the production log; the f64 round-trip would drift it to ...662912.
715        let cap = "6575581573690662958";
716        state.bid_ask.depth.asks = vec![MetricDepthBin {
717            bin_idx: 0,
718            // 3100 * 2^64
719            price: big("57184906628499610009600"),
720            cumulative_volume: big(cap),
721            // Full-bin input at avg price 3055 (~6.5756 WETH * 3055) ≈ 20088 USDC, below the
722            // 30000 USDC input so the whole bin is consumed and the trade is depth-exhausted.
723            cumulative_input_volume: big("20088000000"),
724        }];
725
726        let err = state
727            .get_amount_out(
728                // 30000 USDC buys more WETH than the depth can fill.
729                BigUint::from(30_000_000_000u64),
730                &state.quote_token,
731                &state.base_token,
732            )
733            .unwrap_err();
734
735        match err {
736            SimulationError::InvalidInput(msg, Some(res)) => {
737                assert!(msg.contains("depth exhausted"), "unexpected message: {msg}");
738                // Exact cap, not the f64-reconstructed 6575581573690662912.
739                assert_eq!(res.amount, BigUint::from_str(cap).unwrap());
740            }
741            other => panic!("expected InvalidInput, got {other:?}"),
742        }
743    }
744
745    #[test]
746    fn test_get_limits_caps_to_depth() {
747        let mut state = state();
748        state.bid_ask.depth.bids = vec![MetricDepthBin {
749            bin_idx: 0,
750            // 2900 * 2^64
751            price: big("53495557813757699686400"),
752            // 1500 USDC of depth, below the 30000 USDC aggregate inventory.
753            cumulative_volume: big("1500000000"),
754            // 1500 USDC / avg price 2950 ≈ 0.508 WETH.
755            cumulative_input_volume: big("508474576271186440"),
756        }];
757
758        let (sell_limit, buy_limit) = state
759            .get_limits(state.base_token.address.clone(), state.quote_token.address.clone())
760            .unwrap();
761
762        // Output limit follows the depth, not the aggregate inventory.
763        assert_eq!(buy_limit, BigUint::from(1_500_000_000u64));
764        // Input limit is Metric's own accounting: the last bin's cumulativeInputVolume, not a
765        // top-of-book reconstruction (which would understate it by the price impact).
766        assert_eq!(sell_limit, BigUint::from(508_474_576_271_186_440u128));
767    }
768
769    #[test]
770    fn test_get_limits_aggregate_truncates_depth() {
771        let mut state = state();
772        // Aggregate inventory (1000 USDC) is below the 3000 USDC depth total, so the exact
773        // last-bin input no longer applies and the limit falls back to the top-of-book estimate.
774        state.bid_ask.total_token1_available = Some(big("1000000000"));
775        state.bid_ask.depth.bids = vec![MetricDepthBin {
776            bin_idx: 0,
777            // 2900 * 2^64
778            price: big("53495557813757699686400"),
779            cumulative_volume: big("3000000000"),
780            cumulative_input_volume: big("1016949152542372881"),
781        }];
782
783        let (sell_limit, buy_limit) = state
784            .get_limits(state.base_token.address.clone(), state.quote_token.address.clone())
785            .unwrap();
786
787        // Output limit follows the aggregate inventory, not the deeper book.
788        assert_eq!(buy_limit, BigUint::from(1_000_000_000u64));
789        // Input limit estimated at the top-of-book bid (3000): 1000 USDC / 3000 = 1/3 WETH,
790        // rounded through the f64 estimate path (this fallback is an estimate by design).
791        assert_eq!(sell_limit, BigUint::from(333_333_333_333_333_312u128));
792    }
793
794    #[test]
795    fn test_get_limits_uses_aggregate_without_depth() {
796        let state = state();
797
798        let (_, buy_limit) = state
799            .get_limits(state.base_token.address.clone(), state.quote_token.address.clone())
800            .unwrap();
801
802        // No depth bins: fall back to aggregate inventory (30000 USDC).
803        assert_eq!(buy_limit, BigUint::from(30_000_000_000u64));
804    }
805
806    #[test]
807    fn test_get_amount_out_walks_bid_depth() {
808        let mut state = state();
809        state.bid_ask.depth.bids = vec![MetricDepthBin {
810            bin_idx: 0,
811            // 2900 * 2^64
812            price: big("53495557813757699686400"),
813            cumulative_volume: big("3000000000"),
814            // Full-bin input at avg price 2950 (3000 USDC / 2950) ≈ 1.0169 WETH.
815            cumulative_input_volume: big("1016949152542372881"),
816        }];
817
818        let result = state
819            .get_amount_out(
820                BigUint::from(1_000_000_000_000_000_000u128),
821                &state.base_token,
822                &state.quote_token,
823            )
824            .unwrap();
825
826        // Pro-rata at the bin's own average price (3000 USDC over 1.016949... WETH = 2950):
827        // 1 WETH buys exactly 2950 USDC.
828        assert_eq!(result.amount, BigUint::from(2_950_000_000u64));
829    }
830
831    #[test]
832    fn test_get_amount_out_walks_ask_depth() {
833        let mut state = state();
834        state.bid_ask.depth.asks = vec![MetricDepthBin {
835            bin_idx: 0,
836            // 3100 * 2^64
837            price: big("57184906628499610009600"),
838            cumulative_volume: big("1000000000000000000"),
839            // Full-bin input at avg price 3055 (1 WETH * 3055) = 3055 USDC.
840            cumulative_input_volume: big("3055000000"),
841        }];
842
843        let result = state
844            .get_amount_out(BigUint::from(3_000_000_000u64), &state.quote_token, &state.base_token)
845            .unwrap();
846
847        assert!(result.amount < BigUint::from(1_000_000_000_000_000_000u128));
848        assert!(result.amount > BigUint::from(980_000_000_000_000_000u128));
849    }
850
851    fn depth_bin(cumulative_volume: &str, cumulative_input_volume: &str) -> MetricDepthBin {
852        MetricDepthBin {
853            bin_idx: 0,
854            // 2900 * 2^64; the integer walk prices from the volume/input columns, not this field.
855            price: big("53495557813757699686400"),
856            cumulative_volume: big(cumulative_volume),
857            cumulative_input_volume: big(cumulative_input_volume),
858        }
859    }
860
861    #[test]
862    fn test_depth_output_for_input_partially_fills_bid_bin() {
863        // Full-bin input at price 2950: 3000 USDC costs 3000/2950 ≈ 1.0169 WETH.
864        let bins = vec![depth_bin("3000000000", "1016949152542372881")];
865
866        let fill = depth_output_for_input(
867            &bins,
868            &BigUint::from(1_000_000_000_000_000_000u128),
869            &BigUint::from(3_000_000_000u64),
870        )
871        .unwrap();
872
873        // Pro-rata within the bin: 1 WETH buys exactly 2950 USDC.
874        assert_eq!(fill.output, BigUint::from(2_950_000_000u64));
875        assert!(!fill.exhausted);
876    }
877
878    #[test]
879    fn test_depth_output_for_input_partially_fills_ask_bin() {
880        // Full-bin input at price 3055: 1 WETH costs 3055 USDC.
881        let bins = vec![depth_bin("1000000000000000000", "3055000000")];
882
883        let fill = depth_output_for_input(
884            &bins,
885            &BigUint::from(3_000_000_000u64),
886            &BigUint::from(1_000_000_000_000_000_000u128),
887        )
888        .unwrap();
889
890        // Pro-rata within the bin, rounded down: 1e18 * 3000 / 3055.
891        let expected = BigUint::from(1_000_000_000_000_000_000u128) *
892            BigUint::from(3_000_000_000u64) /
893            BigUint::from(3_055_000_000u64);
894        assert_eq!(fill.output, expected);
895        assert!(!fill.exhausted);
896    }
897
898    #[test]
899    fn test_depth_output_for_input_exhausts_available_depth() {
900        let bins = vec![depth_bin("3000000000", "1016949152542372881")];
901
902        let fill = depth_output_for_input(
903            &bins,
904            &BigUint::from(2_000_000_000_000_000_000u128),
905            &BigUint::from(3_000_000_000u64),
906        )
907        .unwrap();
908
909        assert_eq!(fill.output, BigUint::from(3_000_000_000u64));
910        assert!(fill.exhausted);
911    }
912
913    #[test]
914    fn test_depth_output_for_input_walks_multiple_bins() {
915        // Bin 1 sells 3000 USDC for 1 WETH; bin 2 sells another 3000 USDC for 1.1 WETH.
916        let bins = vec![
917            depth_bin("3000000000", "1000000000000000000"),
918            depth_bin("6000000000", "2100000000000000000"),
919        ];
920
921        let fill = depth_output_for_input(
922            &bins,
923            // 1.55 WETH: consumes bin 1 fully, then half of bin 2's 1.1 WETH.
924            &BigUint::from(1_550_000_000_000_000_000u128),
925            &BigUint::from(6_000_000_000u64),
926        )
927        .unwrap();
928
929        // 3000 + 3000 * 0.55/1.1 = 4500 USDC.
930        assert_eq!(fill.output, BigUint::from(4_500_000_000u64));
931        assert!(!fill.exhausted);
932    }
933
934    #[test]
935    fn test_depth_output_for_input_caps_slice_to_aggregate_inventory() {
936        let bins = vec![depth_bin("3000000000", "1000000000000000000")];
937
938        // Aggregate inventory truncates the bin to 1500 USDC; the fillable slice costs a
939        // pro-rata 0.5 WETH, so 1 WETH exhausts it.
940        let fill = depth_output_for_input(
941            &bins,
942            &BigUint::from(1_000_000_000_000_000_000u128),
943            &BigUint::from(1_500_000_000u64),
944        )
945        .unwrap();
946
947        assert_eq!(fill.output, BigUint::from(1_500_000_000u64));
948        assert!(fill.exhausted);
949    }
950
951    #[test]
952    fn test_depth_output_for_input_rejects_inconsistent_bin() {
953        // Volume without input would hand out output for free.
954        let bins = vec![depth_bin("3000000000", "0")];
955
956        let err = depth_output_for_input(
957            &bins,
958            &BigUint::from(1_000_000_000_000_000_000u128),
959            &BigUint::from(3_000_000_000u64),
960        )
961        .unwrap_err();
962
963        assert!(matches!(err, SimulationError::RecoverableError(_)));
964    }
965
966    #[test]
967    fn test_depth_output_for_input_rejects_non_monotonic_bins() {
968        let bins = vec![
969            depth_bin("3000000000", "1000000000000000000"),
970            // Cumulative volume goes backwards.
971            depth_bin("2000000000", "2000000000000000000"),
972        ];
973
974        let err = depth_output_for_input(
975            &bins,
976            &BigUint::from(2_000_000_000_000_000_000u128),
977            &BigUint::from(3_000_000_000u64),
978        )
979        .unwrap_err();
980
981        assert!(matches!(err, SimulationError::RecoverableError(_)));
982    }
983
984    #[tokio::test]
985    #[ignore = "hits Metric's public API"]
986    async fn test_live_metric_api_state_get_amount_out_and_signed_quote() {
987        use crate::rfq::protocols::metric::models::PaginatedMetadataResponse;
988
989        // Base: the only supported chain with pools published on the live API so far.
990        let weth = base_weth();
991        let usdc = base_usdc();
992        let config = crate::rfq::constants::get_metric_config();
993        let base_url = config
994            .base_url
995            .trim_end_matches('/')
996            .to_string();
997        let client = MetricClient::new(
998            Chain::Base,
999            HashSet::from([weth.address.clone(), usdc.address.clone()]),
1000            0.0,
1001            base_url.clone(),
1002            config.api_key.clone(),
1003            Duration::from_secs(1),
1004            Duration::from_secs(5),
1005        )
1006        .unwrap();
1007
1008        let http_client = reqwest::Client::new();
1009        let metadata: PaginatedMetadataResponse = http_client
1010            .get(format!("{base_url}/public/v1/evm/8453/metadata"))
1011            .header("accept", "application/json")
1012            .query(&[("count", "500")])
1013            .send()
1014            .await
1015            .unwrap()
1016            .json()
1017            .await
1018            .unwrap();
1019
1020        let mut selected = None;
1021        for pool in metadata
1022            .data
1023            .into_iter()
1024            .filter(|pool| pool.token0 == weth.address && pool.token1 == usdc.address)
1025        {
1026            let checksummed =
1027                alloy::primitives::Address::from_slice(&pool.pool_address).to_checksum(None);
1028            let mut request = http_client
1029                .get(format!("{base_url}/public/v1/evm/8453/{checksummed}/bid_ask"))
1030                .header("accept", "application/json");
1031            if let Some(api_key) = &config.api_key {
1032                request = request.bearer_auth(api_key);
1033            }
1034            let bid_ask: MetricBidAskResponse = request
1035                .send()
1036                .await
1037                .unwrap()
1038                .json()
1039                .await
1040                .unwrap();
1041            let has_enough_quote_liquidity = bid_ask
1042                .total_token1_available()
1043                .map(|available| available > BigUint::from(10u8))
1044                .unwrap_or(false);
1045            if bid_ask.is_quotable() && has_enough_quote_liquidity {
1046                selected = Some((pool, bid_ask));
1047                break;
1048            }
1049        }
1050
1051        let Some((metadata, bid_ask)) = selected else {
1052            eprintln!("Metric live API returned no liquid Base WETH/USDC pool; skipping");
1053            return;
1054        };
1055
1056        let state = MetricState::new(weth, usdc, metadata, bid_ask, client);
1057        assert!(state.bid_ask.is_quotable());
1058
1059        let amount_in = BigUint::from(1_000_000_000u64);
1060        let indicative_quote = state
1061            .get_amount_out(amount_in.clone(), &state.base_token, &state.quote_token)
1062            .unwrap();
1063        let trader = Bytes::from_str("0x0000000000000000000000000000000000000001").unwrap();
1064        let signed_quote = state
1065            .request_signed_quote(GetAmountOutParams {
1066                amount_in,
1067                token_in: state.base_token.address.clone(),
1068                token_out: state.quote_token.address.clone(),
1069                sender: trader.clone(),
1070                receiver: trader,
1071            })
1072            .await
1073            .unwrap();
1074
1075        assert!(indicative_quote.amount > BigUint::from(0u8));
1076        assert!(signed_quote.amount_out > BigUint::from(0u8));
1077        assert_eq!(signed_quote.base_token, state.base_token.address);
1078        assert_eq!(signed_quote.quote_token, state.quote_token.address);
1079        // The heartbeat model relays no oracle-update args, so the quote carries no attributes.
1080        assert!(signed_quote.quote_attributes.is_empty());
1081    }
1082}