diff --git a/src/constants.ts b/src/constants.ts index c762cfc..e752206 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,6 +1,6 @@ import type { SupportedChainId } from "./types"; -export const SETTLER_ABI = [ +export const SETTLER_META_TXN_ABI = [ { inputs: [ { @@ -27,13 +27,11 @@ export const SETTLER_ABI = [ stateMutability: "nonpayable", type: "function", }, -]; + { stateMutability: "payable", type: "receive" }, +] as const; export const FUNCTION_SELECTORS = { EXECUTE_META_TXN: "0xfd3ad6d4" }; -export const NATIVE_TOKEN_ADDRESS = - "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"; - export const NATIVE_SYMBOL_BY_CHAIN_ID: { [key in SupportedChainId]: string } = { 1: "ETH", // Ethereum @@ -45,6 +43,8 @@ export const NATIVE_SYMBOL_BY_CHAIN_ID: { [key in SupportedChainId]: string } = 43114: "AVAX", // Avalanche }; -export const MULTICALL3_ADDRESS = "0xcA11bde05977b3631167028862bE2a173976CA11"; +export const NATIVE_TOKEN_ADDRESS = `0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE`; + +export const MULTICALL3_ADDRESS = `0xcA11bde05977b3631167028862bE2a173976CA11`; -export const NATIVE_ETH_ADDRESS = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"; +export const ERC_4337_ENTRY_POINT = `0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789`; diff --git a/src/index.ts b/src/index.ts index 0649ea6..5098ea9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,16 +6,18 @@ import { decodeFunctionData, } from "viem"; import { - SETTLER_ABI, MULTICALL3_ADDRESS, FUNCTION_SELECTORS, + ERC_4337_ENTRY_POINT, NATIVE_TOKEN_ADDRESS, + SETTLER_META_TXN_ABI, NATIVE_SYMBOL_BY_CHAIN_ID, } from "./constants"; import { transferLogs, isChainIdSupported, - extractNativeTransfer, + calculateNativeTransfer, + parseSmartContractWalletTx, } from "./utils"; import type { Hash, Chain, Address, Transport, PublicClient } from "viem"; import type { TraceTransactionSchema } from "./types"; @@ -23,9 +25,11 @@ import type { TraceTransactionSchema } from "./types"; export async function parseSwap({ publicClient, transactionHash: hash, + smartContractWallet, }: { publicClient: PublicClient; transactionHash: Address; + smartContractWallet?: Address; }) { const chainId = await publicClient.getChainId(); @@ -48,7 +52,11 @@ export async function parseSwap({ const { from: taker, value, to } = transaction; - const nativeTransferAmount = extractNativeTransfer(trace, taker); + const isToERC4337 = to === ERC_4337_ENTRY_POINT.toLowerCase(); + + const nativeAmountToTaker = calculateNativeTransfer(trace, { + recipient: taker, + }); const transactionReceipt = await publicClient.getTransactionReceipt({ hash }); @@ -59,6 +67,21 @@ export async function parseSwap({ transactionReceipt, }); + if (isToERC4337) { + if (!smartContractWallet) { + throw new Error( + "This is an ERC-4337 transaction. You must provide a smart contract wallet address to 0x-parser." + ); + } + + return parseSmartContractWalletTx({ + logs, + trace, + chainId, + smartContractWallet, + }); + } + const fromTaker = logs.filter( (log) => log.from.toLowerCase() === taker.toLowerCase() ); @@ -66,13 +89,13 @@ export async function parseSwap({ let input = fromTaker.length ? fromTaker[0] : logs[0]; let output = - nativeTransferAmount === "0" + nativeAmountToTaker === "0" ? logs.find((log) => { return log.to.toLowerCase() === taker.toLowerCase(); }) : { symbol: NATIVE_SYMBOL_BY_CHAIN_ID[chainId], - amount: nativeTransferAmount, + amount: nativeAmountToTaker, address: NATIVE_TOKEN_ADDRESS, }; @@ -82,41 +105,42 @@ export async function parseSwap({ data: transaction.input, }); - const { args: settlerArgs } = decodeFunctionData({ - abi: SETTLER_ABI, + const { args: settlerArgs } = decodeFunctionData({ + abi: SETTLER_META_TXN_ABI, data: multicallArgs[0][1].callData, }); const takerForGaslessApprovalSwap = settlerArgs[0].recipient.toLowerCase() as Address; - const nativeTransferAmount = extractNativeTransfer( - trace, - takerForGaslessApprovalSwap - ); + const nativeAmountToTaker = calculateNativeTransfer(trace, { + recipient: takerForGaslessApprovalSwap, + }); - if (nativeTransferAmount === "0") { + if (nativeAmountToTaker === "0") { output = output = logs[logs.length - 1]; } else { output = { symbol: NATIVE_SYMBOL_BY_CHAIN_ID[chainId], - amount: nativeTransferAmount, + amount: nativeAmountToTaker, address: NATIVE_TOKEN_ADDRESS, }; } } if (transaction.input.startsWith(FUNCTION_SELECTORS.EXECUTE_META_TXN)) { - const { args } = decodeFunctionData({ - abi: SETTLER_ABI, + const { args } = decodeFunctionData({ + abi: SETTLER_META_TXN_ABI, data: transaction.input, }); const { 3: msgSender } = args; - const nativeTransferAmount = extractNativeTransfer(trace, msgSender); + const nativeAmountToTaker = calculateNativeTransfer(trace, { + recipient: msgSender, + }); - if (nativeTransferAmount === "0") { + if (nativeAmountToTaker === "0") { output = logs[logs.length - 1]; const takerReceived = logs.filter( (log) => log.to.toLowerCase() === msgSender.toLowerCase() @@ -138,7 +162,7 @@ export async function parseSwap({ } else { output = { symbol: NATIVE_SYMBOL_BY_CHAIN_ID[chainId], - amount: nativeTransferAmount, + amount: nativeAmountToTaker, address: NATIVE_TOKEN_ADDRESS, }; } diff --git a/src/tests/index.test.ts b/src/tests/index.test.ts index 3dea4b0..b42cad3 100644 --- a/src/tests/index.test.ts +++ b/src/tests/index.test.ts @@ -8,7 +8,7 @@ import { import { arbitrum, base, mainnet, optimism, polygon } from "viem/chains"; import { test, expect } from "vitest"; import { parseSwap } from "../index"; -import { NATIVE_ETH_ADDRESS } from "../constants"; +import { NATIVE_TOKEN_ADDRESS } from "../constants"; require("dotenv").config(); @@ -347,7 +347,7 @@ test("parse a swap on Base (DEGEN for ETH)", async () => { tokenOut: { symbol: "ETH", amount: "0.006410046715601835", - address: NATIVE_ETH_ADDRESS, + address: NATIVE_TOKEN_ADDRESS, }, }); }); @@ -373,7 +373,7 @@ test("parse a swap on Base (ETH for BRETT)", async () => { tokenIn: { symbol: "ETH", amount: "0.027500863104380774", - address: NATIVE_ETH_ADDRESS, + address: NATIVE_TOKEN_ADDRESS, }, tokenOut: { symbol: "BRETT", @@ -409,7 +409,7 @@ test("parse a gasless approval + gasless swap on Polygon (USDC for MATIC)", asyn tokenOut: { symbol: "MATIC", amount: "15.513683571865599415", - address: NATIVE_ETH_ADDRESS, + address: NATIVE_TOKEN_ADDRESS, }, }); }); @@ -471,7 +471,7 @@ test("parse a gasless swap on Base (USDC for ETH) for SettlerMetaTxn", async () tokenOut: { symbol: "ETH", amount: "0.006847116541535933", - address: NATIVE_ETH_ADDRESS, + address: NATIVE_TOKEN_ADDRESS, }, }); }); @@ -502,7 +502,7 @@ test("parse a gasless swap on Base (weirdo for ETH) for SettlerMetaTxn", async ( tokenOut: { symbol: "ETH", amount: "0.039633073597929391", - address: NATIVE_ETH_ADDRESS, + address: NATIVE_TOKEN_ADDRESS, }, }); }); @@ -564,7 +564,7 @@ test("parse a gasless swap on on Arbitrum (ARB for ETH)", async () => { tokenOut: { symbol: "ETH", amount: "0.000304461782666722", - address: NATIVE_ETH_ADDRESS, + address: NATIVE_TOKEN_ADDRESS, }, }); }); @@ -661,3 +661,120 @@ test("parse a swap on BNB Chain (ETH for USDC) for execute", async () => { }, }); }); + +test("throws when smart contract wallet is not passed", async () => { + const publicClient = createPublicClient({ + chain: base, + transport: http( + `https://base-mainnet.g.alchemy.com/v2/${process.env.ALCHEMY_API_KEY}` + ), + }) as PublicClient; + + const transactionHash = + "0x756289cdedd4c007268ef208fe2758a9fb6efd49fb241397b67089512b497662"; + + expect(async () => { + await parseSwap({ + publicClient, + transactionHash, + }); + }).rejects.toThrowError( + "This is an ERC-4337 transaction. You must provide a smart contract wallet address to 0x-parser." + ); +}); + +// https://basescan.org/tx/0x756289cdedd4c007268ef208fe2758a9fb6efd49fb241397b67089512b497662 +test("parse a swap on Base (DEGEN for BRETT) with smart contract wallet", async () => { + const publicClient = createPublicClient({ + chain: base, + transport: http( + `https://base-mainnet.g.alchemy.com/v2/${process.env.ALCHEMY_API_KEY}` + ), + }) as PublicClient; + + const transactionHash = + "0x756289cdedd4c007268ef208fe2758a9fb6efd49fb241397b67089512b497662"; + + const result = await parseSwap({ + publicClient, + transactionHash, + smartContractWallet: "0x3F6dAB60Cc16377Df9684959e20962f44De20988", + }); + + expect(result).toEqual({ + tokenIn: { + symbol: "DEGEN", + amount: "882.414233540058884907", + address: "0x4ed4E862860beD51a9570b96d89aF5E1B0Efefed", + }, + tokenOut: { + symbol: "BRETT", + amount: "48.014669721245576995", + address: "0x532f27101965dd16442E59d40670FaF5eBB142E4", + }, + }); +}); + +// https://basescan.org/tx/0xaa09479aafdb1a33815fb3842c350ccedf5e3f9eaec31b8cba1f41eea674a8f3 +test("parse a swap on Base (BRETT for ETH) with smart contract wallet", async () => { + const publicClient = createPublicClient({ + chain: base, + transport: http( + `https://base-mainnet.g.alchemy.com/v2/${process.env.ALCHEMY_API_KEY}` + ), + }) as PublicClient; + + const transactionHash = + "0xaa09479aafdb1a33815fb3842c350ccedf5e3f9eaec31b8cba1f41eea674a8f3"; + + const result = await parseSwap({ + publicClient, + transactionHash, + smartContractWallet: "0x3F6dAB60Cc16377Df9684959e20962f44De20988", + }); + + expect(result).toEqual({ + tokenIn: { + symbol: "BRETT", + amount: "48.014669721245576995", + address: "0x532f27101965dd16442E59d40670FaF5eBB142E4", + }, + tokenOut: { + symbol: "ETH", + amount: "0.001482901054900327", + address: NATIVE_TOKEN_ADDRESS, + }, + }); +}); + +// https://basescan.org/tx/0xe289a22987dcedfacb13584211c1d723ef5c42ea6e0dfd5c4d3271d20dec9ddc +test("parse a swap on Base (ETH for USDC) with smart contract wallet", async () => { + const publicClient = createPublicClient({ + chain: base, + transport: http( + `https://base-mainnet.g.alchemy.com/v2/${process.env.ALCHEMY_API_KEY}` + ), + }) as PublicClient; + + const transactionHash = + "0xe289a22987dcedfacb13584211c1d723ef5c42ea6e0dfd5c4d3271d20dec9ddc"; + + const result = await parseSwap({ + publicClient, + transactionHash, + smartContractWallet: "0x3F6dAB60Cc16377Df9684959e20962f44De20988", + }); + + expect(result).toEqual({ + tokenIn: { + symbol: "ETH", + amount: "0.001", + address: NATIVE_TOKEN_ADDRESS, + }, + tokenOut: { + symbol: "USDC", + amount: "2.600807", + address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + }, + }); +}); diff --git a/src/utils/index.ts b/src/utils/index.ts index 9057ba5..20546ed 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,6 +1,12 @@ import { fromHex, erc20Abi, getAddress, formatUnits, formatEther } from "viem"; +import { NATIVE_SYMBOL_BY_CHAIN_ID, NATIVE_TOKEN_ADDRESS } from "../constants"; import type { Address } from "viem"; -import type { Trace, EnrichLogsArgs, SupportedChainId } from "../types"; +import type { + Trace, + EnrichedLog, + EnrichLogsArgs, + SupportedChainId, +} from "../types"; export function isChainIdSupported( chainId: number @@ -8,21 +14,31 @@ export function isChainIdSupported( return [1, 10, 56, 137, 8453, 42161, 43114].includes(chainId); } -export function extractNativeTransfer(trace: Trace, recipient: Address) { +export function calculateNativeTransfer( + trace: Trace, + options: { recipient: Address; direction?: "to" | "from" } +): string { + const { recipient, direction = "to" } = options; let totalTransferred = 0n; + const recipientLower = recipient.toLowerCase(); + + function processCall(call: Trace) { + if (!call.value) return; + + const relevantAddress = direction === "from" ? call.from : call.to; + + if (relevantAddress.toLowerCase() === recipientLower) { + totalTransferred += fromHex(call.value, "bigint"); + } + } function traverseCalls(calls: Trace[]) { - calls.forEach((call) => { - if ( - call.to.toLowerCase() === recipient.toLowerCase() && - fromHex(call.value, "bigint") > 0n - ) { - totalTransferred = totalTransferred + fromHex(call.value, "bigint"); - } + for (const call of calls) { + processCall(call); if (call.calls && call.calls.length > 0) { traverseCalls(call.calls); } - }); + } } traverseCalls(trace.calls); @@ -44,13 +60,15 @@ export async function transferLogs({ }[] > { const EVENT_SIGNATURES = { - Transfer: - "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + Transfer: `0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef`, } as const; - const { logs } = transactionReceipt; - const transferLogsAddresses = logs + const transferLogsAddresses = transactionReceipt.logs .filter((log) => log.topics[0] === EVENT_SIGNATURES.Transfer) - .map((log) => ({ ...log, address: getAddress(log.address) })); + .map((log) => ({ + ...log, + address: getAddress(log.address), + })); + const contracts = [ ...transferLogsAddresses.map((log) => ({ abi: erc20Abi, @@ -86,3 +104,77 @@ export async function transferLogs({ function convertHexToAddress(hexString: string): string { return `0x${hexString.slice(-40)}`; } + +export function parseSmartContractWalletTx({ + logs, + trace, + chainId, + smartContractWallet, +}: { + logs: EnrichedLog[]; + trace: Trace; + chainId: SupportedChainId; + smartContractWallet: Address; +}) { + const transfersLogsFromSmartContractWallet = logs.reduce<{ + output?: EnrichedLog; + input?: EnrichedLog; + }>((acc, curr) => { + if (curr.to === smartContractWallet) return { ...acc, output: curr }; + if (curr.from === smartContractWallet) return { ...acc, input: curr }; + return acc; + }, {}); + + let { input, output } = transfersLogsFromSmartContractWallet; + + const nativeAmountToTaker = calculateNativeTransfer(trace, { + recipient: smartContractWallet, + }); + + const nativeAmountFromTaker = calculateNativeTransfer(trace, { + recipient: smartContractWallet, + direction: "from", + }); + + if (!output && nativeAmountToTaker !== "0") { + return { + tokenIn: { + address: input?.address, + amount: input?.amount, + symbol: input?.symbol, + }, + tokenOut: { + address: NATIVE_TOKEN_ADDRESS, + amount: nativeAmountToTaker, + symbol: NATIVE_SYMBOL_BY_CHAIN_ID[chainId], + }, + }; + } else if (!input && nativeAmountFromTaker !== "0") { + const inputLog = logs.filter((log) => log.symbol === "WETH")[0]; + return { + tokenIn: { + address: NATIVE_TOKEN_ADDRESS, + amount: inputLog?.amount, + symbol: NATIVE_SYMBOL_BY_CHAIN_ID[chainId], + }, + tokenOut: { + address: output?.address, + amount: output?.amount, + symbol: output?.symbol, + }, + }; + } else { + return { + tokenIn: { + address: input?.address, + amount: input?.amount, + symbol: input?.symbol, + }, + tokenOut: { + address: output?.address, + amount: output?.amount, + symbol: output?.symbol, + }, + }; + } +}