fix: history calls an execute or multicall a Swap only when sent to a router (closes #580) #643
@@ -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
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -2237,4 +2237,4 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { decode };
|
module.exports = { decode, routerRelease };
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user