use alloy_primitives::{address, Address, Bytes, U256};
use serde::Deserialize;
use std::str::FromStr;
use wp_evm_aggregator_family as ag;
pub use wp_evm_aggregator_family::data::{
AggregatorError, AggregatorQuote, AggregatorRequest, PlanFragment,
};
pub const LIFI_DIAMOND: Address = address!("0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE");
pub const API_BASE_URL: &str = "https://li.quest/v1/quote";
pub const CHAIN_ID_ETHEREUM: u64 = 1;
#[derive(Debug, Deserialize)]
struct LifiQuoteResponse {
#[serde(rename = "transactionRequest")]
transaction_request: LifiTransactionRequest,
estimate: LifiEstimate,
}
#[derive(Debug, Deserialize)]
struct LifiTransactionRequest {
to: String,
data: String,
value: String,
}
#[derive(Debug, Deserialize)]
struct LifiEstimate {
#[serde(rename = "fromAmount")]
from_amount: String,
#[serde(rename = "toAmount")]
to_amount: String,
#[serde(rename = "approvalAddress")]
approval_address: String,
}
pub async fn fetch_quote(
client: &reqwest::Client,
api_key: Option<&str>,
request: &AggregatorRequest,
) -> Result<AggregatorQuote, AggregatorError> {
let sell_amount = match (request.sell_amount, request.buy_amount) {
(Some(s), None) => s,
(None, Some(_)) => {
return Err(AggregatorError::Protocol(
"LiFi quote endpoint does not support exact-out (buyAmount); \
use sellAmount for exact-in quotes"
.to_string(),
));
}
(None, None) => return Err(AggregatorError::MissingAmount),
(Some(_), Some(_)) => return Err(AggregatorError::AmbiguousAmount),
};
let slippage_fraction = request.slippage.as_bps() as f64 / 10_000.0;
let mut req_builder = client.get(API_BASE_URL).query(&[
("fromChain", request.chain_id.to_string()),
("toChain", request.chain_id.to_string()),
("fromToken", request.sell_token.to_string()),
("toToken", request.buy_token.to_string()),
("fromAmount", sell_amount.to_string()),
("fromAddress", request.taker.to_string()),
("slippage", slippage_fraction.to_string()),
]);
if let Some(key) = api_key {
req_builder = req_builder.header("x-lifi-api-key", key);
}
let resp = req_builder.send().await.map_err(|e| AggregatorError::Http(format!("{e}")))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(AggregatorError::Http(format!("HTTP {status}: {body}")));
}
let parsed: LifiQuoteResponse =
resp.json().await.map_err(|e| AggregatorError::MalformedResponse(format!("{e}")))?;
let to = Address::from_str(&parsed.transaction_request.to)
.map_err(|e| AggregatorError::MalformedResponse(format!("transaction.to: {e}")))?;
let allowance_target = Address::from_str(&parsed.estimate.approval_address)
.map_err(|e| AggregatorError::MalformedResponse(format!("approvalAddress: {e}")))?;
let value_hex = parsed
.transaction_request
.value
.strip_prefix("0x")
.unwrap_or(&parsed.transaction_request.value);
let value = if value_hex.is_empty() {
U256::ZERO
} else {
U256::from_str_radix(value_hex, 16)
.map_err(|e| AggregatorError::MalformedResponse(format!("value hex: {e}")))?
};
let sell_amount_resp = U256::from_str_radix(&parsed.estimate.from_amount, 10)
.map_err(|e| AggregatorError::MalformedResponse(format!("fromAmount: {e}")))?;
let buy_amount = U256::from_str_radix(&parsed.estimate.to_amount, 10)
.map_err(|e| AggregatorError::MalformedResponse(format!("toAmount: {e}")))?;
let data_hex = parsed
.transaction_request
.data
.strip_prefix("0x")
.unwrap_or(&parsed.transaction_request.data);
if !data_hex.len().is_multiple_of(2) {
return Err(AggregatorError::MalformedResponse(
"transaction.data has odd-length hex string".to_string(),
));
}
let data_bytes = hex::decode(data_hex)
.map_err(|e| AggregatorError::MalformedResponse(format!("data hex: {e}")))?;
let data = Bytes::from(data_bytes);
Ok(AggregatorQuote {
to,
data,
value,
sell_amount: sell_amount_resp,
buy_amount,
allowance_target,
})
}
pub fn plan_swap(
request: &AggregatorRequest,
quote: &AggregatorQuote,
) -> Result<PlanFragment, AggregatorError> {
ag::plan::execute_quote(request, quote)
}
#[cfg(test)]
mod tests {
use super::*;
use wp_evm_base::types::SlippageBps;
#[test]
fn lifi_diamond_is_canonical_mainnet_address() {
assert_eq!(LIFI_DIAMOND, address!("0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE"));
}
#[test]
fn api_base_url_is_v1_quote_endpoint() {
assert_eq!(API_BASE_URL, "https://li.quest/v1/quote");
}
#[test]
fn fixture_response_parses_correctly() {
let fixture = r#"{
"transactionRequest": {
"from": "0x0000000000000000000000000000000000000099",
"to": "0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE",
"data": "0xdeadbeef",
"value": "0x0",
"gasLimit": "0x30d40",
"gasPrice": "0x3b9aca00"
},
"estimate": {
"fromAmount": "1000000",
"toAmount": "500000000000000",
"toAmountMin": "498000000000000",
"approvalAddress": "0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE",
"executionDuration": 30
},
"action": {},
"tool": "uniswap",
"type": "lifi"
}"#;
let parsed: LifiQuoteResponse = serde_json::from_str(fixture).expect("fixture parses");
assert_eq!(parsed.transaction_request.to, "0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE");
assert_eq!(parsed.transaction_request.data, "0xdeadbeef");
assert_eq!(parsed.transaction_request.value, "0x0");
assert_eq!(parsed.estimate.from_amount, "1000000");
assert_eq!(parsed.estimate.to_amount, "500000000000000");
assert_eq!(parsed.estimate.approval_address, "0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE");
}
#[test]
fn fixture_value_hex_converts_to_u256() {
let fixture = r#"{
"transactionRequest": {
"from": "0x0000000000000000000000000000000000000099",
"to": "0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE",
"data": "0xabcd",
"value": "0x1bc16d674ec80000"
},
"estimate": {
"fromAmount": "2000000000000000000",
"toAmount": "1000000000",
"toAmountMin": "995000000",
"approvalAddress": "0x0000000000000000000000000000000000000000"
}
}"#;
let parsed: LifiQuoteResponse = serde_json::from_str(fixture).expect("fixture parses");
let value_hex = parsed.transaction_request.value.strip_prefix("0x").unwrap();
let value = U256::from_str_radix(value_hex, 16).unwrap();
assert_eq!(value, U256::from(2_000_000_000_000_000_000u64));
}
#[test]
fn plan_swap_delegates_to_family_execute_quote() {
let req = AggregatorRequest {
sell_token: address!("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
buy_token: address!("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
sell_amount: Some(U256::from(1_000_000u64)),
buy_amount: None,
chain_id: CHAIN_ID_ETHEREUM,
taker: address!("0x0000000000000000000000000000000000000099"),
slippage: SlippageBps::new(50),
};
let quote = AggregatorQuote {
to: LIFI_DIAMOND,
data: alloy_primitives::bytes!("deadbeef"),
value: U256::ZERO,
sell_amount: U256::from(1_000_000u64),
buy_amount: U256::from(500_000_000_000_000u64),
allowance_target: LIFI_DIAMOND,
};
let frag = plan_swap(&req, "e).expect("valid plan");
assert_eq!(frag.calls.len(), 1);
assert_eq!(frag.calls[0].target, LIFI_DIAMOND);
assert_eq!(frag.approvals.len(), 1);
assert_eq!(frag.approvals[0].spender, LIFI_DIAMOND);
}
#[test]
fn plan_swap_native_sell_has_no_approval_and_forwards_value() {
let req = AggregatorRequest {
sell_token: Address::ZERO,
buy_token: address!("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
sell_amount: Some(U256::from(1_000_000_000_000_000_000u64)),
buy_amount: None,
chain_id: CHAIN_ID_ETHEREUM,
taker: address!("0x0000000000000000000000000000000000000099"),
slippage: SlippageBps::new(50),
};
let quote = AggregatorQuote {
to: LIFI_DIAMOND,
data: alloy_primitives::bytes!("deadbeef"),
value: U256::from(1_000_000_000_000_000_000u64),
sell_amount: req.sell_amount.unwrap(),
buy_amount: U256::from(500_000_000_000_000u64),
allowance_target: LIFI_DIAMOND,
};
let frag = plan_swap(&req, "e).expect("native-sell plan");
assert!(frag.approvals.is_empty());
assert_eq!(frag.value, quote.value);
assert_eq!(frag.calls[0].value, quote.value);
}
#[test]
fn plan_swap_rejects_missing_amount_request() {
let req = AggregatorRequest {
sell_token: address!("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
buy_token: address!("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
sell_amount: None,
buy_amount: None,
chain_id: CHAIN_ID_ETHEREUM,
taker: address!("0x0000000000000000000000000000000000000099"),
slippage: SlippageBps::new(50),
};
let quote = AggregatorQuote {
to: LIFI_DIAMOND,
data: alloy_primitives::bytes!("deadbeef"),
value: U256::ZERO,
sell_amount: U256::from(1_000_000u64),
buy_amount: U256::from(500_000_000_000_000u64),
allowance_target: LIFI_DIAMOND,
};
let err = plan_swap(&req, "e).expect_err("missing amount should fail");
assert!(matches!(err, AggregatorError::MissingAmount));
}
#[test]
fn plan_swap_erc20_sell_uses_lifi_diamond_approval() {
let req = AggregatorRequest {
sell_token: address!("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
buy_token: address!("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
sell_amount: Some(U256::from(1_000_000u64)),
buy_amount: None,
chain_id: CHAIN_ID_ETHEREUM,
taker: address!("0x0000000000000000000000000000000000000099"),
slippage: SlippageBps::new(50),
};
let quote = AggregatorQuote {
to: LIFI_DIAMOND,
data: alloy_primitives::bytes!("deadbeef"),
value: U256::ZERO,
sell_amount: U256::from(1_000_000u64),
buy_amount: U256::from(500_000_000_000_000u64),
allowance_target: LIFI_DIAMOND,
};
let frag = plan_swap(&req, "e).expect("erc20-sell plan");
assert_eq!(frag.approvals.len(), 1);
assert_eq!(frag.approvals[0].token, req.sell_token);
assert_eq!(frag.approvals[0].spender, LIFI_DIAMOND);
assert_eq!(frag.approvals[0].min_amount, quote.sell_amount);
}
}