// Decode Uniswap Universal Router execute() calldata into human-readable // swap details. Designed to be extended with other DEX decoders later. const { Interface, AbiCoder, getBytes, formatUnits } = require("ethers"); const { TOKEN_BY_ADDRESS } = require("./tokenList"); const coder = AbiCoder.defaultAbiCoder(); const ROUTER_IFACE = new Interface([ "function execute(bytes commands, bytes[] inputs, uint256 deadline)", ]); // Universal Router command IDs (lower 5 bits of each command byte) const COMMAND_NAMES = { 0x00: "V3 Swap (Exact In)", 0x01: "V3 Swap (Exact Out)", 0x02: "Permit2 Transfer", 0x03: "Permit2 Permit Batch", 0x04: "Sweep", 0x05: "Transfer", 0x06: "Pay Portion", 0x08: "V2 Swap (Exact In)", 0x09: "V2 Swap (Exact Out)", 0x0a: "Permit2 Permit", 0x0b: "Wrap ETH", 0x0c: "Unwrap WETH", 0x0d: "Permit2 Transfer Batch", 0x0e: "Balance Check", 0x10: "V4 Swap", 0x11: "V3 Position Mgr Permit", 0x12: "V3 Position Mgr Call", 0x13: "V4 Initialize Pool", 0x14: "V4 Position Mgr Call", 0x21: "Execute Sub-Plan", }; function formatAmount(raw, decimals) { const parts = formatUnits(raw, decimals).split("."); if (parts.length === 1) return parts[0] + ".0000"; const dec = (parts[1] + "0000").slice(0, 4); return parts[0] + "." + dec; } function tokenInfo(address) { if (!address || address === "0x0000000000000000000000000000000000000000") { return { symbol: "ETH", decimals: 18, address: null }; } const t = TOKEN_BY_ADDRESS.get(address.toLowerCase()); if (t) return { symbol: t.symbol, decimals: t.decimals, address }; return { symbol: null, decimals: 18, address }; } // Decode PERMIT2_PERMIT (command 0x0a) input bytes. // ABI: ((address token, uint160 amount, uint48 expiration, uint48 nonce), // address spender, uint256 sigDeadline), bytes signature function decodePermit2(input) { try { const d = coder.decode( [ "tuple(tuple(address,uint160,uint48,uint48),address,uint256)", "bytes", ], input, ); return { token: d[0][0][0], amount: d[0][0][1], spender: d[0][1] }; } catch { return null; } } // Decode BALANCE_CHECK_ERC20 (command 0x0e) input bytes. // ABI: (address owner, address token, uint256 minBalance) function decodeBalanceCheck(input) { try { const d = coder.decode(["address", "address", "uint256"], input); return { owner: d[0], token: d[1], minBalance: d[2] }; } catch { return null; } } // Decode V2_SWAP_EXACT_IN (command 0x08) input bytes. // ABI: (address recipient, uint256 amountIn, uint256 amountOutMin, // address[] path, bool payerIsUser) function decodeV2SwapExactIn(input) { try { const d = coder.decode( ["address", "uint256", "uint256", "address[]", "bool"], input, ); return { amountIn: d[1], amountOutMin: d[2], tokenIn: d[3][0], tokenOut: d[3][d[3].length - 1], }; } catch { return null; } } // Decode V2_SWAP_EXACT_OUT (command 0x09) input bytes. // ABI: (address recipient, uint256 amountOut, uint256 amountInMax, // address[] path, bool payerIsUser) function decodeV2SwapExactOut(input) { try { const d = coder.decode( ["address", "uint256", "uint256", "address[]", "bool"], input, ); return { amountOut: d[1], amountInMax: d[2], tokenIn: d[3][0], tokenOut: d[3][d[3].length - 1], }; } catch { return null; } } // Decode V3 swap path (packed: token(20) + fee(3) + token(20) ...) function decodeV3Path(pathHex) { const hex = pathHex.startsWith("0x") ? pathHex.slice(2) : pathHex; if (hex.length < 40) return null; const tokenIn = "0x" + hex.slice(0, 40); const tokenOut = "0x" + hex.slice(-40); return { tokenIn, tokenOut }; } // Decode V3_SWAP_EXACT_IN (command 0x00) input bytes. // ABI: (address recipient, uint256 amountIn, uint256 amountOutMin, // bytes path, bool payerIsUser) function decodeV3SwapExactIn(input) { try { const d = coder.decode( ["address", "uint256", "uint256", "bytes", "bool"], input, ); const path = decodeV3Path(d[3]); if (!path) return null; return { amountIn: d[1], amountOutMin: d[2], tokenIn: path.tokenIn, tokenOut: path.tokenOut, }; } catch { return null; } } // Decode WRAP_ETH (command 0x0b) input bytes. // ABI: (address recipient, uint256 amount) function decodeWrapEth(input) { try { const d = coder.decode(["address", "uint256"], input); return { amount: d[1] }; } catch { return null; } } // Try to decode a Universal Router execute() call. // Returns { name, description, details } matching the format used by // the approval UI, or null if the calldata is not a recognised execute(). function decode(data, toAddress) { try { const parsed = ROUTER_IFACE.parseTransaction({ data }); if (!parsed) return null; const commandsBytes = getBytes(parsed.args[0]); const inputs = parsed.args[1]; const deadline = parsed.args[2]; let inputToken = null; let inputAmount = null; let outputToken = null; let minOutput = null; let hasUnwrapWeth = false; const commandNames = []; for (let i = 0; i < commandsBytes.length; i++) { const cmdId = commandsBytes[i] & 0x1f; commandNames.push( COMMAND_NAMES[cmdId] || "Command 0x" + cmdId.toString(16).padStart(2, "0"), ); try { if (cmdId === 0x0a) { const p = decodePermit2(inputs[i]); if (p) { inputToken = p.token; inputAmount = p.amount; } } if (cmdId === 0x0e) { const b = decodeBalanceCheck(inputs[i]); if (b) { outputToken = b.token; minOutput = b.minBalance; } } if (cmdId === 0x00) { const s = decodeV3SwapExactIn(inputs[i]); if (s) { if (!inputToken) inputToken = s.tokenIn; if (!outputToken) outputToken = s.tokenOut; if (!inputAmount) inputAmount = s.amountIn; if (!minOutput) minOutput = s.amountOutMin; } } if (cmdId === 0x08) { const s = decodeV2SwapExactIn(inputs[i]); if (s) { if (!inputToken) inputToken = s.tokenIn; if (!outputToken) outputToken = s.tokenOut; if (!inputAmount) inputAmount = s.amountIn; if (!minOutput) minOutput = s.amountOutMin; } } if (cmdId === 0x0b) { const w = decodeWrapEth(inputs[i]); if (w && !inputToken) { inputToken = "0x0000000000000000000000000000000000000000"; inputAmount = w.amount; } } if (cmdId === 0x0c) { hasUnwrapWeth = true; } } catch { // Skip commands we can't decode } } // Resolve token info const inInfo = tokenInfo(inputToken); const outInfo = hasUnwrapWeth ? { symbol: "ETH", decimals: 18, address: null } : tokenInfo(outputToken); const inSymbol = inInfo.symbol; const outSymbol = outInfo.symbol; const name = inSymbol && outSymbol ? "Swap " + inSymbol + " \u2192 " + outSymbol : "Uniswap Swap"; const details = []; details.push({ label: "Protocol", value: "Uniswap Universal Router", address: toAddress, }); if (inputToken && inInfo.address) { const label = inSymbol ? inSymbol + " (" + inputToken + ")" : inputToken; details.push({ label: "Token In", value: label, address: inputToken, isToken: true, }); } else if (inSymbol === "ETH") { details.push({ label: "Token In", value: "ETH (native)" }); } if (inputAmount !== null && inputAmount !== undefined) { const maxUint160 = BigInt( "0xffffffffffffffffffffffffffffffffffffffff", ); const amountStr = inputAmount >= maxUint160 ? "Unlimited" : formatAmount(inputAmount, inInfo.decimals) + (inSymbol ? " " + inSymbol : ""); details.push({ label: "Amount", value: amountStr }); } if (outSymbol) { if (outInfo.address) { const label = outSymbol ? outSymbol + " (" + outputToken + ")" : outputToken; details.push({ label: "Token Out", value: label, address: outputToken, isToken: true, }); } else { details.push({ label: "Token Out", value: outSymbol }); } } if (minOutput !== null && minOutput !== undefined) { const minStr = formatAmount(minOutput, outInfo.decimals) + (outSymbol ? " " + outSymbol : ""); details.push({ label: "Min. received", value: minStr }); } details.push({ label: "Steps", value: commandNames.join(" \u2192 ") }); const deadlineDate = new Date(Number(deadline) * 1000); details.push({ label: "Deadline", value: deadlineDate.toISOString().replace("T", " ").slice(0, 19), }); return { name, description: "Swap via Uniswap Universal Router", details, }; } catch { return null; } } module.exports = { decode };