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#[derive(Clone, Serialize, Deserialize)]
24pub struct MetricState {
25    pub base_token: Token,
26    pub quote_token: Token,
27    pub metadata: MetricMetadata,
28    pub bid_ask: MetricBidAskResponse,
29    pub client: MetricClient,
30}
31
32impl fmt::Debug for MetricState {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        f.debug_struct("MetricState")
35            .field("base_token", &self.base_token)
36            .field("quote_token", &self.quote_token)
37            .field("pool", &self.metadata.pool_address)
38            .field("latest_block", &self.bid_ask.latest_block)
39            .finish_non_exhaustive()
40    }
41}
42
43impl MetricState {
44    pub fn new(
45        base_token: Token,
46        quote_token: Token,
47        metadata: MetricMetadata,
48        bid_ask: MetricBidAskResponse,
49        client: MetricClient,
50    ) -> Self {
51        Self { base_token, quote_token, metadata, bid_ask, client }
52    }
53
54    fn direction(
55        &self,
56        token_in: &Bytes,
57        token_out: &Bytes,
58    ) -> Result<MetricDirection, SimulationError> {
59        if token_in == &self.base_token.address && token_out == &self.quote_token.address {
60            Ok(MetricDirection::ZeroForOne)
61        } else if token_in == &self.quote_token.address && token_out == &self.base_token.address {
62            Ok(MetricDirection::OneForZero)
63        } else {
64            Err(SimulationError::InvalidInput(
65                format!(
66                    "Invalid token addresses. Got in={token_in}, out={token_out}, expected {} / {}",
67                    self.base_token.address, self.quote_token.address
68                ),
69                None,
70            ))
71        }
72    }
73
74    fn quote_with_depth(
75        &self,
76        direction: MetricDirection,
77        amount_in_human: f64,
78        token_out_decimals: u32,
79        max_output: &BigUint,
80    ) -> Result<Option<DepthQuote>, SimulationError> {
81        let (start_price, bins) = match direction {
82            MetricDirection::ZeroForOne => (self.bid_ask.bid_price()?, &self.bid_ask.depth.bids),
83            MetricDirection::OneForZero => (self.bid_ask.ask_price()?, &self.bid_ask.depth.asks),
84        };
85
86        // Some pools still return an empty depth object. In that case the top-of-book quote is
87        // the best signal we have, so keep the old flat-price path.
88        let Some(depth_max_output) = depth_max_output(bins)? else {
89            return Ok(None);
90        };
91
92        let effective_max_output = depth_max_output.min(max_output.clone());
93        if effective_max_output.is_zero() {
94            return Ok(Some(DepthQuote {
95                amount_out_human: 0.0,
96                max_output: effective_max_output,
97                exhausted: amount_in_human > 0.0,
98            }));
99        }
100
101        let max_output_human =
102            raw_to_human(&effective_max_output, token_out_decimals, "depth max output")?;
103        let depth_fill = depth_output_for_input(
104            direction,
105            bins,
106            start_price,
107            amount_in_human,
108            token_out_decimals,
109            max_output_human,
110        )?;
111
112        Ok(Some(DepthQuote {
113            amount_out_human: depth_fill.output_human,
114            max_output: effective_max_output,
115            exhausted: depth_fill.exhausted,
116        }))
117    }
118}
119
120#[derive(Debug, Clone, Copy)]
121enum MetricDirection {
122    ZeroForOne,
123    OneForZero,
124}
125
126struct DepthQuote {
127    amount_out_human: f64,
128    max_output: BigUint,
129    exhausted: bool,
130}
131
132struct DepthFill {
133    output_human: f64,
134    exhausted: bool,
135}
136
137#[typetag::serde]
138impl ProtocolSim for MetricState {
139    fn fee(&self) -> f64 {
140        0.0
141    }
142
143    fn spot_price(&self, base: &Token, quote: &Token) -> Result<f64, SimulationError> {
144        let bid = self.bid_ask.bid_price()?;
145        let ask = self.bid_ask.ask_price()?;
146        let mid = (bid + ask) / 2.0;
147        if base.address == self.base_token.address && quote.address == self.quote_token.address {
148            Ok(mid)
149        } else if base.address == self.quote_token.address &&
150            quote.address == self.base_token.address
151        {
152            Ok(1.0 / mid)
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 amount_in_human = amount_in.to_f64().ok_or_else(|| {
172            SimulationError::RecoverableError("Can't convert amount in to f64".into())
173        })? / 10_f64.powi(token_in.decimals as i32);
174
175        let (flat_amount_out_human, max_output) = match direction {
176            MetricDirection::ZeroForOne => {
177                let price = self.bid_ask.bid_price()?;
178                (amount_in_human * price, self.bid_ask.total_token1_available()?)
179            }
180            MetricDirection::OneForZero => {
181                let price = self.bid_ask.ask_price()?;
182                (amount_in_human / price, self.bid_ask.total_token0_available()?)
183            }
184        };
185        // Prefer size-aware depth when Metric exposes it, otherwise use best bid/ask with only
186        // the aggregate inventory cap.
187        let depth_quote =
188            self.quote_with_depth(direction, amount_in_human, token_out.decimals, &max_output)?;
189        let (amount_out_human, effective_max_output, exhausted) = match depth_quote {
190            Some(quote) => (quote.amount_out_human, quote.max_output, quote.exhausted),
191            None => (flat_amount_out_human, max_output.clone(), false),
192        };
193
194        let amount_out =
195            BigUint::from_f64(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        // When the trade is capped by available depth/inventory, the exact answer is the BigUint
200        // cap itself. Reconstructing it from the f64 human amount loses precision — an 18-decimal
201        // cap can drift by a few wei through the f64 round-trip — so return the cap directly and
202        // only fall back to the f64 result for genuine partial fills.
203        let capped_amount = if exhausted {
204            effective_max_output.clone()
205        } else {
206            amount_out
207                .clone()
208                .min(effective_max_output.clone())
209        };
210        let res = GetAmountOutResult {
211            amount: capped_amount.clone(),
212            gas: BigUint::from(170_000u64),
213            new_state: self.clone_box(),
214        };
215
216        if amount_out > effective_max_output {
217            return Err(SimulationError::InvalidInput(
218                format!(
219                    "Metric pool has not enough liquidity. Requested output {amount_out} exceeds \
220                     available {effective_max_output}"
221                ),
222                Some(res),
223            ));
224        }
225        if exhausted {
226            return Err(SimulationError::InvalidInput(
227                format!(
228                    "Metric pool depth exhausted. Input {amount_in} cannot be fully filled; \
229                     tradable depth caps output at {effective_max_output}"
230                ),
231                Some(res),
232            ));
233        }
234
235        Ok(res)
236    }
237
238    fn get_limits(
239        &self,
240        sell_token: Bytes,
241        buy_token: Bytes,
242    ) -> Result<(BigUint, BigUint), SimulationError> {
243        let direction = self.direction(&sell_token, &buy_token)?;
244        match direction {
245            MetricDirection::ZeroForOne => {
246                let price = self.bid_ask.bid_price()?;
247                // Mirror get_amount_out: the tradable output is capped by the published depth,
248                // not just aggregate inventory. Reporting the aggregate would advertise a limit
249                // that get_amount_out then rejects as depth-exhausted.
250                let buy_limit =
251                    cap_to_depth(self.bid_ask.total_token1_available()?, &self.bid_ask.depth.bids)?;
252                let buy_limit_human = buy_limit.to_f64().ok_or_else(|| {
253                    SimulationError::RecoverableError("Can't convert buy limit to f64".into())
254                })? / 10_f64.powi(self.quote_token.decimals as i32);
255                let sell_limit = buy_limit_human / price;
256                let sell_limit =
257                    BigUint::from_f64(sell_limit * 10_f64.powi(self.base_token.decimals as i32))
258                        .ok_or_else(|| {
259                            SimulationError::RecoverableError(
260                                "Can't convert sell limit to BigUint".into(),
261                            )
262                        })?;
263                Ok((sell_limit, buy_limit))
264            }
265            MetricDirection::OneForZero => {
266                let price = self.bid_ask.ask_price()?;
267                // Mirror get_amount_out: the tradable output is capped by the published depth,
268                // not just aggregate inventory. Reporting the aggregate would advertise a limit
269                // that get_amount_out then rejects as depth-exhausted.
270                let buy_limit =
271                    cap_to_depth(self.bid_ask.total_token0_available()?, &self.bid_ask.depth.asks)?;
272                let buy_limit_human = buy_limit.to_f64().ok_or_else(|| {
273                    SimulationError::RecoverableError("Can't convert buy limit to f64".into())
274                })? / 10_f64.powi(self.base_token.decimals as i32);
275                let sell_limit = buy_limit_human * price;
276                let sell_limit =
277                    BigUint::from_f64(sell_limit * 10_f64.powi(self.quote_token.decimals as i32))
278                        .ok_or_else(|| {
279                        SimulationError::RecoverableError(
280                            "Can't convert sell limit to BigUint".into(),
281                        )
282                    })?;
283                Ok((sell_limit, buy_limit))
284            }
285        }
286    }
287
288    fn as_indicatively_priced(&self) -> Result<&dyn IndicativelyPriced, SimulationError> {
289        Ok(self)
290    }
291
292    fn delta_transition(
293        &mut self,
294        _delta: ProtocolStateDelta,
295        _tokens: &HashMap<Bytes, Token>,
296        _balances: &Balances,
297    ) -> Result<(), TransitionError> {
298        // RFQ updates arrive as full API snapshots, not block deltas.
299        Err(TransitionError::DecodeError(
300            "Metric RFQ state is snapshot-based and does not support deltas".into(),
301        ))
302    }
303
304    fn clone_box(&self) -> Box<dyn ProtocolSim> {
305        Box::new(self.clone())
306    }
307
308    fn as_any(&self) -> &dyn Any {
309        self
310    }
311
312    fn as_any_mut(&mut self) -> &mut dyn Any {
313        self
314    }
315
316    fn eq(&self, other: &dyn ProtocolSim) -> bool {
317        if let Some(other_state) = other
318            .as_any()
319            .downcast_ref::<MetricState>()
320        {
321            self.base_token == other_state.base_token &&
322                self.quote_token == other_state.quote_token &&
323                self.metadata == other_state.metadata &&
324                self.bid_ask == other_state.bid_ask
325        } else {
326            false
327        }
328    }
329}
330
331fn depth_max_output(bins: &[MetricDepthBin]) -> Result<Option<BigUint>, SimulationError> {
332    bins.last()
333        .map(|bin| {
334            bin.cumulative_volume()
335                .map_err(SimulationError::from)
336        })
337        .transpose()
338}
339
340/// Caps an aggregate-inventory output limit by the published depth, when present.
341///
342/// Returns the smaller of `aggregate` and the cumulative depth volume so the reported limit
343/// never exceeds what a depth walk can actually fill. Pools that expose no depth bins fall back
344/// to `aggregate`.
345fn cap_to_depth(aggregate: BigUint, bins: &[MetricDepthBin]) -> Result<BigUint, SimulationError> {
346    match depth_max_output(bins)? {
347        Some(depth) => Ok(depth.min(aggregate)),
348        None => Ok(aggregate),
349    }
350}
351
352fn depth_output_for_input(
353    direction: MetricDirection,
354    bins: &[MetricDepthBin],
355    start_price: f64,
356    input_human: f64,
357    output_decimals: u32,
358    max_output_human: f64,
359) -> Result<DepthFill, SimulationError> {
360    if input_human == 0.0 || max_output_human == 0.0 {
361        return Ok(DepthFill { output_human: 0.0, exhausted: input_human > 0.0 });
362    }
363
364    let mut current_price = start_price;
365    let mut previous_volume = 0.0;
366    let mut remaining_input = input_human;
367    let mut output_human = 0.0;
368
369    for bin in bins {
370        let bin_price = bin.price()?;
371        // Metric reports cumulative depth in output-token units for the side being walked.
372        // Adjacent differences give the volume available in each linear price segment.
373        let cumulative =
374            raw_to_human(&bin.cumulative_volume()?, output_decimals, "depth cumulative volume")?;
375        let volume_in_bin = cumulative - previous_volume;
376        previous_volume = cumulative;
377
378        if volume_in_bin <= 0.0 {
379            current_price = bin_price;
380            continue;
381        }
382
383        let output_capacity = max_output_human - output_human;
384        if output_capacity <= 0.0 {
385            break;
386        }
387        let fillable_volume = volume_in_bin.min(output_capacity);
388        // If the aggregate liquidity cap cuts this bin short, the segment can end before bin_price.
389        let segment_exit_price =
390            current_price + (bin_price - current_price) * fillable_volume / volume_in_bin;
391        // First price this whole segment. If the remaining input can pay that cost, the quote
392        // consumes fillable_volume completely and then continues into the next depth bin.
393        let full_segment_input = depth_segment_input_required(
394            direction,
395            fillable_volume,
396            current_price,
397            segment_exit_price,
398        )?;
399
400        if remaining_input >= full_segment_input {
401            output_human += fillable_volume;
402            remaining_input -= full_segment_input;
403            current_price = segment_exit_price;
404            continue;
405        }
406
407        // The remaining input is not enough for the whole segment, so the trade stops between
408        // current_price and segment_exit_price. Invert this segment's price formula to compute
409        // the partial output bought by remaining_input.
410        output_human += depth_segment_output_for_input(
411            direction,
412            remaining_input,
413            fillable_volume,
414            current_price,
415            segment_exit_price,
416        )?;
417        remaining_input = 0.0;
418        break;
419    }
420
421    if remaining_input > 1e-12 && output_human < max_output_human - 1e-12 {
422        return Err(SimulationError::RecoverableError(
423            "Metric depth has not enough cumulative volume".into(),
424        ));
425    }
426
427    Ok(DepthFill {
428        output_human: output_human.min(max_output_human),
429        exhausted: remaining_input > 1e-12,
430    })
431}
432
433fn depth_segment_input_required(
434    direction: MetricDirection,
435    output_human: f64,
436    entry_price: f64,
437    exit_price: f64,
438) -> Result<f64, SimulationError> {
439    let average_price = depth_segment_average_price(entry_price, exit_price)?;
440    match direction {
441        MetricDirection::ZeroForOne => Ok(output_human / average_price),
442        MetricDirection::OneForZero => Ok(output_human * average_price),
443    }
444}
445
446fn depth_segment_output_for_input(
447    direction: MetricDirection,
448    input_human: f64,
449    segment_output_human: f64,
450    entry_price: f64,
451    exit_price: f64,
452) -> Result<f64, SimulationError> {
453    if segment_output_human <= 0.0 || entry_price <= 0.0 {
454        return Err(SimulationError::RecoverableError(
455            "Metric depth has invalid price curve".into(),
456        ));
457    }
458
459    // Variables for this segment:
460    //   V  = segment_output_human, the max output available in this segment.
461    //   p0 = entry_price, the price at x = 0.
462    //   p1 = exit_price, the price at x = V.
463    //   x  = output filled inside this segment, where 0 <= x <= V.
464    //   I  = input_human, the input available for this partial segment.
465    //
466    // Metric linearly interpolates the price reached after filling x output:
467    //   exit_price_at_x = p0 + (p1 - p0) * x / V
468    //
469    // Metric prices the partial fill by averaging the start price and that exit price:
470    //   avg_price(x) = (p0 + exit_price_at_x) / 2
471    //
472    // Substitute exit_price_at_x:
473    //   avg_price(x) = (p0 + p0 + (p1 - p0) * x / V) / 2
474    //                = p0 + (p1 - p0) * x / (2 * V)
475    //
476    // Store the x coefficient as average_slope:
477    //   average_slope = (p1 - p0) / (2 * V)
478    //   avg_price(x) = p0 + average_slope * x
479    let average_slope = (exit_price - entry_price) / (2.0 * segment_output_human);
480    let output = match direction {
481        MetricDirection::ZeroForOne => {
482            // ZeroForOne sells base for quote, so price is quote/base and:
483            //
484            //   I = x / avg_price(x)
485            //
486            // Substitute avg_price(x):
487            //   I = x / (p0 + average_slope * x)
488            //
489            // Solve for x:
490            //   x = I * p0 / (1 - I * average_slope)
491            let denominator = 1.0 - input_human * average_slope;
492            if denominator <= 0.0 {
493                return Err(SimulationError::RecoverableError(
494                    "Metric depth has invalid price curve".into(),
495                ));
496            }
497            input_human * entry_price / denominator
498        }
499        MetricDirection::OneForZero => {
500            // OneForZero sells quote for base, so price is quote/base and:
501            //
502            //   I = x * avg_price(x)
503            //
504            // Substitute avg_price(x):
505            //   I = x * (p0 + average_slope * x)
506            //   I = p0 * x + average_slope * x^2
507            //
508            // Rearrange into the standard quadratic form a*x^2 + b*x + c = 0:
509            //   average_slope * x^2 + p0 * x - I = 0
510            //
511            // Here:
512            //   a = average_slope
513            //   b = p0
514            //   c = -I
515            //
516            // The quadratic formula uses sqrt(b^2 - 4*a*c):
517            //   b^2 - 4*a*c = p0^2 + 4 * average_slope * I
518            //
519            // The valid solution is the root inside [0, segment_output_human].
520            if average_slope.abs() < 1e-18 {
521                input_human / entry_price
522            } else {
523                let discriminant =
524                    entry_price.mul_add(entry_price, 4.0 * average_slope * input_human);
525                if discriminant < 0.0 {
526                    return Err(SimulationError::RecoverableError(
527                        "Metric depth has invalid price curve".into(),
528                    ));
529                }
530                let root_a = (-entry_price + discriminant.sqrt()) / (2.0 * average_slope);
531                let root_b = (-entry_price - discriminant.sqrt()) / (2.0 * average_slope);
532                [root_a, root_b]
533                    .into_iter()
534                    .find(|root| {
535                        root.is_finite() && *root >= -1e-12 && *root <= segment_output_human + 1e-12
536                    })
537                    .ok_or_else(|| {
538                        SimulationError::RecoverableError(
539                            "Metric depth has invalid price curve".into(),
540                        )
541                    })?
542            }
543        }
544    };
545
546    Ok(output.clamp(0.0, segment_output_human))
547}
548
549fn depth_segment_average_price(entry_price: f64, exit_price: f64) -> Result<f64, SimulationError> {
550    let average_price = (entry_price + exit_price) / 2.0;
551    if average_price <= 0.0 {
552        return Err(SimulationError::RecoverableError(
553            "Metric depth has non-positive average price".into(),
554        ));
555    }
556    Ok(average_price)
557}
558
559fn raw_to_human(amount: &BigUint, decimals: u32, field: &str) -> Result<f64, SimulationError> {
560    let amount = amount.to_f64().ok_or_else(|| {
561        SimulationError::RecoverableError(format!("Can't convert {field} to f64"))
562    })?;
563    Ok(amount / 10_f64.powi(decimals as i32))
564}
565
566#[async_trait]
567impl IndicativelyPriced for MetricState {
568    async fn request_signed_quote(
569        &self,
570        params: GetAmountOutParams,
571    ) -> Result<SignedQuote, SimulationError> {
572        let direction = self.direction(&params.token_in, &params.token_out)?;
573        let (token_in, token_out) = match direction {
574            MetricDirection::ZeroForOne => (&self.base_token, &self.quote_token),
575            MetricDirection::OneForZero => (&self.quote_token, &self.base_token),
576        };
577        let amount_out = self
578            .get_amount_out(params.amount_in.clone(), token_in, token_out)?
579            .amount;
580
581        // Metric's swap path is independent from the quote API. Execution uses this hook to fetch
582        // signed oracle-update args that can be submitted before the pool swap.
583        Ok(self
584            .client
585            .request_oracle_update_for_pool(&self.metadata, &params, amount_out)
586            .await?)
587    }
588}
589
590#[cfg(test)]
591mod tests {
592    use std::{collections::HashSet, str::FromStr};
593
594    use tokio::time::Duration;
595    use tycho_common::models::Chain;
596
597    use super::*;
598    use crate::rfq::protocols::metric::{client::MetricClient, models::MetricDepth};
599
600    fn weth() -> Token {
601        Token::new(
602            &Bytes::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap(),
603            "WETH",
604            18,
605            0,
606            &[Some(2300)],
607            Chain::Ethereum,
608            100,
609        )
610    }
611
612    fn usdc() -> Token {
613        Token::new(
614            &Bytes::from_str("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48").unwrap(),
615            "USDC",
616            6,
617            0,
618            &[Some(1)],
619            Chain::Ethereum,
620            100,
621        )
622    }
623
624    fn state() -> MetricState {
625        let weth = weth();
626        let usdc = usdc();
627        let metadata = MetricMetadata {
628            pool_address: Bytes::from_str("0xbF48bCf474d57fF82A3215319229e0DE1476A557").unwrap(),
629            token0: weth.address.clone(),
630            token1: usdc.address.clone(),
631        };
632        let bid_ask = MetricBidAskResponse {
633            // 3000 * 2^64
634            bid_adj: "55340232221128654848000".to_string(),
635            // 3010 * 2^64
636            ask_adj: "55524699661865750400000".to_string(),
637            quote_available: true,
638            total_token0_available: "10000000000000000000".to_string(),
639            total_token1_available: "30000000000".to_string(),
640            latest_block: 100,
641            depth: MetricDepth::default(),
642        };
643        let client = MetricClient::new(
644            Chain::Ethereum,
645            HashSet::new(),
646            0.0,
647            HashSet::new(),
648            "http://localhost:8080".to_string(),
649            None,
650            Duration::from_secs(1),
651            Duration::from_secs(1),
652        )
653        .unwrap();
654        MetricState::new(weth, usdc, metadata, bid_ask, client)
655    }
656
657    #[test]
658    fn test_get_amount_out_zero_for_one() {
659        let state = state();
660        let result = state
661            .get_amount_out(
662                BigUint::from(1_000_000_000_000_000_000u128),
663                &state.base_token,
664                &state.quote_token,
665            )
666            .unwrap();
667
668        assert_eq!(result.amount, BigUint::from(3_000_000_000u64));
669    }
670
671    #[test]
672    fn test_get_amount_out_one_for_zero() {
673        let state = state();
674        let result = state
675            .get_amount_out(BigUint::from(3_010_000_000u64), &state.quote_token, &state.base_token)
676            .unwrap();
677
678        assert_eq!(result.amount, BigUint::from(1_000_000_000_000_000_000u128));
679    }
680
681    #[test]
682    fn test_get_amount_out_caps_to_available_liquidity() {
683        let mut state = state();
684        state.bid_ask.total_token1_available = "1500000000".to_string();
685        let err = state
686            .get_amount_out(
687                BigUint::from(1_000_000_000_000_000_000u128),
688                &state.base_token,
689                &state.quote_token,
690            )
691            .unwrap_err();
692
693        assert!(matches!(err, SimulationError::InvalidInput(_, Some(_))));
694    }
695
696    #[test]
697    fn test_get_amount_out_depth_exhausted_reports_depth_message() {
698        let mut state = state();
699        state.bid_ask.depth.bids = vec![MetricDepthBin {
700            bin_idx: 0,
701            // 2900 * 2^64
702            price: "53495557813757699686400".to_string(),
703            // Only 3000 USDC of depth, far less than the 30000 USDC aggregate inventory.
704            cumulative_volume: "3000000000".to_string(),
705        }];
706
707        let err = state
708            .get_amount_out(
709                // 2 WETH buys more output than the depth can fill.
710                BigUint::from(2_000_000_000_000_000_000u128),
711                &state.base_token,
712                &state.quote_token,
713            )
714            .unwrap_err();
715
716        match err {
717            SimulationError::InvalidInput(msg, Some(_)) => {
718                assert!(msg.contains("depth exhausted"), "unexpected message: {msg}");
719            }
720            other => panic!("expected InvalidInput, got {other:?}"),
721        }
722    }
723
724    #[test]
725    fn test_get_amount_out_exhausted_returns_exact_cap() {
726        let mut state = state();
727        // An 18-decimal cap above 2^53 that is NOT representable exactly as f64. This is the
728        // value from the production log; the f64 round-trip would drift it to ...662912.
729        let cap = "6575581573690662958";
730        state.bid_ask.depth.asks = vec![MetricDepthBin {
731            bin_idx: 0,
732            // 3100 * 2^64
733            price: "57184906628499610009600".to_string(),
734            cumulative_volume: cap.to_string(),
735        }];
736
737        let err = state
738            .get_amount_out(
739                // 30000 USDC buys more WETH than the depth can fill.
740                BigUint::from(30_000_000_000u64),
741                &state.quote_token,
742                &state.base_token,
743            )
744            .unwrap_err();
745
746        match err {
747            SimulationError::InvalidInput(msg, Some(res)) => {
748                assert!(msg.contains("depth exhausted"), "unexpected message: {msg}");
749                // Exact cap, not the f64-reconstructed 6575581573690662912.
750                assert_eq!(res.amount, BigUint::from_str(cap).unwrap());
751            }
752            other => panic!("expected InvalidInput, got {other:?}"),
753        }
754    }
755
756    #[test]
757    fn test_get_limits_caps_to_depth() {
758        let mut state = state();
759        state.bid_ask.depth.bids = vec![MetricDepthBin {
760            bin_idx: 0,
761            // 2900 * 2^64
762            price: "53495557813757699686400".to_string(),
763            // 1500 USDC of depth, below the 30000 USDC aggregate inventory.
764            cumulative_volume: "1500000000".to_string(),
765        }];
766
767        let (sell_limit, buy_limit) = state
768            .get_limits(state.base_token.address.clone(), state.quote_token.address.clone())
769            .unwrap();
770
771        // Output limit follows the depth, not the aggregate inventory.
772        assert_eq!(buy_limit, BigUint::from(1_500_000_000u64));
773        // Input limit derived from the depth-capped output at the top-of-book bid (3000).
774        assert_eq!(sell_limit, BigUint::from(500_000_000_000_000_000u128));
775    }
776
777    #[test]
778    fn test_get_limits_uses_aggregate_without_depth() {
779        let state = state();
780
781        let (_, buy_limit) = state
782            .get_limits(state.base_token.address.clone(), state.quote_token.address.clone())
783            .unwrap();
784
785        // No depth bins: fall back to aggregate inventory (30000 USDC).
786        assert_eq!(buy_limit, BigUint::from(30_000_000_000u64));
787    }
788
789    #[test]
790    fn test_get_amount_out_walks_bid_depth() {
791        let mut state = state();
792        state.bid_ask.depth.bids = vec![MetricDepthBin {
793            bin_idx: 0,
794            // 2900 * 2^64
795            price: "53495557813757699686400".to_string(),
796            cumulative_volume: "3000000000".to_string(),
797        }];
798
799        let result = state
800            .get_amount_out(
801                BigUint::from(1_000_000_000_000_000_000u128),
802                &state.base_token,
803                &state.quote_token,
804            )
805            .unwrap();
806
807        assert!(result.amount < BigUint::from(3_000_000_000u64));
808        assert!(result.amount > BigUint::from(2_950_000_000u64));
809    }
810
811    #[test]
812    fn test_get_amount_out_walks_ask_depth() {
813        let mut state = state();
814        state.bid_ask.depth.asks = vec![MetricDepthBin {
815            bin_idx: 0,
816            // 3100 * 2^64
817            price: "57184906628499610009600".to_string(),
818            cumulative_volume: "1000000000000000000".to_string(),
819        }];
820
821        let result = state
822            .get_amount_out(BigUint::from(3_000_000_000u64), &state.quote_token, &state.base_token)
823            .unwrap();
824
825        assert!(result.amount < BigUint::from(1_000_000_000_000_000_000u128));
826        assert!(result.amount > BigUint::from(980_000_000_000_000_000u128));
827    }
828
829    #[test]
830    fn test_depth_output_for_input_partially_fills_bid_bin() {
831        let bins = vec![MetricDepthBin {
832            bin_idx: 0,
833            // 2900 * 2^64
834            price: "53495557813757699686400".to_string(),
835            cumulative_volume: "3000000000".to_string(),
836        }];
837
838        let fill =
839            depth_output_for_input(MetricDirection::ZeroForOne, &bins, 3000.0, 1.0, 6, 3000.0)
840                .unwrap();
841
842        assert!((fill.output_human - 2950.8196721311474).abs() < 1e-9);
843        assert!(!fill.exhausted);
844    }
845
846    #[test]
847    fn test_depth_output_for_input_partially_fills_ask_bin() {
848        let bins = vec![MetricDepthBin {
849            bin_idx: 0,
850            // 3100 * 2^64
851            price: "57184906628499610009600".to_string(),
852            cumulative_volume: "1000000000000000000".to_string(),
853        }];
854
855        let fill =
856            depth_output_for_input(MetricDirection::OneForZero, &bins, 3010.0, 3000.0, 18, 1.0)
857                .unwrap();
858
859        assert!((fill.output_human - 0.9822534928279816).abs() < 1e-12);
860        assert!(!fill.exhausted);
861    }
862
863    #[test]
864    fn test_depth_output_for_input_exhausts_available_depth() {
865        let bins = vec![MetricDepthBin {
866            bin_idx: 0,
867            // 2900 * 2^64
868            price: "53495557813757699686400".to_string(),
869            cumulative_volume: "3000000000".to_string(),
870        }];
871
872        let fill =
873            depth_output_for_input(MetricDirection::ZeroForOne, &bins, 3000.0, 2.0, 6, 3000.0)
874                .unwrap();
875
876        assert_eq!(fill.output_human, 3000.0);
877        assert!(fill.exhausted);
878    }
879
880    #[tokio::test]
881    #[ignore = "hits Metric's public API"]
882    async fn test_live_metric_api_state_get_amount_out_and_oracle_update() {
883        let weth = weth();
884        let usdc = usdc();
885        let base_url = std::env::var("METRIC_API_URL")
886            .unwrap_or_else(|_| "http://54.199.103.16:8080".to_string())
887            .trim_end_matches('/')
888            .to_string();
889        let client = MetricClient::new(
890            Chain::Ethereum,
891            HashSet::from([weth.address.clone(), usdc.address.clone()]),
892            0.0,
893            HashSet::new(),
894            base_url.clone(),
895            None,
896            Duration::from_secs(1),
897            Duration::from_secs(5),
898        )
899        .unwrap();
900
901        let http_client = reqwest::Client::new();
902        let metadata: Vec<MetricMetadata> = http_client
903            .get(format!("{base_url}/ethereum/metadata"))
904            .header("accept", "application/json")
905            .send()
906            .await
907            .unwrap()
908            .json()
909            .await
910            .unwrap();
911
912        let mut selected = None;
913        for pool in metadata
914            .into_iter()
915            .filter(|pool| pool.token0 == weth.address && pool.token1 == usdc.address)
916        {
917            let bid_ask: MetricBidAskResponse = http_client
918                .get(format!("{base_url}/ethereum/{}/bid_ask", pool.pool_address))
919                .header("accept", "application/json")
920                .send()
921                .await
922                .unwrap()
923                .json()
924                .await
925                .unwrap();
926            let has_enough_quote_liquidity = bid_ask
927                .total_token1_available()
928                .map(|available| available > BigUint::from(10u8))
929                .unwrap_or(false);
930            if bid_ask.quote_available && has_enough_quote_liquidity {
931                selected = Some((pool, bid_ask));
932                break;
933            }
934        }
935
936        let Some((metadata, bid_ask)) = selected else {
937            eprintln!("Metric live API returned no liquid Ethereum WETH/USDC pool; skipping");
938            return;
939        };
940
941        let state = MetricState::new(weth, usdc, metadata, bid_ask, client);
942        assert!(state.bid_ask.quote_available);
943
944        let amount_in = BigUint::from(1_000_000_000u64);
945        let indicative_quote = state
946            .get_amount_out(amount_in.clone(), &state.base_token, &state.quote_token)
947            .unwrap();
948        let trader = Bytes::from_str("0x0000000000000000000000000000000000000001").unwrap();
949        let signed_quote = state
950            .request_signed_quote(GetAmountOutParams {
951                amount_in,
952                token_in: state.base_token.address.clone(),
953                token_out: state.quote_token.address.clone(),
954                sender: trader.clone(),
955                receiver: trader,
956            })
957            .await
958            .unwrap();
959
960        assert!(indicative_quote.amount > BigUint::from(0u8));
961        assert!(signed_quote.amount_out > BigUint::from(0u8));
962        assert_eq!(signed_quote.base_token, state.base_token.address);
963        assert_eq!(signed_quote.quote_token, state.quote_token.address);
964        assert!(!signed_quote.quote_attributes["oracle_update_0_args"].is_empty());
965    }
966}