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