diff --git a/src/index.ts b/src/index.ts index 8fa7275..64c6c6d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -87,3 +87,8 @@ export async function parseSwap({ callData: transaction.input, }); } + +/** + * This function is specific to 0x API transactions on Settler. + */ +export { parseSwapV2 } from "./parseSwapV2"; diff --git a/src/parseSwapV2.ts b/src/parseSwapV2.ts new file mode 100644 index 0000000..9315e78 --- /dev/null +++ b/src/parseSwapV2.ts @@ -0,0 +1,140 @@ +import { erc20Abi, getAddress, formatUnits } from "viem"; +import type { + Chain, + Address, + Transport, + PublicClient, + TransactionReceipt, +} from "viem"; + +const NATIVE_TOKEN_ADDRESS = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"; + +type SupportedChainId = 1 | 8453; + +export const NATIVE_SYMBOL_BY_CHAIN_ID: { [key in SupportedChainId]: string } = + { + 1: "ETH", // Ethereum + 8453: "ETH", // Base + }; + +export function isChainIdSupported( + chainId: number +): chainId is SupportedChainId { + return [1, 10, 56, 137, 8453, 42161].includes(chainId); +} + +export interface EnrichLogsArgs { + transactionReceipt: TransactionReceipt; + publicClient: PublicClient; +} +export interface EnrichedLog { + to: Address; + from: Address; + symbol: string; + amount: string; + address: Address; + decimals: number; +} + +export async function transferLogs({ + publicClient, + transactionReceipt, +}: EnrichLogsArgs): Promise { + const EVENT_SIGNATURES = { + Transfer: + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + } as const; + const { logs } = transactionReceipt; + const transferLogsAddresses = logs + .filter((log) => log.topics[0] === EVENT_SIGNATURES.Transfer) + .map((log) => ({ ...log, address: getAddress(log.address) })); + const contracts = [ + ...transferLogsAddresses.map((log) => ({ + abi: erc20Abi, + address: log.address, + functionName: "symbol", + })), + ...transferLogsAddresses.map((log) => ({ + abi: erc20Abi, + address: log.address, + functionName: "decimals", + })), + ]; + const results = await publicClient.multicall({ contracts }); + const midpoint = Math.floor(results.length / 2); + const enrichedLogs = transferLogsAddresses + .map((log, index) => { + const symbol = results[index].result as string; + const decimals = results[midpoint + index].result as number; + const amount = + log.data === "0x" ? "0" : formatUnits(BigInt(log.data), decimals); + const { address, topics } = log; + const { 1: fromHex, 2: toHex } = topics; + const from = getAddress(convertHexToAddress(fromHex)); + const to = getAddress(convertHexToAddress(toHex)); + + return { to, from, symbol, amount, address, decimals }; + }) + .filter((log) => log.amount !== "0"); + + return enrichedLogs; +} + +function convertHexToAddress(hexString: string): string { + return `0x${hexString.slice(-40)}`; +} + +export async function parseSwapV2({ + publicClient, + transactionHash, +}: { + 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 isNativeSell = value > 0n; + const logs = await transferLogs({ + publicClient, + transactionReceipt, + }); + + const input = logs[0]; + const output = logs[logs.length - 1]; + if (isNativeSell) { + const sellAmount = formatUnits(value, 18); + return { + tokenIn: { + symbol: NATIVE_SYMBOL_BY_CHAIN_ID[chainId], + amount: sellAmount, + address: NATIVE_TOKEN_ADDRESS, + }, + tokenOut: { + symbol: output.symbol, + amount: output.amount, + address: output.address, + }, + }; + } + return { + tokenIn: { + symbol: input.symbol, + amount: input.amount, + address: input.address, + }, + tokenOut: { + symbol: output.symbol, + amount: output.amount, + address: output.address, + }, + }; +} diff --git a/src/tests/parseSwapV2.test.ts b/src/tests/parseSwapV2.test.ts new file mode 100644 index 0000000..f60b4be --- /dev/null +++ b/src/tests/parseSwapV2.test.ts @@ -0,0 +1,202 @@ +import { http, createPublicClient } from "viem"; +import { mainnet } from "viem/chains"; +import { test, expect } from "vitest"; +import { parseSwapV2 } from "../index"; + +require("dotenv").config(); + +if (!process.env.ETH_MAINNET_RPC) { + throw new Error("An RPC URL required."); +} + +const publicClient = createPublicClient({ + chain: mainnet, + transport: http(process.env.ETH_MAINNET_RPC), +}); + +// https://etherscan.io/tx/0x2b9a12398613887e9813594e8583f488f0e8392d8e6e0ba8d9e140065826dd00 +test("parses swapped amounts case 1 (default)", async () => { + const transactionHash = + "0x2b9a12398613887e9813594e8583f488f0e8392d8e6e0ba8d9e140065826dd00"; + + const result = await parseSwapV2({ + publicClient, + transactionHash, + }); + + expect(result).toEqual({ + tokenIn: { + symbol: "USDC", + amount: "30.084159", + address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + }, + tokenOut: { + symbol: "USDT", + amount: "30.069172", + address: "0xdAC17F958D2ee523a2206206994597C13D831ec7", + }, + }); +}); + +// https://etherscan.io/tx/0x76b744ab42b05b30624bd5027b4f7da841cdc357bb1d6ee74e3d9e049dd8a126 +test("parses swapped amounts case 2 (default)", async () => { + const transactionHash = + "0x76b744ab42b05b30624bd5027b4f7da841cdc357bb1d6ee74e3d9e049dd8a126"; + + const result = await parseSwapV2({ + publicClient, + transactionHash, + }); + + expect(result).toEqual({ + tokenIn: { + symbol: "USDC", + amount: "1", + address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + }, + tokenOut: { + symbol: "WETH", + amount: "0.000280757770903965", + address: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + }, + }); +}); + +// https://etherscan.io/tx/0x565e8e0582b620ee06618ee0b7705dc0e7f56dfd88b5eb3e008c0858f6f806d8 +test("parses swapped amounts case 3 (default)", async () => { + const transactionHash = + "0x565e8e0582b620ee06618ee0b7705dc0e7f56dfd88b5eb3e008c0858f6f806d8"; + + const result = await parseSwapV2({ + publicClient, + transactionHash, + }); + + expect(result).toEqual({ + tokenIn: { + symbol: "USDC", + amount: "8.15942", + address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + }, + tokenOut: { + symbol: "WBTC", + amount: "0.00013188", + address: "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", + }, + }); +}); + +// https://etherscan.io/tx/0xd024750c7dcb99ace02c6b083c68d73dcfebdee252ccbeb1b83981b609693271 +test("parses swapped amounts case 4 (default)", async () => { + const transactionHash = + "0xd024750c7dcb99ace02c6b083c68d73dcfebdee252ccbeb1b83981b609693271"; + + const result = await parseSwapV2({ + publicClient, + transactionHash, + }); + + expect(result).toEqual({ + tokenIn: { + symbol: "USDC", + amount: "335.142587", + address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + }, + tokenOut: { + symbol: "WETH", + amount: "0.105662100963455883", + address: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + }, + }); +}); + +// https://etherscan.io/tx/0x4cbcf2e2512adb7e28f19f8cf28ddc29a9f9fea93c842cf3b735eeb526fe34b3 +test("parses swapped amounts case 5 (native sell token)", async () => { + const transactionHash = + "0x4cbcf2e2512adb7e28f19f8cf28ddc29a9f9fea93c842cf3b735eeb526fe34b3"; + + const result = await parseSwapV2({ + publicClient, + transactionHash, + }); + + expect(result).toEqual({ + tokenIn: { + symbol: "ETH", + amount: "0.04", + address: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + }, + tokenOut: { + symbol: "USDC", + amount: "126.580558", + address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + }, + }); +}); + +// https://etherscan.io/tx/0x28c5bb3768bb64e81e1f3753ed1a8c30f0484a434d6c2b4af825d258ecb3bcf0 +test("parses swapped amounts case 6 (buy DNT 404 token)", async () => { + const transactionHash = + "0x28c5bb3768bb64e81e1f3753ed1a8c30f0484a434d6c2b4af825d258ecb3bcf0"; + + const result = await parseSwapV2({ + publicClient, + transactionHash, + }); + + expect(result).toEqual({ + tokenIn: { + symbol: "USDC", + amount: "95", + address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + }, + tokenOut: { + symbol: "CHIB", + amount: "10.527633901274097318", + address: "0x7068263EDa099fB93BB3215c05e728c0b54b3137", + }, + }); +}); + +// https://etherscan.io/tx/0xb8beef6bf857f2fc22905b2872120abc634900b45941478aa9cf0ad1ceffcd67 +// https://gopluslabs.io/token-security/1/0xcf0c122c6b73ff809c693db761e7baebe62b6a2e +test("parses swapped amounts case 6 (buy FoT token, FLOKI)", async () => { + const transactionHash = + "0xb8beef6bf857f2fc22905b2872120abc634900b45941478aa9cf0ad1ceffcd67"; + + const result = await parseSwapV2({ + publicClient, + transactionHash, + }); + + expect(result).toEqual({ + tokenIn: { + symbol: "USDC", + amount: "31.580558", + address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + }, + tokenOut: { + symbol: "FLOKI", + amount: "172036.330384861", + address: "0xcf0C122c6b73ff809C693DB761e7BaeBe62b6a2E", + }, + }); +}); + +// https://explorer.celo.org/tx/0x615c5089f772a8f2074750e8c6070013d288af7681435aba1771f6bfc63d1286 +test("throws an error for unsupported chains)", async () => { + const transactionHash = + "0x615c5089f772a8f2074750e8c6070013d288af7681435aba1771f6bfc63d1286"; + + const publicClient = createPublicClient({ + chain: mainnet, + transport: http("https://rpc.ankr.com/celo"), + }); + + expect(async () => { + await parseSwapV2({ + publicClient, + transactionHash, + }); + }).rejects.toThrowError("chainId 42220 is unsupported…"); +});