odos_sdk/
sor.rs

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