odos_sdk/
assemble.rs

1// SPDX-FileCopyrightText: 2025 Semiotic AI, Inc.
2//
3// SPDX-License-Identifier: Apache-2.0
4
5use std::fmt::Display;
6
7use alloy_network::TransactionBuilder;
8use alloy_primitives::{hex, Address, U256};
9use alloy_rpc_types::TransactionRequest;
10use serde::{Deserialize, Serialize};
11
12/// Request to the Odos Assemble API: <https://docs.odos.xyz/build/api-docs>
13#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
14#[serde(rename_all = "camelCase")]
15pub struct AssembleRequest {
16    pub user_addr: Address,
17    pub path_id: String,
18    pub simulate: bool,
19    pub receiver: Option<Address>,
20}
21
22impl Display for AssembleRequest {
23    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24        write!(
25            f,
26            "AssembleRequest {{ user_addr: {}, path_id: {}, simulate: {}, receiver: {} }}",
27            self.user_addr,
28            self.path_id,
29            self.simulate,
30            self.receiver
31                .as_ref()
32                .map_or("None".to_string(), |s| s.to_string())
33        )
34    }
35}
36
37/// Response from the Odos Assemble API: <https://docs.odos.xyz/build/api-docs>
38#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
39#[serde(rename_all = "camelCase")]
40pub struct AssemblyResponse {
41    pub transaction: TransactionData,
42    pub simulation: Option<Simulation>,
43}
44
45impl Display for AssemblyResponse {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        write!(
48            f,
49            "AssemblyResponse {{ transaction: {}, simulation: {} }}",
50            self.transaction,
51            self.simulation
52                .as_ref()
53                .map_or("None".to_string(), |s| s.to_string())
54        )
55    }
56}
57
58/// Transaction data from the Odos Assemble API: <https://docs.odos.xyz/build/api-docs>
59#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
60#[serde(rename_all = "camelCase")]
61pub struct TransactionData {
62    pub to: Address,
63    pub from: Address,
64    pub data: String,
65    pub value: String,
66    pub gas: i128,
67    pub gas_price: u128,
68    pub chain_id: u64,
69    pub nonce: u64,
70}
71
72/// Convert [`TransactionData`] to a [`TransactionRequest`].
73impl TryFrom<TransactionData> for TransactionRequest {
74    type Error = crate::OdosError;
75
76    fn try_from(data: TransactionData) -> Result<Self, Self::Error> {
77        let input = hex::decode(&data.data)?;
78        let value = parse_value(&data.value)?;
79
80        Ok(TransactionRequest::default()
81            .with_input(input)
82            .with_value(value))
83    }
84}
85
86impl Display for TransactionData {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        write!(
89            f,
90            "TransactionData {{ to: {}, from: {}, data: {}, value: {}, gas: {}, gas_price: {}, chain_id: {}, nonce: {} }}",
91            self.to,
92            self.from,
93            self.data,
94            self.value,
95            self.gas,
96            self.gas_price,
97            self.chain_id,
98            self.nonce
99        )
100    }
101}
102
103/// Simulation from the Odos Assemble API: <https://docs.odos.xyz/build/api-docs>
104#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
105#[serde(rename_all = "camelCase")]
106pub struct Simulation {
107    is_success: bool,
108    amounts_out: Vec<String>,
109    gas_estimate: i64,
110    simulation_error: SimulationError,
111}
112
113impl Simulation {
114    pub fn is_success(&self) -> bool {
115        self.is_success
116    }
117
118    pub fn error_message(&self) -> &str {
119        &self.simulation_error.error_message
120    }
121}
122
123impl Display for Simulation {
124    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125        write!(
126            f,
127            "Simulation {{ is_success: {}, amounts_out: {:?}, gas_estimate: {}, simulation_error: {} }}",
128            self.is_success,
129            self.amounts_out,
130            self.gas_estimate,
131            self.simulation_error.error_message
132        )
133    }
134}
135
136/// Simulation error from the Odos Assemble API: <https://docs.odos.xyz/build/api-docs>
137#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
138#[serde(rename_all = "camelCase")]
139pub struct SimulationError {
140    r#type: String,
141    error_message: String,
142}
143
144impl SimulationError {
145    pub fn error_message(&self) -> &str {
146        &self.error_message
147    }
148}
149
150impl Display for SimulationError {
151    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152        write!(f, "Simulation error: {}", self.error_message)
153    }
154}
155
156/// Parse a value string as U256, supporting both decimal and hexadecimal formats
157///
158/// This function attempts to parse the value as decimal first, then as hexadecimal
159/// (with optional "0x" prefix) if decimal parsing fails.
160///
161/// # Arguments
162///
163/// * `value` - The string value to parse
164///
165/// # Returns
166///
167/// * `Ok(U256)` - The parsed value
168/// * `Err(OdosError)` - If the value cannot be parsed in either format
169///
170/// # Examples
171///
172/// ```rust
173/// # use odos_sdk::parse_value;
174/// # use alloy_primitives::U256;
175/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
176/// // Decimal format
177/// let val = parse_value("1000")?;
178/// assert_eq!(val, U256::from(1000));
179///
180/// // Hexadecimal with 0x prefix
181/// let val = parse_value("0xff")?;
182/// assert_eq!(val, U256::from(255));
183///
184/// // Hexadecimal without prefix
185/// let val = parse_value("ff")?;
186/// assert_eq!(val, U256::from(255));
187/// # Ok(())
188/// # }
189/// ```
190pub fn parse_value(value: &str) -> crate::Result<U256> {
191    use crate::OdosError;
192
193    if value == "0" {
194        return Ok(U256::ZERO);
195    }
196
197    // Try parsing as decimal first
198    U256::from_str_radix(value, 10).or_else(|decimal_err| {
199        // If decimal fails, try hexadecimal (with optional "0x" prefix)
200        let hex_value = value.strip_prefix("0x").unwrap_or(value);
201        U256::from_str_radix(hex_value, 16).map_err(|hex_err| {
202            OdosError::invalid_input(format!(
203                "Failed to parse value '{}' as decimal ({}) or hexadecimal ({})",
204                value, decimal_err, hex_err
205            ))
206        })
207    })
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn test_parse_value_zero() {
216        let result = parse_value("0").unwrap();
217        assert_eq!(result, U256::ZERO);
218    }
219
220    #[test]
221    fn test_parse_value_decimal() {
222        // Small values
223        assert_eq!(parse_value("1").unwrap(), U256::from(1));
224        assert_eq!(parse_value("123").unwrap(), U256::from(123));
225        assert_eq!(parse_value("1000").unwrap(), U256::from(1000));
226
227        // Large values
228        assert_eq!(
229            parse_value("1000000000000000000").unwrap(),
230            U256::from(1000000000000000000u64)
231        );
232
233        // Very large values (beyond u64)
234        let large_decimal = "123456789012345678901234567890";
235        let result = parse_value(large_decimal);
236        assert!(result.is_ok(), "Should parse large decimal values");
237    }
238
239    #[test]
240    fn test_parse_value_hex_with_prefix() {
241        // With 0x prefix
242        assert_eq!(parse_value("0x0").unwrap(), U256::ZERO);
243        assert_eq!(parse_value("0xff").unwrap(), U256::from(255));
244        assert_eq!(parse_value("0xFF").unwrap(), U256::from(255));
245        assert_eq!(parse_value("0x1234").unwrap(), U256::from(0x1234));
246        assert_eq!(parse_value("0xabcdef").unwrap(), U256::from(0xabcdef));
247    }
248
249    #[test]
250    fn test_parse_value_hex_without_prefix() {
251        // Pure hex letters (no decimal interpretation) - falls back to hex parsing
252        assert_eq!(parse_value("ff").unwrap(), U256::from(255));
253        assert_eq!(parse_value("FF").unwrap(), U256::from(255));
254        assert_eq!(parse_value("abcdef").unwrap(), U256::from(0xabcdef));
255        assert_eq!(parse_value("ABCDEF").unwrap(), U256::from(0xabcdef));
256
257        // Ambiguous: "1234" can be decimal or hex
258        // Decimal parsing takes precedence, so this is 1234 not 0x1234
259        assert_eq!(parse_value("1234").unwrap(), U256::from(1234));
260        assert_ne!(parse_value("1234").unwrap(), U256::from(0x1234));
261    }
262
263    #[test]
264    fn test_parse_value_invalid() {
265        // Invalid characters (not valid decimal or hex)
266        let result = parse_value("xyz");
267        assert!(result.is_err(), "Invalid characters should fail");
268
269        // Mixed invalid
270        let result = parse_value("0xGHI");
271        assert!(result.is_err(), "Invalid hex characters should fail");
272
273        // Special characters
274        let result = parse_value("12@34");
275        assert!(result.is_err(), "Special characters should fail");
276
277        // Note: Empty string "" actually succeeds with from_str_radix(10) -> returns 0
278        // This is standard Rust behavior, so we accept it
279        let result = parse_value("");
280        assert_eq!(
281            result.unwrap(),
282            U256::ZERO,
283            "Empty string parses to zero (standard Rust behavior)"
284        );
285    }
286
287    #[test]
288    fn test_parse_value_edge_cases() {
289        // Leading zeros
290        assert_eq!(parse_value("00123").unwrap(), U256::from(123));
291        assert_eq!(parse_value("0x00ff").unwrap(), U256::from(255));
292
293        // Max u64
294        let max_u64_str = u64::MAX.to_string();
295        let result = parse_value(&max_u64_str).unwrap();
296        assert_eq!(result, U256::from(u64::MAX));
297
298        // Max u128
299        let max_u128_str = u128::MAX.to_string();
300        let result = parse_value(&max_u128_str);
301        assert!(result.is_ok(), "Should handle u128::MAX");
302    }
303
304    #[test]
305    fn test_parse_value_realistic_transaction_values() {
306        // 1 ETH in wei
307        let one_eth = "1000000000000000000";
308        assert_eq!(
309            parse_value(one_eth).unwrap(),
310            U256::from(1000000000000000000u64)
311        );
312
313        // 100 ETH in wei (typical transaction)
314        let hundred_eth = "100000000000000000000";
315        let result = parse_value(hundred_eth);
316        assert!(result.is_ok(), "Should parse 100 ETH");
317
318        // Gas price in hex (common format)
319        let gas_price_hex = "0x2540be400"; // 10 gwei
320        let result = parse_value(gas_price_hex);
321        assert!(result.is_ok(), "Should parse hex gas price");
322    }
323
324    #[test]
325    fn test_parse_value_error_messages() {
326        // Verify error messages contain useful info
327        let result = parse_value("invalid");
328        match result {
329            Err(e) => {
330                let error_msg = e.to_string();
331                assert!(
332                    error_msg.contains("invalid"),
333                    "Error should mention the invalid value"
334                );
335                assert!(
336                    error_msg.contains("decimal") || error_msg.contains("hexadecimal"),
337                    "Error should mention attempted parsing formats"
338                );
339            }
340            Ok(_) => panic!("Should have failed to parse 'invalid'"),
341        }
342    }
343}