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