use alloy_primitives::{Address, U256};
use serde::{Deserialize, Serialize};
use crate::{
parse_value, Chain, OdosClient, QuoteRequest, ReferralCode, Result, SingleQuoteResponse,
Slippage, SwapBuilder, TransactionData,
};
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
#[serde(untagged)]
pub enum ChainInput {
Id(u64),
Name(String),
}
impl ChainInput {
pub fn resolve(&self) -> Result<Chain> {
match self {
Self::Id(id) => Chain::from_chain_id(*id).map_err(|err| {
crate::OdosError::invalid_input(format!("Unsupported Odos chain '{}': {}", id, err))
}),
Self::Name(name) => Chain::from_name(name).map_err(|err| {
crate::OdosError::invalid_input(format!(
"Unsupported Odos chain '{}': {}",
name, err
))
}),
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SwapRequest {
pub chain: ChainInput,
pub from_token: String,
pub from_amount: String,
pub to_token: String,
pub signer: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recipient: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slippage_percent: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slippage_bps: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub referral_code: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compact: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub simple: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disable_rfqs: Option<bool>,
}
impl SwapRequest {
pub fn validate(&self) -> Result<ValidatedSwapRequest> {
let chain = self.chain.resolve()?;
let input_token = parse_address("fromToken", &self.from_token)?;
let input_amount = parse_amount("fromAmount", &self.from_amount)?;
let output_token = parse_address("toToken", &self.to_token)?;
let signer = parse_address("signer", &self.signer)?;
let recipient = self
.recipient
.as_deref()
.map(|value| parse_address("recipient", value))
.transpose()?
.unwrap_or(signer);
let slippage = resolve_slippage(self.slippage_percent, self.slippage_bps)?;
let referral = self
.referral_code
.map(ReferralCode::new)
.unwrap_or(ReferralCode::NONE);
if input_amount.is_zero() {
return Err(crate::OdosError::invalid_input(
"fromAmount must be greater than zero",
));
}
if input_token == output_token {
return Err(crate::OdosError::invalid_input(
"fromToken and toToken must be different",
));
}
Ok(ValidatedSwapRequest {
chain,
input_token,
input_amount,
output_token,
signer,
recipient,
slippage,
referral,
compact: self.compact.unwrap_or(false),
simple: self.simple.unwrap_or(false),
disable_rfqs: self.disable_rfqs.unwrap_or(false),
})
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct ValidatedSwapRequest {
pub chain: Chain,
pub input_token: Address,
pub input_amount: U256,
pub output_token: Address,
pub signer: Address,
pub recipient: Address,
pub slippage: Slippage,
pub referral: ReferralCode,
pub compact: bool,
pub simple: bool,
pub disable_rfqs: bool,
}
impl ValidatedSwapRequest {
pub fn quote_request(&self) -> QuoteRequest {
QuoteRequest::builder()
.chain_id(self.chain.id())
.input_tokens(vec![(self.input_token, self.input_amount).into()])
.output_tokens(vec![(self.output_token, 1).into()])
.slippage_limit_percent(self.slippage.as_percent())
.user_addr(self.signer)
.compact(self.compact)
.simple(self.simple)
.referral_code(self.referral.code())
.disable_rfqs(self.disable_rfqs)
.build()
}
pub fn swap_builder<'a>(&self, client: &'a OdosClient) -> SwapBuilder<'a> {
client
.swap()
.chain(self.chain)
.from_token(self.input_token, self.input_amount)
.to_token(self.output_token)
.slippage(self.slippage)
.signer(self.signer)
.recipient(self.recipient)
.referral(self.referral)
.compact(self.compact)
.simple(self.simple)
.disable_rfqs(self.disable_rfqs)
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct QuoteSummary {
pub chain_id: u64,
pub chain_name: String,
pub signer: String,
pub recipient: String,
pub from_token: String,
pub from_amount: String,
pub to_token: String,
pub to_amount: String,
pub slippage_percent: f64,
pub path_id: String,
pub price_impact_percent: f64,
pub gas_estimate: f64,
pub gas_estimate_value: f64,
pub net_out_value: f64,
pub partner_fee_percent: f64,
pub gwei_per_gas: f64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
}
impl QuoteSummary {
fn from_quote(request: &ValidatedSwapRequest, quote: &SingleQuoteResponse) -> Self {
let mut warnings = Vec::new();
if quote.price_impact() >= 3.0 {
warnings.push(format!(
"High price impact detected ({:.2}%)",
quote.price_impact()
));
}
if quote.gas_estimate_value() > quote.net_out_value() && quote.net_out_value() > 0.0 {
warnings.push("Estimated gas cost exceeds quoted net output value".to_string());
}
if quote.out_amount().is_none() {
warnings.push("Primary output amount was missing from the quote response".to_string());
}
Self {
chain_id: request.chain.id(),
chain_name: request.chain.to_string(),
signer: request.signer.to_string(),
recipient: request.recipient.to_string(),
from_token: request.input_token.to_string(),
from_amount: request.input_amount.to_string(),
to_token: request.output_token.to_string(),
to_amount: quote
.out_amount()
.cloned()
.unwrap_or_else(|| "0".to_string()),
slippage_percent: request.slippage.as_percent(),
path_id: quote.path_id().to_string(),
price_impact_percent: quote.price_impact(),
gas_estimate: quote.gas_estimate(),
gas_estimate_value: quote.gas_estimate_value(),
net_out_value: quote.net_out_value(),
partner_fee_percent: quote.partner_fee_percent(),
gwei_per_gas: quote.gwei_per_gas(),
warnings,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TransactionSummary {
pub to: String,
pub from: String,
pub data: String,
pub value: String,
pub gas: u64,
pub gas_price: u128,
pub chain_id: u64,
pub nonce: u64,
}
impl From<TransactionData> for TransactionSummary {
fn from(value: TransactionData) -> Self {
let gas = value.gas.clamp(0, i128::from(u64::MAX)) as u64;
if i128::from(gas) != value.gas {
tracing::warn!(
raw_gas = value.gas,
clamped_gas = gas,
"API returned out-of-range gas value; clamped to u64",
);
}
Self {
to: value.to.to_string(),
from: value.from.to_string(),
data: value.data,
value: value.value,
gas,
gas_price: value.gas_price,
chain_id: value.chain_id,
nonce: value.nonce,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TransactionPlan {
pub quote: QuoteSummary,
pub transaction: TransactionSummary,
}
impl OdosClient {
pub async fn quote_for_tooling(&self, request: &SwapRequest) -> Result<QuoteSummary> {
let (validated, quote) = self.validated_quote(request).await?;
Ok(QuoteSummary::from_quote(&validated, "e))
}
pub async fn build_transaction_plan(&self, request: &SwapRequest) -> Result<TransactionPlan> {
let (validated, quote) = self.validated_quote(request).await?;
let tx = self
.assemble_tx_data(validated.signer, validated.recipient, quote.path_id())
.await?;
Ok(TransactionPlan {
quote: QuoteSummary::from_quote(&validated, "e),
transaction: tx.into(),
})
}
async fn validated_quote(
&self,
request: &SwapRequest,
) -> Result<(ValidatedSwapRequest, SingleQuoteResponse)> {
let validated = request.validate()?;
let quote = self.quote(&validated.quote_request()).await?;
Ok((validated, quote))
}
}
fn parse_address(field: &str, value: &str) -> Result<Address> {
value.parse().map_err(|err| {
crate::OdosError::invalid_input(format!(
"{field} must be a valid 0x-prefixed EVM address: {err}"
))
})
}
fn parse_amount(field: &str, value: &str) -> Result<U256> {
parse_value(value).map_err(|err| {
crate::OdosError::invalid_input(format!(
"{field} must be a decimal or hexadecimal integer amount: {err}"
))
})
}
fn resolve_slippage(percent: Option<f64>, bps: Option<u16>) -> Result<Slippage> {
match (percent, bps) {
(Some(percent), Some(bps)) => {
let percent_slippage =
Slippage::percent(percent).map_err(crate::OdosError::invalid_input)?;
let bps_slippage = Slippage::bps(bps).map_err(crate::OdosError::invalid_input)?;
if percent_slippage.as_bps() != bps_slippage.as_bps() {
return Err(crate::OdosError::invalid_input(format!(
"slippagePercent ({percent}) and slippageBps ({bps}) disagree"
)));
}
Ok(percent_slippage)
}
(Some(percent), None) => {
Slippage::percent(percent).map_err(crate::OdosError::invalid_input)
}
(None, Some(bps)) => Slippage::bps(bps).map_err(crate::OdosError::invalid_input),
(None, None) => Ok(Slippage::standard()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use alloy_primitives::address;
use serde_json::json;
#[test]
fn test_swap_request_defaults() {
let request = SwapRequest {
chain: ChainInput::Name("base".to_string()),
from_token: "0x4200000000000000000000000000000000000006".to_string(),
from_amount: "1000000000000000".to_string(),
to_token: "0x833589fCD6EDb6E08f4c7C32D4f71b54bdA02913".to_string(),
signer: "0x742d35Cc6634C0532925a3b8D35f3e7a5edD29c0".to_string(),
recipient: None,
slippage_percent: None,
slippage_bps: None,
referral_code: None,
compact: None,
simple: None,
disable_rfqs: None,
};
let validated = request.validate().unwrap();
assert_eq!(validated.chain, Chain::base());
assert_eq!(validated.recipient, validated.signer);
assert_eq!(validated.slippage, Slippage::standard());
assert_eq!(validated.referral, ReferralCode::NONE);
assert!(!validated.compact);
assert!(!validated.simple);
assert!(!validated.disable_rfqs);
}
#[test]
fn test_swap_request_rejects_same_token() {
let request = SwapRequest {
chain: ChainInput::Id(1),
from_token: address!("a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").to_string(),
from_amount: "1000000".to_string(),
to_token: address!("a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").to_string(),
signer: address!("742d35Cc6634C0532925a3b8D35f3e7a5edD29c0").to_string(),
recipient: None,
slippage_percent: Some(0.5),
slippage_bps: None,
referral_code: None,
compact: None,
simple: None,
disable_rfqs: None,
};
let err = request.validate().unwrap_err();
assert!(err.to_string().contains("must be different"));
}
#[test]
fn test_swap_request_accepts_matching_slippage_inputs() {
let request = SwapRequest {
chain: ChainInput::Name("ethereum".to_string()),
from_token: address!("a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").to_string(),
from_amount: "1000000".to_string(),
to_token: address!("c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").to_string(),
signer: address!("742d35Cc6634C0532925a3b8D35f3e7a5edD29c0").to_string(),
recipient: None,
slippage_percent: Some(0.5),
slippage_bps: Some(50),
referral_code: Some(42),
compact: Some(true),
simple: Some(false),
disable_rfqs: Some(true),
};
let validated = request.validate().unwrap();
assert_eq!(validated.slippage.as_bps(), 50);
assert_eq!(validated.referral.code(), 42);
assert!(validated.compact);
assert!(validated.disable_rfqs);
}
#[test]
fn test_swap_request_rejects_conflicting_slippage_inputs() {
let request = SwapRequest {
chain: ChainInput::Name("ethereum".to_string()),
from_token: address!("a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").to_string(),
from_amount: "1000000".to_string(),
to_token: address!("c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").to_string(),
signer: address!("742d35Cc6634C0532925a3b8D35f3e7a5edD29c0").to_string(),
recipient: None,
slippage_percent: Some(0.5),
slippage_bps: Some(75),
referral_code: None,
compact: None,
simple: None,
disable_rfqs: None,
};
let err = request.validate().unwrap_err();
assert!(err.to_string().contains("disagree"));
}
#[test]
fn test_chain_input_deserializes_integer() {
let input: ChainInput = serde_json::from_value(json!(1)).unwrap();
assert_eq!(input, ChainInput::Id(1));
assert_eq!(input.resolve().unwrap(), Chain::ethereum());
}
#[test]
fn test_chain_input_deserializes_string_name() {
let input: ChainInput = serde_json::from_value(json!("base")).unwrap();
assert_eq!(input, ChainInput::Name("base".to_string()));
assert_eq!(input.resolve().unwrap(), Chain::base());
}
#[test]
fn test_chain_input_deserializes_string_number() {
let input: ChainInput = serde_json::from_value(json!("1")).unwrap();
assert_eq!(input, ChainInput::Name("1".to_string()));
assert_eq!(input.resolve().unwrap(), Chain::ethereum());
}
#[test]
fn test_chain_input_round_trip_id() {
let original = ChainInput::Id(8453);
let json = serde_json::to_value(&original).unwrap();
let deserialized: ChainInput = serde_json::from_value(json).unwrap();
assert_eq!(deserialized, original);
assert_eq!(deserialized.resolve().unwrap(), Chain::base());
}
#[test]
fn test_chain_input_round_trip_name() {
let original = ChainInput::Name("arbitrum".to_string());
let json = serde_json::to_value(&original).unwrap();
let deserialized: ChainInput = serde_json::from_value(json).unwrap();
assert_eq!(deserialized, original);
assert_eq!(deserialized.resolve().unwrap(), Chain::arbitrum());
}
#[test]
fn test_swap_request_deserializes_from_json() {
let json = json!({
"chain": 1,
"fromToken": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
"fromAmount": "1000000",
"toToken": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
"signer": "0x742d35Cc6634C0532925a3b8D35f3e7a5edD29c0"
});
let request: SwapRequest = serde_json::from_value(json).unwrap();
assert_eq!(request.chain, ChainInput::Id(1));
let validated = request.validate().unwrap();
assert_eq!(validated.chain, Chain::ethereum());
}
#[test]
fn test_swap_request_deserializes_string_chain() {
let json = json!({
"chain": "base",
"fromToken": "0x4200000000000000000000000000000000000006",
"fromAmount": "1000000000000000",
"toToken": "0x833589fCD6EDb6E08f4c7C32D4f71b54bdA02913",
"signer": "0x742d35Cc6634C0532925a3b8D35f3e7a5edD29c0"
});
let request: SwapRequest = serde_json::from_value(json).unwrap();
assert_eq!(request.chain, ChainInput::Name("base".to_string()));
let validated = request.validate().unwrap();
assert_eq!(validated.chain, Chain::base());
}
fn make_tx_data(gas: i128) -> TransactionData {
TransactionData {
to: address!("0000000000000000000000000000000000000001"),
from: address!("0000000000000000000000000000000000000002"),
data: "0x".to_string(),
value: "0".to_string(),
gas,
gas_price: 1,
chain_id: 1,
nonce: 0,
}
}
#[test]
fn test_transaction_summary_clamps_negative_gas() {
let summary: TransactionSummary = make_tx_data(-1).into();
assert_eq!(summary.gas, 0);
}
#[test]
fn test_transaction_summary_clamps_overflow_gas() {
let summary: TransactionSummary = make_tx_data(i128::MAX).into();
assert_eq!(summary.gas, u64::MAX);
}
#[test]
fn test_transaction_summary_preserves_valid_gas() {
let summary: TransactionSummary = make_tx_data(300_000).into();
assert_eq!(summary.gas, 300_000);
}
}