Skip to content

Commit 1214de8

Browse files
authored
fix swaps evm plugin (#2332)
1 parent 024bc8d commit 1214de8

File tree

3 files changed

+326
-62
lines changed

3 files changed

+326
-62
lines changed

packages/plugin-evm/src/actions/swap.ts

+237-61
Original file line numberDiff line numberDiff line change
@@ -9,90 +9,266 @@ import {
99
executeRoute,
1010
ExtendedChain,
1111
getRoutes,
12+
Route,
1213
} from "@lifi/sdk";
1314

1415
import { initWalletProvider, WalletProvider } from "../providers/wallet";
1516
import { swapTemplate } from "../templates";
16-
import type { SwapParams, Transaction } from "../types";
17-
import { parseEther } from "viem";
17+
import type { SwapParams, SwapQuote, Transaction } from "../types";
18+
import { Address, ByteArray, encodeFunctionData, Hex, parseAbi, parseEther, parseUnits } from "viem";
19+
import { BebopRoute } from '../types/index';
1820

1921
export { swapTemplate };
2022

2123
export class SwapAction {
22-
private config;
24+
private lifiConfig;
25+
private bebopChainsMap;
2326

2427
constructor(private walletProvider: WalletProvider) {
25-
this.config = createConfig({
26-
integrator: "eliza",
27-
chains: Object.values(this.walletProvider.chains).map((config) => ({
28-
id: config.id,
29-
name: config.name,
30-
key: config.name.toLowerCase(),
31-
chainType: "EVM" as const,
32-
nativeToken: {
33-
...config.nativeCurrency,
34-
chainId: config.id,
35-
address: "0x0000000000000000000000000000000000000000",
36-
coinKey: config.nativeCurrency.symbol,
37-
priceUSD: "0",
38-
logoURI: "",
39-
symbol: config.nativeCurrency.symbol,
40-
decimals: config.nativeCurrency.decimals,
41-
name: config.nativeCurrency.name,
42-
},
43-
rpcUrls: {
44-
public: { http: [config.rpcUrls.default.http[0]] },
45-
},
46-
blockExplorerUrls: [config.blockExplorers.default.url],
47-
metamask: {
48-
chainId: `0x${config.id.toString(16)}`,
49-
chainName: config.name,
50-
nativeCurrency: config.nativeCurrency,
51-
rpcUrls: [config.rpcUrls.default.http[0]],
28+
this.walletProvider = walletProvider;
29+
let lifiChains: ExtendedChain[] = [];
30+
for (const config of Object.values(this.walletProvider.chains)) {
31+
try {
32+
lifiChains.push({
33+
id: config.id,
34+
name: config.name,
35+
key: config.name.toLowerCase(),
36+
chainType: "EVM" as const,
37+
nativeToken: {
38+
...config.nativeCurrency,
39+
chainId: config.id,
40+
address: "0x0000000000000000000000000000000000000000",
41+
coinKey: config.nativeCurrency.symbol,
42+
priceUSD: "0",
43+
logoURI: "",
44+
symbol: config.nativeCurrency.symbol,
45+
decimals: config.nativeCurrency.decimals,
46+
name: config.nativeCurrency.name,
47+
},
48+
rpcUrls: {
49+
public: { http: [config.rpcUrls.default.http[0]] },
50+
},
5251
blockExplorerUrls: [config.blockExplorers.default.url],
53-
},
54-
coin: config.nativeCurrency.symbol,
55-
mainnet: true,
56-
diamondAddress: "0x0000000000000000000000000000000000000000",
57-
})) as ExtendedChain[],
58-
});
52+
metamask: {
53+
chainId: `0x${config.id.toString(16)}`,
54+
chainName: config.name,
55+
nativeCurrency: config.nativeCurrency,
56+
rpcUrls: [config.rpcUrls.default.http[0]],
57+
blockExplorerUrls: [config.blockExplorers.default.url],
58+
},
59+
coin: config.nativeCurrency.symbol,
60+
mainnet: true,
61+
diamondAddress: "0x0000000000000000000000000000000000000000",
62+
} as ExtendedChain);
63+
} catch {
64+
// Skip chains with missing config in viem
65+
}
66+
}
67+
this.lifiConfig = createConfig({
68+
integrator: "eliza",
69+
chains: lifiChains
70+
})
71+
this.bebopChainsMap = {
72+
'mainnet': 'ethereum'
73+
}
5974
}
6075

6176
async swap(params: SwapParams): Promise<Transaction> {
6277
const walletClient = this.walletProvider.getWalletClient(params.chain);
6378
const [fromAddress] = await walletClient.getAddresses();
6479

65-
const routes = await getRoutes({
66-
fromChainId: this.walletProvider.getChainConfigs(params.chain).id,
67-
toChainId: this.walletProvider.getChainConfigs(params.chain).id,
68-
fromTokenAddress: params.fromToken,
69-
toTokenAddress: params.toToken,
70-
fromAmount: parseEther(params.amount).toString(),
71-
fromAddress: fromAddress,
72-
options: {
73-
slippage: params.slippage || 0.5,
74-
order: "RECOMMENDED",
75-
},
80+
// Getting quotes from different aggregators and sorting them by minAmount (amount after slippage)
81+
const sortedQuotes: SwapQuote[] = await this.getSortedQuotes(fromAddress, params);
82+
83+
// Trying to execute the best quote by amount, fallback to the next one if it fails
84+
for (const quote of sortedQuotes) {
85+
let res;
86+
switch (quote.aggregator) {
87+
case "lifi":
88+
res = await this.executeLifiQuote(quote);
89+
break;
90+
case "bebop":
91+
res = await this.executeBebopQuote(quote, params);
92+
break
93+
default:
94+
throw new Error("No aggregator found");
95+
}
96+
if (res !== undefined) return res;
97+
}
98+
throw new Error("Execution failed");
99+
}
100+
101+
private async getSortedQuotes(fromAddress: Address, params: SwapParams): Promise<SwapQuote[]> {
102+
const decimalsAbi = parseAbi(['function decimals() view returns (uint8)']);
103+
const decimals = await this.walletProvider.getPublicClient(params.chain).readContract({
104+
address: params.fromToken,
105+
abi: decimalsAbi,
106+
functionName: 'decimals',
76107
});
108+
const quotes: SwapQuote[] | undefined = await Promise.all([
109+
this.getLifiQuote(fromAddress, params, decimals),
110+
this.getBebopQuote(fromAddress, params, decimals)
111+
]);
112+
const sortedQuotes: SwapQuote[] = quotes.filter((quote) => quote !== undefined) as SwapQuote[];
113+
sortedQuotes.sort((a, b) => BigInt(a.minOutputAmount) > BigInt(b.minOutputAmount) ? -1 : 1);
114+
if (sortedQuotes.length === 0) throw new Error("No routes found");
115+
return sortedQuotes;
116+
}
117+
118+
private async getLifiQuote(fromAddress: Address, params: SwapParams, fromTokenDecimals: number): Promise<SwapQuote | undefined> {
119+
try {
120+
const routes = await getRoutes({
121+
fromChainId: this.walletProvider.getChainConfigs(params.chain).id,
122+
toChainId: this.walletProvider.getChainConfigs(params.chain).id,
123+
fromTokenAddress: params.fromToken,
124+
toTokenAddress: params.toToken,
125+
fromAmount: parseUnits(params.amount, fromTokenDecimals).toString(),
126+
fromAddress: fromAddress,
127+
options: {
128+
slippage: params.slippage / 100 || 0.005,
129+
order: "RECOMMENDED",
130+
},
131+
});
132+
if (!routes.routes.length) throw new Error("No routes found");
133+
return {
134+
aggregator: "lifi",
135+
minOutputAmount: routes.routes[0].steps[0].estimate.toAmountMin,
136+
swapData: routes.routes[0]
137+
}
138+
} catch (error) {
139+
console.debug("Error in getLifiQuote:", error.message);
140+
return undefined;
141+
}
142+
}
77143

78-
if (!routes.routes.length) throw new Error("No routes found");
144+
private async getBebopQuote(fromAddress: Address, params: SwapParams, fromTokenDecimals: number): Promise<SwapQuote | undefined> {
145+
try {
146+
const url = `https://api.bebop.xyz/router/${this.bebopChainsMap[params.chain] ?? params.chain}/v1/quote`;
147+
const reqParams = new URLSearchParams({
148+
sell_tokens: params.fromToken,
149+
buy_tokens: params.toToken,
150+
sell_amounts: parseUnits(params.amount, fromTokenDecimals).toString(),
151+
taker_address: fromAddress,
152+
approval_type: 'Standard',
153+
skip_validation: 'true',
154+
gasless: 'false',
155+
source: 'eliza'
156+
});
157+
const response = await fetch(`${url}?${reqParams.toString()}`, {
158+
method: 'GET',
159+
headers: {'accept': 'application/json'},
160+
});
161+
if (!response.ok) {
162+
throw Error(response.statusText);
163+
}
164+
const data = await response.json();
165+
const route: BebopRoute = {
166+
data: data.routes[0].quote.tx.data,
167+
sellAmount: parseUnits(params.amount, fromTokenDecimals).toString(),
168+
approvalTarget: data.routes[0].quote.approvalTarget as `0x${string}`,
169+
from: data.routes[0].quote.tx.from as `0x${string}`,
170+
value: data.routes[0].quote.tx.value.toString(),
171+
to: data.routes[0].quote.tx.to as `0x${string}`,
172+
gas: data.routes[0].quote.tx.gas.toString(),
173+
gasPrice: data.routes[0].quote.tx.gasPrice.toString()
174+
}
175+
return {
176+
aggregator: "bebop",
177+
minOutputAmount: data.routes[0].quote.buyTokens[params.toToken].minimumAmount.toString(),
178+
swapData: route
179+
}
79180

80-
const execution = await executeRoute(routes.routes[0], this.config);
81-
const process = execution.steps[0]?.execution?.process[0];
181+
} catch (error) {
182+
console.debug("Error in getBebopQuote:", error.message);
183+
return undefined;
184+
}
185+
}
186+
187+
private async executeLifiQuote(quote: SwapQuote): Promise<Transaction | undefined> {
188+
try {
189+
const route: Route = quote.swapData as Route;
190+
const execution = await executeRoute(quote.swapData as Route, this.lifiConfig);
191+
const process = execution.steps[0]?.execution?.process[0];
82192

83-
if (!process?.status || process.status === "FAILED") {
84-
throw new Error("Transaction failed");
193+
if (!process?.status || process.status === "FAILED") {
194+
throw new Error("Transaction failed");
195+
}
196+
return {
197+
hash: process.txHash as `0x${string}`,
198+
from: route.fromAddress! as `0x${string}`,
199+
to: route.steps[0].estimate.approvalAddress as `0x${string}`,
200+
value: 0n,
201+
data: process.data as `0x${string}`,
202+
chainId: route.fromChainId
203+
}
204+
} catch (error) {
205+
return undefined;
85206
}
207+
}
86208

87-
return {
88-
hash: process.txHash as `0x${string}`,
89-
from: fromAddress,
90-
to: routes.routes[0].steps[0].estimate
91-
.approvalAddress as `0x${string}`,
92-
value: 0n,
93-
data: process.data as `0x${string}`,
94-
chainId: this.walletProvider.getChainConfigs(params.chain).id,
95-
};
209+
private async executeBebopQuote(quote: SwapQuote, params: SwapParams): Promise<Transaction | undefined> {
210+
try {
211+
const bebopRoute: BebopRoute = quote.swapData as BebopRoute;
212+
const allowanceAbi = parseAbi(['function allowance(address,address) view returns (uint256)']);
213+
const allowance: bigint = await this.walletProvider.getPublicClient(params.chain).readContract({
214+
address: params.fromToken,
215+
abi: allowanceAbi,
216+
functionName: 'allowance',
217+
args: [bebopRoute.from, bebopRoute.approvalTarget]
218+
});
219+
if (allowance < BigInt(bebopRoute.sellAmount)) {
220+
const approvalData = encodeFunctionData({
221+
abi: parseAbi(['function approve(address,uint256)']),
222+
functionName: 'approve',
223+
args: [bebopRoute.approvalTarget, BigInt(bebopRoute.sellAmount)],
224+
});
225+
await this.walletProvider.getWalletClient(params.chain).sendTransaction({
226+
account: this.walletProvider.getWalletClient(params.chain).account,
227+
to: params.fromToken,
228+
value: 0n,
229+
data: approvalData,
230+
kzg: {
231+
blobToKzgCommitment: function (_: ByteArray): ByteArray {
232+
throw new Error("Function not implemented.");
233+
},
234+
computeBlobKzgProof: function (
235+
_blob: ByteArray,
236+
_commitment: ByteArray
237+
): ByteArray {
238+
throw new Error("Function not implemented.");
239+
},
240+
},
241+
chain: undefined,
242+
});
243+
}
244+
const hash = await this.walletProvider.getWalletClient(params.chain).sendTransaction({
245+
account: this.walletProvider.getWalletClient(params.chain).account,
246+
to: bebopRoute.to,
247+
value: BigInt(bebopRoute.value),
248+
data: bebopRoute.data as Hex,
249+
kzg: {
250+
blobToKzgCommitment: function (_: ByteArray): ByteArray {
251+
throw new Error("Function not implemented.");
252+
},
253+
computeBlobKzgProof: function (
254+
_blob: ByteArray,
255+
_commitment: ByteArray
256+
): ByteArray {
257+
throw new Error("Function not implemented.");
258+
},
259+
},
260+
chain: undefined,
261+
});
262+
return {
263+
hash,
264+
from: this.walletProvider.getWalletClient(params.chain).account.address,
265+
to: bebopRoute.to,
266+
value: BigInt(bebopRoute.value),
267+
data: bebopRoute.data as Hex,
268+
};
269+
} catch (error) {
270+
return undefined;
271+
}
96272
}
97273
}
98274

+71
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
2+
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
3+
import { Account, Chain, Hex } from "viem";
4+
5+
import { TransferAction } from "../actions/transfer";
6+
import { WalletProvider } from "../providers/wallet";
7+
import { SwapAction, swapAction } from '../actions/swap';
8+
9+
// Mock the ICacheManager
10+
const mockCacheManager = {
11+
get: vi.fn().mockResolvedValue(null),
12+
set: vi.fn(),
13+
};
14+
15+
describe("Swap Action", () => {
16+
let wp: WalletProvider;
17+
18+
beforeEach(async () => {
19+
vi.clearAllMocks();
20+
mockCacheManager.get.mockResolvedValue(null);
21+
22+
const pk = generatePrivateKey();
23+
const customChains = prepareChains();
24+
wp = new WalletProvider(pk, mockCacheManager as any, customChains);
25+
});
26+
27+
afterEach(() => {
28+
vi.clearAllTimers();
29+
});
30+
31+
describe("Constructor", () => {
32+
it("should initialize with wallet provider", () => {
33+
const ta = new SwapAction(wp);
34+
35+
expect(ta).toBeDefined();
36+
});
37+
});
38+
describe("Swap", () => {
39+
let ta: SwapAction;
40+
let receiver: Account;
41+
42+
beforeEach(() => {
43+
ta = new SwapAction(wp);
44+
receiver = privateKeyToAccount(generatePrivateKey());
45+
});
46+
47+
it("swap throws if not enough gas/tokens", async () => {
48+
const ta = new SwapAction(wp);
49+
await expect(
50+
ta.swap({
51+
chain: "base",
52+
fromToken: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
53+
toToken: "0x4200000000000000000000000000000000000006",
54+
amount: "100",
55+
slippage: 0.5,
56+
})
57+
).rejects.toThrow("Execution failed");
58+
});
59+
});
60+
});
61+
62+
const prepareChains = () => {
63+
const customChains: Record<string, Chain> = {};
64+
const chainNames = ["base"];
65+
chainNames.forEach(
66+
(chain) =>
67+
(customChains[chain] = WalletProvider.genChainFromName(chain))
68+
);
69+
70+
return customChains;
71+
};

0 commit comments

Comments
 (0)