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("e_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;