1use alloy_primitives::{Address, U256};
13use serde::{Deserialize, Serialize};
14
15use crate::{
16 parse_value, Chain, OdosClient, QuoteRequest, ReferralCode, Result, SingleQuoteResponse,
17 Slippage, SwapBuilder, TransactionData,
18};
19
20#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
29#[serde(untagged)]
30pub enum ChainInput {
31 Id(u64),
33 Name(String),
36}
37
38impl ChainInput {
39 pub fn resolve(&self) -> Result<Chain> {
41 match self {
42 Self::Id(id) => Chain::from_chain_id(*id).map_err(|err| {
43 crate::OdosError::invalid_input(format!("Unsupported Odos chain '{}': {}", id, err))
44 }),
45 Self::Name(name) => Chain::from_name(name).map_err(|err| {
46 crate::OdosError::invalid_input(format!(
47 "Unsupported Odos chain '{}': {}",
48 name, err
49 ))
50 }),
51 }
52 }
53}
54
55#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
57#[serde(rename_all = "camelCase")]
58pub struct SwapRequest {
59 pub chain: ChainInput,
60 pub from_token: String,
61 pub from_amount: String,
62 pub to_token: String,
63 pub signer: String,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub recipient: Option<String>,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub slippage_percent: Option<f64>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub slippage_bps: Option<u16>,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub referral_code: Option<u32>,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub compact: Option<bool>,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub simple: Option<bool>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub disable_rfqs: Option<bool>,
78}
79
80impl SwapRequest {
81 pub fn validate(&self) -> Result<ValidatedSwapRequest> {
83 let chain = self.chain.resolve()?;
84 let input_token = parse_address("fromToken", &self.from_token)?;
85 let input_amount = parse_amount("fromAmount", &self.from_amount)?;
86 let output_token = parse_address("toToken", &self.to_token)?;
87 let signer = parse_address("signer", &self.signer)?;
88 let recipient = self
89 .recipient
90 .as_deref()
91 .map(|value| parse_address("recipient", value))
92 .transpose()?
93 .unwrap_or(signer);
94 let slippage = resolve_slippage(self.slippage_percent, self.slippage_bps)?;
95 let referral = self
96 .referral_code
97 .map(ReferralCode::new)
98 .unwrap_or(ReferralCode::NONE);
99
100 if input_amount.is_zero() {
101 return Err(crate::OdosError::invalid_input(
102 "fromAmount must be greater than zero",
103 ));
104 }
105
106 if input_token == output_token {
107 return Err(crate::OdosError::invalid_input(
108 "fromToken and toToken must be different",
109 ));
110 }
111
112 Ok(ValidatedSwapRequest {
113 chain,
114 input_token,
115 input_amount,
116 output_token,
117 signer,
118 recipient,
119 slippage,
120 referral,
121 compact: self.compact.unwrap_or(false),
122 simple: self.simple.unwrap_or(false),
123 disable_rfqs: self.disable_rfqs.unwrap_or(false),
124 })
125 }
126}
127
128#[derive(Clone, Debug, PartialEq)]
130pub struct ValidatedSwapRequest {
131 pub chain: Chain,
132 pub input_token: Address,
133 pub input_amount: U256,
134 pub output_token: Address,
135 pub signer: Address,
136 pub recipient: Address,
137 pub slippage: Slippage,
138 pub referral: ReferralCode,
139 pub compact: bool,
140 pub simple: bool,
141 pub disable_rfqs: bool,
142}
143
144impl ValidatedSwapRequest {
145 pub fn quote_request(&self) -> QuoteRequest {
147 QuoteRequest::builder()
148 .chain_id(self.chain.id())
149 .input_tokens(vec![(self.input_token, self.input_amount).into()])
150 .output_tokens(vec![(self.output_token, 1).into()])
151 .slippage_limit_percent(self.slippage.as_percent())
152 .user_addr(self.signer)
153 .compact(self.compact)
154 .simple(self.simple)
155 .referral_code(self.referral.code())
156 .disable_rfqs(self.disable_rfqs)
157 .build()
158 }
159
160 pub fn swap_builder<'a>(&self, client: &'a OdosClient) -> SwapBuilder<'a> {
162 client
163 .swap()
164 .chain(self.chain)
165 .from_token(self.input_token, self.input_amount)
166 .to_token(self.output_token)
167 .slippage(self.slippage)
168 .signer(self.signer)
169 .recipient(self.recipient)
170 .referral(self.referral)
171 .compact(self.compact)
172 .simple(self.simple)
173 .disable_rfqs(self.disable_rfqs)
174 }
175}
176
177#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
179#[serde(rename_all = "camelCase")]
180pub struct QuoteSummary {
181 pub chain_id: u64,
182 pub chain_name: String,
183 pub signer: String,
184 pub recipient: String,
185 pub from_token: String,
186 pub from_amount: String,
187 pub to_token: String,
188 pub to_amount: String,
189 pub slippage_percent: f64,
190 pub path_id: String,
191 pub price_impact_percent: f64,
192 pub gas_estimate: f64,
193 pub gas_estimate_value: f64,
194 pub net_out_value: f64,
195 pub partner_fee_percent: f64,
196 pub gwei_per_gas: f64,
197 #[serde(default, skip_serializing_if = "Vec::is_empty")]
198 pub warnings: Vec<String>,
199}
200
201impl QuoteSummary {
202 fn from_quote(request: &ValidatedSwapRequest, quote: &SingleQuoteResponse) -> Self {
203 let mut warnings = Vec::new();
204
205 if quote.price_impact() >= 3.0 {
206 warnings.push(format!(
207 "High price impact detected ({:.2}%)",
208 quote.price_impact()
209 ));
210 }
211
212 if quote.gas_estimate_value() > quote.net_out_value() && quote.net_out_value() > 0.0 {
213 warnings.push("Estimated gas cost exceeds quoted net output value".to_string());
214 }
215
216 if quote.out_amount().is_none() {
217 warnings.push("Primary output amount was missing from the quote response".to_string());
218 }
219
220 Self {
221 chain_id: request.chain.id(),
222 chain_name: request.chain.to_string(),
223 signer: request.signer.to_string(),
224 recipient: request.recipient.to_string(),
225 from_token: request.input_token.to_string(),
226 from_amount: request.input_amount.to_string(),
227 to_token: request.output_token.to_string(),
228 to_amount: quote
229 .out_amount()
230 .cloned()
231 .unwrap_or_else(|| "0".to_string()),
232 slippage_percent: request.slippage.as_percent(),
233 path_id: quote.path_id().to_string(),
234 price_impact_percent: quote.price_impact(),
235 gas_estimate: quote.gas_estimate(),
236 gas_estimate_value: quote.gas_estimate_value(),
237 net_out_value: quote.net_out_value(),
238 partner_fee_percent: quote.partner_fee_percent(),
239 gwei_per_gas: quote.gwei_per_gas(),
240 warnings,
241 }
242 }
243}
244
245#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
247#[serde(rename_all = "camelCase")]
248pub struct TransactionSummary {
249 pub to: String,
250 pub from: String,
251 pub data: String,
252 pub value: String,
253 pub gas: u64,
254 pub gas_price: u128,
255 pub chain_id: u64,
256 pub nonce: u64,
257}
258
259impl From<TransactionData> for TransactionSummary {
260 fn from(value: TransactionData) -> Self {
264 let gas = value.gas.clamp(0, i128::from(u64::MAX)) as u64;
265 if i128::from(gas) != value.gas {
266 tracing::warn!(
267 raw_gas = value.gas,
268 clamped_gas = gas,
269 "API returned out-of-range gas value; clamped to u64",
270 );
271 }
272 Self {
273 to: value.to.to_string(),
274 from: value.from.to_string(),
275 data: value.data,
276 value: value.value,
277 gas,
278 gas_price: value.gas_price,
279 chain_id: value.chain_id,
280 nonce: value.nonce,
281 }
282 }
283}
284
285#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
287#[serde(rename_all = "camelCase")]
288pub struct TransactionPlan {
289 pub quote: QuoteSummary,
290 pub transaction: TransactionSummary,
291}
292
293impl OdosClient {
294 pub async fn quote_for_tooling(&self, request: &SwapRequest) -> Result<QuoteSummary> {
296 let (validated, quote) = self.validated_quote(request).await?;
297 Ok(QuoteSummary::from_quote(&validated, "e))
298 }
299
300 pub async fn build_transaction_plan(&self, request: &SwapRequest) -> Result<TransactionPlan> {
303 let (validated, quote) = self.validated_quote(request).await?;
304 let tx = self
305 .assemble_tx_data(validated.signer, validated.recipient, quote.path_id())
306 .await?;
307
308 Ok(TransactionPlan {
309 quote: QuoteSummary::from_quote(&validated, "e),
310 transaction: tx.into(),
311 })
312 }
313
314 async fn validated_quote(
316 &self,
317 request: &SwapRequest,
318 ) -> Result<(ValidatedSwapRequest, SingleQuoteResponse)> {
319 let validated = request.validate()?;
320 let quote = self.quote(&validated.quote_request()).await?;
321 Ok((validated, quote))
322 }
323}
324
325fn parse_address(field: &str, value: &str) -> Result<Address> {
326 value.parse().map_err(|err| {
327 crate::OdosError::invalid_input(format!(
328 "{field} must be a valid 0x-prefixed EVM address: {err}"
329 ))
330 })
331}
332
333fn parse_amount(field: &str, value: &str) -> Result<U256> {
334 parse_value(value).map_err(|err| {
335 crate::OdosError::invalid_input(format!(
336 "{field} must be a decimal or hexadecimal integer amount: {err}"
337 ))
338 })
339}
340
341fn resolve_slippage(percent: Option<f64>, bps: Option<u16>) -> Result<Slippage> {
342 match (percent, bps) {
343 (Some(percent), Some(bps)) => {
344 let percent_slippage =
345 Slippage::percent(percent).map_err(crate::OdosError::invalid_input)?;
346 let bps_slippage = Slippage::bps(bps).map_err(crate::OdosError::invalid_input)?;
347
348 if percent_slippage.as_bps() != bps_slippage.as_bps() {
349 return Err(crate::OdosError::invalid_input(format!(
350 "slippagePercent ({percent}) and slippageBps ({bps}) disagree"
351 )));
352 }
353
354 Ok(percent_slippage)
355 }
356 (Some(percent), None) => {
357 Slippage::percent(percent).map_err(crate::OdosError::invalid_input)
358 }
359 (None, Some(bps)) => Slippage::bps(bps).map_err(crate::OdosError::invalid_input),
360 (None, None) => Ok(Slippage::standard()),
361 }
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367 use alloy_primitives::address;
368 use serde_json::json;
369
370 #[test]
371 fn test_swap_request_defaults() {
372 let request = SwapRequest {
373 chain: ChainInput::Name("base".to_string()),
374 from_token: "0x4200000000000000000000000000000000000006".to_string(),
375 from_amount: "1000000000000000".to_string(),
376 to_token: "0x833589fCD6EDb6E08f4c7C32D4f71b54bdA02913".to_string(),
377 signer: "0x742d35Cc6634C0532925a3b8D35f3e7a5edD29c0".to_string(),
378 recipient: None,
379 slippage_percent: None,
380 slippage_bps: None,
381 referral_code: None,
382 compact: None,
383 simple: None,
384 disable_rfqs: None,
385 };
386
387 let validated = request.validate().unwrap();
388 assert_eq!(validated.chain, Chain::base());
389 assert_eq!(validated.recipient, validated.signer);
390 assert_eq!(validated.slippage, Slippage::standard());
391 assert_eq!(validated.referral, ReferralCode::NONE);
392 assert!(!validated.compact);
393 assert!(!validated.simple);
394 assert!(!validated.disable_rfqs);
395 }
396
397 #[test]
398 fn test_swap_request_rejects_same_token() {
399 let request = SwapRequest {
400 chain: ChainInput::Id(1),
401 from_token: address!("a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").to_string(),
402 from_amount: "1000000".to_string(),
403 to_token: address!("a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").to_string(),
404 signer: address!("742d35Cc6634C0532925a3b8D35f3e7a5edD29c0").to_string(),
405 recipient: None,
406 slippage_percent: Some(0.5),
407 slippage_bps: None,
408 referral_code: None,
409 compact: None,
410 simple: None,
411 disable_rfqs: None,
412 };
413
414 let err = request.validate().unwrap_err();
415 assert!(err.to_string().contains("must be different"));
416 }
417
418 #[test]
419 fn test_swap_request_accepts_matching_slippage_inputs() {
420 let request = SwapRequest {
421 chain: ChainInput::Name("ethereum".to_string()),
422 from_token: address!("a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").to_string(),
423 from_amount: "1000000".to_string(),
424 to_token: address!("c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").to_string(),
425 signer: address!("742d35Cc6634C0532925a3b8D35f3e7a5edD29c0").to_string(),
426 recipient: None,
427 slippage_percent: Some(0.5),
428 slippage_bps: Some(50),
429 referral_code: Some(42),
430 compact: Some(true),
431 simple: Some(false),
432 disable_rfqs: Some(true),
433 };
434
435 let validated = request.validate().unwrap();
436 assert_eq!(validated.slippage.as_bps(), 50);
437 assert_eq!(validated.referral.code(), 42);
438 assert!(validated.compact);
439 assert!(validated.disable_rfqs);
440 }
441
442 #[test]
443 fn test_swap_request_rejects_conflicting_slippage_inputs() {
444 let request = SwapRequest {
445 chain: ChainInput::Name("ethereum".to_string()),
446 from_token: address!("a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").to_string(),
447 from_amount: "1000000".to_string(),
448 to_token: address!("c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").to_string(),
449 signer: address!("742d35Cc6634C0532925a3b8D35f3e7a5edD29c0").to_string(),
450 recipient: None,
451 slippage_percent: Some(0.5),
452 slippage_bps: Some(75),
453 referral_code: None,
454 compact: None,
455 simple: None,
456 disable_rfqs: None,
457 };
458
459 let err = request.validate().unwrap_err();
460 assert!(err.to_string().contains("disagree"));
461 }
462
463 #[test]
464 fn test_chain_input_deserializes_integer() {
465 let input: ChainInput = serde_json::from_value(json!(1)).unwrap();
466 assert_eq!(input, ChainInput::Id(1));
467 assert_eq!(input.resolve().unwrap(), Chain::ethereum());
468 }
469
470 #[test]
471 fn test_chain_input_deserializes_string_name() {
472 let input: ChainInput = serde_json::from_value(json!("base")).unwrap();
473 assert_eq!(input, ChainInput::Name("base".to_string()));
474 assert_eq!(input.resolve().unwrap(), Chain::base());
475 }
476
477 #[test]
478 fn test_chain_input_deserializes_string_number() {
479 let input: ChainInput = serde_json::from_value(json!("1")).unwrap();
480 assert_eq!(input, ChainInput::Name("1".to_string()));
481 assert_eq!(input.resolve().unwrap(), Chain::ethereum());
482 }
483
484 #[test]
485 fn test_chain_input_round_trip_id() {
486 let original = ChainInput::Id(8453);
487 let json = serde_json::to_value(&original).unwrap();
488 let deserialized: ChainInput = serde_json::from_value(json).unwrap();
489 assert_eq!(deserialized, original);
490 assert_eq!(deserialized.resolve().unwrap(), Chain::base());
491 }
492
493 #[test]
494 fn test_chain_input_round_trip_name() {
495 let original = ChainInput::Name("arbitrum".to_string());
496 let json = serde_json::to_value(&original).unwrap();
497 let deserialized: ChainInput = serde_json::from_value(json).unwrap();
498 assert_eq!(deserialized, original);
499 assert_eq!(deserialized.resolve().unwrap(), Chain::arbitrum());
500 }
501
502 #[test]
503 fn test_swap_request_deserializes_from_json() {
504 let json = json!({
505 "chain": 1,
506 "fromToken": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
507 "fromAmount": "1000000",
508 "toToken": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
509 "signer": "0x742d35Cc6634C0532925a3b8D35f3e7a5edD29c0"
510 });
511 let request: SwapRequest = serde_json::from_value(json).unwrap();
512 assert_eq!(request.chain, ChainInput::Id(1));
513
514 let validated = request.validate().unwrap();
515 assert_eq!(validated.chain, Chain::ethereum());
516 }
517
518 #[test]
519 fn test_swap_request_deserializes_string_chain() {
520 let json = json!({
521 "chain": "base",
522 "fromToken": "0x4200000000000000000000000000000000000006",
523 "fromAmount": "1000000000000000",
524 "toToken": "0x833589fCD6EDb6E08f4c7C32D4f71b54bdA02913",
525 "signer": "0x742d35Cc6634C0532925a3b8D35f3e7a5edD29c0"
526 });
527 let request: SwapRequest = serde_json::from_value(json).unwrap();
528 assert_eq!(request.chain, ChainInput::Name("base".to_string()));
529
530 let validated = request.validate().unwrap();
531 assert_eq!(validated.chain, Chain::base());
532 }
533
534 fn make_tx_data(gas: i128) -> TransactionData {
535 TransactionData {
536 to: address!("0000000000000000000000000000000000000001"),
537 from: address!("0000000000000000000000000000000000000002"),
538 data: "0x".to_string(),
539 value: "0".to_string(),
540 gas,
541 gas_price: 1,
542 chain_id: 1,
543 nonce: 0,
544 }
545 }
546
547 #[test]
548 fn test_transaction_summary_clamps_negative_gas() {
549 let summary: TransactionSummary = make_tx_data(-1).into();
550 assert_eq!(summary.gas, 0);
551 }
552
553 #[test]
554 fn test_transaction_summary_clamps_overflow_gas() {
555 let summary: TransactionSummary = make_tx_data(i128::MAX).into();
556 assert_eq!(summary.gas, u64::MAX);
557 }
558
559 #[test]
560 fn test_transaction_summary_preserves_valid_gas() {
561 let summary: TransactionSummary = make_tx_data(300_000).into();
562 assert_eq!(summary.gas, 300_000);
563 }
564}