Skip to main content

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