From 8b6c2b4df938fd936bdad82ba4a8972acdce2190 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Mon, 19 Oct 2026 04:40:55 +0200 Subject: [PATCH] fix: the swap summary is shown only for a transaction sent to a Universal Router (closes #520) The Universal Router decoder recognised execute() by its selector alone, so the same calldata sent to any address was described as a swap through the router. It now keeps the router deployments Uniswap publishes for Ethereum Mainnet and Sepolia, and decodes only a transaction sent to one of them on its network, ignoring address case. TxApproval passes the transaction's chain id, TransactionDetail the active network's. Model: opus-5-5 --- README.md | 12 +- src/popup/views/approval.js | 22 +- src/popup/views/transactionDetail.js | 9 +- src/shared/uniswap.js | 49 +++- tests/approvalDisplayFloor.test.js | 6 +- tests/fabricatedDecimals.test.js | 2 +- tests/uniswap.test.js | 91 +++---- tests/uniswapRouterAddress.test.js | 298 ++++++++++++++++++++++ tests/uniswapTokenOut.test.js | 2 +- tests/uniswapUndeterminedTokenIn.test.js | 14 +- tests/uniswapUndeterminedTokenOut.test.js | 8 +- tests/uniswapUnknownDecimals.test.js | 2 +- 12 files changed, 445 insertions(+), 70 deletions(-) create mode 100644 tests/uniswapRouterAddress.test.js diff --git a/README.md b/README.md index a7b69e0..5cd23cd 100644 --- a/README.md +++ b/README.md @@ -2880,10 +2880,14 @@ Currently supported: or recipient, and amount. - **Uniswap Universal Router**: `execute()` calls — shows swap direction (e.g. "Swap USDT → ETH"), token addresses, amounts, execution steps, and deadline. - Decodes Permit2, V2/V3/V4 swaps, wrap/unwrap, and balance checks. A V4 swap - action is read only in the parameter shape deployed on mainnet and Sepolia, - not in the newer one on `v4-periphery` `main`, which adds a `minHopPriceX36` - field. An action in any other shape is not read, and its step is listed as + The swap summary is shown only for a transaction sent to one of the Universal + Router addresses Uniswap publishes for the network: on TxApproval the network + the transaction is for, on TransactionDetail the active network. The same + calldata sent to any other address is shown as raw data. Decodes Permit2, + V2/V3/V4 swaps, wrap/unwrap, and balance checks. A V4 swap action is read only + in the parameter shape deployed on mainnet and Sepolia, not in the newer one + on `v4-periphery` `main`, which adds a `minHopPriceX36` field. An action in + any other shape is not read, and its step is listed as `V4 Swap (could not be decoded)`. ### Non-Goals Forever diff --git a/src/popup/views/approval.js b/src/popup/views/approval.js index 164e3be..79f2294 100644 --- a/src/popup/views/approval.js +++ b/src/popup/views/approval.js @@ -79,9 +79,10 @@ function tokenAmountText(rawAmount, decimals, symbol) { }; } -// Try to decode calldata using known ABIs. +// Try to decode calldata sent to `toAddress` on the network with chain id +// `chainId`, using known ABIs. // Returns { name, description, details } or null. -function decodeCalldata(data, toAddress) { +function decodeCalldata(data, toAddress, chainId) { if (!data || data === "0x" || data.length < 10) return null; // Where a token's scale is looked for, for every decoder below: the ERC-20 @@ -185,7 +186,12 @@ function decodeCalldata(data, toAddress) { } // Try Uniswap Universal Router - const routerResult = uniswap.decode(data, toAddress, decimalsSources); + const routerResult = uniswap.decode( + data, + toAddress, + chainId, + decimalsSources, + ); if (routerResult) return routerResult; return null; @@ -269,8 +275,14 @@ function showTxApproval(details) { chainId: approvedTx.chainId, }; - // If this is an ERC-20 call, try to extract the real recipient and amount - const decoded = decodeCalldata(approvedTx.data, toAddr || ""); + // If this is an ERC-20 call, try to extract the real recipient and amount. + // Decoded for the network the transaction is for, which the Network line + // names. + const decoded = decodeCalldata( + approvedTx.data, + toAddr || "", + approvedTx.chainId, + ); if (decoded && decoded.details) { // The asset the status summary is counted in: an ERC-20 call's Token // contract, or a swap's input token. Its symbol is resolved from the diff --git a/src/popup/views/transactionDetail.js b/src/popup/views/transactionDetail.js index 0c624be..004000d 100644 --- a/src/popup/views/transactionDetail.js +++ b/src/popup/views/transactionDetail.js @@ -21,7 +21,7 @@ const { displaySymbol, goBack, } = require("./helpers"); -const { state } = require("../../shared/state"); +const { state, currentNetwork } = require("../../shared/state"); const { nativeCurrencyByChainId } = require("../../shared/networks"); const { formatEther, formatUnits } = require("ethers"); const { log, debugFetch } = require("../../shared/log"); @@ -316,7 +316,12 @@ async function loadFullTxDetails(txHash, chainId) { // Decoded against the address the transaction was sent to, as the // explorer reports it, not the history entry's `to`: for a token // transfer that is the transfer's recipient, not the token contract. - const decoded = decodeCalldata(inputData, txData.to?.hash || ""); + // On the active network, the one whose explorer was just asked. + const decoded = decodeCalldata( + inputData, + txData.to?.hash || "", + currentNetwork().chainId, + ); if (decoded) { // Render decoded calldata matching approval view style actionEl.textContent = decoded.name; diff --git a/src/shared/uniswap.js b/src/shared/uniswap.js index d988b0a..fe9896a 100644 --- a/src/shared/uniswap.js +++ b/src/shared/uniswap.js @@ -8,6 +8,7 @@ const { resolveTokenSymbol, unknownDecimalsAmount, } = require("./approvalAmount"); +const { NETWORKS } = require("./networks"); const coder = AbiCoder.defaultAbiCoder(); @@ -15,6 +16,41 @@ const ROUTER_IFACE = new Interface([ "function execute(bytes commands, bytes[] inputs, uint256 deadline)", ]); +// The Universal Router deployments on each network, by chain id, copied from +// the deployment files Uniswap publishes, each address named by its key there. +// The files also list `UnsupportedProtocol`, which is not a router. +const ROUTER_ADDRESSES = new Map([ + [ + NETWORKS.mainnet.chainId, + // https://github.com/Uniswap/universal-router/blob/543e1a19d6e21e31ced2512eec5792b50f13a0ba/deploy-addresses/mainnet.json + [ + "0xEf1c6E67703c7BD7107eed8303Fbe6EC2554BF6B", // UniversalRouterV1 + "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", // UniversalRouterV1_2_V2Support + "0x66a9893cc07d91d95644aedd05d03f95e1dba8af", // UniversalRouterV2 + "0x4C82D1fBFe28C977cBB58D8C7FF8FCF9F70a2cCA", // UniversalRouterV2_1_1 + "0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85", // UniversalRouterV2_1_2 + ], + ], + [ + NETWORKS.sepolia.chainId, + // https://github.com/Uniswap/universal-router/blob/543e1a19d6e21e31ced2512eec5792b50f13a0ba/deploy-addresses/sepolia.json + [ + "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", // UniversalRouterV1_2_V2Support + "0x3a9d48ab9751398bbfa63ad67599bb04e4bdf98b", // UniversalRouterV2 + "0x7DfD4F31be6814D2906BDE155c3e1B146EAc1468", // UniversalRouterV2_1_1 + "0x7E4f6c5e954Da5c61B3423D81E2277431Ac043f3", // UniversalRouterV2_1_2 + ], + ], +]); + +// Whether `address` is a Universal Router deployment on the network with this +// chain id. Address case is ignored. +function isRouterAddress(address, chainId) { + const routers = ROUTER_ADDRESSES.get(chainId) || []; + const wanted = (address || "").toLowerCase(); + return routers.some((router) => router.toLowerCase() === wanted); +} + // Universal Router command IDs (lower 5 bits of each command byte) const COMMAND_NAMES = { 0x00: "V3 Swap (Exact In)", @@ -120,7 +156,7 @@ const NO_MINIMUM = "None (no minimum guaranteed)"; const MAX_UINT160 = BigInt("0xffffffffffffffffffffffffffffffffffffffff"); // WETH, the token UNWRAP_WETH turns into ETH: on mainnet, then on Sepolia. -// decode() is not told the network, so it takes either. +// Either one counts on either network. const WETH_ADDRESSES = [ "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", @@ -559,10 +595,15 @@ function decodeV4Swap(input) { } } -// Try to decode a Universal Router execute() call. +// Try to decode a Universal Router execute() call sent to `toAddress` on the +// network with chain id `chainId`. // 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) { +// the approval UI, or null if the calldata is not a recognised execute() or +// `toAddress` is not a Universal Router on that network: the same calldata +// sent to any other contract is not a swap through the router, whatever that +// contract does with it. +function decode(data, toAddress, chainId, sources) { + if (!isRouterAddress(toAddress, chainId)) return null; try { const parsed = ROUTER_IFACE.parseTransaction({ data }); if (!parsed) return null; diff --git a/tests/approvalDisplayFloor.test.js b/tests/approvalDisplayFloor.test.js index 03f617b..d131b3c 100644 --- a/tests/approvalDisplayFloor.test.js +++ b/tests/approvalDisplayFloor.test.js @@ -68,7 +68,11 @@ function swapData(amountIn, amountOutMin) { } function swapDetail(amountIn, amountOutMin, label) { - const decoded = uniswap.decode(swapData(amountIn, amountOutMin), ROUTER); + const decoded = uniswap.decode( + swapData(amountIn, amountOutMin), + ROUTER, + "0x1", + ); return decoded.details.find((d) => d.label === label); } diff --git a/tests/fabricatedDecimals.test.js b/tests/fabricatedDecimals.test.js index 24bc2db..6b0e92a 100644 --- a/tests/fabricatedDecimals.test.js +++ b/tests/fabricatedDecimals.test.js @@ -136,7 +136,7 @@ function erc20AmountLine(data, tokenAddress) { } function swapAmountLine(data) { - return decodeCalldata(data, ROUTER).details.find( + return decodeCalldata(data, ROUTER, "0x1").details.find( (d) => d.label === "Amount", ).value; } diff --git a/tests/uniswap.test.js b/tests/uniswap.test.js index 7d87934..199f1b2 100644 --- a/tests/uniswap.test.js +++ b/tests/uniswap.test.js @@ -1,6 +1,7 @@ const { AbiCoder, Interface, solidityPacked } = require("ethers"); const uniswap = require("../src/shared/uniswap"); +// A Universal Router on Ethereum Mainnet, chain id 0x1. const ROUTER_ADDR = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af"; const USDT_ADDR = "0xdAC17F958D2ee523a2206206994597C13D831ec7"; const WETH_ADDR = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"; @@ -315,13 +316,13 @@ function detail(result, label) { describe("uniswap decoder", () => { test("returns null for non-execute calldata", () => { - expect(uniswap.decode("0x", ROUTER_ADDR)).toBeNull(); - expect(uniswap.decode("0xdeadbeef", ROUTER_ADDR)).toBeNull(); - expect(uniswap.decode(null, ROUTER_ADDR)).toBeNull(); + expect(uniswap.decode("0x", ROUTER_ADDR, "0x1")).toBeNull(); + expect(uniswap.decode("0xdeadbeef", ROUTER_ADDR, "0x1")).toBeNull(); + expect(uniswap.decode(null, ROUTER_ADDR, "0x1")).toBeNull(); }); test("decodes first-ever AutistMask swap (PERMIT2_PERMIT + V4_SWAP)", () => { - const result = uniswap.decode(FIRST_SWAP_CALLDATA, ROUTER_ADDR); + const result = uniswap.decode(FIRST_SWAP_CALLDATA, ROUTER_ADDR, "0x1"); expect(result).not.toBeNull(); expect(result.name).toBe("Swap USDT \u2192 ETH"); @@ -361,6 +362,7 @@ describe("uniswap decoder", () => { const result = uniswap.decode( v4SwapCalldata(id, deployed), ROUTER_ADDR, + "0x1", ); expect(detail(result, "Steps").value).toBe("V4 Swap"); }, @@ -376,6 +378,7 @@ describe("uniswap decoder", () => { const result = uniswap.decode( v4SwapCalldata(id, newer), ROUTER_ADDR, + "0x1", ); expect(detail(result, "Amount")).toBeUndefined(); expect(detail(result, "Min. received")).toBeUndefined(); @@ -406,7 +409,7 @@ describe("uniswap decoder", () => { ], 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); expect(detail(result, "Steps").value).toBe( "V4 Swap (could not be decoded)", ); @@ -427,7 +430,7 @@ describe("uniswap decoder", () => { [encodeV4Swap(new Uint8Array([id]), [newer])], 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); expect(detail(result, "Steps").value).toBe( "V4 Swap (could not be decoded)", ); @@ -457,7 +460,7 @@ describe("uniswap decoder", () => { ], 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); expect(result.name).toBe("Uniswap Swap"); expect(detail(result, "Amount").value).toBe("2.0000 USDT"); expect(detail(result, "Token Out").value).toBe( @@ -485,7 +488,7 @@ describe("uniswap decoder", () => { ], 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); expect(result.name).toBe("Uniswap Swap"); expect(detail(result, "Token In").value).toBe( "Unknown (could not be decoded)", @@ -507,7 +510,7 @@ describe("uniswap decoder", () => { ], 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); expect(detail(result, "Token Out").value).toBe( "Unknown (could not be decoded)", ); @@ -527,7 +530,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); expect(result).not.toBeNull(); expect(result.name).toBe("Swap USDT \u2192 WETH"); @@ -554,7 +557,7 @@ describe("uniswap decoder", () => { 1767225600n, ); - const result = uniswap.decode(data, ROUTER_ADDR); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); expect(result.name).toBe("Swap USDC → WETH"); expect(detail(result, "Token In").address).toBe(USDC_ADDR); expect(detail(result, "Token Out").address).toBe(WETH_ADDR); @@ -586,7 +589,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); expect(result.name).toBe("Swap ETH → USDC"); const amount = detail(result, "Amount"); @@ -615,7 +618,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); expect(result.name).toBe("Swap USDC → ETH"); expect(detail(result, "Amount").value).toBe("Up to 1500.0000 USDC"); expect(detail(result, "Token Out").value).toBe("ETH"); @@ -658,7 +661,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); expect(detail(result, "Token Out").address).toBe(tokenOut); expect(detail(result, "Min. received").value).toBe(minReceived); }, @@ -679,7 +682,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); expect(result.name).toBe("Swap USDT → USDC"); expect(detail(result, "Token Out").address).toBe(USDC_ADDR); expect(detail(result, "Min. received").value).toBe("1.9000 USDC"); @@ -712,7 +715,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); expect(detail(result, "Token Out").value).toBe("ETH"); expect(detail(result, "Min. received").value).toBe("0.4000 ETH"); }); @@ -731,7 +734,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); expect(detail(result, "Token Out").value).toBe("ETH"); expect(detail(result, "Min. received").value).toBe("0.0005 ETH"); }); @@ -756,7 +759,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); expect(detail(result, "Token Out").value).toBe( "Unknown (not named in the calldata)", ); @@ -779,7 +782,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); expect(result).not.toBeNull(); expect(result.name).toBe("Swap USDT \u2192 WETH"); }); @@ -791,7 +794,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); expect(result).not.toBeNull(); const tokenIn = result.details.find((d) => d.label === "Token In"); @@ -814,7 +817,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); expect(result).not.toBeNull(); // UNWRAP_WETH means output is native ETH expect(result.name).toBe("Swap USDT \u2192 ETH"); @@ -830,7 +833,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); expect(result).not.toBeNull(); const minOut = result.details.find((d) => d.label === "Min. received"); @@ -850,7 +853,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); expect(result).not.toBeNull(); // The V4 swap action's parameters are empty, so that step is said to @@ -862,7 +865,7 @@ describe("uniswap decoder", () => { }); test("shows the deadline as a UTC date and time", () => { - const result = uniswap.decode(FIRST_SWAP_CALLDATA, ROUTER_ADDR); + const result = uniswap.decode(FIRST_SWAP_CALLDATA, ROUTER_ADDR, "0x1"); expect(detail(result, "Deadline").value).toBe("2026-02-27 08:25:51"); }); @@ -880,7 +883,7 @@ describe("uniswap decoder", () => { 2n ** 256n - 1n, ); - const result = uniswap.decode(data, ROUTER_ADDR); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); expect(result).not.toBeNull(); expect(result.name).toBe("Swap USDT \u2192 WETH"); expect(detail(result, "Deadline").value).toBe( @@ -895,7 +898,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); expect(result).not.toBeNull(); const amount = result.details.find((d) => d.label === "Amount"); @@ -970,7 +973,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); expect(result).not.toBeNull(); // Before decodeV4Swap fix: name would be "Swap USDT → ETH" or "Uniswap Swap" // After fix: correctly identifies both tokens from V4 sub-actions @@ -1004,7 +1007,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); expect(result).not.toBeNull(); // The amount and the token it is counted in come from the same hop. @@ -1044,7 +1047,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); expect(result).not.toBeNull(); expect(detail(result, "Token Out").value).toBe( @@ -1089,7 +1092,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); expect(result).not.toBeNull(); expect(detail(result, "Token Out").value).toContain("USDC"); @@ -1118,7 +1121,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); expect(result).not.toBeNull(); expect(detail(result, "Token Out").value).toContain("USDC"); @@ -1155,7 +1158,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); expect(result).not.toBeNull(); expect(detail(result, "Token In").value).toContain("WETH"); @@ -1348,7 +1351,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); expect(result.name).toBe("Swap USDT → USDC"); expect(detail(result, "Token In").address).toBe(USDT_ADDR); expect(detail(result, "Token Out").address).toBe(USDC_ADDR); @@ -1400,7 +1403,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); expect(detail(result, "Steps").value).toBe( "V4 Swap (could not be decoded)", ); @@ -1459,7 +1462,7 @@ describe("uniswap decoder", () => { ], 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); expect(result.name).toBe("Uniswap Swap"); expect(detail(result, "Token In").address).toBe( USDT_ADDR.toLowerCase(), @@ -1515,7 +1518,7 @@ describe("uniswap decoder", () => { ], 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); expect(result.name).toBe("Uniswap Swap"); expect(detail(result, "Token In").value).toBe( "Unknown (not named in the calldata)", @@ -1538,7 +1541,7 @@ describe("uniswap decoder", () => { ], 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); expect(detail(result, "Token Out").value).toBe( "Unknown (not named in the calldata)", ); @@ -1572,7 +1575,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); expect(result.name).toBe("Swap USDT → USDC"); expect(detail(result, "Token In").address).toBe(USDT_ADDR); expect(detail(result, "Token Out").address).toBe(USDC_ADDR); @@ -1596,7 +1599,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); expect(result).not.toBeNull(); expect(detail(result, "Token In").value).toContain("USDT"); @@ -1627,7 +1630,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); expect(result).not.toBeNull(); expect(detail(result, "Token Out").value).toContain("WETH"); @@ -1648,7 +1651,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); expect(result).not.toBeNull(); expect(detail(result, "Token Out").value).toContain("USDC"); @@ -1667,7 +1670,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); expect(result).not.toBeNull(); expect(detail(result, "Token Out").value).toContain("USDT"); @@ -1687,7 +1690,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); expect(result).not.toBeNull(); expect(result.name).toBe("Uniswap Swap"); @@ -1707,7 +1710,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); expect(result).not.toBeNull(); expect(result.name).toBe("Uniswap Swap"); diff --git a/tests/uniswapRouterAddress.test.js b/tests/uniswapRouterAddress.test.js new file mode 100644 index 0000000..653bfc4 --- /dev/null +++ b/tests/uniswapRouterAddress.test.js @@ -0,0 +1,298 @@ +// Which transactions are shown as a swap through the Uniswap Universal Router +// (https://git.eeqj.de/sneak/AutistMask/issues/520). +// +// The decoder used to recognise the router's execute() call by its selector +// alone, so the same calldata sent to an ordinary address was shown as a swap +// through the router, with that address as its Protocol. It is now decoded +// only when the transaction is sent to a Universal Router deployment on the +// network it is for; anywhere else it is left to the raw data. +// +// The transaction detail screen and the approval screen are driven against a +// minimal DOM stub in the shape tests/tokenTransferDetail.test.js uses, the +// approval screen opened the way tests/swapStatusAmount.test.js opens it. + +const { AbiCoder, Interface, getAddress, solidityPacked } = require("ethers"); + +jest.mock("../src/shared/log", () => ({ + log: { + debugf: () => {}, + infof: () => {}, + warnf: () => {}, + errorf: () => {}, + }, + debugFetch: async (url) => ({ + ok: true, + json: async () => mockExplorerAnswer(url), + }), + setRuntimeDebug: () => {}, + isDebug: () => false, +})); + +globalThis.chrome = { + storage: { local: { get: async () => ({}), set: async () => {} } }, +}; + +const { state } = require("../src/shared/state"); +const approval = require("../src/popup/views/approval"); +const { decodeCalldata } = approval; +const transactionDetail = require("../src/popup/views/transactionDetail"); + +const MAINNET = "0x1"; +const SEPOLIA = "0xaa36a7"; + +// From Uniswap's deployment files: one router on Ethereum Mainnet only, one on +// Sepolia only, and one at the same address on both. +const MAINNET_ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af"; +const SEPOLIA_ROUTER = "0x3a9d48ab9751398bbfa63ad67599bb04e4bdf98b"; +const ROUTER_ON_BOTH = "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD"; +// The address with no code from the issue. +const ORDINARY = "0x0b0050Da68435b3EcBC236c40F16dB2070bCD79A"; + +const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe"; +const SEPOLIA_WETH = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14"; +const TOKEN = "0xE2E0000000000000000000000000000000000E2e"; +const HUNDREDTH_ETH = 10000000000000000n; + +const coder = AbiCoder.defaultAbiCoder(); +const routerIface = new Interface([ + "function execute(bytes commands, bytes[] inputs, uint256 deadline)", +]); + +// The calldata from the issue: wrap 0.01 ETH, then a V3 swap of it into a +// token. +const SWAP_DATA = routerIface.encodeFunctionData("execute", [ + solidityPacked(["uint8", "uint8"], [0x0b, 0x00]), + [ + coder.encode(["address", "uint256"], [RECIPIENT, HUNDREDTH_ETH]), + coder.encode( + ["address", "uint256", "uint256", "bytes", "bool"], + [ + RECIPIENT, + HUNDREDTH_ETH, + 1n, + solidityPacked( + ["address", "uint24", "address"], + [SEPOLIA_WETH, 3000, TOKEN], + ), + false, + ], + ), + ], + 9999999999n, +]); + +beforeEach(() => { + state.trackedTokens = []; + state.wallets = []; +}); + +describe("execute() calldata on the approval screen's decoder", () => { + test("sent to a router on the transaction's network, it is a swap", () => { + const onMainnet = decodeCalldata(SWAP_DATA, MAINNET_ROUTER, MAINNET); + expect(onMainnet.description).toBe("Swap via Uniswap Universal Router"); + expect(onMainnet.details[0]).toMatchObject({ + label: "Protocol", + address: MAINNET_ROUTER, + }); + expect( + decodeCalldata(SWAP_DATA, SEPOLIA_ROUTER, SEPOLIA).description, + ).toBe("Swap via Uniswap Universal Router"); + }); + + test("sent to an ordinary address, it is not decoded", () => { + expect(decodeCalldata(SWAP_DATA, ORDINARY, MAINNET)).toBeNull(); + expect(decodeCalldata(SWAP_DATA, ORDINARY, SEPOLIA)).toBeNull(); + }); + + test("sent to a router of the other network only, it is not decoded", () => { + expect(decodeCalldata(SWAP_DATA, SEPOLIA_ROUTER, MAINNET)).toBeNull(); + expect(decodeCalldata(SWAP_DATA, MAINNET_ROUTER, SEPOLIA)).toBeNull(); + }); + + test("a router deployed at the same address on both networks is one on each", () => { + expect( + decodeCalldata(SWAP_DATA, ROUTER_ON_BOTH, MAINNET), + ).not.toBeNull(); + expect( + decodeCalldata(SWAP_DATA, ROUTER_ON_BOTH, SEPOLIA), + ).not.toBeNull(); + }); + + test("the router address matches in any letter case", () => { + const upper = "0x" + MAINNET_ROUTER.slice(2).toUpperCase(); + for (const address of [getAddress(MAINNET_ROUTER), upper]) { + expect(decodeCalldata(SWAP_DATA, address, MAINNET)).not.toBeNull(); + } + }); +}); + +// The transaction the explorer reports: SWAP_DATA, sent to `mockSentTo`. +const TX_HASH = "0x" + "5".repeat(64); +let mockSentTo = null; + +function mockExplorerAnswer(url) { + if (url.endsWith("/transactions/" + TX_HASH)) { + return { + to: { hash: mockSentTo }, + raw_input: SWAP_DATA, + method: "execute", + }; + } + return {}; +} + +function makeElement(id) { + const classes = new Set(); + const el = { + id, + textContent: "", + value: "", + innerHTML: "", + style: {}, + dataset: {}, + classList: { + add: (...names) => names.forEach((n) => classes.add(n)), + remove: (...names) => names.forEach((n) => classes.delete(n)), + contains: (n) => classes.has(n), + toggle: () => false, + }, + addEventListener: () => {}, + querySelectorAll: () => [], + appendChild: () => {}, + }; + // Views reach for .parentElement to hide whole sections. + Object.defineProperty(el, "parentElement", { + get: () => node(id + "-parent"), + }); + return el; +} + +function makeDocument() { + const els = new Map(); + return { + getElementById(id) { + // The debug banner is created on demand by helpers.js; absent + // is the state a non-debug popup is in. + if (id === "debug-banner") return null; + if (!els.has(id)) els.set(id, makeElement(id)); + return els.get(id); + }, + createElement: () => makeElement("created"), + body: { prepend: () => {} }, + }; +} + +function node(id) { + return globalThis.document.getElementById(id); +} + +// Open the transaction detail screen with Sepolia active, for the +// transaction the explorer reports sent to `sentTo`, and wait for its +// decoded details. +async function showDetailSentTo(sentTo) { + mockSentTo = sentTo; + globalThis.document = makeDocument(); + globalThis.window = { location: { search: "" } }; + state.networkId = "sepolia"; + state.blockscoutUrl = "https://explorer.example/api/v2"; + state.viewData = {}; + state.viewStack = []; + state.currentView = null; + transactionDetail.show({ + hash: TX_HASH, + from: RECIPIENT, + to: sentTo, + value: "", + symbol: "SepoliaETH", + timestamp: 1790000000, + isError: false, + direction: "contract", + directionLabel: "Swap", + method: "execute", + chainId: SEPOLIA, + }); + for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0)); +} + +describe("execute() calldata on the transaction detail screen", () => { + test("sent to a router on the active network, it is a swap", async () => { + await showDetailSentTo(SEPOLIA_ROUTER); + expect(node("tx-detail-calldata-details").innerHTML).toContain( + "Swap via Uniswap Universal Router", + ); + }); + + test("sent to an ordinary address, it is not decoded", async () => { + await showDetailSentTo(ORDINARY); + expect(node("tx-detail-calldata-action").textContent).toBe("execute"); + expect(node("tx-detail-calldata-details").innerHTML).toBe(""); + expect(node("tx-detail-rawdata").innerHTML).toContain(SWAP_DATA); + }); + + test("sent to a router of the other network only, it is not decoded", async () => { + await showDetailSentTo(MAINNET_ROUTER); + expect(node("tx-detail-calldata-action").textContent).toBe("execute"); + expect(node("tx-detail-calldata-details").innerHTML).toBe(""); + expect(node("tx-detail-rawdata").innerHTML).toContain(SWAP_DATA); + }); +}); + +// Open the approval screen with Ethereum Mainnet active, for a site's Sepolia +// transaction that sends SWAP_DATA to `sentTo`. +async function showApprovalSentTo(sentTo) { + globalThis.document = makeDocument(); + globalThis.window = { location: { search: "" }, close: () => {} }; + globalThis.chrome.runtime = { + connect: () => ({ postMessage: () => {} }), + sendMessage: (_msg, reply) => + reply({ + type: "tx", + origin: "https://dapp.example", + isPhishingDomain: false, + approvedFrom: RECIPIENT, + approvedTx: { + type: 2, + from: RECIPIENT, + chainId: SEPOLIA, + nonce: "0x7", + gasLimit: "0x30d40", + maxPriorityFeePerGas: "0x3b9aca00", + maxFeePerGas: "0x77359400", + to: sentTo, + value: "0x" + HUNDREDTH_ETH.toString(16), + data: SWAP_DATA, + accessList: [], + }, + }), + }; + state.networkId = "mainnet"; + state.currentView = null; + await approval.show(1); +} + +// The router is on Sepolia's list only, so the swap is shown only if the +// screen decodes for the transaction's network: on Ethereum Mainnet, the +// active network, that address is not a router. +describe("execute() calldata on the approval screen, for a Sepolia transaction while Ethereum Mainnet is active", () => { + test("sent to a router on Sepolia, it is a swap", async () => { + await showApprovalSentTo(SEPOLIA_ROUTER); + expect(node("approve-tx-decoded").classList.contains("hidden")).toBe( + false, + ); + expect(node("approve-tx-decoded-details").innerHTML).toContain( + "Swap via Uniswap Universal Router", + ); + }); + + test("sent to an ordinary address, it is shown as raw data", async () => { + await showApprovalSentTo(ORDINARY); + expect(node("approve-tx-decoded").classList.contains("hidden")).toBe( + true, + ); + expect(node("approve-tx-decoded-details").innerHTML).toBe(""); + expect(node("approve-tx-data").textContent).toBe(SWAP_DATA); + expect( + node("approve-tx-data-section").classList.contains("hidden"), + ).toBe(false); + }); +}); diff --git a/tests/uniswapTokenOut.test.js b/tests/uniswapTokenOut.test.js index e7c38c8..a3a7aa4 100644 --- a/tests/uniswapTokenOut.test.js +++ b/tests/uniswapTokenOut.test.js @@ -53,7 +53,7 @@ function unwrapData() { } function detail(data, label, sources) { - const decoded = uniswap.decode(data, ROUTER, sources || {}); + const decoded = uniswap.decode(data, ROUTER, "0x1", sources || {}); return decoded.details.find((d) => d.label === label); } diff --git a/tests/uniswapUndeterminedTokenIn.test.js b/tests/uniswapUndeterminedTokenIn.test.js index f8266ec..4d078b2 100644 --- a/tests/uniswapUndeterminedTokenIn.test.js +++ b/tests/uniswapUndeterminedTokenIn.test.js @@ -79,7 +79,7 @@ function balanceCheck(token, minBalance) { } function detail(data, label) { - const decoded = uniswap.decode(data, ROUTER, {}); + const decoded = uniswap.decode(data, ROUTER, "0x1", {}); expect(decoded).not.toBeNull(); return decoded.details.find((d) => d.label === label); } @@ -113,7 +113,9 @@ describe("an execute() whose input currency never decoded", () => { }); test("does not name ETH in the swap title either", () => { - expect(uniswap.decode(data(), ROUTER, {}).name).toBe("Uniswap Swap"); + expect(uniswap.decode(data(), ROUTER, "0x1", {}).name).toBe( + "Uniswap Swap", + ); }); }); @@ -123,7 +125,9 @@ describe("an execute() that names only an output token", () => { test("refuses the input rather than defaulting it to ETH", () => { expect(detail(data(), "Token In").value).toBe(REFUSAL); - expect(uniswap.decode(data(), ROUTER, {}).name).toBe("Uniswap Swap"); + expect(uniswap.decode(data(), ROUTER, "0x1", {}).name).toBe( + "Uniswap Swap", + ); }); test("still states the output it did establish", () => { @@ -170,7 +174,9 @@ describe("native ETH in, which V4 spells as the zero address", () => { expect(detail(data, "Token In").value).toBe("ETH (native)"); expect(detail(data, "Amount").value).toBe("1.0000 ETH"); - expect(uniswap.decode(data, ROUTER, {}).name).toBe("Swap ETH → USDT"); + expect(uniswap.decode(data, ROUTER, "0x1", {}).name).toBe( + "Swap ETH → USDT", + ); }); test("WRAP_ETH is still ETH at 18 decimals", () => { diff --git a/tests/uniswapUndeterminedTokenOut.test.js b/tests/uniswapUndeterminedTokenOut.test.js index 6af16c6..8f0a687 100644 --- a/tests/uniswapUndeterminedTokenOut.test.js +++ b/tests/uniswapUndeterminedTokenOut.test.js @@ -105,7 +105,7 @@ function v3ExactIn(tokenIn, tokenOut, amountIn, amountOutMin) { } function detail(data, label) { - const decoded = uniswap.decode(data, ROUTER, {}); + const decoded = uniswap.decode(data, ROUTER, "0x1", {}); expect(decoded).not.toBeNull(); return decoded.details.find((d) => d.label === label); } @@ -140,7 +140,7 @@ describe("a V4 step that states a Min. received but no output currency", () => { }); test("does not name ETH in the swap title either", () => { - const decoded = uniswap.decode(data(), ROUTER, {}); + const decoded = uniswap.decode(data(), ROUTER, "0x1", {}); expect(decoded.name).toBe("Uniswap Swap"); }); }); @@ -206,6 +206,8 @@ describe("native ETH out, which V4 spells as the zero address", () => { test("is still named ETH and still scaled at 18 decimals", () => { expect(detail(data(), "Token Out").value).toBe("ETH"); expect(detail(data(), "Min. received").value).toBe("0.5000 ETH"); - expect(uniswap.decode(data(), ROUTER, {}).name).toBe("Swap USDT → ETH"); + expect(uniswap.decode(data(), ROUTER, "0x1", {}).name).toBe( + "Swap USDT → ETH", + ); }); }); diff --git a/tests/uniswapUnknownDecimals.test.js b/tests/uniswapUnknownDecimals.test.js index 86fa427..0abeb22 100644 --- a/tests/uniswapUnknownDecimals.test.js +++ b/tests/uniswapUnknownDecimals.test.js @@ -82,7 +82,7 @@ function walletsHolding(token, decimals) { // decodeCalldata() rather than uniswap.decode() directly, because the scale // sources the screen supplies are part of what is under test. function swapDetail(data, label) { - const decoded = decodeCalldata(data, ROUTER); + const decoded = decodeCalldata(data, ROUTER, "0x1"); return decoded.details.find((d) => d.label === label); }