wp-evm-lifi 0.1.14

LiFi aggregator protocol facade for waterpump-evm
Documentation
//! LiFi aggregator protocol facade.
//!
//! Thin wrapper that hits the LiFi API (`https://li.quest/v1/quote`)
//! and converts the response into an `AggregatorQuote` for consumption
//! by the aggregator family's `plan::execute_quote`.
//!
//! # Authentication
//!
//! LiFi's API can be called without authentication (rate-limited) or
//! with an API key passed via the `x-lifi-api-key` header. `fetch_quote`
//! accepts `api_key: Option<&str>` so the facade works in both modes.
//!
//! # Scope
//!
//! - Multi-chain same-chain swaps (fromChain == toChain == request.chain_id);
//!   cross-chain bridging deferred.
//! - Exact-in only (fromAmount) — exact-out not supported by LiFi's
//!   quote endpoint

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,
};

/// Canonical LiFi Diamond contract on Ethereum mainnet.
///
/// Typical approval target for LiFi swaps, though facades should
/// always read the actual target from `estimate.approvalAddress` in
/// the response rather than hardcoding.
pub const LIFI_DIAMOND: Address = address!("0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE");

/// LiFi Quote API base URL.
pub const API_BASE_URL: &str = "https://li.quest/v1/quote";

/// Ethereum mainnet chain ID.
pub const CHAIN_ID_ETHEREUM: u64 = 1;

// --- LiFi response types -------------------------------------------------

#[derive(Debug, Deserialize)]
struct LifiQuoteResponse {
    #[serde(rename = "transactionRequest")]
    transaction_request: LifiTransactionRequest,
    estimate: LifiEstimate,
}

#[derive(Debug, Deserialize)]
struct LifiTransactionRequest {
    to: String,
    data: String,
    /// Hex-encoded (`"0x..."`), not decimal.
    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,
}

// --- facade API ----------------------------------------------------------

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}")))?;

    // LiFi encodes value as hex ("0x..."), not decimal — strip prefix and parse base-16.
    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}")))?
    };

    // Amounts are decimal strings inside the estimate sub-object.
    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);

    // Guard against odd-length hex strings — hex::decode requires even length.
    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();
        // 0x1bc16d674ec80000 = 2_000_000_000_000_000_000 = 2 ETH
        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, &quote).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, &quote).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, &quote).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, &quote).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);
    }
}