From 7c4f2e5525d63c31f187dad215f12b27bf8d826c Mon Sep 17 00:00:00 2001
From: clawbot <35+clawbot@noreply.example.org>
Date: Tue, 20 Oct 2026 01:18:49 +0000
Subject: [PATCH] fix: transaction screens name a token from the tokens of the
transaction's network (closes #629)
decodeCalldata() looked a token's symbol and decimals up in the active
network's tracked tokens and balances, whatever chain id it was given, so
TransactionDetail and TxApproval showed a token tracked on another network
by address only, with its amount in base units. The lookup now uses what the
wallet knows of the tokens on the transaction's network: the active
network's tracked tokens and balances, or for another network the tokens
tracked on it and no balances. TxApproval's token labels, the symbol it
hands the wait, success and error screens, and SuccessTx's decoded token
label use the same lookup.
Model: opus-5-5
---
README.md | 33 ++---
src/popup/views/approval.js | 26 ++--
src/popup/views/helpers.js | 38 +++--
src/popup/views/txStatus.js | 2 +-
tests/approvalTokenNetwork.test.js | 220 +++++++++++++++++++++++++++++
tests/tokenTransferDetail.test.js | 48 ++++++-
tests/txStatus.test.js | 27 ++++
7 files changed, 351 insertions(+), 43 deletions(-)
create mode 100644 tests/approvalTokenNetwork.test.js
diff --git a/README.md b/README.md
index 31716d2..418bba9 100644
--- a/README.md
+++ b/README.md
@@ -1116,22 +1116,23 @@ with the same floor (`.5` reads `.5000`).
**Specific Exception — unknown token scale:** Calldata carries base units and no
scale, so every amount on the dApp approval screen needs the token's `decimals`.
-It is resolved from the bundled token list, then from the tokens the user
-tracks, then from what the block explorer reported for the contract
-(`resolveTokenDecimals()` in `src/shared/approvalAmount.js`). Where none of them
-answers, the amount is not formatted: the line reads
-`5000000000 base units (decimals unknown)`. A guessed scale is not an
-approximation but a different number — 1,000 units of a 6-decimal token
-formatted at 18 decimals reads `0.000000001` — on the screen whose only job is
-to state what is being authorized. Both amount paths of that screen take this
-rule: the ERC-20 `transfer`/`approve` line (`src/popup/views/approval.js`) and
-the swap's `Amount` and `Min. received` lines (`src/shared/uniswap.js`). The
-token permission warning on the signature screen takes the same rule for its
-amounts. An unbounded allowance or permit needs no scale to describe and is
-still shown as `Unlimited`. A source's answer counts only if it is a whole
-number from 0 to 80: `decimals()` returns a `uint8`, but `formatUnits()` cannot
-format more than 80 decimal places, so a token that reports 81 to 255 is shown
-as one whose scale nothing knows.
+It is resolved from the bundled token list, then from the tokens the user tracks
+on the network the transaction is for, then, when that is the active network,
+from what the block explorer reported for the contract in the wallet's balances,
+which are the active network's only (`resolveTokenDecimals()` in
+`src/shared/approvalAmount.js`). Where none of them answers, the amount is not
+formatted: the line reads `5000000000 base units (decimals unknown)`. A guessed
+scale is not an approximation but a different number — 1,000 units of a
+6-decimal token formatted at 18 decimals reads `0.000000001` — on the screen
+whose only job is to state what is being authorized. Both amount paths of that
+screen take this rule: the ERC-20 `transfer`/`approve` line
+(`src/popup/views/approval.js`) and the swap's `Amount` and `Min. received`
+lines (`src/shared/uniswap.js`). The token permission warning on the signature
+screen takes the same rule for its amounts. An unbounded allowance or permit
+needs no scale to describe and is still shown as `Unlimited`. A source's answer
+counts only if it is a whole number from 0 to 80: `decimals()` returns a
+`uint8`, but `formatUnits()` cannot format more than 80 decimal places, so a
+token that reports 81 to 255 is shown as one whose scale nothing knows.
The rule holds only if nothing invents a scale UPSTREAM of it. Those three
sources are read as authoritative, so a value written into one of them cannot be
diff --git a/src/popup/views/approval.js b/src/popup/views/approval.js
index 8f26992..6911927 100644
--- a/src/popup/views/approval.js
+++ b/src/popup/views/approval.js
@@ -11,6 +11,7 @@ const {
onViewLeave,
formatFee,
tokenLabel,
+ tokenSourcesByChainId,
} = require("./helpers");
const { state, saveState, currentNetwork } = require("../../shared/state");
const {
@@ -85,13 +86,12 @@ function tokenAmountText(rawAmount, decimals, symbol) {
function decodeCalldata(data, toAddress, chainId, fromAddress) {
if (!data || data === "0x" || data.length < 10) return null;
- // Where a token's scale is looked for, for every decoder below: the ERC-20
- // amount line and the swap's Amount and Min. received lines resolve it the
- // same way, and refuse to format the same way when it is nowhere.
- const decimalsSources = {
- trackedTokens: state.trackedTokens,
- wallets: state.wallets,
- };
+ // Where a token's name and scale are looked for, for every decoder below:
+ // what the wallet knows of the tokens on the network with `chainId`, which
+ // need not be the active one. The ERC-20 amount line and the swap's Amount
+ // and Min. received lines resolve the scale the same way, and refuse to
+ // format the same way when it is nowhere.
+ const decimalsSources = tokenSourcesByChainId(chainId);
// Try ERC-20 (approve / transfer)
try {
@@ -261,10 +261,7 @@ function showTxApproval(details) {
const toAddr = approvedTx.to;
const ethValue = formatEther(approvedTx.value || "0");
- const sources = {
- trackedTokens: state.trackedTokens,
- wallets: state.wallets,
- };
+ const sources = tokenSourcesByChainId(approvedTx.chainId);
// Build txInfo for status screens
pendingTxDetails = {
@@ -344,8 +341,9 @@ function showTxApproval(details) {
$("approve-tx-origin").textContent = details.origin;
$("approve-tx-from").innerHTML = approvalAddressHtml(details.approvedFrom);
- // Show token symbol next to contract address if known
- const symbol = toAddr ? tokenLabel(toAddr) : null;
+ // Show token symbol next to contract address if known on the network the
+ // transaction is for
+ const symbol = toAddr ? tokenLabel(toAddr, approvedTx.chainId) : null;
if (toAddr) {
let toHtml = "";
if (symbol) {
@@ -388,7 +386,7 @@ function showTxApproval(details) {
detailsHtml += `
')
+ .find((line) =>
+ line.startsWith(`
${label}
`),
+ );
+}
+
+// Open the approval screen, with Ethereum Mainnet active and FROM holding
+// `tokenBalances` there, for the site's Sepolia transaction sending
+// TRANSFER_DATA to the token. Confirm answers the signed transaction with a
+// failure, which leads to the error screen.
+async function showSepoliaTransfer(tokenBalances = []) {
+ globalThis.document = makeDocument();
+ globalThis.window = { location: { search: "" }, close: () => {} };
+ globalThis.chrome.runtime = {
+ connect: () => ({ postMessage: () => {} }),
+ sendMessage: (msg, reply) => {
+ if (!reply) return;
+ if (msg.type !== "AUTISTMASK_GET_APPROVAL") {
+ return reply({
+ error: "The transaction could not be sent.",
+ retryable: false,
+ });
+ }
+ reply({
+ type: "tx",
+ origin: "https://dapp.example",
+ isPhishingDomain: false,
+ approvedFrom: FROM,
+ approvedTx: {
+ type: 2,
+ from: FROM,
+ chainId: NETWORKS.sepolia.chainId,
+ nonce: "0x7",
+ gasLimit: "0x30d40",
+ maxPriorityFeePerGas: "0x3b9aca00",
+ maxFeePerGas: "0x77359400",
+ to: TOKEN,
+ value: "0x0",
+ data: TRANSFER_DATA,
+ accessList: [],
+ },
+ });
+ },
+ };
+ state.networkId = "mainnet";
+ state.activeAddress = FROM;
+ state.wallets = [
+ {
+ type: "hd",
+ name: "Wallet 1",
+ xpub: "xpub-wallet-1",
+ encryptedSecret: "encrypted-secret-1",
+ nextIndex: 1,
+ addresses: [
+ { address: FROM, index: 0, balance: "0", tokenBalances },
+ ],
+ },
+ ];
+ state.currentView = null;
+ txStatus.init({ doRefreshAndRender: () => {} });
+ approval.init({});
+ await approval.show(1);
+}
+
+async function confirm() {
+ decryptWithPassword.mockResolvedValue(PHRASE);
+ node("approve-tx-password").value = "any password";
+ await click("btn-approve-tx");
+}
+
+beforeEach(() => {
+ state.trackedTokens = [];
+ state.networkTrackedTokens = {};
+});
+
+describe("a token tracked on Sepolia only", () => {
+ beforeEach(() => {
+ state.networkTrackedTokens = {
+ sepolia: [{ address: TOKEN, symbol: "TDOL", decimals: 6 }],
+ };
+ });
+
+ test("is named on the Contract line and the decoded Token line, with its amount at its scale", async () => {
+ await showSepoliaTransfer();
+ expect(node("approve-tx-to").innerHTML).toContain(
+ '
TDOL
',
+ );
+ expect(decodedLine("Token")).toContain(
+ '
TDOL
',
+ );
+ expect(decodedLine("Amount")).toContain(
+ '
1234.5000 TDOL
',
+ );
+ });
+
+ test("labels the amount on the error screen that follows", async () => {
+ await showSepoliaTransfer();
+ await confirm();
+ expect(state.currentView).toBe("error-tx");
+ expect(node("error-tx-summary").textContent).toBe("1234.5000 TDOL");
+ });
+});
+
+// The same contract address on mainnet is another token, so what the wallet
+// tracks and holds there names nothing on Sepolia.
+test("a token tracked and held on mainnet only is not named, and its amount is in base units", async () => {
+ state.trackedTokens = [{ address: TOKEN, symbol: "MDOL", decimals: 18 }];
+ await showSepoliaTransfer([
+ { address: TOKEN, symbol: "MDOL", decimals: 18, balance: "1.0" },
+ ]);
+ expect(node("approve-tx-to").innerHTML).not.toContain("MDOL");
+ expect(decodedLine("Token")).toContain(
+ '
Unknown token
',
+ );
+ expect(decodedLine("Amount")).toContain(
+ '
1234500000 base units (decimals unknown)
',
+ );
+});
diff --git a/tests/tokenTransferDetail.test.js b/tests/tokenTransferDetail.test.js
index e7b3b54..9ac9d9b 100644
--- a/tests/tokenTransferDetail.test.js
+++ b/tests/tokenTransferDetail.test.js
@@ -12,7 +12,9 @@
// whichever network is active
// (https://git.eeqj.de/sneak/AutistMask/issues/584), and the transaction is
// read from that network's Blockscout, the one Settings has for it
-// (https://git.eeqj.de/sneak/AutistMask/issues/623).
+// (https://git.eeqj.de/sneak/AutistMask/issues/623). Its decoded details name
+// the token, and scale its amount, from what the wallet knows of that
+// network's tokens (https://git.eeqj.de/sneak/AutistMask/issues/629).
//
// The row is the one the real history parser makes from the explorer's answer,
// opened through transactionDetail.show(), which is what a click on the row
@@ -165,6 +167,7 @@ beforeEach(() => {
},
];
state.trackedTokens = [{ address: TOKEN, symbol: "TDOL", decimals: 6 }];
+ state.networkTrackedTokens = {};
state.viewData = {};
state.viewStack = [];
state.currentView = null;
@@ -245,3 +248,46 @@ test("with mainnet active and no Blockscout remembered for Sepolia, a Sepolia tr
NETWORKS.sepolia.defaultBlockscoutUrl + "/transactions/" + TX_HASH,
]);
});
+
+// Open the Sepolia transfer's detail screen with mainnet active, and wait for
+// its decoded details.
+async function showSepoliaTransferWithMainnetActive() {
+ state.networkId = "mainnet";
+ state.blockscoutUrl = OTHER_EXPLORER;
+ state.networkEndpoints = { sepolia: { blockscoutUrl: EXPLORER } };
+ const [row] = await fetchRecentTransactions(
+ HOLDER,
+ EXPLORER,
+ NETWORKS.sepolia.chainId,
+ );
+ transactionDetail.show(row);
+ for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
+}
+
+test("with mainnet active, a Sepolia token transfer's decoded details name the token tracked on Sepolia and its amount at its scale", async () => {
+ state.trackedTokens = [];
+ state.networkTrackedTokens = {
+ sepolia: [{ address: TOKEN, symbol: "TDOL", decimals: 6 }],
+ };
+ await showSepoliaTransferWithMainnetActive();
+
+ expect(decodedLine("Token")).toContain('
TDOL
');
+ expect(decodedLine("Amount")).toContain(
+ '
1234.5000 TDOL
',
+ );
+});
+
+// The same contract address on mainnet is another token, so what the wallet
+// tracks and holds there names nothing on Sepolia.
+test("with mainnet active, a Sepolia token transfer's decoded details take nothing from mainnet's tracked tokens or balances", async () => {
+ state.trackedTokens = [{ address: TOKEN, symbol: "MDOL", decimals: 18 }];
+ state.wallets[0].addresses[0].tokenBalances = [
+ { address: TOKEN, symbol: "MDOL", decimals: 18, balance: "1.0" },
+ ];
+ await showSepoliaTransferWithMainnetActive();
+
+ expect(decodedLine("Token")).not.toContain("MDOL");
+ expect(decodedLine("Amount")).toContain(
+ '
1234500000 base units (decimals unknown)
',
+ );
+});
diff --git a/tests/txStatus.test.js b/tests/txStatus.test.js
index a78e9b6..f2ce665 100644
--- a/tests/txStatus.test.js
+++ b/tests/txStatus.test.js
@@ -150,6 +150,8 @@ beforeEach(() => {
state.rpcUrl = NETWORKS.mainnet.defaultRpcUrl;
state.networkEndpoints = {};
state.wallets = [];
+ state.trackedTokens = [];
+ state.networkTrackedTokens = {};
state.viewData = {};
state.viewStack = [];
state.currentView = null;
@@ -627,6 +629,31 @@ describe("SuccessTx and ErrorTx for a Sepolia transaction with mainnet active",
expectSepoliaLinks();
});
+ // https://git.eeqj.de/sneak/AutistMask/issues/629
+ test("SuccessTx's decoded action names the token from the tokens tracked on Sepolia", async () => {
+ function expectTokenNamed() {
+ expect(getElement("success-tx-decoded").innerHTML).toContain(
+ '
TDOL
',
+ );
+ }
+ state.networkTrackedTokens = {
+ sepolia: [{ address: TOKEN, symbol: "TDOL", decimals: 6 }],
+ };
+ const decoded = {
+ name: "Transfer",
+ details: [{ label: "Token", address: TOKEN, isToken: true }],
+ };
+ mockReceiptLookup.mockResolvedValue({ blockNumber: 21000000 });
+ txStatus.showWait({ ...sepoliaTx, decoded }, TX_HASH);
+ await jest.advanceTimersByTimeAsync(10000);
+ expect(state.currentView).toBe("success-tx");
+ expectTokenNamed();
+
+ elements.clear();
+ txStatus.renderSuccess();
+ expectTokenNamed();
+ });
+
test("the recipient and hash on ErrorTx link to the Sepolia explorer", () => {
function expectSepoliaLinks() {
expect(getElement("error-tx-to").innerHTML).toContain(
--
2.54.0