Skip to main content

wp_evm_lifi/
lib.rs

1//! LiFi aggregator protocol facade.
2//!
3//! Thin wrapper that hits the LiFi API (`https://li.quest/v1/quote`)
4//! and converts the response into an `AggregatorQuote` for consumption
5//! by the aggregator family's `plan::execute_quote`.
6//!
7//! # Authentication
8//!
9//! LiFi's API can be called without authentication (rate-limited) or
10//! with an API key passed via the `x-lifi-api-key` header. `fetch_quote`
11//! accepts `api_key: Option<&str>` so the facade works in both modes.
12//!
13//! # Scope
14//!
15//! - Multi-chain same-chain swaps (fromChain == toChain == request.chain_id);
16//!   cross-chain bridging deferred.
17//! - Exact-in only (fromAmount) — exact-out not supported by LiFi's
18//!   quote endpoint
19
20use 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
29/// Canonical LiFi Diamond contract on Ethereum mainnet.
30///
31/// Typical approval target for LiFi swaps, though facades should
32/// always read the actual target from `estimate.approvalAddress` in
33/// the response rather than hardcoding.
34pub const LIFI_DIAMOND: Address = address!("0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE");
35
36/// LiFi Quote API base URL.
37pub const API_BASE_URL: &str = "https://li.quest/v1/quote";
38
39/// Ethereum mainnet chain ID.
40pub const CHAIN_ID_ETHEREUM: u64 = 1;
41
42// --- LiFi response types -------------------------------------------------
43
44#[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    /// Hex-encoded (`"0x..."`), not decimal.
56    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
69// --- facade API ----------------------------------------------------------
70
71pub 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    // LiFi encodes value as hex ("0x..."), not decimal — strip prefix and parse base-16.
122    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    // Amounts are decimal strings inside the estimate sub-object.
135    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    // Guard against odd-length hex strings — hex::decode requires even length.
147    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        // 0x1bc16d674ec80000 = 2_000_000_000_000_000_000 = 2 ETH
242        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, &quote).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, &quote).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, &quote).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, &quote).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}