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(