1use alloy_primitives::{address, Address, Bytes, U256};
21use serde::Deserialize;
22use std::str::FromStr;
23use wp_evm_aggregator_family as ag;
24
25pub use wp_evm_aggregator_family::data::{
26 AggregatorError, AggregatorQuote, AggregatorRequest, PlanFragment,
27};
28
29pub const LIFI_DIAMOND: Address = address!("0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE");
35
36pub const API_BASE_URL: &str = "https://li.quest/v1/quote";
38
39pub const CHAIN_ID_ETHEREUM: u64 = 1;
41
42#[derive(Debug, Deserialize)]
45struct LifiQuoteResponse {
46 #[serde(rename = "transactionRequest")]
47 transaction_request: LifiTransactionRequest,
48 estimate: LifiEstimate,
49}
50
51#[derive(Debug, Deserialize)]
52struct LifiTransactionRequest {
53 to: String,
54 data: String,
55 value: String,
57}
58
59#[derive(Debug, Deserialize)]
60struct LifiEstimate {
61 #[serde(rename = "fromAmount")]
62 from_amount: String,
63 #[serde(rename = "toAmount")]
64 to_amount: String,
65 #[serde(rename = "approvalAddress")]
66 approval_address: String,
67}
68
69pub async fn fetch_quote(
72 client: &reqwest::Client,
73 api_key: Option<&str>,
74 request: &AggregatorRequest,
75) -> Result<AggregatorQuote, AggregatorError> {
76 let sell_amount = match (request.sell_amount, request.buy_amount) {
77 (Some(s), None) => s,
78 (None, Some(_)) => {
79 return Err(AggregatorError::Protocol(
80 "LiFi quote endpoint does not support exact-out (buyAmount); \
81 use sellAmount for exact-in quotes"
82 .to_string(),
83 ));
84 }
85 (None, None) => return Err(AggregatorError::MissingAmount),
86 (Some(_), Some(_)) => return Err(AggregatorError::AmbiguousAmount),
87 };
88
89 let slippage_fraction = request.slippage.as_bps() as f64 / 10_000.0;
90
91 let mut req_builder = client.get(API_BASE_URL).query(&[
92 ("fromChain", request.chain_id.to_string()),
93 ("toChain", request.chain_id.to_string()),
94 ("fromToken", request.sell_token.to_string()),
95 ("toToken", request.buy_token.to_string()),
96 ("fromAmount", sell_amount.to_string()),
97 ("fromAddress", request.taker.to_string()),
98 ("slippage", slippage_fraction.to_string()),
99 ]);
100
101 if let Some(key) = api_key {
102 req_builder = req_builder.header("x-lifi-api-key", key);
103 }
104
105 let resp = req_builder.send().await.map_err(|e| AggregatorError::Http(format!("{e}")))?;
106
107 let status = resp.status();
108 if !status.is_success() {
109 let body = resp.text().await.unwrap_or_default();
110 return Err(AggregatorError::Http(format!("HTTP {status}: {body}")));
111 }
112
113 let parsed: LifiQuoteResponse =
114 resp.json().await.map_err(|e| AggregatorError::MalformedResponse(format!("{e}")))?;
115
116 let to = Address::from_str(&parsed.transaction_request.to)
117 .map_err(|e| AggregatorError::MalformedResponse(format!("transaction.to: {e}")))?;
118 let allowance_target = Address::from_str(&parsed.estimate.approval_address)
119 .map_err(|e| AggregatorError::MalformedResponse(format!("approvalAddress: {e}")))?;
120
121 let value_hex = parsed
123 .transaction_request
124 .value
125 .strip_prefix("0x")
126 .unwrap_or(&parsed.transaction_request.value);
127 let value = if value_hex.is_empty() {
128 U256::ZERO
129 } else {
130 U256::from_str_radix(value_hex, 16)
131 .map_err(|e| AggregatorError::MalformedResponse(format!("value hex: {e}")))?
132 };
133
134 let sell_amount_resp = U256::from_str_radix(&parsed.estimate.from_amount, 10)
136 .map_err(|e| AggregatorError::MalformedResponse(format!("fromAmount: {e}")))?;
137 let buy_amount = U256::from_str_radix(&parsed.estimate.to_amount, 10)
138 .map_err(|e| AggregatorError::MalformedResponse(format!("toAmount: {e}")))?;
139
140 let data_hex = parsed
141 .transaction_request
142 .data
143 .strip_prefix("0x")
144 .unwrap_or(&parsed.transaction_request.data);
145
146 if !data_hex.len().is_multiple_of(2) {
148 return Err(AggregatorError::MalformedResponse(
149 "transaction.data has odd-length hex string".to_string(),
150 ));
151 }
152
153 let data_bytes = hex::decode(data_hex)
154 .map_err(|e| AggregatorError::MalformedResponse(format!("data hex: {e}")))?;
155 let data = Bytes::from(data_bytes);
156
157 Ok(AggregatorQuote {
158 to,
159 data,
160 value,
161 sell_amount: sell_amount_resp,
162 buy_amount,
163 allowance_target,
164 })
165}
166
167pub fn plan_swap(
168 request: &AggregatorRequest,
169 quote: &AggregatorQuote,
170) -> Result<PlanFragment, AggregatorError> {
171 ag::plan::execute_quote(request, quote)
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177 use wp_evm_base::types::SlippageBps;
178
179 #[test]
180 fn lifi_diamond_is_canonical_mainnet_address() {
181 assert_eq!(LIFI_DIAMOND, address!("0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE"));
182 }
183
184 #[test]
185 fn api_base_url_is_v1_quote_endpoint() {
186 assert_eq!(API_BASE_URL, "https://li.quest/v1/quote");
187 }
188
189 #[test]
190 fn fixture_response_parses_correctly() {
191 let fixture = r#"{
192 "transactionRequest": {
193 "from": "0x0000000000000000000000000000000000000099",
194 "to": "0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE",
195 "data": "0xdeadbeef",
196 "value": "0x0",
197 "gasLimit": "0x30d40",
198 "gasPrice": "0x3b9aca00"
199 },
200 "estimate": {
201 "fromAmount": "1000000",
202 "toAmount": "500000000000000",
203 "toAmountMin": "498000000000000",
204 "approvalAddress": "0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE",
205 "executionDuration": 30
206 },
207 "action": {},
208 "tool": "uniswap",
209 "type": "lifi"
210 }"#;
211
212 let parsed: LifiQuoteResponse = serde_json::from_str(fixture).expect("fixture parses");
213 assert_eq!(parsed.transaction_request.to, "0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE");
214 assert_eq!(parsed.transaction_request.data, "0xdeadbeef");
215 assert_eq!(parsed.transaction_request.value, "0x0");
216 assert_eq!(parsed.estimate.from_amount, "1000000");
217 assert_eq!(parsed.estimate.to_amount, "500000000000000");
218 assert_eq!(parsed.estimate.approval_address, "0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE");
219 }
220
221 #[test]
222 fn fixture_value_hex_converts_to_u256() {
223 let fixture = r#"{
224 "transactionRequest": {
225 "from": "0x0000000000000000000000000000000000000099",
226 "to": "0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE",
227 "data": "0xabcd",
228 "value": "0x1bc16d674ec80000"
229 },
230 "estimate": {
231 "fromAmount": "2000000000000000000",
232 "toAmount": "1000000000",
233 "toAmountMin": "995000000",
234 "approvalAddress": "0x0000000000000000000000000000000000000000"
235 }
236 }"#;
237
238 let parsed: LifiQuoteResponse = serde_json::from_str(fixture).expect("fixture parses");
239 let value_hex = parsed.transaction_request.value.strip_prefix("0x").unwrap();
240 let value = U256::from_str_radix(value_hex, 16).unwrap();
241 assert_eq!(value, U256::from(2_000_000_000_000_000_000u64));
243 }
244
245 #[test]
246 fn plan_swap_delegates_to_family_execute_quote() {
247 let req = AggregatorRequest {
248 sell_token: address!("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
249 buy_token: address!("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
250 sell_amount: Some(U256::from(1_000_000u64)),
251 buy_amount: None,
252 chain_id: CHAIN_ID_ETHEREUM,
253 taker: address!("0x0000000000000000000000000000000000000099"),
254 slippage: SlippageBps::new(50),
255 };
256 let quote = AggregatorQuote {
257 to: LIFI_DIAMOND,
258 data: alloy_primitives::bytes!("deadbeef"),
259 value: U256::ZERO,
260 sell_amount: U256::from(1_000_000u64),
261 buy_amount: U256::from(500_000_000_000_000u64),
262 allowance_target: LIFI_DIAMOND,
263 };
264 let frag = plan_swap(&req, "e).expect("valid plan");
265 assert_eq!(frag.calls.len(), 1);
266 assert_eq!(frag.calls[0].target, LIFI_DIAMOND);
267 assert_eq!(frag.approvals.len(), 1);
268 assert_eq!(frag.approvals[0].spender, LIFI_DIAMOND);
269 }
270
271 #[test]
272 fn plan_swap_native_sell_has_no_approval_and_forwards_value() {
273 let req = AggregatorRequest {
274 sell_token: Address::ZERO,
275 buy_token: address!("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
276 sell_amount: Some(U256::from(1_000_000_000_000_000_000u64)),
277 buy_amount: None,
278 chain_id: CHAIN_ID_ETHEREUM,
279 taker: address!("0x0000000000000000000000000000000000000099"),
280 slippage: SlippageBps::new(50),
281 };
282 let quote = AggregatorQuote {
283 to: LIFI_DIAMOND,
284 data: alloy_primitives::bytes!("deadbeef"),
285 value: U256::from(1_000_000_000_000_000_000u64),
286 sell_amount: req.sell_amount.unwrap(),
287 buy_amount: U256::from(500_000_000_000_000u64),
288 allowance_target: LIFI_DIAMOND,
289 };
290 let frag = plan_swap(&req, "e).expect("native-sell plan");
291 assert!(frag.approvals.is_empty());
292 assert_eq!(frag.value, quote.value);
293 assert_eq!(frag.calls[0].value, quote.value);
294 }
295
296 #[test]
297 fn plan_swap_rejects_missing_amount_request() {
298 let req = AggregatorRequest {
299 sell_token: address!("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
300 buy_token: address!("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
301 sell_amount: None,
302 buy_amount: None,
303 chain_id: CHAIN_ID_ETHEREUM,
304 taker: address!("0x0000000000000000000000000000000000000099"),
305 slippage: SlippageBps::new(50),
306 };
307 let quote = AggregatorQuote {
308 to: LIFI_DIAMOND,
309 data: alloy_primitives::bytes!("deadbeef"),
310 value: U256::ZERO,
311 sell_amount: U256::from(1_000_000u64),
312 buy_amount: U256::from(500_000_000_000_000u64),
313 allowance_target: LIFI_DIAMOND,
314 };
315 let err = plan_swap(&req, "e).expect_err("missing amount should fail");
316 assert!(matches!(err, AggregatorError::MissingAmount));
317 }
318
319 #[test]
320 fn plan_swap_erc20_sell_uses_lifi_diamond_approval() {
321 let req = AggregatorRequest {
322 sell_token: address!("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
323 buy_token: address!("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
324 sell_amount: Some(U256::from(1_000_000u64)),
325 buy_amount: None,
326 chain_id: CHAIN_ID_ETHEREUM,
327 taker: address!("0x0000000000000000000000000000000000000099"),
328 slippage: SlippageBps::new(50),
329 };
330 let quote = AggregatorQuote {
331 to: LIFI_DIAMOND,
332 data: alloy_primitives::bytes!("deadbeef"),
333 value: U256::ZERO,
334 sell_amount: U256::from(1_000_000u64),
335 buy_amount: U256::from(500_000_000_000_000u64),
336 allowance_target: LIFI_DIAMOND,
337 };
338 let frag = plan_swap(&req, "e).expect("erc20-sell plan");
339 assert_eq!(frag.approvals.len(), 1);
340 assert_eq!(frag.approvals[0].token, req.sell_token);
341 assert_eq!(frag.approvals[0].spender, LIFI_DIAMOND);
342 assert_eq!(frag.approvals[0].min_amount, quote.sell_amount);
343 }
344}