1use std::{any::Any, collections::HashMap, fmt};
2
3use async_trait::async_trait;
4use num_bigint::BigUint;
5use num_traits::{FromPrimitive, ToPrimitive};
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::{
19 client::RFQClient,
20 protocols::native::{client::NativeClient, models::NativePriceData},
21};
22
23#[derive(Clone, Serialize, Deserialize)]
27pub struct NativeState {
28 pub base_token: Token,
29 pub quote_token: Token,
30 pub book: NativePriceData,
31 pub client: NativeClient,
32}
33
34impl fmt::Debug for NativeState {
35 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36 f.debug_struct("NativeState")
37 .field("base_token", &self.base_token)
38 .field("quote_token", &self.quote_token)
39 .finish_non_exhaustive()
40 }
41}
42
43impl NativeState {
44 pub fn new(
45 base_token: Token,
46 quote_token: Token,
47 mut book: NativePriceData,
48 client: NativeClient,
49 ) -> Result<Self, SimulationError> {
50 book.bids
52 .retain(|level| level.quantity != 0.0);
53 book.asks
54 .retain(|level| level.quantity != 0.0);
55 let state = NativeState { base_token, quote_token, book, client };
56 state.validate_book()?;
57 Ok(state)
58 }
59
60 fn validate_book(&self) -> Result<(), SimulationError> {
61 if self.book.base_address != self.base_token.address ||
62 self.book.quote_address != self.quote_token.address
63 {
64 return Err(SimulationError::FatalError(
65 "Native book token addresses do not match state tokens".to_string(),
66 ));
67 }
68 let minimums = [
69 self.book.minimum_in_base,
70 self.book.minimum_in_quote,
71 self.book.minimum_out_base,
72 self.book.minimum_out_quote,
73 ];
74 if minimums
75 .iter()
76 .any(|minimum| !minimum.is_finite() || *minimum < 0.0)
77 {
78 return Err(SimulationError::FatalError(
79 "Native book contains an invalid minimum amount".to_string(),
80 ));
81 }
82 if self
83 .book
84 .bids
85 .iter()
86 .chain(self.book.asks.iter())
87 .any(|level| {
88 !level.quantity.is_finite() ||
89 level.quantity < 0.0 ||
90 !level.price.is_finite() ||
91 level.price <= 0.0
92 })
93 {
94 return Err(SimulationError::FatalError(
95 "Native book contains an invalid price level".to_string(),
96 ));
97 }
98
99 Ok(())
100 }
101
102 fn enforce_minimum(
103 amount: &BigUint,
104 minimum: f64,
105 amount_kind: &str,
106 ) -> Result<(), SimulationError> {
107 if minimum == 0.0 {
108 return Ok(())
109 }
110
111 let minimum = BigUint::from_f64(minimum.ceil()).ok_or_else(|| {
114 SimulationError::FatalError(format!(
115 "Can't convert Native minimum {amount_kind} amount to BigUint"
116 ))
117 })?;
118 if amount < &minimum {
119 return Err(SimulationError::RecoverableError(format!(
120 "Amount below minimum {amount_kind}. Amount: {amount}, min amount: {minimum}"
121 )))
122 }
123
124 Ok(())
125 }
126}
127
128#[typetag::serde]
129impl ProtocolSim for NativeState {
130 fn fee(&self) -> f64 {
131 0.0
132 }
133
134 fn spot_price(&self, base: &Token, quote: &Token) -> Result<f64, SimulationError> {
135 let inverse = if base.address == self.base_token.address &&
136 quote.address == self.quote_token.address
137 {
138 false
139 } else if base.address == self.quote_token.address &&
140 quote.address == self.base_token.address
141 {
142 true
143 } else {
144 return Err(SimulationError::RecoverableError(format!(
145 "Invalid token addresses: {}, {}",
146 base.address, quote.address
147 )))
148 };
149
150 let best_bid = self
151 .book
152 .bids
153 .first()
154 .map(|lvl| lvl.price);
155 let best_ask = self
156 .book
157 .asks
158 .first()
159 .map(|lvl| lvl.price);
160
161 let average_price = match (best_bid, best_ask) {
162 (Some(bid), Some(ask)) => bid.midpoint(ask),
163 (Some(bid), None) => bid,
164 (None, Some(ask)) => ask,
165 (None, None) => {
166 return Err(SimulationError::RecoverableError("No liquidity".to_string()))
167 }
168 };
169
170 let spot_price = if inverse { average_price.recip() } else { average_price };
171
172 if !spot_price.is_finite() || spot_price <= 0.0 {
173 return Err(SimulationError::RecoverableError(
174 "Native spot price is not positive and finite".to_string(),
175 ))
176 }
177
178 Ok(spot_price)
179 }
180
181 fn get_amount_out(
182 &self,
183 amount_in: BigUint,
184 token_in: &Token,
185 token_out: &Token,
186 ) -> Result<GetAmountOutResult, SimulationError> {
187 let is_sell_base = token_in.address == self.base_token.address &&
188 token_out.address == self.quote_token.address;
189 let is_sell_quote = token_in.address == self.quote_token.address &&
190 token_out.address == self.base_token.address;
191
192 if !is_sell_base && !is_sell_quote {
193 return Err(SimulationError::InvalidInput(
194 format!(
195 "Invalid token addresses. Got in={}, out={}",
196 token_in.address, token_out.address
197 ),
198 None,
199 ));
200 }
201
202 if amount_in == BigUint::ZERO {
203 return Err(SimulationError::InvalidInput(
204 "Native swap amount must be greater than zero".to_string(),
205 None,
206 ));
207 }
208
209 let (minimum_in, minimum_out) = if is_sell_base {
210 (self.book.minimum_in_base, self.book.minimum_out_quote)
211 } else {
212 (self.book.minimum_in_quote, self.book.minimum_out_base)
213 };
214 Self::enforce_minimum(&amount_in, minimum_in, "input")?;
215
216 let amount_in_f64 = amount_in.to_f64().ok_or_else(|| {
217 SimulationError::RecoverableError("Can't convert amount in to f64".into())
218 })? / 10f64.powi(token_in.decimals as i32);
219
220 let levels = if is_sell_base {
221 self.book.bids.clone()
222 } else {
223 NativePriceData::invert_price_levels(&self.book.asks)
224 };
225
226 if levels.is_empty() {
227 return Err(SimulationError::RecoverableError("No liquidity".into()));
228 }
229
230 let (amount_out_f64, remaining) =
231 NativePriceData::get_amount_out_from_levels(amount_in_f64, &levels);
232
233 let res = GetAmountOutResult {
234 amount: BigUint::from_f64(amount_out_f64 * 10f64.powi(token_out.decimals as i32))
235 .ok_or_else(|| {
236 SimulationError::RecoverableError("Can't convert amount out to BigUint".into())
237 })?,
238 gas: BigUint::from(134_000u64), new_state: self.clone_box(),
240 };
241
242 if remaining > 0.0 {
243 return Err(SimulationError::InvalidInput(
244 format!("Pool has not enough liquidity to support complete swap. Input amount: {}, consumed: {}", amount_in_f64, amount_in_f64 - remaining),
245 Some(res),
246 ));
247 }
248
249 Self::enforce_minimum(&res.amount, minimum_out, "output")?;
250
251 Ok(res)
252 }
253
254 fn get_limits(
255 &self,
256 sell_token: Bytes,
257 buy_token: Bytes,
258 ) -> Result<(BigUint, BigUint), SimulationError> {
259 let is_sell_base =
260 sell_token == self.base_token.address && buy_token == self.quote_token.address;
261 let is_sell_quote =
262 sell_token == self.quote_token.address && buy_token == self.base_token.address;
263
264 if !is_sell_base && !is_sell_quote {
265 return Err(SimulationError::InvalidInput(
266 format!("Invalid token addresses. Got sell={}, buy={}", sell_token, buy_token),
267 None,
268 ));
269 }
270
271 let levels = if is_sell_base {
272 self.book.bids.clone()
273 } else {
274 NativePriceData::invert_price_levels(&self.book.asks)
275 };
276
277 if levels.is_empty() {
278 return Err(SimulationError::RecoverableError("No liquidity".into()));
279 }
280
281 let (total_sell_amount, total_buy_amount) =
282 levels
283 .iter()
284 .fold((0.0, 0.0), |(sell_sum, buy_sum), level| {
285 (sell_sum + level.quantity, buy_sum + level.quantity * level.price)
286 });
287
288 let sell_decimals =
289 if is_sell_base { self.base_token.decimals } else { self.quote_token.decimals };
290 let buy_decimals =
291 if is_sell_base { self.quote_token.decimals } else { self.base_token.decimals };
292
293 let sell_limit = BigUint::from_f64(total_sell_amount * 10f64.powi(sell_decimals as i32))
294 .ok_or_else(|| {
295 SimulationError::RecoverableError("Can't convert limit to BigUInt".into())
296 })?;
297 let buy_limit = BigUint::from_f64(total_buy_amount * 10f64.powi(buy_decimals as i32))
298 .ok_or_else(|| {
299 SimulationError::RecoverableError("Can't convert limit to BigUInt".into())
300 })?;
301
302 Ok((sell_limit, buy_limit))
303 }
304
305 fn delta_transition(
306 &mut self,
307 _delta: ProtocolStateDelta,
308 _tokens: &HashMap<Bytes, Token>,
309 _balances: &Balances,
310 ) -> Result<(), TransitionError> {
311 Err(TransitionError::DecodeError("Not implemented".into()))
312 }
313
314 fn clone_box(&self) -> Box<dyn ProtocolSim> {
315 Box::new(self.clone())
316 }
317
318 fn as_any(&self) -> &dyn Any {
319 self
320 }
321
322 fn as_any_mut(&mut self) -> &mut dyn Any {
323 self
324 }
325
326 fn eq(&self, other: &dyn ProtocolSim) -> bool {
327 if let Some(other_state) = other
328 .as_any()
329 .downcast_ref::<NativeState>()
330 {
331 self.base_token == other_state.base_token &&
332 self.quote_token == other_state.quote_token &&
333 self.book == other_state.book
334 } else {
335 false
336 }
337 }
338
339 fn as_indicatively_priced(&self) -> Result<&dyn IndicativelyPriced, SimulationError> {
340 Ok(self)
341 }
342}
343
344#[async_trait]
345impl IndicativelyPriced for NativeState {
346 async fn request_signed_quote(
347 &self,
348 params: GetAmountOutParams,
349 ) -> Result<SignedQuote, SimulationError> {
350 Ok(self
351 .client
352 .request_binding_quote(¶ms)
353 .await?)
354 }
355}
356
357#[cfg(test)]
358mod tests {
359 use std::{collections::HashSet, str::FromStr};
360
361 use rstest::rstest;
362 use tokio::time::Duration;
363 use tycho_common::models::Chain;
364
365 use super::*;
366 use crate::rfq::protocols::native::models::NativePriceLevel;
367
368 fn token(address: &str, symbol: &str, decimals: u32) -> Token {
369 Token::new(
370 &Bytes::from_str(address).unwrap(),
371 symbol,
372 decimals,
373 0,
374 &[],
375 Chain::Ethereum,
376 100,
377 )
378 }
379
380 fn state() -> NativeState {
381 let base_token = token("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", "WETH", 18);
382 let quote_token = token("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", "USDC", 6);
383 let book = NativePriceData {
384 base_address: base_token.address.clone(),
385 quote_address: quote_token.address.clone(),
386 minimum_in_base: 100_000_000_000.0,
387 minimum_in_quote: 100.0,
388 minimum_out_base: 0.0,
389 minimum_out_quote: 0.0,
390 bids: vec![NativePriceLevel { quantity: 1.0, price: 2_000.0 }],
391 asks: vec![NativePriceLevel { quantity: 1.0, price: 2_000.0 }],
392 };
393 let client = NativeClient::new(
394 Chain::Ethereum,
395 String::new(),
396 HashSet::new(),
397 0.0,
398 HashSet::new(),
399 Duration::from_secs(5),
400 Duration::from_secs(5),
401 )
402 .unwrap();
403
404 NativeState::new(base_token, quote_token, book, client).unwrap()
405 }
406
407 #[test]
408 fn accepts_base_sell_at_atomic_input_minimum() {
409 let state = state();
410
411 let result = state.get_amount_out(
412 BigUint::from(100_000_000_000u64),
413 &state.base_token,
414 &state.quote_token,
415 );
416
417 assert!(result.is_ok());
418 }
419
420 #[test]
421 fn rejects_base_sell_below_atomic_input_minimum() {
422 let state = state();
423
424 let result = state.get_amount_out(
425 BigUint::from(99_999_999_999u64),
426 &state.base_token,
427 &state.quote_token,
428 );
429
430 assert!(matches!(result, Err(SimulationError::RecoverableError(_))));
431 }
432
433 #[test]
434 fn accepts_quote_sell_at_atomic_input_minimum() {
435 let state = state();
436
437 let result =
438 state.get_amount_out(BigUint::from(100u64), &state.quote_token, &state.base_token);
439
440 assert!(result.is_ok());
441 }
442
443 #[test]
444 fn rejects_quote_sell_below_atomic_input_minimum() {
445 let state = state();
446
447 let result =
448 state.get_amount_out(BigUint::from(99u64), &state.quote_token, &state.base_token);
449
450 assert!(matches!(result, Err(SimulationError::RecoverableError(_))));
451 }
452
453 #[test]
454 fn calculates_amount_out_for_base_sell() {
455 let state = state();
456
457 let result = state
458 .get_amount_out(
459 BigUint::from(500_000_000_000_000_000u64),
460 &state.base_token,
461 &state.quote_token,
462 )
463 .unwrap();
464
465 assert_eq!(result.amount, BigUint::from(1_000_000_000u64));
466 }
467
468 #[test]
469 fn ignores_zero_quantity_levels() {
470 let mut state = state();
471 state
472 .book
473 .bids
474 .insert(0, NativePriceLevel { quantity: 0.0, price: 1_000.0 });
475 let state = NativeState::new(state.base_token, state.quote_token, state.book, state.client)
476 .unwrap();
477
478 assert_eq!(state.book.bids.len(), 1);
479 assert_eq!(
480 state
481 .spot_price(&state.base_token, &state.quote_token)
482 .unwrap(),
483 2_000.0
484 );
485
486 let result = state
487 .get_amount_out(
488 BigUint::from(500_000_000_000_000_000u64),
489 &state.base_token,
490 &state.quote_token,
491 )
492 .unwrap();
493
494 assert_eq!(result.amount, BigUint::from(1_000_000_000u64));
495 }
496
497 #[test]
498 fn returns_finite_spot_price_for_large_finite_levels() {
499 let mut state = state();
500 state.book.bids[0] = NativePriceLevel { quantity: 1e-306, price: 1e308 };
501 state.book.asks[0] = NativePriceLevel { quantity: 1e-306, price: 1e308 };
502 let state = NativeState::new(state.base_token, state.quote_token, state.book, state.client)
503 .unwrap();
504
505 let price = state
506 .spot_price(&state.base_token, &state.quote_token)
507 .unwrap();
508
509 assert_eq!(price, 1e308);
510 }
511
512 #[test]
513 fn calculates_midpoint_spot_price_in_both_directions() {
514 let mut state = state();
515 state.book.bids[0].price = 1_900.0;
516 state.book.asks[0].price = 2_100.0;
517
518 let direct = state
519 .spot_price(&state.base_token, &state.quote_token)
520 .unwrap();
521 let inverse = state
522 .spot_price(&state.quote_token, &state.base_token)
523 .unwrap();
524
525 assert_eq!(direct, 2_000.0);
526 assert_eq!(inverse, direct.recip());
527 }
528
529 #[test]
530 fn rejects_non_finite_inverted_spot_price() {
531 let mut state = state();
532 let smallest_positive_price = f64::from_bits(1);
533 state.book.bids[0].price = smallest_positive_price;
534 state.book.asks[0].price = smallest_positive_price;
535 let state = NativeState::new(state.base_token, state.quote_token, state.book, state.client)
536 .unwrap();
537
538 let result = state.spot_price(&state.quote_token, &state.base_token);
539
540 assert!(matches!(
541 result,
542 Err(SimulationError::RecoverableError(message))
543 if message.contains("not positive and finite")
544 ));
545 }
546
547 #[test]
548 fn calculates_amount_out_for_quote_sell() {
549 let state = state();
550
551 let result = state
552 .get_amount_out(BigUint::from(1_000_000_000u64), &state.quote_token, &state.base_token)
553 .unwrap();
554
555 assert_eq!(result.amount, BigUint::from(500_000_000_000_000_000u64));
556 }
557
558 #[rstest]
559 #[case::sell_base(true, 2_500_000_000_000_000_000, 3_500_000_000)]
560 #[case::sell_quote(false, 8_192_000_000, 2_500_000_000_000_000_000)]
561 fn consumes_multiple_price_levels(
562 #[case] sell_base: bool,
563 #[case] amount_in: u64,
564 #[case] expected_amount_out: u64,
565 ) {
566 let mut state = state();
567 state.book.bids = vec![
568 NativePriceLevel { quantity: 1.0, price: 2_000.0 },
569 NativePriceLevel { quantity: 2.0, price: 1_000.0 },
570 ];
571 state.book.asks = vec![
572 NativePriceLevel { quantity: 1.0, price: 2_048.0 },
573 NativePriceLevel { quantity: 2.0, price: 4_096.0 },
574 ];
575 let (token_in, token_out) = if sell_base {
578 (&state.base_token, &state.quote_token)
579 } else {
580 (&state.quote_token, &state.base_token)
581 };
582
583 let result = state
584 .get_amount_out(BigUint::from(amount_in), token_in, token_out)
585 .unwrap();
586
587 assert_eq!(result.amount, BigUint::from(expected_amount_out));
588 }
589
590 #[test]
591 fn enforces_base_sell_atomic_output_minimum() {
592 let mut state = state();
593 state.book.minimum_out_quote = 1_000_000_000.0;
594 let amount_in = BigUint::from(500_000_000_000_000_000u64);
595
596 assert!(state
597 .get_amount_out(amount_in.clone(), &state.base_token, &state.quote_token)
598 .is_ok());
599
600 state.book.minimum_out_quote = 1_000_000_001.0;
601 assert!(matches!(
602 state.get_amount_out(amount_in, &state.base_token, &state.quote_token),
603 Err(SimulationError::RecoverableError(message)) if message.contains("minimum output")
604 ));
605 }
606
607 #[test]
608 fn enforces_quote_sell_atomic_output_minimum() {
609 let mut state = state();
610 state.book.minimum_out_base = 500_000_000_000_000.0;
611 let amount_in = BigUint::from(1_000_000u64);
612
613 assert!(state
614 .get_amount_out(amount_in.clone(), &state.quote_token, &state.base_token)
615 .is_ok());
616
617 state.book.minimum_out_base = 500_000_000_000_001.0;
618 assert!(matches!(
619 state.get_amount_out(amount_in, &state.quote_token, &state.base_token),
620 Err(SimulationError::RecoverableError(message)) if message.contains("minimum output")
621 ));
622 }
623
624 #[test]
625 fn returns_partial_result_when_amount_exceeds_depth() {
626 let state = state();
627
628 let result = state.get_amount_out(
629 BigUint::from(2_000_000_000_000_000_000u64),
630 &state.base_token,
631 &state.quote_token,
632 );
633
634 match result {
635 Err(SimulationError::InvalidInput(_, Some(partial))) => {
636 assert_eq!(partial.amount, BigUint::from(2_000_000_000u64));
637 }
638 other => panic!("Expected insufficient-liquidity result, got {other:?}"),
639 }
640 }
641
642 #[test]
643 fn rejects_sub_unit_partial_fill() {
644 let mut state = state();
645 state.book.minimum_in_base = 0.0;
646 state.book.bids[0].quantity = 0.5e-18;
647
648 let result =
649 state.get_amount_out(BigUint::from(1u64), &state.base_token, &state.quote_token);
650
651 assert!(matches!(result, Err(SimulationError::InvalidInput(_, Some(_)))));
652 }
653
654 #[test]
655 fn rejects_zero_amount() {
656 let state = state();
657
658 let result = state.get_amount_out(BigUint::ZERO, &state.base_token, &state.quote_token);
659
660 assert!(matches!(result, Err(SimulationError::InvalidInput(_, None))));
661 }
662
663 #[test]
664 fn gets_base_sell_limits() {
665 let state = state();
666
667 let limits = state
668 .get_limits(state.base_token.address.clone(), state.quote_token.address.clone())
669 .unwrap();
670
671 assert_eq!(limits.0, BigUint::from(1_000_000_000_000_000_000u64));
672 assert_eq!(limits.1, BigUint::from(2_000_000_000u64));
673 }
674
675 #[test]
676 fn gets_quote_sell_limits() {
677 let state = state();
678
679 let limits = state
680 .get_limits(state.quote_token.address.clone(), state.base_token.address.clone())
681 .unwrap();
682
683 assert_eq!(limits.0, BigUint::from(2_000_000_000u64));
684 assert_eq!(limits.1, BigUint::from(1_000_000_000_000_000_000u64));
685 }
686
687 #[test]
688 fn rejects_invalid_pair() {
689 let mut state = state();
690 let other = token("0x1111111111111111111111111111111111111111", "OTHER", 18);
691
692 assert!(matches!(
693 state.get_amount_out(BigUint::from(1u64), &other, &state.quote_token),
694 Err(SimulationError::InvalidInput(_, None))
695 ));
696 assert!(matches!(
697 state.get_limits(other.address.clone(), state.quote_token.address.clone()),
698 Err(SimulationError::InvalidInput(_, None))
699 ));
700
701 state.book.bids.clear();
703 state.book.asks.clear();
704 assert!(matches!(
705 state.spot_price(&other, &state.quote_token),
706 Err(SimulationError::RecoverableError(message))
707 if message.contains("Invalid token addresses")
708 ));
709 }
710
711 #[test]
712 fn rejects_invalid_book_state() {
713 let mut state = state();
714 state.book.bids[0].price = 0.0;
715
716 assert!(matches!(
717 NativeState::new(state.base_token, state.quote_token, state.book, state.client),
718 Err(SimulationError::FatalError(_))
719 ));
720 }
721
722 #[test]
723 fn rejects_invalid_output_minimum() {
724 let mut state = state();
725 state.book.minimum_out_base = -1.0;
726
727 assert!(matches!(
728 NativeState::new(state.base_token, state.quote_token, state.book, state.client),
729 Err(SimulationError::FatalError(_))
730 ));
731 }
732
733 #[test]
734 fn rejects_mismatched_book_tokens() {
735 let mut state = state();
736 state.book.base_address = Bytes::zero(20);
737
738 assert!(matches!(
739 NativeState::new(state.base_token, state.quote_token, state.book, state.client),
740 Err(SimulationError::FatalError(_))
741 ));
742 }
743
744 #[test]
745 fn reports_no_liquidity_for_empty_direction() {
746 let mut state = state();
747 state.book.bids.clear();
748
749 assert!(matches!(
750 state.get_amount_out(
751 BigUint::from(500_000_000_000_000_000u64),
752 &state.base_token,
753 &state.quote_token,
754 ),
755 Err(SimulationError::RecoverableError(_))
756 ));
757 assert!(matches!(
758 state.get_limits(state.base_token.address.clone(), state.quote_token.address.clone()),
759 Err(SimulationError::RecoverableError(_))
760 ));
761 }
762}