fix: a confirmation screen drawn again keeps the network it was reviewed for (closes #586)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s

ConfirmTx set the transaction's chain id to the active network's every
time it drew the screen, so a popup reopened onto a saved confirmation
after a network switch in Settings described the reviewed transfer as
the new network's and sent it there.

show() now sets the chain id only on a transaction Send hands over, and
the screen describes the transaction entirely on that network: native
currency, USD values, explorer links, and the node and explorer its
checks ask. explorerUrl(), renderAddressHtml(), getPrice(), formatFee()
and checkEtherscanLabel() take an optional chain id for this. A saved
transaction whose chain id names no network is not restored.

Model: opus-5-5
This commit was merged in pull request #587.
This commit is contained in:
2026-10-19 07:38:15 +02:00
parent b5d2cedb57
commit 1f5cdeb7b4
9 changed files with 435 additions and 72 deletions
+6 -2
View File
@@ -13,6 +13,7 @@
// cannot be required outside a browser.
const { RESTORABLE_VIEWS } = require("../shared/restorableViews");
const { SUPPORTED_CHAIN_IDS } = require("../shared/networks");
// The views this page load has rendered.
//
@@ -125,13 +126,16 @@ function isRenderableDecoded(value) {
// The pending transaction confirmTx.show() renders: `token` reaches
// renderAddressHtml() when it is not "ETH", and `from`/`to` reach
// addressTitle(), makeBlockie() and getLocalWarnings().
// addressTitle(), makeBlockie() and getLocalWarnings(). `chainId`, which
// show() fills in when it is absent, picks the network whose explorer the
// address links open, so it has to name one.
function isRenderablePendingTx(value) {
return (
isRecord(value) &&
isText(value.token) &&
isAddressText(value.from) &&
isAddressText(value.to)
isAddressText(value.to) &&
(!value.chainId || SUPPORTED_CHAIN_IDS.has(value.chainId))
);
}
+61 -25
View File
@@ -11,7 +11,6 @@ const {
addressTitle,
escapeHtml,
displaySymbol,
nativeCurrency,
renderAddressHtml,
blockieHtml,
attachCopyHandlers,
@@ -20,6 +19,10 @@ const {
formatFee,
} = require("./helpers");
const { state, currentNetwork } = require("../../shared/state");
const {
networkByChainId,
nativeCurrencyByChainId,
} = require("../../shared/networks");
const { getSignerForAddress } = require("../../shared/wallet");
const { walletDefect } = require("../../shared/walletDefects");
const { decryptWithPassword } = require("../../shared/vault");
@@ -68,11 +71,11 @@ function restore() {
}
}
function confirmAddressHtml(address, ensName, title) {
function confirmAddressHtml(address, ensName, title, chainId) {
const blockie = blockieHtml(address);
return (
`<div class="mb-1">${blockie}</div>` +
renderAddressHtml(address, { title, ensName })
renderAddressHtml(address, { title, ensName, chainId })
);
}
@@ -87,8 +90,10 @@ function valueWithUsd(text, usdAmount) {
// once its amount is re-derived from the fee estimate.
function renderAmount(txInfo) {
const isErc20 = txInfo.token !== "ETH";
const rawSymbol = isErc20 ? txInfo.tokenSymbol || "?" : nativeCurrency();
const price = isErc20 ? getPrice(rawSymbol) : getPrice("ETH");
const rawSymbol = isErc20
? txInfo.tokenSymbol || "?"
: nativeCurrencyByChainId(txInfo.chainId);
const price = getPrice(isErc20 ? rawSymbol : "ETH", txInfo.chainId);
const amountUsd = price ? parseFloat(txInfo.amount) * price : null;
$("confirm-amount").textContent = valueWithUsd(
txInfo.amount + " " + displaySymbol(rawSymbol),
@@ -101,16 +106,22 @@ function show(txInfo) {
feeStatus = FEE_PENDING;
feeWei = null;
maxSendFees = null;
// The network this screen is drawn for, and the only one it sends on:
// Settings can switch the active network while the screen is open. The
// wait, success and error screens label the amount by it too.
txInfo.chainId = currentNetwork().chainId;
// The network this screen is drawn for, and the only one it sends on: the
// one active when Send handed the transaction over. Drawn again from the
// saved transaction, as when the popup reopens onto this screen, it keeps
// that network, so Sign & Send refuses once Settings has switched to
// another. Everything this screen shows is that network's: native
// currency, USD values, explorer links, and the node and explorer its
// checks ask. The wait, success and error screens label amounts by it.
if (!txInfo.chainId) txInfo.chainId = currentNetwork().chainId;
const isErc20 = txInfo.token !== "ETH";
// The raw symbol is the price-table key; the capped one is what the
// screen says. Truncating before the lookup would silently drop the
// price of any token whose symbol is long enough to be capped.
const rawSymbol = isErc20 ? txInfo.tokenSymbol || "?" : nativeCurrency();
const rawSymbol = isErc20
? txInfo.tokenSymbol || "?"
: nativeCurrencyByChainId(txInfo.chainId);
const symbol = displaySymbol(rawSymbol);
// Transaction type
@@ -126,7 +137,7 @@ function show(txInfo) {
if (isErc20) {
$("confirm-token-contract").innerHTML = renderAddressHtml(
txInfo.token,
{},
{ chainId: txInfo.chainId },
);
tokenSection.classList.remove("hidden");
attachCopyHandlers(tokenSection);
@@ -140,6 +151,7 @@ function show(txInfo) {
txInfo.from,
null,
fromTitle,
txInfo.chainId,
);
// To (with blockie)
@@ -148,14 +160,15 @@ function show(txInfo) {
txInfo.to,
txInfo.ensName,
toTitle,
txInfo.chainId,
);
$("confirm-to-ens").classList.add("hidden");
renderAmount(txInfo);
// Balance (with inline USD)
const ethPrice = getPrice("ETH");
const tokenPrice = getPrice(rawSymbol);
const ethPrice = getPrice("ETH", txInfo.chainId);
const tokenPrice = getPrice(rawSymbol, txInfo.chainId);
if (isErc20) {
// null is a balance whose scale nothing knows, not a balance of zero
// (https://git.eeqj.de/sneak/AutistMask/issues/349). The send is
@@ -212,9 +225,9 @@ function show(txInfo) {
$("confirm-gas-error").classList.toggle("hidden", !isErc20);
$("confirm-gas-error").textContent =
"You do not have enough " +
nativeCurrency() +
nativeCurrencyByChainId(txInfo.chainId) +
" to pay the network fee for this transfer. Please add " +
nativeCurrency() +
nativeCurrencyByChainId(txInfo.chainId) +
" to this address and try again.";
// Both shown later, by checkRecipientHistory(). The first sentence says
@@ -222,12 +235,12 @@ function show(txInfo) {
$("confirm-recipient-warning").textContent =
"WARNING: The recipient address has never sent a transaction and" +
" holds no " +
nativeCurrency() +
nativeCurrencyByChainId(txInfo.chainId) +
". This may indicate a fresh or unused address. Double-check the" +
" address before sending.";
$("confirm-contract-warning").textContent =
"WARNING: The recipient is a smart contract. Sending " +
nativeCurrency() +
nativeCurrencyByChainId(txInfo.chainId) +
" or tokens directly to a contract may result in permanent loss of" +
" funds.";
@@ -280,7 +293,7 @@ function renderValidation(txInfo) {
const isErc20 = txInfo.token !== "ETH";
const symbol = isErc20
? displaySymbol(txInfo.tokenSymbol || "?")
: nativeCurrency();
: nativeCurrencyByChainId(txInfo.chainId);
const { canSend, codes } = validateTransfer({
isErc20,
@@ -369,9 +382,23 @@ function setVisible(id, visible) {
$(id).style.visibility = visible ? "visible" : "hidden";
}
// The node of the network this transaction was reviewed for, which need not be
// the active one: the popup can reopen onto this screen after Settings switched
// the network. Its RPC URL is the one Settings has for that network: the
// active network's is state.rpcUrl, any other's the one remembered for it (see
// applyChainSwitchFields()).
function reviewedNetworkProvider(txInfo) {
const net = networkByChainId(txInfo.chainId);
if (net.id === state.networkId) {
return getProvider(state.rpcUrl, net.id);
}
const remembered = state.networkEndpoints[net.id] || {};
return getProvider(remembered.rpcUrl || net.defaultRpcUrl, net.id);
}
async function estimateGas(txInfo) {
try {
const provider = getProvider(state.rpcUrl, state.networkId);
const provider = reviewedNetworkProvider(txInfo);
const feeData = await provider.getFeeData();
let gasLimit;
@@ -419,12 +446,17 @@ async function estimateGas(txInfo) {
// does, so the same fee reads the same on both.
if (estimateWei !== null && estimateWei < gasCostWei) {
$("confirm-fee-amount").textContent =
"~" + formatFee(estimateWei, nativeCurrency());
"~" +
formatFee(
estimateWei,
nativeCurrencyByChainId(txInfo.chainId),
txInfo.chainId,
);
$("confirm-fee-reserve").textContent =
"up to " +
truncateAmountNeverZero(formatEther(gasCostWei)) +
" " +
nativeCurrency() +
nativeCurrencyByChainId(txInfo.chainId) +
" reserved";
setVisible("confirm-fee-reserve", true);
} else {
@@ -433,7 +465,8 @@ async function estimateGas(txInfo) {
// cost is not below the reserve. Show the reserve alone.
$("confirm-fee-amount").textContent = formatFee(
gasCostWei,
nativeCurrency(),
nativeCurrencyByChainId(txInfo.chainId),
txInfo.chainId,
);
setVisible("confirm-fee-reserve", false);
}
@@ -523,9 +556,10 @@ function showSendError(message) {
async function checkRecipientHistory(txInfo) {
try {
const provider = getProvider(state.rpcUrl, state.networkId);
const provider = reviewedNetworkProvider(txInfo);
const asyncWarnings = await getFullWarnings(txInfo.to, provider, {
fromAddress: txInfo.from,
chainId: txInfo.chainId,
});
for (const w of asyncWarnings) {
if (w.type === "contract") {
@@ -564,8 +598,10 @@ function init(_ctx) {
return;
}
// Back from Settings only unhides this screen, still drawn for the
// network that was active when it was shown.
// The screen describes the network the transaction was reviewed for,
// which Settings may have switched away from since: with this screen
// open, where Back only unhides it, or before the popup reopened onto
// it.
if (currentNetwork().chainId !== pendingTx.chainId) {
showSendError(
"The active network changed after this transaction was" +
+27 -20
View File
@@ -22,6 +22,7 @@ const { resolveTokenSymbol } = require("../../shared/approvalAmount");
const { DEBUG } = require("../../shared/constants");
const { escapeHtml } = require("../../shared/html");
const { isDebug } = require("../../shared/log");
const { networkByChainId } = require("../../shared/networks");
const { formatUsd, getPrice } = require("../../shared/prices");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { displaySymbol } = require("../../shared/symbolDisplay");
@@ -284,11 +285,12 @@ function unknownableAmount(balance) {
// The active network's native token symbol, `ETH` on mainnet and `SepoliaETH`
// on Sepolia, as src/shared/networks.js names it. The wallet's balances and
// the Send and confirmation screens, which send on the active network, label a
// native amount with this; a transaction already made or requested is labelled
// by its own chain id, through nativeCurrencyByChainId() in networks.js. The
// "ETH" that state.selectedToken and txInfo.token hold is the native token's
// id, not its label, and stays "ETH" on every network.
// the Send screen, which sends on the active network, label a native amount
// with this; the confirmation screen, and a transaction already made or
// requested, label it by the transaction's own chain id, through
// nativeCurrencyByChainId() in networks.js. The "ETH" that state.selectedToken
// and txInfo.token hold is the native token's id, not its label, and stays
// "ETH" on every network.
function nativeCurrency() {
return currentNetwork().nativeCurrency;
}
@@ -307,11 +309,12 @@ function tokenLabel(address) {
// A network fee in wei as the confirmation and approval screens both show it:
// the ETH figure through truncateAmountNeverZero() and labelled `symbol`, the
// native currency of the network the fee is paid on, then its USD value when
// the ETH price is known. The USD value is of the exact fee, not of the
// truncated figure.
function formatFee(wei, symbol) {
// the ETH price is known on the network with `chainId`, or on the active
// network when no chain id is given. The USD value is of the exact fee, not of
// the truncated figure.
function formatFee(wei, symbol, chainId) {
const eth = formatEther(wei);
const ethPrice = getPrice("ETH");
const ethPrice = getPrice("ETH", chainId);
const usd = ethPrice ? formatUsd(parseFloat(eth) * ethPrice) : "";
return (
truncateAmountNeverZero(eth) +
@@ -604,17 +607,19 @@ const EXT_ICON =
`</svg></span>`;
// Block-explorer URLs. The origin is a per-network constant from
// src/shared/networks.js; only the path segment is data, and it comes out
// of explorer JSON (a transaction's from/to, a token's address_hash), which
// nothing upstream validates as hex. percent-encoding it keeps a segment
// that contains a slash, a query or a fragment from re-pointing the link
// somewhere else in the explorer.
function explorerUrl(kind, value) {
return `${currentNetwork().explorerUrl}/${kind}/${encodeURIComponent(value)}`;
// src/shared/networks.js, the explorer of the network with `chainId`, or of
// the active network when no chain id is given; only the path segment is
// data, and it comes out of explorer JSON (a transaction's from/to, a token's
// address_hash), which nothing upstream validates as hex. percent-encoding it
// keeps a segment that contains a slash, a query or a fragment from
// re-pointing the link somewhere else in the explorer.
function explorerUrl(kind, value, chainId) {
const network = chainId ? networkByChainId(chainId) : currentNetwork();
return `${network.explorerUrl}/${kind}/${encodeURIComponent(value)}`;
}
function etherscanAddressUrl(address) {
return explorerUrl("address", address);
function etherscanAddressUrl(address, chainId) {
return explorerUrl("address", address, chainId);
}
// The URL still has to be escaped on the way into href="...": encoding
@@ -680,16 +685,18 @@ function attachCopyHandlers(container) {
// with them, and the "at least 32 characters" guarantee has
// to survive having no current callers to be a guarantee.
// noLink — if true, omit etherscan link
// chainId — the network whose explorer the link opens; the active
// network's when absent
//
// After inserting the returned HTML into the DOM, call
// attachCopyHandlers() on the parent to wire up click-to-copy.
function renderAddressHtml(address, opts) {
const { title, ensName, maxLen, noLink } = opts || {};
const { title, ensName, maxLen, noLink, chainId } = opts || {};
const dot = addressDotHtml(address);
const displayAddr = maxLen
? truncateMiddle(address, Math.max(ADDRESS_MIN_DISPLAY_LEN, maxLen))
: address;
const link = etherscanAddressUrl(address);
const link = etherscanAddressUrl(address, chainId);
const extLink = noLink ? "" : etherscanLinkHtml(link);
let html = "";
+6 -1
View File
@@ -58,6 +58,8 @@ function getLocalWarnings(address, options = {}) {
* @param {object} provider - An ethers.js provider instance.
* @param {object} [options] - Optional context.
* @param {string} [options.fromAddress] - Sender address (for self-send check).
* @param {string} [options.chainId] - Chain id of the network whose explorer
* the Etherscan label check asks; the active network's when omitted.
* @returns {Promise<Array<{type: string, message: string, severity: string}>>}
*/
async function getFullWarnings(address, provider, options = {}) {
@@ -110,7 +112,10 @@ async function getFullWarnings(address, provider, options = {}) {
// Runs for ALL addresses including contracts, since many dangerous
// flagged addresses on Etherscan (drainers, phishing contracts) are contracts.
try {
const etherscanWarning = await checkEtherscanLabel(address);
const etherscanWarning = await checkEtherscanLabel(
address,
options.chainId,
);
if (etherscanWarning) {
warnings.push(etherscanWarning);
}
+9 -4
View File
@@ -2,6 +2,8 @@
// Extension users make the requests directly to Etherscan — no proxy needed.
// This is a best-effort enrichment: network failures return null silently.
const { networkByChainId } = require("./networks");
// Patterns in the page title that indicate a flagged address.
// Title format: "Fake_Phishing184810 | Address: 0x... | Etherscan"
const PHISHING_LABEL_PATTERNS = [/^Fake_Phishing/i, /^Phish:/i, /^Exploiter/i];
@@ -72,18 +74,21 @@ function parseEtherscanPage(html) {
* Returns a warning object if the address is flagged, or null.
* Network failures return null silently (best-effort check).
*
* Uses the current network's explorer URL so the lookup works on both
* mainnet (etherscan.io) and Sepolia (sepolia.etherscan.io).
* Uses the explorer of the network with `chainId`, or of the active network
* when it is omitted: etherscan.io on mainnet, sepolia.etherscan.io on
* Sepolia.
*
* @param {string} address - Ethereum address to check.
* @param {string} [chainId] - Chain id of the network to look it up on.
* @returns {Promise<{type: string, message: string, severity: string}|null>}
*/
async function checkEtherscanLabel(address) {
async function checkEtherscanLabel(address, chainId) {
try {
// Lazy require to avoid pulling in chrome.storage at module scope
// (which breaks unit tests that only exercise parseEtherscanPage).
const { currentNetwork } = require("./state");
const etherscanBase = currentNetwork().explorerUrl + "/address/";
const network = chainId ? networkByChainId(chainId) : currentNetwork();
const etherscanBase = network.explorerUrl + "/address/";
const resp = await fetch(etherscanBase + address, {
headers: { Accept: "text/html" },
});
+6 -3
View File
@@ -2,6 +2,7 @@
const { getTopTokenPrices } = require("./tokenList");
const { isBelowOneMillionth } = require("./amountDisplay");
const { networkByChainId } = require("./networks");
const PRICE_CACHE_TTL = 300000; // 5 minutes
@@ -36,10 +37,12 @@ function clearPrices() {
lastFetchedAt = 0;
}
// Return the USD price for a symbol, or null on testnet / unknown.
function getPrice(symbol) {
// Return the USD price for a symbol on the network with `chainId`, or on the
// active network when no chain id is given; null on testnet / unknown.
function getPrice(symbol, chainId) {
const { currentNetwork } = require("./state");
if (currentNetwork().isTestnet) return null;
const network = chainId ? networkByChainId(chainId) : currentNetwork();
if (network.isTestnet) return null;
return prices[symbol] || null;
}