Skip to main content

odos_sdk/
agent.rs

1// SPDX-FileCopyrightText: 2025 Semiotic AI, Inc.
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! Agent-friendly request and response types.
6//!
7//! This module provides a stable JSON boundary for AI agent tooling that needs
8//! to request quotes and build swap transactions without manually normalizing
9//! chain names, addresses, slippage, and U256 amounts.
10
11use alloy_primitives::{Address, U256};
12use serde::{Deserialize, Serialize};
13
14use crate::{
15    parse_value, Chain, OdosClient, QuoteRequest, ReferralCode, Result, SingleQuoteResponse,
16    Slippage, SwapBuilder, TransactionData,
17};
18
19/// Chain selector that accepts either a numeric chain ID or a common chain name.
20#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
21#[serde(untagged)]
22pub enum AgentChainInput {
23    /// Numeric EVM chain ID.
24    Id(u64),
25    /// Common chain name or alias such as `ethereum`, `mainnet`, `arb`, or `base`.
26    Name(String),
27}
28
29impl AgentChainInput {
30    /// Resolve the agent-facing chain selector into a supported Odos chain.
31    pub fn resolve(&self) -> Result<Chain> {
32        match self {
33            Self::Id(id) => Chain::from_chain_id(*id).map_err(|err| {
34                crate::OdosError::invalid_input(format!("Unsupported Odos chain '{}': {}", id, err))
35            }),
36            Self::Name(name) => Chain::from_name(name).map_err(|err| {
37                crate::OdosError::invalid_input(format!(
38                    "Unsupported Odos chain '{}': {}",
39                    name, err
40                ))
41            }),
42        }
43    }
44}
45
46/// Single-token swap request shape optimized for agent and tool JSON boundaries.
47#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
48#[serde(rename_all = "camelCase")]
49pub struct AgentSwapRequest {
50    pub chain: AgentChainInput,
51    pub from_token: String,
52    pub from_amount: String,
53    pub to_token: String,
54    pub signer: String,
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub recipient: Option<String>,
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub slippage_percent: Option<f64>,
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub slippage_bps: Option<u16>,
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub referral_code: Option<u32>,
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub compact: Option<bool>,
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub simple: Option<bool>,
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub disable_rfqs: Option<bool>,
69}
70
71impl AgentSwapRequest {
72    /// Validate and normalize the request into typed Odos/alloy values.
73    pub fn validate(&self) -> Result<ValidatedAgentSwapRequest> {
74        let chain = self.chain.resolve()?;
75        let input_token = parse_address("fromToken", &self.from_token)?;
76        let input_amount = parse_amount("fromAmount", &self.from_amount)?;
77        let output_token = parse_address("toToken", &self.to_token)?;
78        let signer = parse_address("signer", &self.signer)?;
79        let recipient = self
80            .recipient
81            .as_deref()
82            .map(|value| parse_address("recipient", value))
83            .transpose()?
84            .unwrap_or(signer);
85        let slippage = resolve_slippage(self.slippage_percent, self.slippage_bps)?;
86        let referral = self
87            .referral_code
88            .map(ReferralCode::new)
89            .unwrap_or(ReferralCode::NONE);
90
91        if input_amount.is_zero() {
92            return Err(crate::OdosError::invalid_input(
93                "fromAmount must be greater than zero",
94            ));
95        }
96
97        if input_token == output_token {
98            return Err(crate::OdosError::invalid_input(
99                "fromToken and toToken must be different",
100            ));
101        }
102
103        Ok(ValidatedAgentSwapRequest {
104            chain,
105            input_token,
106            input_amount,
107            output_token,
108            signer,
109            recipient,
110            slippage,
111            referral,
112            compact: self.compact.unwrap_or(false),
113            simple: self.simple.unwrap_or(false),
114            disable_rfqs: self.disable_rfqs.unwrap_or(false),
115        })
116    }
117}
118
119/// Validated single-token swap request with typed values ready for execution.
120#[derive(Clone, Debug, PartialEq)]
121pub struct ValidatedAgentSwapRequest {
122    pub chain: Chain,
123    pub input_token: Address,
124    pub input_amount: U256,
125    pub output_token: Address,
126    pub signer: Address,
127    pub recipient: Address,
128    pub slippage: Slippage,
129    pub referral: ReferralCode,
130    pub compact: bool,
131    pub simple: bool,
132    pub disable_rfqs: bool,
133}
134
135impl ValidatedAgentSwapRequest {
136    /// Build an Odos quote request from the validated swap inputs.
137    pub fn quote_request(&self) -> QuoteRequest {
138        QuoteRequest::builder()
139            .chain_id(self.chain.id())
140            .input_tokens(vec![(self.input_token, self.input_amount).into()])
141            .output_tokens(vec![(self.output_token, 1).into()])
142            .slippage_limit_percent(self.slippage.as_percent())
143            .user_addr(self.signer)
144            .compact(self.compact)
145            .simple(self.simple)
146            .referral_code(self.referral.code())
147            .disable_rfqs(self.disable_rfqs)
148            .build()
149    }
150
151    /// Build a configured high-level swap builder from the validated request.
152    pub fn swap_builder<'a>(&self, client: &'a OdosClient) -> SwapBuilder<'a> {
153        client
154            .swap()
155            .chain(self.chain)
156            .from_token(self.input_token, self.input_amount)
157            .to_token(self.output_token)
158            .slippage(self.slippage)
159            .signer(self.signer)
160            .recipient(self.recipient)
161            .referral(self.referral)
162            .compact(self.compact)
163            .simple(self.simple)
164            .disable_rfqs(self.disable_rfqs)
165    }
166}
167
168/// Compact quote summary intended for agent/tool outputs and confirmation prompts.
169#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
170#[serde(rename_all = "camelCase")]
171pub struct AgentQuoteSummary {
172    pub chain_id: u64,
173    pub chain_name: String,
174    pub signer: String,
175    pub recipient: String,
176    pub from_token: String,
177    pub from_amount: String,
178    pub to_token: String,
179    pub to_amount: String,
180    pub slippage_percent: f64,
181    pub path_id: String,
182    pub price_impact_percent: f64,
183    pub gas_estimate: f64,
184    pub gas_estimate_value: f64,
185    pub net_out_value: f64,
186    pub partner_fee_percent: f64,
187    pub gwei_per_gas: f64,
188    #[serde(default, skip_serializing_if = "Vec::is_empty")]
189    pub warnings: Vec<String>,
190}
191
192impl AgentQuoteSummary {
193    fn from_quote(request: &ValidatedAgentSwapRequest, quote: &SingleQuoteResponse) -> Self {
194        let mut warnings = Vec::new();
195
196        if quote.price_impact() >= 3.0 {
197            warnings.push(format!(
198                "High price impact detected ({:.2}%)",
199                quote.price_impact()
200            ));
201        }
202
203        if quote.gas_estimate_value() > quote.net_out_value() && quote.net_out_value() > 0.0 {
204            warnings.push("Estimated gas cost exceeds quoted net output value".to_string());
205        }
206
207        if quote.out_amount().is_none() {
208            warnings.push("Primary output amount was missing from the quote response".to_string());
209        }
210
211        Self {
212            chain_id: request.chain.id(),
213            chain_name: request.chain.to_string(),
214            signer: request.signer.to_string(),
215            recipient: request.recipient.to_string(),
216            from_token: request.input_token.to_string(),
217            from_amount: request.input_amount.to_string(),
218            to_token: request.output_token.to_string(),
219            to_amount: quote
220                .out_amount()
221                .cloned()
222                .unwrap_or_else(|| "0".to_string()),
223            slippage_percent: request.slippage.as_percent(),
224            path_id: quote.path_id().to_string(),
225            price_impact_percent: quote.price_impact(),
226            gas_estimate: quote.gas_estimate(),
227            gas_estimate_value: quote.gas_estimate_value(),
228            net_out_value: quote.net_out_value(),
229            partner_fee_percent: quote.partner_fee_percent(),
230            gwei_per_gas: quote.gwei_per_gas(),
231            warnings,
232        }
233    }
234}
235
236/// Transaction summary intended for agent/tool outputs.
237#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
238#[serde(rename_all = "camelCase")]
239pub struct AgentTransactionSummary {
240    pub to: String,
241    pub from: String,
242    pub data: String,
243    pub value: String,
244    pub gas: i128,
245    pub gas_price: u128,
246    pub chain_id: u64,
247    pub nonce: u64,
248}
249
250impl From<TransactionData> for AgentTransactionSummary {
251    fn from(value: TransactionData) -> Self {
252        Self {
253            to: value.to.to_string(),
254            from: value.from.to_string(),
255            data: value.data,
256            value: value.value,
257            gas: value.gas,
258            gas_price: value.gas_price,
259            chain_id: value.chain_id,
260            nonce: value.nonce,
261        }
262    }
263}
264
265/// Complete agent-facing transaction plan including both quote context and calldata.
266#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
267#[serde(rename_all = "camelCase")]
268pub struct AgentTransactionPlan {
269    pub quote: AgentQuoteSummary,
270    pub transaction: AgentTransactionSummary,
271}
272
273impl OdosClient {
274    /// Quote a single-token swap using the agent-friendly request shape.
275    pub async fn quote_for_agent(&self, request: &AgentSwapRequest) -> Result<AgentQuoteSummary> {
276        let request = request.validate()?;
277        let quote = self.quote(&request.quote_request()).await?;
278        Ok(AgentQuoteSummary::from_quote(&request, &quote))
279    }
280
281    /// Build transaction calldata for a single-token swap using the agent-friendly request shape.
282    pub async fn build_transaction_for_agent(
283        &self,
284        request: &AgentSwapRequest,
285    ) -> Result<AgentTransactionPlan> {
286        let request = request.validate()?;
287        let quote = self.quote(&request.quote_request()).await?;
288        let tx = self
289            .assemble_tx_data(request.signer, request.recipient, quote.path_id())
290            .await?;
291
292        Ok(AgentTransactionPlan {
293            quote: AgentQuoteSummary::from_quote(&request, &quote),
294            transaction: tx.into(),
295        })
296    }
297}
298
299fn parse_address(field: &str, value: &str) -> Result<Address> {
300    value.parse().map_err(|err| {
301        crate::OdosError::invalid_input(format!(
302            "{field} must be a valid 0x-prefixed EVM address: {err}"
303        ))
304    })
305}
306
307fn parse_amount(field: &str, value: &str) -> Result<U256> {
308    parse_value(value).map_err(|err| {
309        crate::OdosError::invalid_input(format!(
310            "{field} must be a decimal or hexadecimal integer amount: {err}"
311        ))
312    })
313}
314
315fn resolve_slippage(percent: Option<f64>, bps: Option<u16>) -> Result<Slippage> {
316    match (percent, bps) {
317        (Some(percent), Some(bps)) => {
318            let percent_slippage =
319                Slippage::percent(percent).map_err(crate::OdosError::invalid_input)?;
320            let bps_slippage = Slippage::bps(bps).map_err(crate::OdosError::invalid_input)?;
321
322            if percent_slippage.as_bps() != bps_slippage.as_bps() {
323                return Err(crate::OdosError::invalid_input(format!(
324                    "slippagePercent ({percent}) and slippageBps ({bps}) disagree"
325                )));
326            }
327
328            Ok(percent_slippage)
329        }
330        (Some(percent), None) => {
331            Slippage::percent(percent).map_err(crate::OdosError::invalid_input)
332        }
333        (None, Some(bps)) => Slippage::bps(bps).map_err(crate::OdosError::invalid_input),
334        (None, None) => Ok(Slippage::standard()),
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use alloy_primitives::address;
342
343    #[test]
344    fn test_agent_swap_request_defaults() {
345        let request = AgentSwapRequest {
346            chain: AgentChainInput::Name("base".to_string()),
347            from_token: "0x4200000000000000000000000000000000000006".to_string(),
348            from_amount: "1000000000000000".to_string(),
349            to_token: "0x833589fCD6EDb6E08f4c7C32D4f71b54bdA02913".to_string(),
350            signer: "0x742d35Cc6634C0532925a3b8D35f3e7a5edD29c0".to_string(),
351            recipient: None,
352            slippage_percent: None,
353            slippage_bps: None,
354            referral_code: None,
355            compact: None,
356            simple: None,
357            disable_rfqs: None,
358        };
359
360        let validated = request.validate().unwrap();
361        assert_eq!(validated.chain, Chain::base());
362        assert_eq!(validated.recipient, validated.signer);
363        assert_eq!(validated.slippage, Slippage::standard());
364        assert_eq!(validated.referral, ReferralCode::NONE);
365        assert!(!validated.compact);
366        assert!(!validated.simple);
367        assert!(!validated.disable_rfqs);
368    }
369
370    #[test]
371    fn test_agent_swap_request_rejects_same_token() {
372        let request = AgentSwapRequest {
373            chain: AgentChainInput::Id(1),
374            from_token: address!("a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").to_string(),
375            from_amount: "1000000".to_string(),
376            to_token: address!("a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").to_string(),
377            signer: address!("742d35Cc6634C0532925a3b8D35f3e7a5edD29c0").to_string(),
378            recipient: None,
379            slippage_percent: Some(0.5),
380            slippage_bps: None,
381            referral_code: None,
382            compact: None,
383            simple: None,
384            disable_rfqs: None,
385        };
386
387        let err = request.validate().unwrap_err();
388        assert!(err.to_string().contains("must be different"));
389    }
390
391    #[test]
392    fn test_agent_swap_request_accepts_matching_slippage_inputs() {
393        let request = AgentSwapRequest {
394            chain: AgentChainInput::Name("ethereum".to_string()),
395            from_token: address!("a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").to_string(),
396            from_amount: "1000000".to_string(),
397            to_token: address!("c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").to_string(),
398            signer: address!("742d35Cc6634C0532925a3b8D35f3e7a5edD29c0").to_string(),
399            recipient: None,
400            slippage_percent: Some(0.5),
401            slippage_bps: Some(50),
402            referral_code: Some(42),
403            compact: Some(true),
404            simple: Some(false),
405            disable_rfqs: Some(true),
406        };
407
408        let validated = request.validate().unwrap();
409        assert_eq!(validated.slippage.as_bps(), 50);
410        assert_eq!(validated.referral.code(), 42);
411        assert!(validated.compact);
412        assert!(validated.disable_rfqs);
413    }
414
415    #[test]
416    fn test_agent_swap_request_rejects_conflicting_slippage_inputs() {
417        let request = AgentSwapRequest {
418            chain: AgentChainInput::Name("ethereum".to_string()),
419            from_token: address!("a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").to_string(),
420            from_amount: "1000000".to_string(),
421            to_token: address!("c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").to_string(),
422            signer: address!("742d35Cc6634C0532925a3b8D35f3e7a5edD29c0").to_string(),
423            recipient: None,
424            slippage_percent: Some(0.5),
425            slippage_bps: Some(75),
426            referral_code: None,
427            compact: None,
428            simple: None,
429            disable_rfqs: None,
430        };
431
432        let err = request.validate().unwrap_err();
433        assert!(err.to_string().contains("disagree"));
434    }
435}