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