fix: history calls an execute or multicall a Swap only when sent to a router (closes #580) #643

Merged
clawbot merged 1 commits from issue-580-swap-label-router-only into next 2026-10-20 17:19:43 +02:00
4 changed files with 108 additions and 22 deletions
+5 -1
View File
@@ -1999,7 +1999,11 @@ view would leave a wallet one click from deletion.
- Type: transaction classification — one of: Native ETH Transfer, ERC-20 - Type: transaction classification — one of: Native ETH Transfer, ERC-20
Token Transfer, Swap, Token Approval, Contract Call, Contract Creation. A Token Transfer, Swap, Token Approval, Contract Call, Contract Creation. A
transfer with a token contract is an ERC-20 Token Transfer whatever symbol 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" - Status: "Success" or "Failed"
- From: blockie + color dot + full address (tap to copy) + etherscan link; - From: blockie + color dot + full address (tap to copy) + etherscan link;
ENS name if available ENS name if available
+10 -3
View File
@@ -20,6 +20,7 @@ const { toDecimals } = require("./transferAmount");
// truncation without the approval screens' nonzero floor: the transaction // truncation without the approval screens' nonzero floor: the transaction
// detail view is the authoritative record and already shows exact precision. // detail view is the authoritative record and already shows exact precision.
const { truncateAmount: formatTxValue } = require("./amountDisplay"); const { truncateAmount: formatTxValue } = require("./amountDisplay");
const { routerRelease } = require("./uniswap");
// Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum // Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum
// over the address, not part of its identity. Every address comparison in // 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 // Map known DEX methods to "Swap" for cleaner display
const SWAP_METHODS = new Set([ const SWAP_METHODS = new Set([
"execute",
"swap", "swap",
"swapExactTokensForTokens", "swapExactTokensForTokens",
"swapTokensForExactTokens", "swapTokensForExactTokens",
@@ -59,9 +59,16 @@ function parseTx(tx, addrLower, chainId) {
"swapTokensForExactETH", "swapTokensForExactETH",
"swapExactTokensForETH", "swapExactTokensForETH",
"swapETHForExactTokens", "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" ? "Swap"
: method.charAt(0).toUpperCase() + method.slice(1); : method.charAt(0).toUpperCase() + method.slice(1);
direction = "contract"; direction = "contract";
+1 -1
View File
@@ -2237,4 +2237,4 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} }
} }
module.exports = { decode }; module.exports = { decode, routerRelease };
+82 -7
View File
@@ -7,6 +7,12 @@
// only when the transaction is sent to a Universal Router deployment on the // 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. // 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 // The transaction detail screen and the approval screen are driven against a
// minimal DOM stub in the shape tests/tokenTransferDetail.test.js uses, the // minimal DOM stub in the shape tests/tokenTransferDetail.test.js uses, the
// approval screen opened the way tests/swapStatusAmount.test.js opens it. // approval screen opened the way tests/swapStatusAmount.test.js opens it.
@@ -33,6 +39,7 @@ globalThis.chrome = {
}; };
const { state } = require("../src/shared/state"); const { state } = require("../src/shared/state");
const { fetchRecentTransactions } = require("../src/shared/transactions");
const approval = require("../src/popup/views/approval"); const approval = require("../src/popup/views/approval");
const { decodeCalldata } = approval; const { decodeCalldata } = approval;
const transactionDetail = require("../src/popup/views/transactionDetail"); const transactionDetail = require("../src/popup/views/transactionDetail");
@@ -47,6 +54,8 @@ const SEPOLIA_ROUTER = "0x3a9d48ab9751398bbfa63ad67599bb04e4bdf98b";
const ROUTER_ON_BOTH = "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD"; const ROUTER_ON_BOTH = "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD";
// The address with no code from the issue. // The address with no code from the issue.
const ORDINARY = "0x0b0050Da68435b3EcBC236c40F16dB2070bCD79A"; const ORDINARY = "0x0b0050Da68435b3EcBC236c40F16dB2070bCD79A";
// A contract that is not a router, such as a multisig.
const OTHER_CONTRACT = "0x5afe000000000000000000000000000000005afe";
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe"; const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
const SEPOLIA_WETH = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14"; 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`. // The transaction the explorer reports: SWAP_DATA, sent to `mockSentTo`.
const TX_HASH = "0x" + "5".repeat(64); const TX_HASH = "0x" + "5".repeat(64);
let mockSentTo = null; let mockSentTo = null;
// The transactions the explorer reports for RECIPIENT's history.
let mockHistory = [];
function mockExplorerAnswer(url) { function mockExplorerAnswer(url) {
if (url.endsWith("/addresses/" + RECIPIENT + "/transactions")) {
return { items: mockHistory };
}
if (url.endsWith("/transactions/" + TX_HASH)) { if (url.endsWith("/transactions/" + TX_HASH)) {
return { return {
to: { hash: mockSentTo }, to: { hash: mockSentTo },
@@ -187,10 +201,9 @@ function node(id) {
} }
// Open the transaction detail screen with `activeNetworkId` active, for the // Open the transaction detail screen with `activeNetworkId` active, for the
// Sepolia transaction the explorer reports sent to `sentTo`, and wait for its // history row `row`, and wait for its decoded details.
// decoded details. async function showDetail(row, activeNetworkId) {
async function showDetailSentTo(sentTo, activeNetworkId) { mockSentTo = row.to;
mockSentTo = sentTo;
globalThis.document = makeDocument(); globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } }; globalThis.window = { location: { search: "" } };
state.networkId = activeNetworkId; state.networkId = activeNetworkId;
@@ -198,7 +211,16 @@ async function showDetailSentTo(sentTo, activeNetworkId) {
state.viewData = {}; state.viewData = {};
state.viewStack = []; state.viewStack = [];
state.currentView = null; state.currentView = null;
transactionDetail.show({ 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, hash: TX_HASH,
from: RECIPIENT, from: RECIPIENT,
to: sentTo, to: sentTo,
@@ -210,8 +232,9 @@ async function showDetailSentTo(sentTo, activeNetworkId) {
directionLabel: "Swap", directionLabel: "Swap",
method: "execute", method: "execute",
chainId: SEPOLIA, chainId: SEPOLIA,
}); },
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0)); activeNetworkId,
);
} }
describe("execute() calldata on the transaction detail screen", () => { describe("execute() calldata on the transaction detail screen", () => {
@@ -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 // Open the approval screen with Ethereum Mainnet active, for a site's Sepolia
// transaction that sends SWAP_DATA to `sentTo`. // transaction that sends SWAP_DATA to `sentTo`.
async function showApprovalSentTo(sentTo) { async function showApprovalSentTo(sentTo) {