1use 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#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
21#[serde(untagged)]
22pub enum AgentChainInput {
23 Id(u64),
25 Name(String),
27}
28
29impl AgentChainInput {
30 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#[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 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#[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 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 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#[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#[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#[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 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, "e))
279 }
280
281 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, "e),
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}