From 1214de8cd6e4cc6cca28bea86d2770366bfdc3db Mon Sep 17 00:00:00 2001 From: B1boid <47173672+B1boid@users.noreply.github.com> Date: Fri, 17 Jan 2025 23:49:59 +0000 Subject: [PATCH] fix swaps evm plugin (#2332) --- packages/plugin-evm/src/actions/swap.ts | 298 ++++++++++++++++----- packages/plugin-evm/src/tests/swap.test.ts | 71 +++++ packages/plugin-evm/src/types/index.ts | 19 +- 3 files changed, 326 insertions(+), 62 deletions(-) create mode 100644 packages/plugin-evm/src/tests/swap.test.ts diff --git a/packages/plugin-evm/src/actions/swap.ts b/packages/plugin-evm/src/actions/swap.ts index a43dca6cbd8..28f667736ed 100644 --- a/packages/plugin-evm/src/actions/swap.ts +++ b/packages/plugin-evm/src/actions/swap.ts @@ -9,90 +9,266 @@ import { executeRoute, ExtendedChain, getRoutes, + Route, } from "@lifi/sdk"; import { initWalletProvider, WalletProvider } from "../providers/wallet"; import { swapTemplate } from "../templates"; -import type { SwapParams, Transaction } from "../types"; -import { parseEther } from "viem"; +import type { SwapParams, SwapQuote, Transaction } from "../types"; +import { Address, ByteArray, encodeFunctionData, Hex, parseAbi, parseEther, parseUnits } from "viem"; +import { BebopRoute } from '../types/index'; export { swapTemplate }; export class SwapAction { - private config; + private lifiConfig; + private bebopChainsMap; constructor(private walletProvider: WalletProvider) { - this.config = createConfig({ - integrator: "eliza", - chains: Object.values(this.walletProvider.chains).map((config) => ({ - id: config.id, - name: config.name, - key: config.name.toLowerCase(), - chainType: "EVM" as const, - nativeToken: { - ...config.nativeCurrency, - chainId: config.id, - address: "0x0000000000000000000000000000000000000000", - coinKey: config.nativeCurrency.symbol, - priceUSD: "0", - logoURI: "", - symbol: config.nativeCurrency.symbol, - decimals: config.nativeCurrency.decimals, - name: config.nativeCurrency.name, - }, - rpcUrls: { - public: { http: [config.rpcUrls.default.http[0]] }, - }, - blockExplorerUrls: [config.blockExplorers.default.url], - metamask: { - chainId: `0x${config.id.toString(16)}`, - chainName: config.name, - nativeCurrency: config.nativeCurrency, - rpcUrls: [config.rpcUrls.default.http[0]], + this.walletProvider = walletProvider; + let lifiChains: ExtendedChain[] = []; + for (const config of Object.values(this.walletProvider.chains)) { + try { + lifiChains.push({ + id: config.id, + name: config.name, + key: config.name.toLowerCase(), + chainType: "EVM" as const, + nativeToken: { + ...config.nativeCurrency, + chainId: config.id, + address: "0x0000000000000000000000000000000000000000", + coinKey: config.nativeCurrency.symbol, + priceUSD: "0", + logoURI: "", + symbol: config.nativeCurrency.symbol, + decimals: config.nativeCurrency.decimals, + name: config.nativeCurrency.name, + }, + rpcUrls: { + public: { http: [config.rpcUrls.default.http[0]] }, + }, blockExplorerUrls: [config.blockExplorers.default.url], - }, - coin: config.nativeCurrency.symbol, - mainnet: true, - diamondAddress: "0x0000000000000000000000000000000000000000", - })) as ExtendedChain[], - }); + metamask: { + chainId: `0x${config.id.toString(16)}`, + chainName: config.name, + nativeCurrency: config.nativeCurrency, + rpcUrls: [config.rpcUrls.default.http[0]], + blockExplorerUrls: [config.blockExplorers.default.url], + }, + coin: config.nativeCurrency.symbol, + mainnet: true, + diamondAddress: "0x0000000000000000000000000000000000000000", + } as ExtendedChain); + } catch { + // Skip chains with missing config in viem + } + } + this.lifiConfig = createConfig({ + integrator: "eliza", + chains: lifiChains + }) + this.bebopChainsMap = { + 'mainnet': 'ethereum' + } } async swap(params: SwapParams): Promise { const walletClient = this.walletProvider.getWalletClient(params.chain); const [fromAddress] = await walletClient.getAddresses(); - const routes = await getRoutes({ - fromChainId: this.walletProvider.getChainConfigs(params.chain).id, - toChainId: this.walletProvider.getChainConfigs(params.chain).id, - fromTokenAddress: params.fromToken, - toTokenAddress: params.toToken, - fromAmount: parseEther(params.amount).toString(), - fromAddress: fromAddress, - options: { - slippage: params.slippage || 0.5, - order: "RECOMMENDED", - }, + // Getting quotes from different aggregators and sorting them by minAmount (amount after slippage) + const sortedQuotes: SwapQuote[] = await this.getSortedQuotes(fromAddress, params); + + // Trying to execute the best quote by amount, fallback to the next one if it fails + for (const quote of sortedQuotes) { + let res; + switch (quote.aggregator) { + case "lifi": + res = await this.executeLifiQuote(quote); + break; + case "bebop": + res = await this.executeBebopQuote(quote, params); + break + default: + throw new Error("No aggregator found"); + } + if (res !== undefined) return res; + } + throw new Error("Execution failed"); + } + + private async getSortedQuotes(fromAddress: Address, params: SwapParams): Promise { + const decimalsAbi = parseAbi(['function decimals() view returns (uint8)']); + const decimals = await this.walletProvider.getPublicClient(params.chain).readContract({ + address: params.fromToken, + abi: decimalsAbi, + functionName: 'decimals', }); + const quotes: SwapQuote[] | undefined = await Promise.all([ + this.getLifiQuote(fromAddress, params, decimals), + this.getBebopQuote(fromAddress, params, decimals) + ]); + const sortedQuotes: SwapQuote[] = quotes.filter((quote) => quote !== undefined) as SwapQuote[]; + sortedQuotes.sort((a, b) => BigInt(a.minOutputAmount) > BigInt(b.minOutputAmount) ? -1 : 1); + if (sortedQuotes.length === 0) throw new Error("No routes found"); + return sortedQuotes; + } + + private async getLifiQuote(fromAddress: Address, params: SwapParams, fromTokenDecimals: number): Promise { + try { + const routes = await getRoutes({ + fromChainId: this.walletProvider.getChainConfigs(params.chain).id, + toChainId: this.walletProvider.getChainConfigs(params.chain).id, + fromTokenAddress: params.fromToken, + toTokenAddress: params.toToken, + fromAmount: parseUnits(params.amount, fromTokenDecimals).toString(), + fromAddress: fromAddress, + options: { + slippage: params.slippage / 100 || 0.005, + order: "RECOMMENDED", + }, + }); + if (!routes.routes.length) throw new Error("No routes found"); + return { + aggregator: "lifi", + minOutputAmount: routes.routes[0].steps[0].estimate.toAmountMin, + swapData: routes.routes[0] + } + } catch (error) { + console.debug("Error in getLifiQuote:", error.message); + return undefined; + } + } - if (!routes.routes.length) throw new Error("No routes found"); + private async getBebopQuote(fromAddress: Address, params: SwapParams, fromTokenDecimals: number): Promise { + try { + const url = `https://api.bebop.xyz/router/${this.bebopChainsMap[params.chain] ?? params.chain}/v1/quote`; + const reqParams = new URLSearchParams({ + sell_tokens: params.fromToken, + buy_tokens: params.toToken, + sell_amounts: parseUnits(params.amount, fromTokenDecimals).toString(), + taker_address: fromAddress, + approval_type: 'Standard', + skip_validation: 'true', + gasless: 'false', + source: 'eliza' + }); + const response = await fetch(`${url}?${reqParams.toString()}`, { + method: 'GET', + headers: {'accept': 'application/json'}, + }); + if (!response.ok) { + throw Error(response.statusText); + } + const data = await response.json(); + const route: BebopRoute = { + data: data.routes[0].quote.tx.data, + sellAmount: parseUnits(params.amount, fromTokenDecimals).toString(), + approvalTarget: data.routes[0].quote.approvalTarget as `0x${string}`, + from: data.routes[0].quote.tx.from as `0x${string}`, + value: data.routes[0].quote.tx.value.toString(), + to: data.routes[0].quote.tx.to as `0x${string}`, + gas: data.routes[0].quote.tx.gas.toString(), + gasPrice: data.routes[0].quote.tx.gasPrice.toString() + } + return { + aggregator: "bebop", + minOutputAmount: data.routes[0].quote.buyTokens[params.toToken].minimumAmount.toString(), + swapData: route + } - const execution = await executeRoute(routes.routes[0], this.config); - const process = execution.steps[0]?.execution?.process[0]; + } catch (error) { + console.debug("Error in getBebopQuote:", error.message); + return undefined; + } + } + + private async executeLifiQuote(quote: SwapQuote): Promise { + try { + const route: Route = quote.swapData as Route; + const execution = await executeRoute(quote.swapData as Route, this.lifiConfig); + const process = execution.steps[0]?.execution?.process[0]; - if (!process?.status || process.status === "FAILED") { - throw new Error("Transaction failed"); + if (!process?.status || process.status === "FAILED") { + throw new Error("Transaction failed"); + } + return { + hash: process.txHash as `0x${string}`, + from: route.fromAddress! as `0x${string}`, + to: route.steps[0].estimate.approvalAddress as `0x${string}`, + value: 0n, + data: process.data as `0x${string}`, + chainId: route.fromChainId + } + } catch (error) { + return undefined; } + } - return { - hash: process.txHash as `0x${string}`, - from: fromAddress, - to: routes.routes[0].steps[0].estimate - .approvalAddress as `0x${string}`, - value: 0n, - data: process.data as `0x${string}`, - chainId: this.walletProvider.getChainConfigs(params.chain).id, - }; + private async executeBebopQuote(quote: SwapQuote, params: SwapParams): Promise { + try { + const bebopRoute: BebopRoute = quote.swapData as BebopRoute; + const allowanceAbi = parseAbi(['function allowance(address,address) view returns (uint256)']); + const allowance: bigint = await this.walletProvider.getPublicClient(params.chain).readContract({ + address: params.fromToken, + abi: allowanceAbi, + functionName: 'allowance', + args: [bebopRoute.from, bebopRoute.approvalTarget] + }); + if (allowance < BigInt(bebopRoute.sellAmount)) { + const approvalData = encodeFunctionData({ + abi: parseAbi(['function approve(address,uint256)']), + functionName: 'approve', + args: [bebopRoute.approvalTarget, BigInt(bebopRoute.sellAmount)], + }); + await this.walletProvider.getWalletClient(params.chain).sendTransaction({ + account: this.walletProvider.getWalletClient(params.chain).account, + to: params.fromToken, + value: 0n, + data: approvalData, + kzg: { + blobToKzgCommitment: function (_: ByteArray): ByteArray { + throw new Error("Function not implemented."); + }, + computeBlobKzgProof: function ( + _blob: ByteArray, + _commitment: ByteArray + ): ByteArray { + throw new Error("Function not implemented."); + }, + }, + chain: undefined, + }); + } + const hash = await this.walletProvider.getWalletClient(params.chain).sendTransaction({ + account: this.walletProvider.getWalletClient(params.chain).account, + to: bebopRoute.to, + value: BigInt(bebopRoute.value), + data: bebopRoute.data as Hex, + kzg: { + blobToKzgCommitment: function (_: ByteArray): ByteArray { + throw new Error("Function not implemented."); + }, + computeBlobKzgProof: function ( + _blob: ByteArray, + _commitment: ByteArray + ): ByteArray { + throw new Error("Function not implemented."); + }, + }, + chain: undefined, + }); + return { + hash, + from: this.walletProvider.getWalletClient(params.chain).account.address, + to: bebopRoute.to, + value: BigInt(bebopRoute.value), + data: bebopRoute.data as Hex, + }; + } catch (error) { + return undefined; + } } } diff --git a/packages/plugin-evm/src/tests/swap.test.ts b/packages/plugin-evm/src/tests/swap.test.ts new file mode 100644 index 00000000000..93bff4df961 --- /dev/null +++ b/packages/plugin-evm/src/tests/swap.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; +import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; +import { Account, Chain, Hex } from "viem"; + +import { TransferAction } from "../actions/transfer"; +import { WalletProvider } from "../providers/wallet"; +import { SwapAction, swapAction } from '../actions/swap'; + +// Mock the ICacheManager +const mockCacheManager = { + get: vi.fn().mockResolvedValue(null), + set: vi.fn(), +}; + +describe("Swap Action", () => { + let wp: WalletProvider; + + beforeEach(async () => { + vi.clearAllMocks(); + mockCacheManager.get.mockResolvedValue(null); + + const pk = generatePrivateKey(); + const customChains = prepareChains(); + wp = new WalletProvider(pk, mockCacheManager as any, customChains); + }); + + afterEach(() => { + vi.clearAllTimers(); + }); + + describe("Constructor", () => { + it("should initialize with wallet provider", () => { + const ta = new SwapAction(wp); + + expect(ta).toBeDefined(); + }); + }); + describe("Swap", () => { + let ta: SwapAction; + let receiver: Account; + + beforeEach(() => { + ta = new SwapAction(wp); + receiver = privateKeyToAccount(generatePrivateKey()); + }); + + it("swap throws if not enough gas/tokens", async () => { + const ta = new SwapAction(wp); + await expect( + ta.swap({ + chain: "base", + fromToken: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + toToken: "0x4200000000000000000000000000000000000006", + amount: "100", + slippage: 0.5, + }) + ).rejects.toThrow("Execution failed"); + }); + }); +}); + +const prepareChains = () => { + const customChains: Record = {}; + const chainNames = ["base"]; + chainNames.forEach( + (chain) => + (customChains[chain] = WalletProvider.genChainFromName(chain)) + ); + + return customChains; +}; diff --git a/packages/plugin-evm/src/types/index.ts b/packages/plugin-evm/src/types/index.ts index 7f6ae2de012..21cc2893d13 100644 --- a/packages/plugin-evm/src/types/index.ts +++ b/packages/plugin-evm/src/types/index.ts @@ -1,4 +1,4 @@ -import type { Token } from "@lifi/types"; +import type { Route, Token } from "@lifi/types"; import type { Account, Address, @@ -77,6 +77,23 @@ export interface SwapParams { slippage?: number; } +export interface BebopRoute { + data: string; + approvalTarget: Address; + sellAmount: string; + from: Address; + to: Address; + value: string; + gas: string; + gasPrice: string; +} + +export interface SwapQuote { + aggregator: "lifi" | "bebop"; + minOutputAmount: string; + swapData: Route | BebopRoute; +} + export interface BridgeParams { fromChain: SupportedChain; toChain: SupportedChain;