Skip to main content

odos_sdk/
tooling.rs

1// SPDX-FileCopyrightText: 2025 Semiotic AI, Inc.
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! Tool/runtime-friendly request and response types.
6//!
7//! This module provides a stable JSON boundary for tool runtimes, generated
8//! integrations, MCP servers, CLIs, and backend services that need to request
9//! quotes and build swap transactions without manually normalizing chain names,
10//! addresses, slippage, and U256 amounts.
11
12use 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/// Chain selector that accepts either a numeric chain ID or a common chain name.
21///
22/// Uses `#[serde(untagged)]` so both JSON forms work:
23///
24/// - **Integer**: `{ "chain": 1 }` → `ChainInput::Id(1)` → Ethereum
25/// - **String name**: `{ "chain": "base" }` → `ChainInput::Name("base")` → Base
26/// - **String number**: `{ "chain": "1" }` → `ChainInput::Name("1")` → Ethereum
27///   (string digits are parsed back to a chain ID during [`resolve`](Self::resolve))
28#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
29#[serde(untagged)]
30pub enum ChainInput {
31    /// Numeric EVM chain ID (e.g. `1`, `8453`).
32    Id(u64),
33    /// Common chain name, alias, or stringified chain ID
34    /// (e.g. `"ethereum"`, `"base"`, `"1"`).
35    Name(String),
36}
37
38impl ChainInput {
39    /// Resolve this chain selector into a supported Odos chain.
40    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/// Single-token swap request shape optimized for tool/runtime JSON boundaries.
56#[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    /// Validate and normalize the request into typed Odos/alloy values.
82    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/// Validated single-token swap request with typed values ready for execution.
129#[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    /// Build an Odos quote request from the validated swap inputs.
146    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    /// Build a configured high-level swap builder from the validated request.
161    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/// Compact quote summary intended for tool outputs and confirmation prompts.
178#[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/// Transaction summary intended for tool/runtime outputs.
246#[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    /// Converts API transaction data, clamping `gas` from `i128` to `u64`.
261    /// A negative or overflowing value from the API is clamped and logged as a
262    /// warning since it signals an upstream bug.
263    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/// Complete tool-facing transaction plan including both quote context and calldata.
286#[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    /// Quote a single-token swap using the generic tooling request shape.
295    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, &quote))
298    }
299
300    /// Build a transaction plan for a single-token swap using the generic
301    /// tooling request shape.
302    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, &quote),
310            transaction: tx.into(),
311        })
312    }
313
314    /// Shared validation and quoting step used by all tooling entry points.
315    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}