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}
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}