Files
AutistMask/tests/uniswapRouterAddress.test.js
T
clawbot 6d158bf1b1
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
refactor: move the calldata decoder to src/shared/calldataDecoder.js (closes #759)
decodeCalldata() and tokenAmountText() move out of the TxApproval view into
src/shared/calldataDecoder.js. TxApproval, TransactionDetail and the decoder
tests import them from there.

tokenSourcesByChainId() moves from the popup view helpers to
src/shared/state.js, beside currentNetwork(), so the decoder requires nothing
under src/popup/. It reads only the state singleton and the network table.

Model: opus-5-5
2026-11-10 22:33:26 +01:00

389 lines
14 KiB
JavaScript

// 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 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.
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,
}));
// Blockscout is asked only while the stored "Use Blockscout" is on.
globalThis.chrome = {
storage: {
local: {
get: async () => ({ autistmask: { useBlockscout: true } }),
set: async () => {},
},
},
};
const { state } = require("../src/shared/state");
const { fetchRecentTransactions } = require("../src/shared/transactions");
const approval = require("../src/popup/views/approval");
const { decodeCalldata } = require("../src/shared/calldataDecoder");
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";
// A contract that is not a router, such as a multisig.
const OTHER_CONTRACT = "0x5afe000000000000000000000000000000005afe";
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;
// 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 },
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 `activeNetworkId` active, for the
// 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;
state.blockscoutUrl = "https://explorer.example/api/v2";
state.useBlockscout = true;
state.viewData = {};
state.viewStack = [];
state.currentView = null;
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");
expect(node("tx-detail-calldata-details").innerHTML).toContain(
"Swap via Uniswap Universal Router",
);
});
// https://git.eeqj.de/sneak/AutistMask/issues/623
test("sent to a router on its network, with the other network active, it is a swap", async () => {
await showDetailSentTo(SEPOLIA_ROUTER, "mainnet");
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, "sepolia");
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, "sepolia");
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);
});
});
// 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) {
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);
});
});