Skip to main content

tycho_simulation/rfq/protocols/native/
models.rs

1use serde::{Deserialize, Serialize};
2use tycho_common::{models::Chain, Bytes};
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
5#[serde(rename_all = "lowercase")]
6pub enum NativeOrderbookSide {
7    /// The market maker buys base; the taker sells base and receives quote.
8    Bid,
9    /// The market maker sells base; the taker sells quote and receives base.
10    Ask,
11}
12
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14pub struct NativePriceLevel {
15    pub quantity: f64,
16    pub price: f64,
17}
18
19fn deserialize_level<'de, D>(deserializer: D) -> Result<NativePriceLevel, D::Error>
20where
21    D: serde::Deserializer<'de>,
22{
23    let [quantity, price] = <[f64; 2]>::deserialize(deserializer)?;
24    if !quantity.is_finite() || quantity < 0.0 {
25        return Err(serde::de::Error::custom(
26            "price level quantity must be a non-negative finite number",
27        ))
28    }
29    // Native may use zero-quantity placeholders such as [0, 0]. They are discarded when books are
30    // grouped, so their price is irrelevant.
31    if quantity == 0.0 {
32        return Ok(NativePriceLevel { quantity, price })
33    }
34    if !price.is_finite() || price <= 0.0 {
35        return Err(serde::de::Error::custom("price level price must be a positive finite number"))
36    }
37
38    Ok(NativePriceLevel { quantity, price })
39}
40
41fn deserialize_levels<'de, D>(deserializer: D) -> Result<Vec<NativePriceLevel>, D::Error>
42where
43    D: serde::Deserializer<'de>,
44{
45    #[derive(Deserialize)]
46    struct Level(#[serde(deserialize_with = "deserialize_level")] NativePriceLevel);
47
48    Vec::<Level>::deserialize(deserializer).map(|levels| {
49        levels
50            .into_iter()
51            .map(|level| level.0)
52            .collect()
53    })
54}
55
56fn deserialize_non_negative_f64<'de, D>(deserializer: D) -> Result<f64, D::Error>
57where
58    D: serde::Deserializer<'de>,
59{
60    let value = f64::deserialize(deserializer)?;
61    if !value.is_finite() || value < 0.0 {
62        return Err(serde::de::Error::custom("expected a non-negative finite number"))
63    }
64    Ok(value)
65}
66
67#[derive(Debug, Clone, PartialEq, Deserialize)]
68pub struct NativeOrderbookEntry {
69    pub base_address: Bytes,
70    pub quote_address: Bytes,
71    /// Minimum base-token amount in atomic units.
72    ///
73    /// Unlike `levels`, whose quantities are expressed in normal token units, Native Relay's
74    /// aggregated orderbook returns this field in atomic units. It constrains input for a bid and
75    /// output for an ask.
76    #[serde(deserialize_with = "deserialize_non_negative_f64")]
77    pub minimum_in_base: f64,
78    pub side: NativeOrderbookSide,
79    #[serde(deserialize_with = "deserialize_levels")]
80    pub levels: Vec<NativePriceLevel>,
81}
82
83#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
84pub struct NativePriceData {
85    pub base_address: Bytes,
86    pub quote_address: Bytes,
87    /// Atomic base-token minimum input when selling base into bids.
88    pub minimum_in_base: f64,
89    /// Atomic quote-token minimum input when selling quote into asks.
90    pub minimum_in_quote: f64,
91    /// Atomic base-token minimum output when selling quote into asks.
92    pub minimum_out_base: f64,
93    /// Atomic quote-token minimum output when selling base into bids.
94    pub minimum_out_quote: f64,
95    pub bids: Vec<NativePriceLevel>,
96    pub asks: Vec<NativePriceLevel>,
97}
98
99impl NativePriceData {
100    /// Returns `None` when the book cannot be valued or the calculation is non-finite.
101    pub fn calculate_tvl(&self, quote_price_data: Option<&NativePriceData>) -> Option<f64> {
102        let bid_tvl: f64 = self
103            .bids
104            .iter()
105            .map(|level: &NativePriceLevel| level.quantity * level.price)
106            .sum();
107        let ask_tvl: f64 = self
108            .asks
109            .iter()
110            .map(|level: &NativePriceLevel| level.quantity * level.price)
111            .sum();
112
113        let total_tvl = match (self.bids.is_empty(), self.asks.is_empty()) {
114            (false, false) => bid_tvl.midpoint(ask_tvl),
115            (false, true) => bid_tvl,
116            (true, false) => ask_tvl,
117            (true, true) => 0.0,
118        };
119        if !total_tvl.is_finite() {
120            return None
121        }
122
123        if let Some(quote_data) = quote_price_data {
124            let price_of_quote_token = quote_data.get_mid_price(total_tvl, &self.quote_address)?;
125            let converted_tvl = total_tvl * price_of_quote_token;
126            return converted_tvl
127                .is_finite()
128                .then_some(converted_tvl)
129        }
130
131        Some(total_tvl)
132    }
133
134    pub fn get_mid_price(&self, amount: f64, sell_token: &Bytes) -> Option<f64> {
135        if sell_token != &self.base_address && sell_token != &self.quote_address {
136            return None;
137        }
138
139        let inverse = sell_token == &self.quote_address;
140        let asks_price = Self::get_price_for_levels(amount, &self.asks, inverse);
141        let bids_price = Self::get_price_for_levels(amount, &self.bids, inverse);
142
143        match (bids_price, asks_price) {
144            (Some(bid), Some(ask)) => Some(bid.midpoint(ask)),
145            (Some(bid), None) => Some(bid),
146            (None, Some(ask)) => Some(ask),
147            (None, None) => None,
148        }
149    }
150
151    fn get_price_for_levels(
152        amount_in: f64,
153        price_levels: &[NativePriceLevel],
154        invert: bool,
155    ) -> Option<f64> {
156        if price_levels.is_empty() || amount_in <= 0.0 {
157            return None;
158        }
159
160        let levels =
161            if invert { Self::invert_price_levels(price_levels) } else { price_levels.to_vec() };
162
163        let (amount_out, remaining_in) = Self::get_amount_out_from_levels(amount_in, &levels);
164        let consumed_amount_in = amount_in - remaining_in;
165        if consumed_amount_in <= 0.0 {
166            return None;
167        }
168
169        Some(amount_out / consumed_amount_in)
170    }
171
172    pub fn get_amount_out_from_levels(
173        amount_in: f64,
174        price_levels: &[NativePriceLevel],
175    ) -> (f64, f64) {
176        let mut remaining_amount_in = amount_in;
177        let mut amount_out = 0.0;
178
179        for level in price_levels {
180            if remaining_amount_in <= 0.0 {
181                break;
182            }
183
184            let amount_in_available_to_trade = remaining_amount_in.min(level.quantity);
185            amount_out += amount_in_available_to_trade * level.price;
186            remaining_amount_in -= amount_in_available_to_trade;
187        }
188
189        (amount_out, remaining_amount_in)
190    }
191
192    pub fn invert_price_levels(price_levels: &[NativePriceLevel]) -> Vec<NativePriceLevel> {
193        price_levels
194            .iter()
195            .filter(|level| level.price > 0.0)
196            .map(|level| NativePriceLevel {
197                quantity: level.quantity * level.price,
198                price: 1.0 / level.price,
199            })
200            .collect()
201    }
202}
203
204#[derive(Debug, Clone, Serialize)]
205pub struct FirmQuoteRequest {
206    pub from_address: String,
207    pub src_chain: NativeSupportedChain,
208    pub dst_chain: NativeSupportedChain,
209    pub token_in: String,
210    pub token_out: String,
211    pub amount_wei: String,
212    pub version: u32,
213    pub allow_multihop: bool,
214}
215
216// --- Response ---
217
218#[derive(Debug, Clone, Deserialize)]
219#[serde(rename_all = "camelCase")]
220pub struct WidgetFee {
221    pub signer: String,
222    pub fee_recipient: String,
223    pub fee_rate: f64,
224}
225
226#[derive(Debug, Clone, Deserialize)]
227pub struct TxRequest {
228    pub target: String,
229    pub calldata: String,
230    pub value: String,
231}
232
233#[derive(Debug, Clone, Deserialize)]
234#[serde(rename_all = "camelCase")]
235pub struct FirmQuoteOrder {
236    pub pool: String,
237    pub signer: String,
238    pub recipient: String,
239    pub seller_token: String,
240    pub buyer_token: String,
241    pub effective_seller_token_amount: String,
242    pub seller_token_amount: String,
243    pub buyer_token_amount: String,
244    pub deadline_timestamp: u64,
245    pub nonce: u64,
246    pub quote_id: String,
247    pub multi_hop: bool,
248    pub signature: String,
249    pub external_swap_calldata: String,
250    pub amount_out_minimum: String,
251    pub widget_fee: WidgetFee,
252    pub widget_fee_signature: String,
253}
254
255#[derive(Debug, Clone, Deserialize)]
256#[serde(rename_all = "camelCase")]
257pub struct FirmQuoteResponse {
258    pub success: bool,
259    pub orders: Vec<FirmQuoteOrder>,
260    pub widget_fee: WidgetFee,
261    pub widget_fee_signature: String,
262    pub recipient: String,
263    pub amount_in: String,
264    pub amount_out: String,
265    pub amount_out_before_fee: String,
266    pub fallback_swap_data_array: Option<serde_json::Value>,
267    pub token_transfer_fee_on_percent: f64,
268    pub tx_request: TxRequest,
269    pub source: Vec<u32>,
270    pub error_message: String,
271    #[serde(rename = "router_version")]
272    pub router_version: String,
273    pub amount_in_offset: u32,
274    pub amount_out_minimum_offset: u32,
275}
276
277#[derive(Debug, Clone, Deserialize)]
278pub struct NativeApiErrorResponse {
279    pub code: u64,
280    pub message: String,
281}
282
283#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
284#[serde(rename_all = "lowercase")]
285pub enum NativeSupportedChain {
286    Ethereum,
287    Bsc,
288    Arbitrum,
289    Base,
290}
291
292impl TryFrom<Chain> for NativeSupportedChain {
293    type Error = String;
294
295    fn try_from(chain: Chain) -> Result<Self, Self::Error> {
296        match chain {
297            Chain::Ethereum => Ok(NativeSupportedChain::Ethereum),
298            Chain::Bsc => Ok(NativeSupportedChain::Bsc),
299            Chain::Arbitrum => Ok(NativeSupportedChain::Arbitrum),
300            Chain::Base => Ok(NativeSupportedChain::Base),
301            unsupported => Err(format!("Chain {unsupported:?} not supported by Native API")),
302        }
303    }
304}
305
306impl NativeSupportedChain {
307    pub fn as_str(&self) -> &'static str {
308        match self {
309            NativeSupportedChain::Ethereum => "ethereum",
310            NativeSupportedChain::Bsc => "bsc",
311            NativeSupportedChain::Arbitrum => "arbitrum",
312            NativeSupportedChain::Base => "base",
313        }
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use std::str::FromStr;
320
321    use super::*;
322
323    fn addr(address: &str) -> Bytes {
324        Bytes::from_str(address).unwrap()
325    }
326
327    #[test]
328    fn deserializes_native_relay_orderbook_entry() {
329        let json = r#"{
330            "base_symbol": "WETH",
331            "quote_symbol": "USDT",
332            "base_address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
333            "quote_address": "0xdac17f958d2ee523a2206206994597c13d831ec7",
334            "minimum_in_base": 0,
335            "side": "bid",
336            "levels": [[0, 0], [0.0001, 3213.12345], [12.75786733219471, 3210.15]]
337        }"#;
338
339        let entry: NativeOrderbookEntry = serde_json::from_str(json).unwrap();
340        assert_eq!(entry.side, NativeOrderbookSide::Bid);
341        assert_eq!(entry.levels[0].quantity, 0.0);
342        assert_eq!(entry.levels[1].quantity, 0.0001);
343        assert_eq!(entry.levels[1].price, 3213.12345);
344    }
345
346    #[test]
347    fn rejects_negative_orderbook_minimum() {
348        let result = serde_json::from_value::<NativeOrderbookEntry>(serde_json::json!({
349            "base_address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
350            "quote_address": "0xdac17f958d2ee523a2206206994597c13d831ec7",
351            "minimum_in_base": -1,
352            "side": "bid",
353            "levels": [[1, 2_000]],
354        }));
355
356        assert!(result.is_err());
357    }
358
359    #[test]
360    fn rejects_invalid_orderbook_levels() {
361        for level in [[-1.0, 2_000.0], [1.0, 0.0], [1.0, -2_000.0]] {
362            let result = serde_json::from_value::<NativeOrderbookEntry>(serde_json::json!({
363                "base_address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
364                "quote_address": "0xdac17f958d2ee523a2206206994597c13d831ec7",
365                "minimum_in_base": 0,
366                "side": "bid",
367                "levels": [level],
368            }));
369
370            assert!(result.is_err(), "level {level:?} should be rejected");
371        }
372    }
373
374    #[test]
375    fn calculates_tvl_as_average_bid_ask_quote_value() {
376        let price_data = NativePriceData {
377            base_address: addr("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
378            quote_address: addr("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
379            minimum_in_base: 0.0,
380            minimum_in_quote: 0.0,
381            minimum_out_base: 0.0,
382            minimum_out_quote: 0.0,
383            bids: vec![
384                NativePriceLevel { quantity: 1.0, price: 2000.0 },
385                NativePriceLevel { quantity: 2.0, price: 1999.0 },
386            ],
387            asks: vec![
388                NativePriceLevel { quantity: 1.5, price: 2001.0 },
389                NativePriceLevel { quantity: 1.0, price: 2002.0 },
390            ],
391        };
392
393        let tvl = price_data
394            .calculate_tvl(None)
395            .expect("TVL should be finite");
396        assert!((tvl - 5500.75).abs() < 0.01);
397    }
398
399    #[test]
400    fn normalizes_tvl_through_quote_token_market() {
401        let tamara = addr("0x1234567890123456789012345678901234567890");
402        let usdc = addr("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48");
403        let price_data_eth_tamara = NativePriceData {
404            base_address: addr("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
405            quote_address: tamara.clone(),
406            minimum_in_base: 0.0,
407            minimum_in_quote: 0.0,
408            minimum_out_base: 0.0,
409            minimum_out_quote: 0.0,
410            bids: vec![NativePriceLevel { quantity: 3.0, price: 100.0 }],
411            asks: vec![NativePriceLevel { quantity: 3.0, price: 100.0 }],
412        };
413        let price_data_tamara_usdc = NativePriceData {
414            base_address: tamara,
415            quote_address: usdc,
416            minimum_in_base: 0.0,
417            minimum_in_quote: 0.0,
418            minimum_out_base: 0.0,
419            minimum_out_quote: 0.0,
420            bids: vec![NativePriceLevel { quantity: 300.0, price: 9.0 }],
421            asks: vec![NativePriceLevel { quantity: 300.0, price: 11.0 }],
422        };
423
424        assert_eq!(
425            price_data_eth_tamara.calculate_tvl(Some(&price_data_tamara_usdc)),
426            Some(3000.0)
427        );
428    }
429
430    #[test]
431    fn calculates_and_normalizes_tvl_for_one_sided_bid_books() {
432        let tamara = addr("0x1234567890123456789012345678901234567890");
433        let price_data_eth_tamara = NativePriceData {
434            base_address: addr("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
435            quote_address: tamara.clone(),
436            minimum_in_base: 0.0,
437            minimum_in_quote: 0.0,
438            minimum_out_base: 0.0,
439            minimum_out_quote: 0.0,
440            bids: vec![NativePriceLevel { quantity: 3.0, price: 100.0 }],
441            asks: vec![],
442        };
443        let price_data_tamara_usdc = NativePriceData {
444            base_address: tamara,
445            quote_address: addr("0xA0b86991c6218b36c1d19d4a2e9Eb0cE3606eB48"),
446            minimum_in_base: 0.0,
447            minimum_in_quote: 0.0,
448            minimum_out_base: 0.0,
449            minimum_out_quote: 0.0,
450            bids: vec![NativePriceLevel { quantity: 300.0, price: 10.0 }],
451            asks: vec![],
452        };
453
454        assert_eq!(price_data_eth_tamara.calculate_tvl(None), Some(300.0));
455        assert_eq!(
456            price_data_eth_tamara.calculate_tvl(Some(&price_data_tamara_usdc)),
457            Some(3000.0)
458        );
459    }
460
461    #[test]
462    fn rejects_non_finite_derived_tvl() {
463        let weth = addr("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2");
464        let tamara = addr("0x1234567890123456789012345678901234567890");
465        let usdc = addr("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48");
466        let book = |base_address, quote_address, quantity, price| NativePriceData {
467            base_address,
468            quote_address,
469            minimum_in_base: 0.0,
470            minimum_in_quote: 0.0,
471            minimum_out_base: 0.0,
472            minimum_out_quote: 0.0,
473            bids: vec![NativePriceLevel { quantity, price }],
474            asks: vec![],
475        };
476        let overflowing_book = book(weth.clone(), usdc.clone(), 1e308, 2.0);
477        let finite_book = book(weth, tamara.clone(), 1.0, 2.0);
478        let overflowing_conversion_book = book(tamara, usdc, 2.0, 1e308);
479
480        assert_eq!(overflowing_book.calculate_tvl(None), None);
481        assert_eq!(finite_book.calculate_tvl(Some(&overflowing_conversion_book)), None);
482    }
483}