diff --git a/packages/swap/src/common/icons/li.fi.png b/packages/swap/src/common/icons/li.fi.png new file mode 100644 index 000000000..9b8727985 Binary files /dev/null and b/packages/swap/src/common/icons/li.fi.png differ diff --git a/packages/swap/src/configs.ts b/packages/swap/src/configs.ts index b7879e8dd..5344f1321 100644 --- a/packages/swap/src/configs.ts +++ b/packages/swap/src/configs.ts @@ -14,6 +14,7 @@ import changellyIcon from "./common/icons/changelly-logo.png"; import jupiterIcon from "./common/icons/jupiter-logo.png"; import okxIcon from "./common/icons/okx-logo.png"; import rangoIcon from "./common/icons/rango-logo.png"; +import lifiIcon from "./common/icons/li.fi.png"; export type SwapFeeConfig = { referrer: string; @@ -57,6 +58,10 @@ const PROVIDER_INFO: Record< name: ProviderNameProper.changelly, icon: changellyIcon, }, + [ProviderName.lifi]: { + name: ProviderNameProper.lifi, + icon: lifiIcon, + }, }; const FEE_CONFIGS: Partial< @@ -136,6 +141,13 @@ const FEE_CONFIGS: Partial< fee: 0.01, }, }, + // Add this address in https://portal.li.fi/integrations to collect fees + [ProviderName.lifi]: { + [WalletIdentifier.enkrypt]: { + referrer: "0x8f1c0185bb6276638774b9e94985d69d3cdb444a", + fee: 0.002, + }, + }, }; const TOKEN_LISTS: { @@ -188,6 +200,10 @@ const GAS_LIMITS = { Wrap: numberToHex(70000), }; const NATIVE_TOKEN_ADDRESS = "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; +const NATIVE_TOKEN_ADDRESS_SOLANA = "11111111111111111111111111111111"; + +// li.fi integrator Id defined in https://portal.li.fi/integrations +const LIFI_INTEGRATOR_ID = "encrypt-wallet"; /** 0.5% (unit: 0-100 percentage) */ const DEFAULT_SLIPPAGE = "0.5"; @@ -196,6 +212,8 @@ export { FEE_CONFIGS, GAS_LIMITS, NATIVE_TOKEN_ADDRESS, + NATIVE_TOKEN_ADDRESS_SOLANA, + LIFI_INTEGRATOR_ID, TOKEN_LISTS, CHANGELLY_LIST, TOP_TOKEN_INFO_LIST, diff --git a/packages/swap/src/index.ts b/packages/swap/src/index.ts index 1469dd466..c3b57fea8 100644 --- a/packages/swap/src/index.ts +++ b/packages/swap/src/index.ts @@ -57,6 +57,7 @@ import { sortByRank, sortNativeToFront } from "./utils/common"; import SwapToken from "./swapToken"; import { Jupiter } from "./providers/jupiter"; import { OKX } from "./providers/okx"; +import LiFi from "./providers/lifi"; class Swap extends EventEmitter { network: SupportedNetworkName; @@ -154,6 +155,7 @@ class Swap extends EventEmitter { new Rango(this.api as Web3Solana, this.network), new Changelly(this.api, this.network), new OKX(this.api as Web3Solana, this.network), + new LiFi(this.api as Web3Eth, this.network), ]; break; default: @@ -165,6 +167,7 @@ class Swap extends EventEmitter { new Changelly(this.api, this.network), new ZeroX(this.api as Web3Eth, this.network), new Rango(this.api as Web3Eth, this.network), + new LiFi(this.api as Web3Eth, this.network), ]; break; } diff --git a/packages/swap/src/providers/lifi/index.ts b/packages/swap/src/providers/lifi/index.ts new file mode 100644 index 000000000..74a121bc6 --- /dev/null +++ b/packages/swap/src/providers/lifi/index.ts @@ -0,0 +1,433 @@ +import type Web3Eth from "web3-eth"; +import { numberToHex, toBN } from "web3-utils"; +import { DebugLogger } from "@enkryptcom/utils"; +import { + EVMTransaction, + getQuoteOptions, + MinMaxResponse, + ProviderClass, + ProviderFromTokenResponse, + ProviderName, + ProviderQuoteResponse, + ProviderSwapResponse, + ProviderToTokenResponse, + QuoteMetaOptions, + StatusOptions, + StatusOptionsResponse, + SupportedNetworkName, + SwapQuote, + SolanaTransaction, + SwapTransaction, + SwapType, + TokenType, + TransactionStatus, + TransactionType, +} from "../../types"; +import { + DEFAULT_SLIPPAGE, + FEE_CONFIGS, + GAS_LIMITS, + NATIVE_TOKEN_ADDRESS, + NATIVE_TOKEN_ADDRESS_SOLANA, + LIFI_INTEGRATOR_ID, + TOKEN_LISTS, +} from "../../configs"; +import { + getAllowanceTransactions, + TOKEN_AMOUNT_INFINITY_AND_BEYOND, +} from "../../utils/approvals"; +import estimateEVMGasList from "../../common/estimateGasList"; +import { + LiFiQuoteErrorResponse, + LiFiQuoteResponse, + LiFiStatusResponse, +} from "./types"; +import { VersionedTransaction } from "@solana/web3.js"; +import supportedNetworks from "./supported"; + +const logger = new DebugLogger("swap:lifi"); +const BASE_URL = "https://li.quest/v1"; + +class LiFi extends ProviderClass { + tokenList: TokenType[]; + + network: SupportedNetworkName; + + web3eth: Web3Eth; + + name: ProviderName; + + fromTokens: ProviderFromTokenResponse; + + toTokens: ProviderToTokenResponse; + + constructor(web3eth: Web3Eth, network: SupportedNetworkName) { + super(); + this.network = network; + this.tokenList = []; + this.web3eth = web3eth; + this.name = ProviderName.lifi; + this.fromTokens = {}; + this.toTokens = {}; + } + + async init(tokenList: TokenType[]): Promise { + logger.info("init: Initialising..."); + + if (!LiFi.isSupported(this.network)) { + logger.info( + `init: Enkrypt does not support Li.Fi on this network network=${this.network}`, + ); + return; + } + + // Fill from token list + tokenList.forEach((token) => { + this.fromTokens[token.address] = token; + }); + + for (const networkName of Object.keys(supportedNetworks)) { + if (!this.toTokens[networkName]) this.toTokens[networkName] = {}; + + try { + // + const tokenResponse = await fetch(TOKEN_LISTS[networkName]); + const tokenResult = await tokenResponse.json(); + + // map token list to each destination network + tokenResult.all.forEach((token) => { + const tokenAddress = this.normalizeEvmAddress(token.address); + + this.toTokens[networkName][tokenAddress] = { + ...token, + address: tokenAddress, + networkInfo: { + name: networkName, + isAddress: supportedNetworks[networkName].isAddress, + }, + }; + }); + } catch (error) { + console.warn("Error Initialising li.fi tokens list: ", String(error)); + } + } + } + + static isSupported(network: SupportedNetworkName) { + return Object.keys(supportedNetworks).includes( + network as unknown as string, + ); + } + + getFromTokens() { + return this.fromTokens; + } + + getToTokens() { + return this.toTokens; + } + + getMinMaxAmount(): Promise { + return Promise.resolve({ + minimumFrom: toBN("0"), + maximumFrom: toBN(TOKEN_AMOUNT_INFINITY_AND_BEYOND), + minimumTo: toBN("0"), + maximumTo: toBN(TOKEN_AMOUNT_INFINITY_AND_BEYOND), + }); + } + + private normalizeEvmAddress(address: string) { + if (address?.startsWith("0x") && address.length === 42) { + return address.toLowerCase(); + } + // pass non evm addresses without changing case + return address; + } + + private withTimeoutSignal( + signal: AbortSignal | undefined, + timeoutMs: number, + ): { + signal: AbortSignal; + cleanup: () => void; + } { + const aborter = new AbortController(); + const onAbort = () => aborter.abort(signal?.reason); + const onTimeout = () => + aborter.abort(new Error(`Li.Fi API request timed out (${timeoutMs}ms)`)); + const timeout = setTimeout(onTimeout, timeoutMs); + signal?.addEventListener("abort", onAbort); + return { + signal: aborter.signal, + cleanup: () => { + clearTimeout(timeout); + signal?.removeEventListener("abort", onAbort); + }, + }; + } + + private async getLiFiQuote( + options: getQuoteOptions, + meta: QuoteMetaOptions, + context?: { signal?: AbortSignal }, + ): Promise { + let fromTokenAddress = options.fromToken.address; + let toTokenAddress = options.toToken.address; + + if (options.fromToken.name === supportedNetworks.SOLANA.lifiName) { + fromTokenAddress = NATIVE_TOKEN_ADDRESS_SOLANA; + } + if (options.toToken.name === supportedNetworks.SOLANA.lifiName) { + toTokenAddress = NATIVE_TOKEN_ADDRESS_SOLANA; + } + + const params = new URLSearchParams({ + fromChain: String(supportedNetworks[this.network].chainId), + toChain: String( + supportedNetworks[options.toToken.networkInfo.name].chainId, + ), + fromToken: this.normalizeEvmAddress(fromTokenAddress), + toToken: this.normalizeEvmAddress(toTokenAddress), + fromAmount: options.amount.toString(), + fromAddress: this.normalizeEvmAddress(options.fromAddress), + toAddress: this.normalizeEvmAddress(options.toAddress), + slippage: (parseFloat(DEFAULT_SLIPPAGE) / 100).toString(), + integrator: LIFI_INTEGRATOR_ID, + fee: String( + FEE_CONFIGS[this.name]?.[meta.walletIdentifier]?.fee ?? 0.002, + ), + referrer: FEE_CONFIGS[this.name]?.[meta.walletIdentifier]?.referrer ?? "", + }); + const withTimeout = this.withTimeoutSignal(context?.signal, 30_000); + try { + const response = await fetch(`${BASE_URL}/quote?${params.toString()}`, { + signal: withTimeout.signal, + }); + const json = (await response.json()) as + | LiFiQuoteResponse + | LiFiQuoteErrorResponse; + if (!response.ok) { + console.warn("Li.Fi quote error", json); + return null; + } + if (!(json as LiFiQuoteResponse).transactionRequest) { + return null; + } + return json as LiFiQuoteResponse; + } finally { + withTimeout.cleanup(); + } + } + + private getHexValue(value?: string) { + if (!value || value === "0") return "0x0"; + if (value.startsWith("0x")) return value; + return numberToHex(value); + } + + private async getLiFiSwap( + options: getQuoteOptions, + meta: QuoteMetaOptions, + accurateEstimate: boolean, + context?: { signal?: AbortSignal }, + ): Promise<{ + transactions: SwapTransaction[]; + toTokenAmount: ReturnType; + fromTokenAmount: ReturnType; + tool: string; + fromChain: string; + toChain: string; + } | null> { + if ( + !LiFi.isSupported( + options.toToken.networkInfo.name as SupportedNetworkName, + ) || + !LiFi.isSupported(this.network) + ) { + return null; + } + const normalizedFromAddress = this.normalizeEvmAddress(options.fromAddress); + const normalizedFromTokenAddress = this.normalizeEvmAddress( + options.fromToken.address, + ); + + const quote = await this.getLiFiQuote(options, meta, context); + if (!quote?.transactionRequest?.to && !quote?.transactionRequest?.data) { + return null; + } + + const transactions: SwapTransaction[] = []; + const isSolanaTx = !quote.transactionRequest.to; + + if (isSolanaTx) { + // Solana Tx + const bytes = Uint8Array.from( + Buffer.from(quote.transactionRequest.data, "base64"), + ); + const versionedTx = VersionedTransaction.deserialize(bytes); + + const solTx: SolanaTransaction = { + from: this.normalizeEvmAddress(options.fromAddress), + to: this.normalizeEvmAddress(options.toAddress), + kind: versionedTx.version === "legacy" ? "legacy" : "versioned", + serialized: quote.transactionRequest.data, + type: TransactionType.solana, + thirdPartySignatures: [], + }; + transactions.push(solTx); + } else { + // EVM case + if ( + options.fromToken.address !== NATIVE_TOKEN_ADDRESS && + quote.estimate?.approvalAddress + ) { + const normalizedFromToken: TokenType = { + ...options.fromToken, + address: normalizedFromTokenAddress, + }; + const approvalTxs = await getAllowanceTransactions({ + infinityApproval: meta.infiniteApproval, + spender: this.normalizeEvmAddress(quote.estimate.approvalAddress), + web3eth: this.web3eth, + amount: options.amount, + fromAddress: normalizedFromAddress, + fromToken: normalizedFromToken, + }); + transactions.push(...approvalTxs); + } + + transactions.push({ + from: normalizedFromAddress, + gasLimit: quote.transactionRequest.gasLimit + ? this.getHexValue(quote.transactionRequest.gasLimit) + : GAS_LIMITS.swap, + to: this.normalizeEvmAddress(quote.transactionRequest.to), + value: this.getHexValue(quote.transactionRequest.value), + data: quote.transactionRequest.data, + type: TransactionType.evm, + }); + } + + if (accurateEstimate && !isSolanaTx) { + const accurateGasEstimate = await estimateEVMGasList( + transactions as EVMTransaction[], + this.network, + ); + if (accurateGasEstimate) { + if (accurateGasEstimate.isError) return null; + (transactions as EVMTransaction[]).forEach((tx, idx) => { + tx.gasLimit = accurateGasEstimate.result[idx]; + }); + } + } + + return { + transactions, + toTokenAmount: toBN(quote.estimate?.toAmount), + fromTokenAmount: toBN(quote.estimate?.fromAmount), + tool: quote.tool, + fromChain: String(quote.action?.fromChainId ?? ""), + toChain: String(quote.action?.toChainId ?? ""), + }; + } + + getQuote( + options: getQuoteOptions, + meta: QuoteMetaOptions, + context?: { signal?: AbortSignal }, + ): Promise { + return this.getLiFiSwap(options, meta, false, context) + .then(async (res) => { + if (!res) return null; + const response: ProviderQuoteResponse = { + fromTokenAmount: res.fromTokenAmount, + toTokenAmount: res.toTokenAmount, + additionalNativeFees: toBN(0), + provider: this.name, + quote: { + meta, + options, + provider: this.name, + }, + totalGaslimit: res.transactions.reduce( + (total: number, curVal: SwapTransaction) => + curVal.type === TransactionType.evm + ? total + toBN(curVal.gasLimit).toNumber() + : total, + 0, + ), + minMax: await this.getMinMaxAmount(), + }; + return response; + }) + .catch((e) => { + if ((e as Error)?.name === "AbortError") return null; + console.error(e); + return null; + }); + } + + getSwap( + quote: SwapQuote, + context?: { signal?: AbortSignal }, + ): Promise { + return this.getLiFiSwap(quote.options, quote.meta, true, context) + .then((res) => { + if (!res) return null; + const feeConfig = + FEE_CONFIGS[this.name]?.[quote.meta.walletIdentifier]?.fee || 0; + const response: ProviderSwapResponse = { + fromTokenAmount: res.fromTokenAmount, + provider: this.name, + type: SwapType.regular, + toTokenAmount: res.toTokenAmount, + transactions: res.transactions, + additionalNativeFees: toBN(0), + slippage: quote.meta.slippage || DEFAULT_SLIPPAGE, + fee: feeConfig * 100, + getStatusObject: async ( + options: StatusOptions, + ): Promise => ({ + options: { + ...options, + bridge: res.tool, + fromChain: res.fromChain, + toChain: res.toChain, + }, + provider: this.name, + }), + }; + return response; + }) + .catch((e) => { + if ((e as Error)?.name === "AbortError") return null; + console.error(e); + return null; + }); + } + + async getStatus(options: StatusOptions): Promise { + const txHash = options.transactions?.[1]?.hash; + if (!txHash) return TransactionStatus.pending; + + const params = new URLSearchParams({ + txHash, + }); + if (options.bridge) params.set("bridge", String(options.bridge)); + if (options.fromChain) params.set("fromChain", String(options.fromChain)); + if (options.toChain) params.set("toChain", String(options.toChain)); + + const response = await fetch(`${BASE_URL}/status?${params.toString()}`); + if (!response.ok) return TransactionStatus.pending; + const json = (await response.json()) as LiFiStatusResponse; + + if (json.status === "FAILED") return TransactionStatus.failed; + if (json.status === "DONE") { + if (json.substatus === "REFUNDED") return TransactionStatus.failed; + return TransactionStatus.success; + } + return TransactionStatus.pending; + } +} + +export default LiFi; diff --git a/packages/swap/src/providers/lifi/supported.ts b/packages/swap/src/providers/lifi/supported.ts new file mode 100644 index 000000000..c648f9a8a --- /dev/null +++ b/packages/swap/src/providers/lifi/supported.ts @@ -0,0 +1,114 @@ +import { isEVMAddress } from "../../utils/common"; +import { SupportedNetworkName } from "../../types"; +import { isValidSolanaAddress } from "../../utils/solana"; + +/** + * Blockchain names: + * + * Fetch supported EVM chains + * ```sh + * https://li.quest/v1/chains?chainTypes=EVM + * ``` + * + * Fetch Solana chain + * ```sh + * https://li.quest/v1/chains?chainTypes=SVM + * ```` + * + * Supported Networks list is prepared by taking the + * intersection of enkrypt supported chains for swaps + * and li.fi supported networks. + */ + +const supportedNetworks: { + readonly [key in SupportedNetworkName]?: { + chainId: number; + lifiName: string; + isAddress?: (addr: string) => Promise; + }; +} = { + [SupportedNetworkName.Ethereum]: { + chainId: 1, + lifiName: "Ethereum", + isAddress: (address: string) => Promise.resolve(isEVMAddress(address)), + }, + [SupportedNetworkName.Arbitrum]: { + chainId: 42161, + lifiName: "Arbitrum", + isAddress: (address: string) => Promise.resolve(isEVMAddress(address)), + }, + [SupportedNetworkName.Binance]: { + chainId: 56, + lifiName: "BSC", + isAddress: (address: string) => Promise.resolve(isEVMAddress(address)), + }, + [SupportedNetworkName.Base]: { + chainId: 8453, + lifiName: "Base", + isAddress: (address: string) => Promise.resolve(isEVMAddress(address)), + }, + [SupportedNetworkName.Matic]: { + chainId: 137, + lifiName: "Polygon", + isAddress: (address: string) => Promise.resolve(isEVMAddress(address)), + }, + [SupportedNetworkName.Optimism]: { + chainId: 10, + lifiName: "OP Mainnet", + isAddress: (address: string) => Promise.resolve(isEVMAddress(address)), + }, + [SupportedNetworkName.Moonbeam]: { + chainId: 1284, + lifiName: "Moonbeam", + isAddress: (address: string) => Promise.resolve(isEVMAddress(address)), + }, + [SupportedNetworkName.Gnosis]: { + chainId: 100, + lifiName: "Gnosis", + isAddress: (address: string) => Promise.resolve(isEVMAddress(address)), + }, + [SupportedNetworkName.Avalanche]: { + chainId: 43114, + lifiName: "Avalanche", + isAddress: (address: string) => Promise.resolve(isEVMAddress(address)), + }, + [SupportedNetworkName.Kaia]: { + chainId: 8217, + lifiName: "Kaia", + isAddress: (address: string) => Promise.resolve(isEVMAddress(address)), + }, + [SupportedNetworkName.Zksync]: { + chainId: 324, + lifiName: "zkSync", + isAddress: (address: string) => Promise.resolve(isEVMAddress(address)), + }, + [SupportedNetworkName.Blast]: { + chainId: 81457, + lifiName: "Blast", + isAddress: (address: string) => Promise.resolve(isEVMAddress(address)), + }, + [SupportedNetworkName.Telos]: { + chainId: 40, + lifiName: "Telos", + isAddress: (address: string) => Promise.resolve(isEVMAddress(address)), + }, + [SupportedNetworkName.Rootstock]: { + chainId: 30, + lifiName: "Rootstock", + isAddress: (address: string) => + Promise.resolve(isEVMAddress(address.toLowerCase())), + }, + [SupportedNetworkName.Solana]: { + chainId: 1151111081099710, + lifiName: "Solana", + async isAddress(address: string) { + return isValidSolanaAddress(address); + }, + }, +}; + +export const supportedNetworksSet = new Set( + Object.keys(supportedNetworks), +) as unknown as Set; + +export default supportedNetworks; diff --git a/packages/swap/src/providers/lifi/types.ts b/packages/swap/src/providers/lifi/types.ts new file mode 100644 index 000000000..4664f443f --- /dev/null +++ b/packages/swap/src/providers/lifi/types.ts @@ -0,0 +1,28 @@ +export interface LiFiQuoteResponse { + tool?: string; + action?: { + fromAmount?: string; + fromChainId?: number; + toChainId?: number; + }; + estimate?: { + fromAmount?: string; + toAmount?: string; + approvalAddress?: string; + }; + transactionRequest?: { + to: string; + data: string; + value?: string; + gasLimit?: string; + }; +} + +export interface LiFiQuoteErrorResponse { + message?: string; +} + +export interface LiFiStatusResponse { + status?: "NOT_FOUND" | "PENDING" | "DONE" | "FAILED"; + substatus?: "COMPLETED" | "PARTIAL" | "REFUNDED" | string; +} diff --git a/packages/swap/src/types/index.ts b/packages/swap/src/types/index.ts index 72a643e8f..06a50a6b4 100644 --- a/packages/swap/src/types/index.ts +++ b/packages/swap/src/types/index.ts @@ -130,6 +130,7 @@ export enum ProviderName { rango = "rango", jupiter = "jupiter", okx = "okx", + lifi = "lifi", } // eslint-disable-next-line no-shadow @@ -142,6 +143,7 @@ export enum ProviderNameProper { rango = "Rango", jupiter = "Jupiter", okx = "Okx", + lifi = "LI.FI", } // eslint-disable-next-line no-shadow