// 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 { truncateAmountNeverZero } = require("./amountDisplay"); const { resolveTokenDecimals, unknownDecimalsAmount, } = require("./approvalAmount"); 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", }; // The swap's Amount and Min. received lines land on the same approval screen, // and Amount is carried to the wait/success/error screens as the ERC-20 line // is, so they take the same nonzero floor: a swap of an amount below 0.0001 is // not "0.0000", and a slippage floor of one base unit does not read as "you may // receive nothing". function formatAmount(raw, decimals) { return truncateAmountNeverZero(formatUnits(raw, decimals)); } // One wording for either side of the screen: a currency the calldata never // named. It reads as a refusal, the same stance unknownDecimalsAmount() takes // on a scale — not as a token name, and not as a quantity. const UNNAMED_CURRENCY = "Unknown (not named in the calldata)"; // Explicit presence, never truthiness. Every gate in this file that guards a // decoded value goes through here: an address is never falsy once set, but an // amount of 0n is, and a gate that cannot tell a genuine zero from an absent // value is the trap this decoder has now been bitten by five times. function present(value) { return value !== null && value !== undefined; } // Uniswap V4 spells "use the whole open delta" as an amount of zero: // v4-periphery `src/libraries/ActionConstants.sol` declares // `uint128 internal constant OPEN_DELTA = 0` ("used to signal that an action // should use the input value of the open delta on the pool manager or of the // balance that the contract holds"), and `src/V4Router.sol` substitutes the // full open credit whenever an exact-in swap action's `amountIn` equals it: // // uint128 amountIn = params.amountIn; // if (amountIn == ActionConstants.OPEN_DELTA) { // amountIn = _getFullCredit(...).toUint128(); // } // // in both `_swapExactInputSingle` and `_swapExactInput`. Sentinel and literal // zero are the same uint128 word, so the encoding CANNOT distinguish them — // and the router does not try: it reads every zero as the sentinel, so in V4 // there is no such thing as an exact-in swap of literally zero. The amount is // therefore not stated by the calldata at all; it is whatever credit is open // at execution time. It is carried as this sentinel rather than as 0n because // printing "0.0000" would state the exact inverse of what will happen — // "nothing is being swapped" for a step that swaps the entire balance. // // `amountOutMinimum` gets no such mapping: V4Router compares it directly // (`if (amountOut < params.amountOutMinimum) revert V4TooLittleReceived`), so // a zero minimum is a literal zero slippage floor and is stated as one. Nor do // the V2/V3 paths have it — universal-router's `V3SwapRouter.v3SwapExactInput` // special-cases only `ActionConstants.CONTRACT_BALANCE` (1<<255), never zero — // so a zero `amountIn` there is a literal zero and is displayed as one. const OPEN_DELTA = Symbol("v4-open-delta"); // The two amount lines that state a fact instead of a quantity. Same register // as UNNAMED_CURRENCY — a sentence in the value slot, so it cannot be misread // as a number — and deliberately not a third phrasing of "not named": these // say different things. const OPEN_DELTA_AMOUNT = "All available (V4 open delta)"; const NO_MINIMUM = "None (no minimum guaranteed)"; // Permit2 amounts are uint160; the maximum is Permit2's "unbounded". const MAX_UINT160 = BigInt("0xffffffffffffffffffffffffffffffffffffffff"); // `decimals` is null when nothing knows this token's scale. It is not // defaulted to 18: the swap lines land on the same approval screen as the // ERC-20 line, and a scale guessed there is what showed a 1,000 USDT swap as // 0.000000000001. `sources` is { trackedTokens, wallets }, shaped as they are // on `state`; resolveTokenDecimals() reads the bundled list, then those. // // A null `address` means UNDETERMINED — the calldata named no currency for // that side — and is refused rather than named. It is not native ETH: Uniswap // V4 spells native ETH as `Currency.wrap(address(0))` (v4-core // `type Currency is address`), and a Currency is ABI-encoded as a plain // address word, so every decode site here gets back the truthy string // "0x0000000000000000000000000000000000000000" for it — never null. WRAP_ETH // sets that same explicit zero address, and an UNWRAP_WETH output is caught by // its caller before this is consulted, so nothing that genuinely is ETH // arrives null. Naming a null ETH states the wrong asset and formats its // amount at the wrong scale. function tokenInfo(address, sources) { if (!address) { return { symbol: null, decimals: null, address: null }; } if (address === "0x0000000000000000000000000000000000000000") { return { symbol: "ETH", decimals: 18, address: null }; } const t = TOKEN_BY_ADDRESS.get(address.toLowerCase()); return { symbol: t ? t.symbol : null, decimals: resolveTokenDecimals(address, sources), address, }; } // A swap amount line. With a scale it is the token quantity; with none it is // the base-unit integer with the unknown scale stated, the same refusal the // ERC-20 amount line makes, so the screen has one way of saying it. `display` // is the line on the screen, `raw` is what the status screens carry. function amountText(raw, info) { if (info.decimals === null) { const unknown = unknownDecimalsAmount(raw); return { raw: unknown, display: unknown }; } const formatted = formatAmount(raw, info.decimals); return { raw: formatted, display: formatted + (info.symbol ? " " + info.symbol : ""), }; } // 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) // // Nothing calls this: decode() has no 0x09 arm, so a V2 exact-out swap gets // its command name and no token or amount detail. Kept for the fix, which is // https://git.eeqj.de/sneak/AutistMask/issues/283. // eslint-disable-next-line no-unused-vars 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; } } // V4 inner action IDs const V4_SWAP_EXACT_IN_SINGLE = 0x06; const V4_SWAP_EXACT_IN = 0x07; const V4_SWAP_EXACT_OUT_SINGLE = 0x08; const V4_SWAP_EXACT_OUT = 0x09; const V4_SETTLE = 0x0b; const V4_TAKE = 0x0e; // A V4 exact-in `amountIn`, read the way V4Router reads it: zero is // ActionConstants.OPEN_DELTA, not a quantity of zero. See the OPEN_DELTA // comment above. The exact-OUT actions below decode no amounts at all, so // their own OPEN_DELTA mapping on `amountOut` never reaches the screen. function v4ExactInAmount(raw) { return raw === 0n ? OPEN_DELTA : raw; } // Decode V4_SWAP (command 0x10) input bytes. // The input is ABI-encoded as (bytes actions, bytes[] params). // We extract token addresses from SETTLE (input) and TAKE (output) sub-actions, // and swap amounts from the swap sub-actions. function decodeV4Swap(input) { try { const d = coder.decode(["bytes", "bytes[]"], input); const actions = getBytes(d[0]); const params = d[1]; let settleToken = null; let takeToken = null; let amountIn = null; let amountOutMin = null; for (let i = 0; i < actions.length; i++) { const actionId = actions[i]; try { if (actionId === V4_SETTLE) { // SETTLE: (address currency, uint256 maxAmount, bool payerIsUser) const s = coder.decode( ["address", "uint256", "bool"], params[i], ); settleToken = s[0]; } else if (actionId === V4_TAKE) { // TAKE: (address currency, address recipient, uint256 amount) const t = coder.decode( ["address", "address", "uint256"], params[i], ); takeToken = t[0]; } else if ( actionId === V4_SWAP_EXACT_IN || actionId === V4_SWAP_EXACT_IN_SINGLE ) { // Extract amounts from exact-in swap actions if (actionId === V4_SWAP_EXACT_IN) { // ExactInputParams: (address currencyIn, // tuple(address,uint24,int24,address,bytes)[] path, // uint128 amountIn, uint128 amountOutMin) try { const s = coder.decode( [ "tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)", ], params[i], ); if (!present(settleToken)) settleToken = s[0][0]; const path = s[0][1]; if (path.length > 0 && !present(takeToken)) { takeToken = path[path.length - 1][0]; } if (!present(amountIn)) { amountIn = v4ExactInAmount(s[0][2]); } if (!present(amountOutMin)) { amountOutMin = s[0][3]; } } catch { // Fall through — SETTLE/TAKE will provide tokens } } else { // ExactInputSingleParams: (tuple(address,address,uint24,int24,address) poolKey, // bool zeroForOne, uint128 amountIn, uint128 amountOutMin, bytes hookData) try { const s = coder.decode( [ "tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)", ], params[i], ); const poolKey = s[0][0]; const zeroForOne = s[0][1]; if (!present(settleToken)) settleToken = zeroForOne ? poolKey[0] : poolKey[1]; if (!present(takeToken)) takeToken = zeroForOne ? poolKey[1] : poolKey[0]; if (!present(amountIn)) { amountIn = v4ExactInAmount(s[0][2]); } if (!present(amountOutMin)) { amountOutMin = s[0][3]; } } catch { // Fall through } } } else if ( actionId === V4_SWAP_EXACT_OUT || actionId === V4_SWAP_EXACT_OUT_SINGLE ) { if (actionId === V4_SWAP_EXACT_OUT) { try { const s = coder.decode( [ "tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)", ], params[i], ); if (!present(takeToken)) takeToken = s[0][0]; const path = s[0][1]; if (path.length > 0 && !present(settleToken)) { settleToken = path[path.length - 1][0]; } } catch { // Fall through } } else { try { const s = coder.decode( [ "tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)", ], params[i], ); const poolKey = s[0][0]; const zeroForOne = s[0][1]; if (!present(settleToken)) settleToken = zeroForOne ? poolKey[0] : poolKey[1]; if (!present(takeToken)) takeToken = zeroForOne ? poolKey[1] : poolKey[0]; } catch { // Fall through } } } } catch { // Skip sub-actions we can't decode } } return { tokenIn: settleToken, tokenOut: takeToken, amountIn, amountOutMin, }; } 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, sources) { 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 inputEstablished = false; let outputToken = null; let minOutput = null; let hasUnwrapWeth = false; const commandNames = []; // THE INVARIANT: an amount and the token it is counted in always come // from the same hop. A figure is never rendered against a token that // did not supply it. // // Both sides are therefore set as a PAIR — never field by field, and // never on truthiness. An address is never falsy once set but an // amount of 0n is, so gating the two halves independently let a hop // with a zero amount fix the token and leave the amount open; the next // hop's figure was then displayed against the first hop's token, at the // first hop's scale. A V3 USDT->WETH hop with amountIn 0 followed by a // V2 WETH->USDC hop of 0.5e18 rendered "500000000000.0000 USDT". // // The input side is fixed by the first hop that states either half, the // output side by the last, because the final leg is what the user // receives. A half the establishing hop did not state stays null and // the line says so, rather than being filled in from a different hop. const setInput = (token, amount) => { inputToken = present(token) ? token : null; inputAmount = present(amount) ? amount : null; inputEstablished = true; }; const setInputOnce = (token, amount) => { if (inputEstablished) return; if (!present(token) && !present(amount)) return; setInput(token, amount); }; const setOutput = (token, amount) => { if (!present(token) && !present(amount)) return; outputToken = present(token) ? token : null; minOutput = present(amount) ? amount : null; }; 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]); // A permit states both halves itself, so it may replace an // input side an earlier hop established without breaking // the invariant. if (p) setInput(p.token, p.amount); } if (cmdId === 0x0e) { const b = decodeBalanceCheck(inputs[i]); if (b) setOutput(b.token, b.minBalance); } if (cmdId === 0x00) { const s = decodeV3SwapExactIn(inputs[i]); if (s) { setInputOnce(s.tokenIn, s.amountIn); // Always update output: in multi-step swaps (V3 → V4), // the last swap step determines the final output token // and minimum received amount. setOutput(s.tokenOut, s.amountOutMin); } } if (cmdId === 0x08) { const s = decodeV2SwapExactIn(inputs[i]); if (s) { setInputOnce(s.tokenIn, s.amountIn); setOutput(s.tokenOut, s.amountOutMin); } } if (cmdId === 0x0b) { const w = decodeWrapEth(inputs[i]); if (w) { setInputOnce( "0x0000000000000000000000000000000000000000", w.amount, ); } } if (cmdId === 0x10) { const v4 = decodeV4Swap(inputs[i]); if (v4) { setInputOnce(v4.tokenIn, v4.amountIn); // Always update output: last swap step wins. A step // that carries the Min. received figure but decoded no // output currency makes the output *undetermined* — it // is neither ETH nor whatever an earlier step named, // and that figure is no longer counted in that token. // Equally, a step that names an output currency but no // minimum leaves Min. received unstated rather than // keeping an earlier step's figure beside the new // token. setOutput() is both rules; a step that states // neither half leaves the output alone. setOutput(v4.tokenOut, v4.amountOutMin); } } if (cmdId === 0x0c) { hasUnwrapWeth = true; } } catch { // Skip commands we can't decode } } // Resolve token info. A null token on either side means the calldata // named no currency for it; tokenInfo() refuses rather than calling it // ETH. UNWRAP_WETH is the one output that is ETH without a currency to // decode, and it is answered here rather than left to that rule. const inInfo = tokenInfo(inputToken, sources); const outInfo = hasUnwrapWeth ? { symbol: "ETH", decimals: 18, address: null } : tokenInfo(outputToken, sources); 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 (present(inputToken) && present(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)" }); } else { // Nothing established the input token, so the line says that // rather than going missing or naming a token by default. Same // wording as the Token Out refusal below: the two sides of this // screen must not describe the same condition in two ways. details.push({ label: "Token In", value: UNNAMED_CURRENCY }); } if (present(inputAmount)) { // Two amounts need no scale to describe and are named rather than // formatted: V4's open delta, which is not a quantity at all (see // OPEN_DELTA), and an unbounded permit. The open-delta test comes // first — the sentinel is not a bigint and cannot be compared with // one. let amount; if (inputAmount === OPEN_DELTA) { amount = { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT }; } else if (inputAmount >= MAX_UINT160) { amount = { raw: "Unlimited", display: "Unlimited" }; } else { amount = amountText(inputAmount, inInfo); } details.push({ label: "Amount", value: amount.display, rawValue: amount.raw, }); } // Keyed on the address, not the symbol: a token absent from the // bundled list has no symbol, and gating the line on one dropped it // entirely, leaving a Min. received figure with nothing saying what is // being received. The Token In line above already falls back to the // address; this does the same. if (present(outInfo.address)) { const label = outSymbol ? outSymbol + " (" + outInfo.address + ")" : outInfo.address; details.push({ label: "Token Out", value: label, address: outInfo.address, isToken: true, }); } else if (outSymbol) { details.push({ label: "Token Out", value: outSymbol }); } else { // Nothing established the output token, so the line says that // rather than going missing or naming a token by default, and a // Min. received figure below it is never attached to a token the // calldata did not state. details.push({ label: "Token Out", value: UNNAMED_CURRENCY }); } if (present(minOutput)) { // A zero floor is the one case the user most needs stated: the // swap guarantees nothing back. It is said in words, in the same // register as UNNAMED_CURRENCY, because "0.0000 WETH" reads as an // artifact of the four-decimal rule rather than as "this may // return nothing" — and because it is true at every scale, so it // holds even when the output token's decimals are unknown. details.push({ label: "Min. received", value: minOutput === 0n ? NO_MINIMUM : amountText(minOutput, outInfo).display, }); } 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 };