odos_sdk/
sor.rs

1// SPDX-FileCopyrightText: 2025 Semiotic AI, Inc.
2//
3// SPDX-License-Identifier: Apache-2.0
4
5use alloy_network::TransactionBuilder;
6use alloy_primitives::{hex, Address};
7use alloy_rpc_types::TransactionRequest;
8use reqwest::Response;
9use serde_json::Value;
10use tracing::instrument;
11
12use crate::{
13    api::OdosApiErrorResponse, error_code::OdosErrorCode, parse_value, AssembleRequest,
14    AssemblyRequest, AssemblyResponse, ClientConfig, OdosError, OdosHttpClient, Result,
15    RetryConfig, SwapBuilder,
16};
17
18use super::TransactionData;
19
20use crate::{QuoteRequest, SingleQuoteResponse};
21
22/// The Odos API client
23///
24/// This is the primary interface for interacting with the Odos API. It provides
25/// methods for obtaining swap quotes and assembling transactions.
26///
27/// # Architecture
28///
29/// The client is built on top of [`OdosHttpClient`], which handles:
30/// - HTTP connection management and pooling
31/// - Automatic retries with exponential backoff
32/// - Rate limit handling
33/// - Timeout management
34///
35/// # Examples
36///
37/// ## Basic usage with defaults
38/// ```rust
39/// use odos_sdk::OdosClient;
40///
41/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
42/// let client = OdosClient::new()?;
43/// # Ok(())
44/// # }
45/// ```
46///
47/// ## Custom configuration
48/// ```rust
49/// use odos_sdk::{OdosClient, ClientConfig, Endpoint};
50///
51/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
52/// let config = ClientConfig {
53///     endpoint: Endpoint::public_v3(),
54///     ..Default::default()
55/// };
56/// let client = OdosClient::with_config(config)?;
57/// # Ok(())
58/// # }
59/// ```
60///
61/// ## Using retry configuration
62/// ```rust
63/// use odos_sdk::{OdosClient, RetryConfig};
64///
65/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
66/// // Conservative retries - only network errors
67/// let client = OdosClient::with_retry_config(RetryConfig::conservative())?;
68/// # Ok(())
69/// # }
70/// ```
71#[derive(Debug, Clone)]
72pub struct OdosClient {
73    client: OdosHttpClient,
74}
75
76impl OdosClient {
77    /// Create a new Odos client with default configuration
78    ///
79    /// Uses default settings:
80    /// - Public API endpoint
81    /// - API version V2
82    /// - 30 second timeout
83    /// - 3 retry attempts with exponential backoff
84    ///
85    /// # Errors
86    ///
87    /// Returns an error if the underlying HTTP client cannot be initialized.
88    /// This is rare and typically only occurs due to system resource issues.
89    ///
90    /// # Examples
91    ///
92    /// ```rust
93    /// use odos_sdk::OdosClient;
94    ///
95    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
96    /// let client = OdosClient::new()?;
97    /// # Ok(())
98    /// # }
99    /// ```
100    pub fn new() -> Result<Self> {
101        Ok(Self {
102            client: OdosHttpClient::new()?,
103        })
104    }
105
106    /// Create a new Odos SOR client with custom configuration
107    ///
108    /// Allows full control over client behavior including timeouts,
109    /// retries, endpoint selection, and API version.
110    ///
111    /// # Arguments
112    ///
113    /// * `config` - The client configuration
114    ///
115    /// # Errors
116    ///
117    /// Returns an error if the underlying HTTP client cannot be initialized.
118    ///
119    /// # Examples
120    ///
121    /// ```rust
122    /// use odos_sdk::{OdosClient, ClientConfig, Endpoint};
123    ///
124    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
125    /// let config = ClientConfig {
126    ///     endpoint: Endpoint::enterprise_v3(),
127    ///     ..Default::default()
128    /// };
129    /// let client = OdosClient::with_config(config)?;
130    /// # Ok(())
131    /// # }
132    /// ```
133    pub fn with_config(config: ClientConfig) -> Result<Self> {
134        Ok(Self {
135            client: OdosHttpClient::with_config(config)?,
136        })
137    }
138
139    /// Create a client with custom retry configuration
140    ///
141    /// This is a convenience constructor that creates a client with the specified
142    /// retry behavior while using default values for other configuration options.
143    ///
144    /// # Examples
145    ///
146    /// ```rust
147    /// use odos_sdk::{OdosClient, RetryConfig};
148    ///
149    /// // No retries - handle all errors at application level
150    /// let client = OdosClient::with_retry_config(RetryConfig::no_retries()).unwrap();
151    ///
152    /// // Conservative retries - only network errors
153    /// let client = OdosClient::with_retry_config(RetryConfig::conservative()).unwrap();
154    ///
155    /// // Custom retry behavior
156    /// let retry_config = RetryConfig {
157    ///     max_retries: 5,
158    ///     retry_server_errors: true,
159    ///     ..Default::default()
160    /// };
161    /// let client = OdosClient::with_retry_config(retry_config).unwrap();
162    /// ```
163    pub fn with_retry_config(retry_config: RetryConfig) -> Result<Self> {
164        let config = ClientConfig {
165            retry_config,
166            ..Default::default()
167        };
168        Self::with_config(config)
169    }
170
171    /// Get the client configuration
172    pub fn config(&self) -> &ClientConfig {
173        self.client.config()
174    }
175
176    /// Create a high-level swap builder
177    ///
178    /// This is the recommended way to build swaps for most use cases.
179    /// It provides a simple, ergonomic API that handles the quote → assemble → build flow.
180    ///
181    /// # Examples
182    ///
183    /// ```rust,no_run
184    /// use odos_sdk::{OdosClient, Chain, Slippage};
185    /// use alloy_primitives::{address, U256};
186    ///
187    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
188    /// let client = OdosClient::new()?;
189    ///
190    /// let tx = client
191    ///     .swap()
192    ///     .chain(Chain::ethereum())
193    ///     .from_token(address!("a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"), U256::from(1_000_000))
194    ///     .to_token(address!("c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"))
195    ///     .slippage(Slippage::percent(0.5)?)
196    ///     .signer(address!("742d35Cc6634C0532925a3b8D35f3e7a5edD29c0"))
197    ///     .build_transaction()
198    ///     .await?;
199    /// # Ok(())
200    /// # }
201    /// ```
202    pub fn swap(&self) -> SwapBuilder<'_> {
203        SwapBuilder::new(self)
204    }
205
206    /// Get a swap quote from the Odos API
207    ///
208    /// Requests a quote for swapping tokens on the configured chain.
209    /// The quote includes routing information, price impact, gas estimates,
210    /// and a path ID that can be used to assemble the transaction.
211    ///
212    /// # Arguments
213    ///
214    /// * `quote_request` - The quote request containing swap parameters
215    ///
216    /// # Returns
217    ///
218    /// Returns a [`SingleQuoteResponse`] containing:
219    /// - Path ID for transaction assembly
220    /// - Expected output amounts
221    /// - Gas estimates
222    /// - Price impact
223    /// - Routing information
224    ///
225    /// # Errors
226    ///
227    /// This method can fail with various errors:
228    /// - [`OdosError::Api`] - API returned an error (invalid input, unsupported chain, etc.)
229    /// - [`OdosError::RateLimit`] - Rate limit exceeded
230    /// - [`OdosError::Http`] - Network error
231    /// - [`OdosError::Timeout`] - Request timeout
232    ///
233    /// Server errors (5xx) are automatically retried based on the retry configuration.
234    ///
235    /// # Examples
236    ///
237    /// ```rust,no_run
238    /// use odos_sdk::{OdosClient, QuoteRequest, InputToken, OutputToken};
239    /// use alloy_primitives::{address, U256};
240    ///
241    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
242    /// let client = OdosClient::new()?;
243    ///
244    /// let quote_request = QuoteRequest::builder()
245    ///     .chain_id(1) // Ethereum mainnet
246    ///     .input_tokens(vec![InputToken::new(
247    ///         address!("a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"), // USDC
248    ///         U256::from(1000000) // 1 USDC (6 decimals)
249    ///     )])
250    ///     .output_tokens(vec![OutputToken::new(
251    ///         address!("c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"), // WETH
252    ///         1 // 100% to WETH
253    ///     )])
254    ///     .slippage_limit_percent(0.5)
255    ///     .user_addr(address!("0000000000000000000000000000000000000000"))
256    ///     .compact(false)
257    ///     .simple(false)
258    ///     .referral_code(0)
259    ///     .disable_rfqs(false)
260    ///     .build();
261    ///
262    /// let quote = client.quote(&quote_request).await?;
263    /// println!("Path ID: {}", quote.path_id());
264    /// # Ok(())
265    /// # }
266    /// ```
267    #[instrument(skip(self), level = "debug")]
268    pub async fn quote(&self, quote_request: &QuoteRequest) -> Result<SingleQuoteResponse> {
269        let response = self
270            .client
271            .execute_with_retry(|| {
272                let mut builder = self
273                    .client
274                    .inner()
275                    .post(self.client.config().endpoint.quote_url())
276                    .header("accept", "application/json")
277                    .json(quote_request);
278
279                // Add API key header if available
280                if let Some(ref api_key) = self.client.config().api_key {
281                    builder = builder.header("X-API-Key", api_key.as_str());
282                }
283
284                builder
285            })
286            .await?;
287
288        if response.status().is_success() {
289            let single_quote_response = response.json().await?;
290            Ok(single_quote_response)
291        } else {
292            let status = response.status();
293
294            // Try to parse structured error response
295            let body_text = response
296                .text()
297                .await
298                .unwrap_or_else(|e| format!("Failed to read response body: {}", e));
299
300            let (message, code, trace_id) =
301                match serde_json::from_str::<OdosApiErrorResponse>(&body_text) {
302                    Ok(error_response) => {
303                        let error_code = OdosErrorCode::from(error_response.error_code);
304                        (
305                            error_response.detail,
306                            error_code,
307                            Some(error_response.trace_id),
308                        )
309                    }
310                    Err(_) => (body_text, OdosErrorCode::Unknown(0), None),
311                };
312
313            Err(OdosError::api_error_with_code(
314                status, message, code, trace_id,
315            ))
316        }
317    }
318
319    /// Deprecated: Use [`quote`](Self::quote) instead
320    #[deprecated(since = "0.25.0", note = "Use `quote` instead")]
321    pub async fn get_swap_quote(
322        &self,
323        quote_request: &QuoteRequest,
324    ) -> Result<SingleQuoteResponse> {
325        self.quote(quote_request).await
326    }
327
328    #[instrument(skip(self), level = "debug")]
329    pub async fn get_assemble_response(
330        &self,
331        assemble_request: AssembleRequest,
332    ) -> Result<Response> {
333        self.client
334            .execute_with_retry(|| {
335                let mut builder = self
336                    .client
337                    .inner()
338                    .post(self.client.config().endpoint.assemble_url())
339                    .header("Content-Type", "application/json")
340                    .json(&assemble_request);
341
342                // Add API key header if available
343                if let Some(ref api_key) = self.client.config().api_key {
344                    builder = builder.header("X-API-Key", api_key.as_str());
345                }
346
347                builder
348            })
349            .await
350    }
351
352    /// Assemble transaction data from a quote
353    ///
354    /// Takes a path ID from a quote response and assembles the complete
355    /// transaction data needed to execute the swap on-chain.
356    ///
357    /// # Arguments
358    ///
359    /// * `signer_address` - Address that will sign and send the transaction
360    /// * `output_recipient` - Address that will receive the output tokens
361    /// * `path_id` - Path ID from a previous quote response
362    ///
363    /// # Returns
364    ///
365    /// Returns [`TransactionData`] containing:
366    /// - Transaction calldata (`data`)
367    /// - ETH value to send (`value`)
368    /// - Target contract address (`to`)
369    /// - Gas estimates
370    ///
371    /// # Errors
372    ///
373    /// - [`OdosError::Api`] - Invalid path ID, expired quote, or other API error
374    /// - [`OdosError::RateLimit`] - Rate limit exceeded
375    /// - [`OdosError::Http`] - Network error
376    /// - [`OdosError::Timeout`] - Request timeout
377    ///
378    /// # Examples
379    ///
380    /// ```rust,no_run
381    /// use odos_sdk::OdosClient;
382    /// use alloy_primitives::address;
383    ///
384    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
385    /// let client = OdosClient::new()?;
386    /// let path_id = "path_id_from_quote_response";
387    ///
388    /// let tx_data = client.assemble_tx_data(
389    ///     address!("0000000000000000000000000000000000000001"),
390    ///     address!("0000000000000000000000000000000000000001"),
391    ///     path_id
392    /// ).await?;
393    /// # Ok(())
394    /// # }
395    /// ```
396    #[instrument(skip(self), level = "debug")]
397    pub async fn assemble_tx_data(
398        &self,
399        signer_address: Address,
400        output_recipient: Address,
401        path_id: &str,
402    ) -> Result<TransactionData> {
403        let assemble_request = AssembleRequest {
404            user_addr: signer_address,
405            path_id: path_id.to_string(),
406            simulate: false,
407            receiver: Some(output_recipient),
408        };
409
410        let response = self.get_assemble_response(assemble_request).await?;
411
412        if !response.status().is_success() {
413            let status = response.status();
414
415            // Try to parse structured error response
416            let body_text = response
417                .text()
418                .await
419                .unwrap_or_else(|_| "Failed to get error message".to_string());
420
421            let (message, code, trace_id) =
422                match serde_json::from_str::<OdosApiErrorResponse>(&body_text) {
423                    Ok(error_response) => {
424                        let error_code = OdosErrorCode::from(error_response.error_code);
425                        (
426                            error_response.detail,
427                            error_code,
428                            Some(error_response.trace_id),
429                        )
430                    }
431                    Err(_) => (body_text, OdosErrorCode::Unknown(0), None),
432                };
433
434            return Err(OdosError::api_error_with_code(
435                status, message, code, trace_id,
436            ));
437        }
438
439        let value: Value = response.json().await?;
440
441        let AssemblyResponse { transaction, .. } = serde_json::from_value(value)?;
442
443        Ok(transaction)
444    }
445
446    /// Assemble a transaction from an assembly request
447    ///
448    /// Assembles transaction data and constructs a [`TransactionRequest`] ready
449    /// for gas parameter configuration and signing. This is a convenience method
450    /// that combines [`assemble_tx_data`](Self::assemble_tx_data) with transaction
451    /// request construction.
452    ///
453    /// # Arguments
454    ///
455    /// * `request` - The assembly request containing addresses and path ID
456    ///
457    /// # Returns
458    ///
459    /// Returns a [`TransactionRequest`] with:
460    /// - `to`: Router contract address
461    /// - `from`: Signer address
462    /// - `data`: Encoded swap calldata
463    /// - `value`: ETH amount to send
464    ///
465    /// Gas parameters (gas limit, gas price) are NOT set and must be configured
466    /// by the caller before signing.
467    ///
468    /// # Errors
469    ///
470    /// - [`OdosError::Api`] - Invalid path ID or API error
471    /// - [`OdosError::RateLimit`] - Rate limit exceeded
472    /// - [`OdosError::Http`] - Network error
473    /// - [`OdosError::Timeout`] - Request timeout
474    /// - [`OdosError::Hex`] - Failed to decode transaction data
475    ///
476    /// # Examples
477    ///
478    /// ```rust,no_run
479    /// use odos_sdk::{OdosClient, AssemblyRequest};
480    /// use alloy_primitives::{address, U256};
481    /// use alloy_chains::NamedChain;
482    ///
483    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
484    /// let client = OdosClient::new()?;
485    ///
486    /// let request = AssemblyRequest::builder()
487    ///     .chain(NamedChain::Mainnet)
488    ///     .signer_address(address!("0000000000000000000000000000000000000001"))
489    ///     .output_recipient(address!("0000000000000000000000000000000000000001"))
490    ///     .router_address(address!("0000000000000000000000000000000000000002"))
491    ///     .token_address(address!("a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"))
492    ///     .token_amount(U256::from(1000000))
493    ///     .path_id("path_id_from_quote".to_string())
494    ///     .build();
495    ///
496    /// let mut tx_request = client.assemble(&request).await?;
497    ///
498    /// // Configure gas parameters before signing
499    /// // tx_request = tx_request.with_gas_limit(300000);
500    /// // tx_request = tx_request.with_max_fee_per_gas(...);
501    /// # Ok(())
502    /// # }
503    /// ```
504    #[instrument(skip(self), level = "debug")]
505    pub async fn assemble(&self, request: &AssemblyRequest) -> Result<TransactionRequest> {
506        let TransactionData { data, value, .. } = self
507            .assemble_tx_data(
508                request.signer_address(),
509                request.output_recipient(),
510                request.path_id(),
511            )
512            .await?;
513
514        Ok(TransactionRequest::default()
515            .with_input(hex::decode(&data)?)
516            .with_value(parse_value(&value)?)
517            .with_to(request.router_address())
518            .with_from(request.signer_address()))
519    }
520
521    /// Deprecated: Use [`assemble`](Self::assemble) instead
522    #[deprecated(since = "0.25.0", note = "Use `assemble` instead")]
523    pub async fn build_base_transaction(
524        &self,
525        swap: &AssemblyRequest,
526    ) -> Result<TransactionRequest> {
527        self.assemble(swap).await
528    }
529}
530
531impl Default for OdosClient {
532    /// Creates a default Odos client with standard configuration.
533    ///
534    /// # Panics
535    ///
536    /// Panics if the underlying HTTP client cannot be initialized.
537    /// This should only fail in extremely rare cases such as:
538    /// - TLS initialization failure
539    /// - System resource exhaustion
540    /// - Invalid system configuration
541    ///
542    /// In practice, this almost never fails and is safe for most use cases.
543    /// See [`OdosHttpClient::default`] for more details.
544    fn default() -> Self {
545        Self::new().expect("Failed to create default OdosClient")
546    }
547}
548
549/// Deprecated alias for [`OdosClient`]
550///
551/// This type alias is provided for backward compatibility.
552/// Use [`OdosClient`] instead in new code.
553#[deprecated(since = "0.25.0", note = "Use `OdosClient` instead")]
554pub type OdosSor = OdosClient;