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
23const 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 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 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 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 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 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 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 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 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
338fn 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 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 if volume_in_bin.is_zero() && input_in_bin.is_zero() {
378 continue;
379 }
380 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 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 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(¶ms.token_in, ¶ms.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 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 bid_adj: big("55340232221128654848000"),
520 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 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 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 price: big("53495557813757699686400"),
687 cumulative_volume: big("3000000000"),
689 cumulative_input_volume: big("1016949152542372881"),
691 }];
692
693 let err = state
694 .get_amount_out(
695 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 let cap = "6575581573690662958";
716 state.bid_ask.depth.asks = vec![MetricDepthBin {
717 bin_idx: 0,
718 price: big("57184906628499610009600"),
720 cumulative_volume: big(cap),
721 cumulative_input_volume: big("20088000000"),
724 }];
725
726 let err = state
727 .get_amount_out(
728 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 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 price: big("53495557813757699686400"),
752 cumulative_volume: big("1500000000"),
754 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 assert_eq!(buy_limit, BigUint::from(1_500_000_000u64));
764 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 state.bid_ask.total_token1_available = Some(big("1000000000"));
775 state.bid_ask.depth.bids = vec![MetricDepthBin {
776 bin_idx: 0,
777 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 assert_eq!(buy_limit, BigUint::from(1_000_000_000u64));
789 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 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 price: big("53495557813757699686400"),
813 cumulative_volume: big("3000000000"),
814 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 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 price: big("57184906628499610009600"),
838 cumulative_volume: big("1000000000000000000"),
839 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 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 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 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 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 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 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 &BigUint::from(1_550_000_000_000_000_000u128),
925 &BigUint::from(6_000_000_000u64),
926 )
927 .unwrap();
928
929 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 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 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 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 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 assert!(signed_quote.quote_attributes.is_empty());
1081 }
1082}