From aed5d42d21b21506cdc87ae9945ca189f32152c9 Mon Sep 17 00:00:00 2001 From: hzhu Date: Tue, 9 Jul 2024 22:29:06 -0700 Subject: [PATCH] fix: account for native token transfer & meta transactions --- .github/workflows/test.yml | 1 + src/parseSwapV2.ts | 216 +++++++++++++++++++++++++++++++--- src/tests/parseSwapV2.test.ts | 204 +++++++++++++++++++++++++++++++- src/utils/index.ts | 2 +- vite.config.ts | 1 + 5 files changed, 399 insertions(+), 25 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7f21c3a..eb5896e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,6 +13,7 @@ jobs: - run: npm test --coverage env: ETH_MAINNET_RPC: ${{ secrets.ETH_MAINNET_RPC }} + ALCHMEY_API_KEY: ${{ secrets.ALCHEMY_API_KEY }} - uses: codecov/codecov-action@v3 with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/src/parseSwapV2.ts b/src/parseSwapV2.ts index 1476ac1..1bde04f 100644 --- a/src/parseSwapV2.ts +++ b/src/parseSwapV2.ts @@ -1,5 +1,17 @@ -import { erc20Abi, getAddress, formatUnits } from "viem"; +import { + fromHex, + erc20Abi, + parseUnits, + getAddress, + formatUnits, + formatEther, + multicall3Abi, + decodeFunctionData, +} from "viem"; + import type { + Hex, + Hash, Chain, Address, Transport, @@ -7,6 +19,35 @@ import type { TransactionReceipt, } from "viem"; +const settlerAbi = [ + { + inputs: [ + { + components: [ + { internalType: "address", name: "recipient", type: "address" }, + { + internalType: "contract IERC20", + name: "buyToken", + type: "address", + }, + { internalType: "uint256", name: "minAmountOut", type: "uint256" }, + ], + internalType: "struct SettlerBase.AllowedSlippage", + name: "slippage", + type: "tuple", + }, + { internalType: "bytes[]", name: "actions", type: "bytes[]" }, + { internalType: "bytes32", name: "", type: "bytes32" }, + { internalType: "address", name: "msgSender", type: "address" }, + { internalType: "bytes", name: "sig", type: "bytes" }, + ], + name: "executeMetaTxn", + outputs: [{ internalType: "bool", name: "", type: "bool" }], + stateMutability: "nonpayable", + type: "function", + }, +]; + const NATIVE_TOKEN_ADDRESS = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"; type SupportedChainId = 1 | 10 | 56 | 137 | 8453 | 42161 | 43114; @@ -22,6 +63,8 @@ export const NATIVE_SYMBOL_BY_CHAIN_ID: { [key in SupportedChainId]: string } = 43114: "AVAX", // Avalanche }; +const MULTICALL3_ADDRESS = "0xcA11bde05977b3631167028862bE2a173976CA11"; + export function isChainIdSupported( chainId: number ): chainId is SupportedChainId { @@ -41,10 +84,76 @@ export interface EnrichedLog { decimals: number; } +interface Trace { + to: Address; + from: Address; + gas: Hex; + gasUsed: Hex; + input: "Hash"; + output: Hash; + calls: Trace[]; + value: Hex; + type: "CALL" | "STATICCALL" | "DELEGATECALL" | "CREATE" | "CREATE2"; +} + +type TraceTransactionSchema = { + Parameters: [ + hash: Hash, + options: + | { + disableStorage?: boolean; + disableStack?: boolean; + enableMemory?: boolean; + enableReturnData?: boolean; + tracer?: string; + } + | { + timeout?: string; + tracerConfig?: { + onlyTopCall?: boolean; + withLog?: boolean; + }; + } + | undefined + ]; + ReturnType: Trace; +}; + +function extractNativeTransfer(trace: Trace, recipient: Address) { + let totalTransferred = 0n; + + function traverseCalls(calls: Trace[]) { + calls.forEach((call) => { + if ( + call.to.toLowerCase() === recipient && + fromHex(call.value, "bigint") > 0n + ) { + totalTransferred = totalTransferred + fromHex(call.value, "bigint"); + } + if (call.calls && call.calls.length > 0) { + traverseCalls(call.calls); + } + }); + } + + traverseCalls(trace.calls); + + return formatEther(totalTransferred); +} + export async function transferLogs({ publicClient, transactionReceipt, -}: EnrichLogsArgs): Promise { +}: EnrichLogsArgs): Promise< + { + to: `0x${string}`; + from: `0x${string}`; + symbol: string; + amount: string; + address: `0x${string}`; + decimals: number; + }[] +> { const EVENT_SIGNATURES = { Transfer: "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", @@ -91,45 +200,116 @@ function convertHexToAddress(hexString: string): string { export async function parseSwapV2({ publicClient, - transactionHash, + transactionHash: hash, }: { publicClient: PublicClient; transactionHash: Address; }) { const chainId = await publicClient.getChainId(); + if (!isChainIdSupported(chainId)) { throw new Error(`chainId ${chainId} is unsupported…`); } - const transactionReceipt = await publicClient.getTransactionReceipt({ - hash: transactionHash, - }); - const { value } = await publicClient.getTransaction({ - hash: transactionHash, - }); + const client = publicClient.extend((client) => ({ + async traceCall(args: { hash: Hash }) { + return client.request({ + method: "debug_traceTransaction", + params: [args.hash, { tracer: "callTracer" }], + }); + }, + })); + + const trace = await client.traceCall({ hash }); + + const transaction = await publicClient.getTransaction({ hash }); + + const { from: taker, value, to } = transaction; + + const nativeTransferAmount = extractNativeTransfer(trace, taker); + + const transactionReceipt = await publicClient.getTransactionReceipt({ hash }); + const isNativeSell = value > 0n; + const logs = await transferLogs({ publicClient, transactionReceipt, }); - const input = logs[0]; - const output = logs[logs.length - 1]; + let input = logs[0]; + + let output = + nativeTransferAmount === "0" + ? logs.find((log) => { + return log.to.toLowerCase() === taker.toLowerCase(); + }) + : { + symbol: NATIVE_SYMBOL_BY_CHAIN_ID[chainId], + amount: nativeTransferAmount, + address: NATIVE_TOKEN_ADDRESS, + }; + + if (to?.toLowerCase() === MULTICALL3_ADDRESS.toLowerCase()) { + const { args: multicallArgs } = decodeFunctionData({ + abi: multicall3Abi, + data: transaction.input, + }); + + const { args: settlerArgs } = decodeFunctionData({ + abi: settlerAbi, + data: multicallArgs[0][1].callData, + }); + + const takerForGaslessApprovalSwap = + settlerArgs[0].recipient.toLowerCase() as Address; + + const nativeTransferAmount = extractNativeTransfer( + trace, + takerForGaslessApprovalSwap + ); + + if (nativeTransferAmount === "0") { + output = output = logs[logs.length - 1]; + } else { + output = { + symbol: NATIVE_SYMBOL_BY_CHAIN_ID[chainId], + amount: nativeTransferAmount, + address: NATIVE_TOKEN_ADDRESS, + }; + } + } + if (isNativeSell) { - const sellAmount = formatUnits(value, 18); + const nativeSellAmount = formatEther(value); + const tokenOut = logs + .filter((log) => log.to.toLowerCase() === taker) + .reduce( + (acc, curr) => ({ + symbol: curr.symbol, + amount: formatUnits( + BigInt(acc.amount) + parseUnits(curr.amount, curr.decimals), + curr.decimals + ), + address: curr.address, + }), + { symbol: "", amount: "", address: "" } + ); + return { tokenIn: { symbol: NATIVE_SYMBOL_BY_CHAIN_ID[chainId], - amount: sellAmount, address: NATIVE_TOKEN_ADDRESS, + amount: nativeSellAmount, }, - tokenOut: { - symbol: output.symbol, - amount: output.amount, - address: output.address, - }, + tokenOut, }; } + + if (!output) { + return null; + } + return { tokenIn: { symbol: input.symbol, diff --git a/src/tests/parseSwapV2.test.ts b/src/tests/parseSwapV2.test.ts index 5ef21e1..75d8a52 100644 --- a/src/tests/parseSwapV2.test.ts +++ b/src/tests/parseSwapV2.test.ts @@ -1,7 +1,14 @@ -import { http, createPublicClient } from "viem"; -import { base, mainnet } from "viem/chains"; +import { + http, + createPublicClient, + type PublicClient, + type Transport, + type Chain, +} from "viem"; +import { arbitrum, base, mainnet, polygon } from "viem/chains"; import { test, expect } from "vitest"; import { parseSwapV2 } from "../index"; +import { NATIVE_ASSET } from "../constants"; require("dotenv").config(); @@ -9,11 +16,39 @@ if (!process.env.ETH_MAINNET_RPC) { throw new Error("An RPC URL required."); } +if (!process.env.ALCHEMY_API_KEY) { + throw new Error("An Alchemy API key is required."); +} + const publicClient = createPublicClient({ chain: mainnet, transport: http(process.env.ETH_MAINNET_RPC), }); +// https://etherscan.io/tx/0x2fc205711fc933ef6e5bcc0bf6e6a9bfc220b2d8073aea4f41305882f485669d +test("parses swapped amounts case 0 (default)", async () => { + const transactionHash = + "0x2fc205711fc933ef6e5bcc0bf6e6a9bfc220b2d8073aea4f41305882f485669d"; + + const result = await parseSwapV2({ + publicClient, + transactionHash, + }); + + expect(result).toEqual({ + tokenIn: { + symbol: "KAI", + amount: "12124969884.736401754", + address: "0xA045Fe936E26e1e1e1Fb27C1f2Ae3643acde0171", + }, + tokenOut: { + symbol: "USDC", + amount: "340.919143", + address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + }, + }); +}); + // https://etherscan.io/tx/0x2b9a12398613887e9813594e8583f488f0e8392d8e6e0ba8d9e140065826dd00 test("parses swapped amounts case 1 (default)", async () => { const transactionHash = @@ -227,11 +262,13 @@ test("throws an error for unsupported chains)", async () => { }); // https://basescan.org/tx/0xa09cb1606e30c3aed8a842723fd6c23cecd838a59f750ab3dbc5ef2c7486e696 -test("parse a swap on Base", async () => { +test("parse a swap on Base (USDC for DAI)", async () => { const publicClient = createPublicClient({ - chain: mainnet, - transport: http("https://mainnet.base.org"), - }); + chain: base, + transport: http( + `https://base-mainnet.g.alchemy.com/v2/${process.env.ALCHEMY_API_KEY}` + ), + }) as PublicClient; const transactionHash = "0xa09cb1606e30c3aed8a842723fd6c23cecd838a59f750ab3dbc5ef2c7486e696"; @@ -254,3 +291,158 @@ test("parse a swap on Base", async () => { }, }); }); + +// https://basescan.org/tx/0xea8bca6e13f2c3e6c1e956308003b8d5da5fca44e03eac7ddbdcea271186ab37 +test("parse a swap on Base (DEGEN for ETH)", async () => { + const publicClient = createPublicClient({ + chain: base, + transport: http( + `https://base-mainnet.g.alchemy.com/v2/${process.env.ALCHEMY_API_KEY}` + ), + }) as PublicClient; + + const transactionHash = + "0xea8bca6e13f2c3e6c1e956308003b8d5da5fca44e03eac7ddbdcea271186ab37"; + + const result = await parseSwapV2({ + publicClient, + transactionHash, + }); + + expect(result).toEqual({ + tokenIn: { + symbol: "DEGEN", + amount: "3173.454530222930443426", + address: "0x4ed4E862860beD51a9570b96d89aF5E1B0Efefed", + }, + tokenOut: { + symbol: "ETH", + amount: "0.006410046715601835", + address: NATIVE_ASSET.address, + }, + }); +}); + +// https://basescan.org/tx/0x9e81eee3f09b79fe1e3700fdb79bf78098b6073ec17e3524498177407ac33a00 +test("parse a swap on Base (ETH for BRETT)", async () => { + const publicClient = createPublicClient({ + chain: base, + transport: http( + `https://base-mainnet.g.alchemy.com/v2/${process.env.ALCHEMY_API_KEY}` + ), + }) as PublicClient; + + const transactionHash = + "0x9e81eee3f09b79fe1e3700fdb79bf78098b6073ec17e3524498177407ac33a00"; + + const result = await parseSwapV2({ + publicClient, + transactionHash, + }); + + expect(result).toEqual({ + tokenIn: { + symbol: "ETH", + amount: "0.027500863104380774", + address: NATIVE_ASSET.address, + }, + tokenOut: { + symbol: "BRETT", + amount: "698.405912537092209301", + address: "0x532f27101965dd16442E59d40670FaF5eBB142E4", + }, + }); +}); + +// https://polygonscan.com/tx/0x438517b81f50858035f4b8e0870f5d797616509b5102c28814bcc378559c213d +test("parse a gasless approval + gasless swap on Polygon (USDC for MATIC)", async () => { + const publicClient = createPublicClient({ + chain: polygon, + transport: http( + `https://polygon-mainnet.g.alchemy.com/v2/${process.env.ALCHEMY_API_KEY}` + ), + }) as PublicClient; + + const transactionHash = + "0x438517b81f50858035f4b8e0870f5d797616509b5102c28814bcc378559c213d"; + + const result = await parseSwapV2({ + publicClient, + transactionHash, + }); + + expect(result).toEqual({ + tokenIn: { + symbol: "USDC", + amount: "7.79692", + address: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", + }, + tokenOut: { + symbol: "MATIC", + amount: "15.513683571865599415", + address: NATIVE_ASSET.address, + }, + }); +}); + +// https://basescan.org/tx/0x40fc248824e11c11debb307a68ba04ff0f068c67de07e6817f5405e055b91c44 +test("parse a gasless swap on Base (USDC for DEGEN)", async () => { + const publicClient = createPublicClient({ + chain: base, + transport: http( + `https://base-mainnet.g.alchemy.com/v2/${process.env.ALCHEMY_API_KEY}` + ), + }) as PublicClient; + + const transactionHash = + "0x40fc248824e11c11debb307a68ba04ff0f068c67de07e6817f5405e055b91c44"; + + const result = await parseSwapV2({ + publicClient, + transactionHash, + }); + + expect(result).toEqual({ + tokenIn: { + symbol: "USDC", + amount: "42.001841", + address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + }, + tokenOut: { + symbol: "DEGEN", + amount: "6570.195174245277347697", + address: "0x4ed4E862860beD51a9570b96d89aF5E1B0Efefed", + }, + }); +}); + +// https://arbiscan.io/tx/0xb2c05194e4ec9ae0f82098ec82a606df544e87c8d6b7726bbb4b1dcc023cb9d7 +test("parse a gasless swap on on Arbitrum (ARB for ETH)", async () => { + const publicClient = createPublicClient({ + chain: arbitrum, + transport: http( + `https://arb-mainnet.g.alchemy.com/v2/${process.env.ALCHEMY_API_KEY}` + ), + }) as PublicClient; + + const transactionHash = + "0xb2c05194e4ec9ae0f82098ec82a606df544e87c8d6b7726bbb4b1dcc023cb9d7"; + + const result = await parseSwapV2({ + publicClient, + transactionHash, + }); + + expect(result).toEqual({ + tokenIn: { + symbol: "ARB", + amount: "1.337", + address: "0x912CE59144191C1204E64559FE8253a0e49E6548", + }, + tokenOut: { + symbol: "ETH", + amount: "0.000304461782666722", + address: NATIVE_ASSET.address, + }, + }); +}); diff --git a/src/utils/index.ts b/src/utils/index.ts index d3d3bd6..3025074 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -57,7 +57,7 @@ export async function transferLogs({ // They are concatenated, so the midpoint separates them. const midpoint = Math.floor(results.length / 2); - // Enrich original logs with additional data (symbol, decimals) and + // Enrich original logs with additional data (symbol, decimals) and // format the transferred amount to a human-readable format. const enrichedLogs = transferLogsAddresses .map((log, index) => { diff --git a/vite.config.ts b/vite.config.ts index 1cc089e..28b7369 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,6 +2,7 @@ import { defineConfig, configDefaults } from "vitest/config"; export default defineConfig({ test: { + testTimeout: 10000, coverage: { exclude: ["examples/**"], },