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 Bid,
9 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 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 #[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 pub minimum_in_base: f64,
89 pub minimum_in_quote: f64,
91 pub minimum_out_base: f64,
93 pub minimum_out_quote: f64,
95 pub bids: Vec<NativePriceLevel>,
96 pub asks: Vec<NativePriceLevel>,
97}
98
99impl NativePriceData {
100 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#[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 Robinhood,
291}
292
293impl TryFrom<Chain> for NativeSupportedChain {
294 type Error = String;
295
296 fn try_from(chain: Chain) -> Result<Self, Self::Error> {
297 match chain {
298 Chain::Ethereum => Ok(NativeSupportedChain::Ethereum),
299 Chain::Bsc => Ok(NativeSupportedChain::Bsc),
300 Chain::Arbitrum => Ok(NativeSupportedChain::Arbitrum),
301 Chain::Base => Ok(NativeSupportedChain::Base),
302 Chain::Robinhood => Ok(NativeSupportedChain::Robinhood),
303 unsupported => Err(format!("Chain {unsupported:?} not supported by Native API")),
304 }
305 }
306}
307
308impl NativeSupportedChain {
309 pub fn as_str(&self) -> &'static str {
310 match self {
311 NativeSupportedChain::Ethereum => "ethereum",
312 NativeSupportedChain::Bsc => "bsc",
313 NativeSupportedChain::Arbitrum => "arbitrum",
314 NativeSupportedChain::Base => "base",
315 NativeSupportedChain::Robinhood => "robinhood",
316 }
317 }
318}
319
320#[cfg(test)]
321mod tests {
322 use std::str::FromStr;
323
324 use super::*;
325
326 fn addr(address: &str) -> Bytes {
327 Bytes::from_str(address).unwrap()
328 }
329
330 #[test]
331 fn deserializes_native_relay_orderbook_entry() {
332 let json = r#"{
333 "base_symbol": "WETH",
334 "quote_symbol": "USDT",
335 "base_address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
336 "quote_address": "0xdac17f958d2ee523a2206206994597c13d831ec7",
337 "minimum_in_base": 0,
338 "side": "bid",
339 "levels": [[0, 0], [0.0001, 3213.12345], [12.75786733219471, 3210.15]]
340 }"#;
341
342 let entry: NativeOrderbookEntry = serde_json::from_str(json).unwrap();
343 assert_eq!(entry.side, NativeOrderbookSide::Bid);
344 assert_eq!(entry.levels[0].quantity, 0.0);
345 assert_eq!(entry.levels[1].quantity, 0.0001);
346 assert_eq!(entry.levels[1].price, 3213.12345);
347 }
348
349 #[test]
350 fn rejects_negative_orderbook_minimum() {
351 let result = serde_json::from_value::<NativeOrderbookEntry>(serde_json::json!({
352 "base_address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
353 "quote_address": "0xdac17f958d2ee523a2206206994597c13d831ec7",
354 "minimum_in_base": -1,
355 "side": "bid",
356 "levels": [[1, 2_000]],
357 }));
358
359 assert!(result.is_err());
360 }
361
362 #[test]
363 fn rejects_invalid_orderbook_levels() {
364 for level in [[-1.0, 2_000.0], [1.0, 0.0], [1.0, -2_000.0]] {
365 let result = serde_json::from_value::<NativeOrderbookEntry>(serde_json::json!({
366 "base_address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
367 "quote_address": "0xdac17f958d2ee523a2206206994597c13d831ec7",
368 "minimum_in_base": 0,
369 "side": "bid",
370 "levels": [level],
371 }));
372
373 assert!(result.is_err(), "level {level:?} should be rejected");
374 }
375 }
376
377 #[test]
378 fn calculates_tvl_as_average_bid_ask_quote_value() {
379 let price_data = NativePriceData {
380 base_address: addr("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
381 quote_address: addr("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
382 minimum_in_base: 0.0,
383 minimum_in_quote: 0.0,
384 minimum_out_base: 0.0,
385 minimum_out_quote: 0.0,
386 bids: vec![
387 NativePriceLevel { quantity: 1.0, price: 2000.0 },
388 NativePriceLevel { quantity: 2.0, price: 1999.0 },
389 ],
390 asks: vec![
391 NativePriceLevel { quantity: 1.5, price: 2001.0 },
392 NativePriceLevel { quantity: 1.0, price: 2002.0 },
393 ],
394 };
395
396 let tvl = price_data
397 .calculate_tvl(None)
398 .expect("TVL should be finite");
399 assert!((tvl - 5500.75).abs() < 0.01);
400 }
401
402 #[test]
403 fn normalizes_tvl_through_quote_token_market() {
404 let tamara = addr("0x1234567890123456789012345678901234567890");
405 let usdc = addr("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48");
406 let price_data_eth_tamara = NativePriceData {
407 base_address: addr("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
408 quote_address: tamara.clone(),
409 minimum_in_base: 0.0,
410 minimum_in_quote: 0.0,
411 minimum_out_base: 0.0,
412 minimum_out_quote: 0.0,
413 bids: vec![NativePriceLevel { quantity: 3.0, price: 100.0 }],
414 asks: vec![NativePriceLevel { quantity: 3.0, price: 100.0 }],
415 };
416 let price_data_tamara_usdc = NativePriceData {
417 base_address: tamara,
418 quote_address: usdc,
419 minimum_in_base: 0.0,
420 minimum_in_quote: 0.0,
421 minimum_out_base: 0.0,
422 minimum_out_quote: 0.0,
423 bids: vec![NativePriceLevel { quantity: 300.0, price: 9.0 }],
424 asks: vec![NativePriceLevel { quantity: 300.0, price: 11.0 }],
425 };
426
427 assert_eq!(
428 price_data_eth_tamara.calculate_tvl(Some(&price_data_tamara_usdc)),
429 Some(3000.0)
430 );
431 }
432
433 #[test]
434 fn calculates_and_normalizes_tvl_for_one_sided_bid_books() {
435 let tamara = addr("0x1234567890123456789012345678901234567890");
436 let price_data_eth_tamara = NativePriceData {
437 base_address: addr("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
438 quote_address: tamara.clone(),
439 minimum_in_base: 0.0,
440 minimum_in_quote: 0.0,
441 minimum_out_base: 0.0,
442 minimum_out_quote: 0.0,
443 bids: vec![NativePriceLevel { quantity: 3.0, price: 100.0 }],
444 asks: vec![],
445 };
446 let price_data_tamara_usdc = NativePriceData {
447 base_address: tamara,
448 quote_address: addr("0xA0b86991c6218b36c1d19d4a2e9Eb0cE3606eB48"),
449 minimum_in_base: 0.0,
450 minimum_in_quote: 0.0,
451 minimum_out_base: 0.0,
452 minimum_out_quote: 0.0,
453 bids: vec![NativePriceLevel { quantity: 300.0, price: 10.0 }],
454 asks: vec![],
455 };
456
457 assert_eq!(price_data_eth_tamara.calculate_tvl(None), Some(300.0));
458 assert_eq!(
459 price_data_eth_tamara.calculate_tvl(Some(&price_data_tamara_usdc)),
460 Some(3000.0)
461 );
462 }
463
464 #[test]
465 fn rejects_non_finite_derived_tvl() {
466 let weth = addr("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2");
467 let tamara = addr("0x1234567890123456789012345678901234567890");
468 let usdc = addr("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48");
469 let book = |base_address, quote_address, quantity, price| NativePriceData {
470 base_address,
471 quote_address,
472 minimum_in_base: 0.0,
473 minimum_in_quote: 0.0,
474 minimum_out_base: 0.0,
475 minimum_out_quote: 0.0,
476 bids: vec![NativePriceLevel { quantity, price }],
477 asks: vec![],
478 };
479 let overflowing_book = book(weth.clone(), usdc.clone(), 1e308, 2.0);
480 let finite_book = book(weth, tamara.clone(), 1.0, 2.0);
481 let overflowing_conversion_book = book(tamara, usdc, 2.0, 1e308);
482
483 assert_eq!(overflowing_book.calculate_tvl(None), None);
484 assert_eq!(finite_book.calculate_tvl(Some(&overflowing_conversion_book)), None);
485 }
486}