1use std::fmt::Display;
6
7use alloy_primitives::{Address, U256};
8use bon::Builder;
9use serde::{Deserialize, Serialize};
10use url::Url;
11
12use crate::{error_code::TraceId, OdosError, Result};
13
14#[cfg(feature = "v2")]
15use {
16 crate::OdosRouterV2::{inputTokenInfo, outputTokenInfo, swapTokenInfo},
17 crate::OdosV2Router::{swapCall, OdosV2RouterCalls},
18 alloy_primitives::Bytes,
19 tracing::debug,
20};
21
22#[cfg(feature = "v3")]
23use {
24 crate::IOdosRouterV3::swapTokenInfo as v3SwapTokenInfo, crate::OdosV3Router::OdosV3RouterCalls,
25};
26
27#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
48#[serde(rename_all = "lowercase")]
49pub enum ApiHost {
50 Public,
54 Enterprise,
59}
60
61impl ApiHost {
62 pub fn base_url(&self) -> Url {
78 match self {
79 ApiHost::Public => Url::parse("https://api.odos.xyz/").unwrap(),
80 ApiHost::Enterprise => Url::parse("https://enterprise-api.odos.xyz/").unwrap(),
81 }
82 }
83}
84
85#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
106#[serde(rename_all = "lowercase")]
107pub enum ApiVersion {
108 V2,
113 V3,
118}
119
120impl ApiVersion {
121 fn path(&self) -> &'static str {
125 match self {
126 ApiVersion::V2 => "v2",
127 ApiVersion::V3 => "v3",
128 }
129 }
130}
131
132#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, Deserialize, Serialize)]
185pub struct Endpoint {
186 host: ApiHost,
187 version: ApiVersion,
188}
189
190impl Endpoint {
191 pub const fn new(host: ApiHost, version: ApiVersion) -> Self {
201 Self { host, version }
202 }
203
204 pub const fn public_v2() -> Self {
217 Self::new(ApiHost::Public, ApiVersion::V2)
218 }
219
220 pub const fn public_v3() -> Self {
233 Self::new(ApiHost::Public, ApiVersion::V3)
234 }
235
236 pub const fn enterprise_v2() -> Self {
249 Self::new(ApiHost::Enterprise, ApiVersion::V2)
250 }
251
252 pub const fn enterprise_v3() -> Self {
265 Self::new(ApiHost::Enterprise, ApiVersion::V3)
266 }
267
268 pub fn quote_url(&self) -> Url {
285 self.host
286 .base_url()
287 .join(&format!("sor/quote/{}", self.version.path()))
288 .unwrap()
289 }
290
291 pub fn assemble_url(&self) -> Url {
305 self.host.base_url().join("sor/assemble").unwrap()
306 }
307
308 pub const fn host(&self) -> ApiHost {
319 self.host
320 }
321
322 pub const fn version(&self) -> ApiVersion {
333 self.version
334 }
335}
336
337impl Default for Endpoint {
338 fn default() -> Self {
342 Self::public_v2()
343 }
344}
345
346#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
348#[serde(rename_all = "camelCase")]
349pub struct InputToken {
350 token_address: Address,
351 amount: String,
353}
354
355impl InputToken {
356 pub fn new(token_address: Address, amount: U256) -> Self {
357 Self {
358 token_address,
359 amount: amount.to_string(),
360 }
361 }
362}
363
364impl From<(Address, U256)> for InputToken {
365 fn from((token_address, amount): (Address, U256)) -> Self {
366 Self::new(token_address, amount)
367 }
368}
369
370impl Display for InputToken {
371 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
372 write!(
373 f,
374 "InputToken {{ token_address: {}, amount: {} }}",
375 self.token_address, self.amount
376 )
377 }
378}
379
380#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
382#[serde(rename_all = "camelCase")]
383pub struct OutputToken {
384 token_address: Address,
385 proportion: u32,
386}
387
388impl OutputToken {
389 pub fn new(token_address: Address, proportion: u32) -> Self {
390 Self {
391 token_address,
392 proportion,
393 }
394 }
395}
396
397impl From<(Address, u32)> for OutputToken {
398 fn from((token_address, proportion): (Address, u32)) -> Self {
399 Self::new(token_address, proportion)
400 }
401}
402
403impl Display for OutputToken {
404 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
405 write!(
406 f,
407 "OutputToken {{ token_address: {}, proportion: {} }}",
408 self.token_address, self.proportion
409 )
410 }
411}
412
413#[derive(Builder, Clone, Debug, Default, PartialEq, PartialOrd, Deserialize, Serialize)]
438#[serde(rename_all = "camelCase")]
439pub struct QuoteRequest {
440 chain_id: u64,
441 input_tokens: Vec<InputToken>,
442 output_tokens: Vec<OutputToken>,
443 slippage_limit_percent: f64,
444 user_addr: Address,
445 compact: bool,
446 simple: bool,
447 referral_code: u32,
448 disable_rfqs: bool,
449 #[builder(default)]
450 source_blacklist: Vec<String>,
451}
452
453#[derive(Clone, Debug, PartialEq, PartialOrd, Deserialize, Serialize)]
455#[serde(rename_all = "camelCase")]
456pub struct SingleQuoteResponse {
457 block_number: u64,
458 data_gas_estimate: u64,
459 gas_estimate: f64,
460 gas_estimate_value: f64,
461 gwei_per_gas: f64,
462 in_amounts: Vec<String>,
463 in_tokens: Vec<Address>,
464 in_values: Vec<f64>,
465 net_out_value: f64,
466 out_amounts: Vec<String>,
467 out_tokens: Vec<Address>,
468 out_values: Vec<f64>,
469 #[serde(default)]
471 partner_fee_percent: f64,
472 path_id: String,
473 path_viz: Option<String>,
474 percent_diff: f64,
475 price_impact: f64,
476}
477
478impl SingleQuoteResponse {
479 pub fn in_amount(&self) -> Option<&String> {
481 self.in_amounts.first()
482 }
483
484 pub fn data_gas_estimate(&self) -> u64 {
486 self.data_gas_estimate
487 }
488
489 pub fn get_block_number(&self) -> u64 {
491 self.block_number
492 }
493
494 pub fn gas_estimate(&self) -> f64 {
496 self.gas_estimate
497 }
498
499 pub fn gas_estimate_value(&self) -> f64 {
501 self.gas_estimate_value
502 }
503
504 pub fn gwei_per_gas(&self) -> f64 {
506 self.gwei_per_gas
507 }
508
509 pub fn in_amounts_iter(&self) -> impl Iterator<Item = &String> {
511 self.in_amounts.iter()
512 }
513
514 pub fn in_amount_u256(&self) -> Result<U256> {
516 let amount_str = self
517 .in_amounts_iter()
518 .next()
519 .ok_or_else(|| OdosError::missing_data("Missing input amount"))?;
520 let amount: u128 = amount_str
521 .parse()
522 .map_err(|_| OdosError::invalid_input("Invalid input amount format"))?;
523 Ok(U256::from(amount))
524 }
525
526 pub fn out_amount(&self) -> Option<&String> {
528 self.out_amounts.first()
529 }
530
531 pub fn out_amounts_iter(&self) -> impl Iterator<Item = &String> {
533 self.out_amounts.iter()
534 }
535
536 pub fn in_tokens_iter(&self) -> impl Iterator<Item = &Address> {
538 self.in_tokens.iter()
539 }
540
541 pub fn first_in_token(&self) -> Option<&Address> {
543 self.in_tokens.first()
544 }
545
546 pub fn out_tokens_iter(&self) -> impl Iterator<Item = &Address> {
547 self.out_tokens.iter()
548 }
549
550 pub fn first_out_token(&self) -> Option<&Address> {
552 self.out_tokens.first()
553 }
554
555 pub fn out_values_iter(&self) -> impl Iterator<Item = &f64> {
557 self.out_values.iter()
558 }
559
560 pub fn path_id(&self) -> &str {
562 &self.path_id
563 }
564
565 pub fn path_definition_as_vec_u8(&self) -> Vec<u8> {
567 self.path_id().as_bytes().to_vec()
568 }
569
570 pub fn swap_input_token_and_amount(&self) -> Result<(Address, U256)> {
572 let input_token = *self
573 .in_tokens_iter()
574 .next()
575 .ok_or_else(|| OdosError::missing_data("Missing input token"))?;
576 let input_amount_in_u256 = self.in_amount_u256()?;
577
578 Ok((input_token, input_amount_in_u256))
579 }
580
581 pub fn price_impact(&self) -> f64 {
583 self.price_impact
584 }
585
586 pub fn net_out_value(&self) -> f64 {
588 self.net_out_value
589 }
590
591 pub fn partner_fee_percent(&self) -> f64 {
593 self.partner_fee_percent
594 }
595}
596
597#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
615#[serde(rename_all = "camelCase")]
616pub struct OdosApiErrorResponse {
617 pub detail: String,
619 #[serde(default)]
622 pub trace_id: Option<TraceId>,
623 pub error_code: u16,
625}
626
627#[cfg(feature = "v2")]
631#[derive(Clone, Debug)]
632pub struct SwapInputs {
633 executor: Address,
634 path_definition: Bytes,
635 input_token_info: inputTokenInfo,
636 output_token_info: outputTokenInfo,
637 value_out_min: U256,
638}
639
640#[cfg(feature = "v2")]
641impl TryFrom<OdosV2RouterCalls> for SwapInputs {
642 type Error = OdosError;
643
644 fn try_from(swap: OdosV2RouterCalls) -> std::result::Result<Self, Self::Error> {
645 match swap {
646 OdosV2RouterCalls::swap(call) => {
647 debug!(
648 swap_type = "V2Router",
649 input.token = %call.tokenInfo.inputToken,
650 input.amount_wei = %call.tokenInfo.inputAmount,
651 output.token = %call.tokenInfo.outputToken,
652 output.min_wei = %call.tokenInfo.outputMin,
653 executor = %call.executor,
654 "Extracting swap inputs from V2 router call"
655 );
656
657 let swapCall {
658 executor,
659 pathDefinition,
660 referralCode,
661 tokenInfo,
662 } = call;
663
664 let _referral_code = referralCode;
665
666 let swapTokenInfo {
667 inputToken,
668 inputAmount,
669 inputReceiver,
670 outputMin,
671 outputQuote,
672 outputReceiver,
673 outputToken,
674 } = tokenInfo;
675
676 let _output_quote = outputQuote;
677
678 Ok(Self {
679 executor,
680 path_definition: pathDefinition,
681 input_token_info: inputTokenInfo {
682 tokenAddress: inputToken,
683 amountIn: inputAmount,
684 receiver: inputReceiver,
685 },
686 output_token_info: outputTokenInfo {
687 tokenAddress: outputToken,
688 relativeValue: U256::from(1),
689 receiver: outputReceiver,
690 },
691 value_out_min: outputMin,
692 })
693 }
694 _ => Err(OdosError::invalid_input("Unexpected OdosV2RouterCalls")),
695 }
696 }
697}
698
699#[cfg(feature = "v3")]
700impl TryFrom<OdosV3RouterCalls> for SwapInputs {
701 type Error = OdosError;
702
703 fn try_from(swap: OdosV3RouterCalls) -> std::result::Result<Self, Self::Error> {
704 match swap {
705 OdosV3RouterCalls::swap(call) => {
706 debug!(
707 swap_type = "V3Router",
708 input.token = %call.tokenInfo.inputToken,
709 input.amount_wei = %call.tokenInfo.inputAmount,
710 output.token = %call.tokenInfo.outputToken,
711 output.min_wei = %call.tokenInfo.outputMin,
712 executor = %call.executor,
713 "Extracting swap inputs from V3 router call"
714 );
715
716 let v3SwapTokenInfo {
717 inputToken,
718 inputAmount,
719 inputReceiver,
720 outputMin,
721 outputQuote,
722 outputReceiver,
723 outputToken,
724 } = call.tokenInfo;
725
726 let _output_quote = outputQuote;
727 let _referral_info = call.referralInfo;
728
729 Ok(Self {
730 executor: call.executor,
731 path_definition: call.pathDefinition,
732 input_token_info: inputTokenInfo {
733 tokenAddress: inputToken,
734 amountIn: inputAmount,
735 receiver: inputReceiver,
736 },
737 output_token_info: outputTokenInfo {
738 tokenAddress: outputToken,
739 relativeValue: U256::from(1),
740 receiver: outputReceiver,
741 },
742 value_out_min: outputMin,
743 })
744 }
745 _ => Err(OdosError::invalid_input("Unexpected OdosV3RouterCalls")),
746 }
747 }
748}
749
750#[cfg(feature = "v2")]
751impl SwapInputs {
752 pub fn executor(&self) -> Address {
754 self.executor
755 }
756
757 pub fn path_definition(&self) -> &Bytes {
759 &self.path_definition
760 }
761
762 pub fn token_address(&self) -> Address {
764 self.input_token_info.tokenAddress
765 }
766
767 pub fn amount_in(&self) -> U256 {
769 self.input_token_info.amountIn
770 }
771
772 pub fn receiver(&self) -> Address {
774 self.input_token_info.receiver
775 }
776
777 pub fn relative_value(&self) -> U256 {
779 self.output_token_info.relativeValue
780 }
781
782 pub fn output_token_address(&self) -> Address {
784 self.output_token_info.tokenAddress
785 }
786
787 pub fn value_out_min(&self) -> U256 {
789 self.value_out_min
790 }
791}
792
793#[cfg(test)]
794mod tests {
795 use super::*;
796
797 #[test]
798 fn test_api_host_base_url() {
799 assert_eq!(ApiHost::Public.base_url().as_str(), "https://api.odos.xyz/");
800 assert_eq!(
801 ApiHost::Enterprise.base_url().as_str(),
802 "https://enterprise-api.odos.xyz/"
803 );
804 }
805
806 #[test]
807 fn test_api_version_path() {
808 assert_eq!(ApiVersion::V2.path(), "v2");
809 assert_eq!(ApiVersion::V3.path(), "v3");
810 }
811
812 #[test]
813 fn test_endpoint_constructors() {
814 let endpoint = Endpoint::public_v2();
815 assert_eq!(endpoint.host(), ApiHost::Public);
816 assert_eq!(endpoint.version(), ApiVersion::V2);
817
818 let endpoint = Endpoint::public_v3();
819 assert_eq!(endpoint.host(), ApiHost::Public);
820 assert_eq!(endpoint.version(), ApiVersion::V3);
821
822 let endpoint = Endpoint::enterprise_v2();
823 assert_eq!(endpoint.host(), ApiHost::Enterprise);
824 assert_eq!(endpoint.version(), ApiVersion::V2);
825
826 let endpoint = Endpoint::enterprise_v3();
827 assert_eq!(endpoint.host(), ApiHost::Enterprise);
828 assert_eq!(endpoint.version(), ApiVersion::V3);
829
830 let endpoint = Endpoint::new(ApiHost::Public, ApiVersion::V2);
831 assert_eq!(endpoint.host(), ApiHost::Public);
832 assert_eq!(endpoint.version(), ApiVersion::V2);
833 }
834
835 #[test]
836 fn test_endpoint_quote_urls() {
837 assert_eq!(
838 Endpoint::public_v2().quote_url().as_str(),
839 "https://api.odos.xyz/sor/quote/v2"
840 );
841 assert_eq!(
842 Endpoint::public_v3().quote_url().as_str(),
843 "https://api.odos.xyz/sor/quote/v3"
844 );
845 assert_eq!(
846 Endpoint::enterprise_v2().quote_url().as_str(),
847 "https://enterprise-api.odos.xyz/sor/quote/v2"
848 );
849 assert_eq!(
850 Endpoint::enterprise_v3().quote_url().as_str(),
851 "https://enterprise-api.odos.xyz/sor/quote/v3"
852 );
853 }
854
855 #[test]
856 fn test_endpoint_assemble_urls() {
857 assert_eq!(
858 Endpoint::public_v2().assemble_url().as_str(),
859 "https://api.odos.xyz/sor/assemble"
860 );
861 assert_eq!(
862 Endpoint::public_v3().assemble_url().as_str(),
863 "https://api.odos.xyz/sor/assemble"
864 );
865 assert_eq!(
866 Endpoint::enterprise_v2().assemble_url().as_str(),
867 "https://enterprise-api.odos.xyz/sor/assemble"
868 );
869 assert_eq!(
870 Endpoint::enterprise_v3().assemble_url().as_str(),
871 "https://enterprise-api.odos.xyz/sor/assemble"
872 );
873 }
874
875 #[test]
876 fn test_endpoint_default() {
877 let endpoint = Endpoint::default();
878 assert_eq!(endpoint.host(), ApiHost::Public);
879 assert_eq!(endpoint.version(), ApiVersion::V2);
880 assert_eq!(
881 endpoint.quote_url().as_str(),
882 "https://api.odos.xyz/sor/quote/v2"
883 );
884 }
885
886 #[test]
887 fn test_endpoint_equality() {
888 assert_eq!(
889 Endpoint::public_v2(),
890 Endpoint::new(ApiHost::Public, ApiVersion::V2)
891 );
892 assert_eq!(
893 Endpoint::enterprise_v3(),
894 Endpoint::new(ApiHost::Enterprise, ApiVersion::V3)
895 );
896 assert_ne!(Endpoint::public_v2(), Endpoint::public_v3());
897 assert_ne!(Endpoint::public_v2(), Endpoint::enterprise_v2());
898 }
899
900 #[test]
901 fn test_odos_api_error_response_accepts_null_trace_id() {
902 let body = r#"{"detail":"x","traceId":null,"errorCode":2999}"#;
903 let parsed: OdosApiErrorResponse = serde_json::from_str(body).unwrap();
904 assert_eq!(parsed.trace_id, None);
905 assert_eq!(
906 parsed.error_code,
907 crate::error_code::OdosErrorCode::AlgoInternal.code()
908 );
909 }
910
911 #[test]
912 fn test_odos_api_error_response_accepts_missing_trace_id() {
913 let body = r#"{"detail":"x","errorCode":2999}"#;
914 let parsed: OdosApiErrorResponse = serde_json::from_str(body).unwrap();
915 assert_eq!(parsed.trace_id, None);
916 assert_eq!(
917 parsed.error_code,
918 crate::error_code::OdosErrorCode::AlgoInternal.code()
919 );
920 }
921
922 #[test]
923 fn test_odos_api_error_response_accepts_present_trace_id() {
924 let body =
925 r#"{"detail":"x","traceId":"10becdc8-a021-4491-8201-a17b657204e0","errorCode":2999}"#;
926 let parsed: OdosApiErrorResponse = serde_json::from_str(body).unwrap();
927 assert!(parsed.trace_id.is_some());
928 assert_eq!(
929 parsed.error_code,
930 crate::error_code::OdosErrorCode::AlgoInternal.code()
931 );
932 }
933}