diff --git a/README.md b/README.md index d77648f..37874cd 100644 --- a/README.md +++ b/README.md @@ -1999,7 +1999,11 @@ view would leave a wallet one click from deletion. - Type: transaction classification — one of: Native ETH Transfer, ERC-20 Token Transfer, Swap, Token Approval, Contract Call, Contract Creation. A transfer with a token contract is an ERC-20 Token Transfer whatever symbol - the token reports. + the token reports. A call named `execute` or `multicall` is a Swap, here + and on its history row, only when it was sent to one of the Universal + Router addresses Uniswap publishes for the network it was sent on (see + Transaction Decoding); sent to any other contract, its history row shows + the method name and this line reads Contract Call. - Status: "Success" or "Failed" - From: blockie + color dot + full address (tap to copy) + etherscan link; ENS name if available diff --git a/src/shared/transactions.js b/src/shared/transactions.js index 8e8a42b..a72f429 100644 --- a/src/shared/transactions.js +++ b/src/shared/transactions.js @@ -20,6 +20,7 @@ const { toDecimals } = require("./transferAmount"); // truncation without the approval screens' nonzero floor: the transaction // detail view is the authoritative record and already shows exact precision. const { truncateAmount: formatTxValue } = require("./amountDisplay"); +const { routerRelease } = require("./uniswap"); // Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum // over the address, not part of its identity. Every address comparison in @@ -51,7 +52,6 @@ function parseTx(tx, addrLower, chainId) { } // Map known DEX methods to "Swap" for cleaner display const SWAP_METHODS = new Set([ - "execute", "swap", "swapExactTokensForTokens", "swapTokensForExactTokens", @@ -59,9 +59,16 @@ function parseTx(tx, addrLower, chainId) { "swapTokensForExactETH", "swapExactTokensForETH", "swapETHForExactTokens", - "multicall", ]); - const label = SWAP_METHODS.has(method) + // Multisigs, smart accounts and many other contracts also have an + // `execute` or `multicall` method, so either is a swap only when sent + // to a Universal Router on this transaction's network: the same + // addresses the approval screen decodes a swap for. + const isSwap = + SWAP_METHODS.has(method) || + ((method === "execute" || method === "multicall") && + routerRelease(to, chainId) !== null); + const label = isSwap ? "Swap" : method.charAt(0).toUpperCase() + method.slice(1); direction = "contract"; diff --git a/src/shared/uniswap.js b/src/shared/uniswap.js index f50b646..0ec4f2d 100644 --- a/src/shared/uniswap.js +++ b/src/shared/uniswap.js @@ -2237,4 +2237,4 @@ function decode(data, toAddress, chainId, sources, fromAddress) { } } -module.exports = { decode }; +module.exports = { decode, routerRelease }; diff --git a/tests/uniswapRouterAddress.test.js b/tests/uniswapRouterAddress.test.js index 54cccf1..e5f805e 100644 --- a/tests/uniswapRouterAddress.test.js +++ b/tests/uniswapRouterAddress.test.js @@ -7,6 +7,12 @@ // 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 history labelled any call named `execute` or `multicall` a +// Swap, whatever address it went to +// (https://git.eeqj.de/sneak/AutistMask/issues/580). It now does so only for +// such a call sent to a Universal Router deployment on the network the +// transaction is on; any other is labelled by its method name. +// // 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. @@ -33,6 +39,7 @@ globalThis.chrome = { }; const { state } = require("../src/shared/state"); +const { fetchRecentTransactions } = require("../src/shared/transactions"); const approval = require("../src/popup/views/approval"); const { decodeCalldata } = approval; const transactionDetail = require("../src/popup/views/transactionDetail"); @@ -47,6 +54,8 @@ const SEPOLIA_ROUTER = "0x3a9d48ab9751398bbfa63ad67599bb04e4bdf98b"; const ROUTER_ON_BOTH = "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD"; // The address with no code from the issue. const ORDINARY = "0x0b0050Da68435b3EcBC236c40F16dB2070bCD79A"; +// A contract that is not a router, such as a multisig. +const OTHER_CONTRACT = "0x5afe000000000000000000000000000000005afe"; const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe"; const SEPOLIA_WETH = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14"; @@ -129,8 +138,13 @@ describe("execute() calldata on the approval screen's decoder", () => { // The transaction the explorer reports: SWAP_DATA, sent to `mockSentTo`. const TX_HASH = "0x" + "5".repeat(64); let mockSentTo = null; +// The transactions the explorer reports for RECIPIENT's history. +let mockHistory = []; function mockExplorerAnswer(url) { + if (url.endsWith("/addresses/" + RECIPIENT + "/transactions")) { + return { items: mockHistory }; + } if (url.endsWith("/transactions/" + TX_HASH)) { return { to: { hash: mockSentTo }, @@ -187,10 +201,9 @@ function node(id) { } // Open the transaction detail screen with `activeNetworkId` active, for the -// Sepolia transaction the explorer reports sent to `sentTo`, and wait for its -// decoded details. -async function showDetailSentTo(sentTo, activeNetworkId) { - mockSentTo = sentTo; +// history row `row`, and wait for its decoded details. +async function showDetail(row, activeNetworkId) { + mockSentTo = row.to; globalThis.document = makeDocument(); globalThis.window = { location: { search: "" } }; state.networkId = activeNetworkId; @@ -198,22 +211,32 @@ async function showDetailSentTo(sentTo, activeNetworkId) { 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, - }); + transactionDetail.show(row); for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0)); } +// Open the transaction detail screen with `activeNetworkId` active, for the +// Sepolia transaction the explorer reports sent to `sentTo`, and wait for its +// decoded details. +async function showDetailSentTo(sentTo, activeNetworkId) { + await showDetail( + { + hash: TX_HASH, + from: RECIPIENT, + to: sentTo, + value: "", + symbol: "SepoliaETH", + timestamp: 1790000000, + isError: false, + direction: "contract", + directionLabel: "Swap", + method: "execute", + chainId: SEPOLIA, + }, + activeNetworkId, + ); +} + 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, "sepolia"); @@ -245,6 +268,58 @@ describe("execute() calldata on the transaction detail screen", () => { }); }); +// The history row made from the explorer's answer for RECIPIENT's `method` +// call sent to the contract `sentTo` on the network with `chainId`. The +// explorer gives the address in checksummed form. +async function historyRow(method, sentTo, chainId) { + mockHistory = [ + { + hash: TX_HASH, + block_number: 7000000, + timestamp: "2026-09-01T00:00:00Z", + from: { hash: RECIPIENT }, + to: { hash: getAddress(sentTo), is_contract: true }, + value: "0", + method, + status: "ok", + }, + ]; + const rows = await fetchRecentTransactions( + RECIPIENT, + "https://explorer.example/api/v2", + chainId, + ); + return rows[0]; +} + +// The history row's label and TransactionDetail's Type line, for a Sepolia +// transaction. The active network does not decide either. +describe.each([ + ["execute", "Execute"], + ["multicall", "Multicall"], +])("a %s() call in the history", (method, methodLabel) => { + test("sent to a router on its network, it is a Swap", async () => { + const row = await historyRow(method, SEPOLIA_ROUTER, SEPOLIA); + expect(row.directionLabel).toBe("Swap"); + await showDetail(row, "mainnet"); + expect(node("tx-detail-type").textContent).toBe("Swap"); + }); + + test("sent to another contract, it is labelled by its method name", async () => { + const row = await historyRow(method, OTHER_CONTRACT, SEPOLIA); + expect(row.directionLabel).toBe(methodLabel); + await showDetail(row, "sepolia"); + expect(node("tx-detail-type").textContent).toBe("Contract Call"); + }); + + test("sent to a router of the other network only, it is labelled by its method name", async () => { + const row = await historyRow(method, MAINNET_ROUTER, SEPOLIA); + expect(row.directionLabel).toBe(methodLabel); + await showDetail(row, "mainnet"); + expect(node("tx-detail-type").textContent).toBe("Contract Call"); + }); +}); + // Open the approval screen with Ethereum Mainnet active, for a site's Sepolia // transaction that sends SWAP_DATA to `sentTo`. async function showApprovalSentTo(sentTo) {