fix: a confirmation screen drawn again keeps the network it was reviewed for (closes #586)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run

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
+18 -8
View File
@@ -1013,10 +1013,12 @@ rendered differently on two screens.
The native token's label is a network's `nativeCurrency` in The native token's label is a network's `nativeCurrency` in
`src/shared/networks.js`: `ETH` on mainnet, `SepoliaETH` on Sepolia. The `src/shared/networks.js`: `ETH` on mainnet, `SepoliaETH` on Sepolia. The
wallet's balances and the Send and confirmation screens, which send on the wallet's balances and the Send screen, which sends on the active network, use
active network, use the active network's. A transaction's figures use the one of the active network's. The confirmation screen uses the one of the network its
the network its chain id names, whichever network is active: the value and fee transaction was reviewed on, the network active when Send showed it, even after
on the approval screen, the amount on the wait, success and error screens, the the network is switched in Settings. A transaction's figures use the one of the
network its chain id names, whichever network is active: the value and fee on
the approval screen, the amount on the wait, success and error screens, the
transaction history and the transaction detail screen, and the refusal of a fee transaction history and the transaction detail screen, and the refusal of a fee
above 1 ETH. A chain id that names no network reads `ETH`. Wherever this above 1 ETH. A chain id that names no network reads `ETH`. Wherever this
document shows ETH as the label of a native balance, value or fee, in a "Native document shows ETH as the label of a native balance, value or fee, in a "Native
@@ -1782,12 +1784,20 @@ view would leave a wallet one click from deletion.
the contract's own answer is read at signing time only to be compared with the contract's own answer is read at signing time only to be compared with
it, and a disagreement is a refusal rather than a preference for either it, and a disagreement is a refusal rather than a preference for either
value (`src/shared/transferAmount.js`) value (`src/shared/transferAmount.js`)
- "Sign & Send" after the active network was switched in Settings while this - "Sign & Send" after the active network was switched in Settings since this
screen was open → nothing is signed; "The active network changed after transaction was reviewed, with this screen open or before the popup
reopened onto it → nothing is signed; "The active network changed after
this transaction was reviewed, so it was not sent. Please go back and this transaction was reviewed, so it was not sent. Please go back and
review it again." in the errors box, no screen change. The transaction review it again." in the errors box, no screen change. The transaction
carries the chain id of the network the screen was shown for, so it cannot carries the chain id of the network active when Send showed this screen,
be signed for another and keeps it when the screen is drawn again from the saved transaction, so
it cannot be signed for another. The screen describes it on that network,
whichever network is active: its amounts are in that network's native
currency, its USD values are that network's, so a testnet transaction
shows none, its From, To and token contract links open that network's
explorer, the fee estimate and the contract and never-sent recipient
checks ask that network's RPC endpoint, and the Etherscan label check asks
that network's explorer
- "Sign & Send" (wrong password) → "Wrong password." on the password error - "Sign & Send" (wrong password) → "Wrong password." on the password error
line, no screen change line, no screen change
- "Sign & Send" signs as the From address above, not as whichever address is - "Sign & Send" signs as the From address above, not as whichever address is
+6 -2
View File
@@ -13,6 +13,7 @@
// cannot be required outside a browser. // cannot be required outside a browser.
const { RESTORABLE_VIEWS } = require("../shared/restorableViews"); const { RESTORABLE_VIEWS } = require("../shared/restorableViews");
const { SUPPORTED_CHAIN_IDS } = require("../shared/networks");
// The views this page load has rendered. // The views this page load has rendered.
// //
@@ -125,13 +126,16 @@ function isRenderableDecoded(value) {
// The pending transaction confirmTx.show() renders: `token` reaches // The pending transaction confirmTx.show() renders: `token` reaches
// renderAddressHtml() when it is not "ETH", and `from`/`to` reach // 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) { function isRenderablePendingTx(value) {
return ( return (
isRecord(value) && isRecord(value) &&
isText(value.token) && isText(value.token) &&
isAddressText(value.from) && 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, addressTitle,
escapeHtml, escapeHtml,
displaySymbol, displaySymbol,
nativeCurrency,
renderAddressHtml, renderAddressHtml,
blockieHtml, blockieHtml,
attachCopyHandlers, attachCopyHandlers,
@@ -20,6 +19,10 @@ const {
formatFee, formatFee,
} = require("./helpers"); } = require("./helpers");
const { state, currentNetwork } = require("../../shared/state"); const { state, currentNetwork } = require("../../shared/state");
const {
networkByChainId,
nativeCurrencyByChainId,
} = require("../../shared/networks");
const { getSignerForAddress } = require("../../shared/wallet"); const { getSignerForAddress } = require("../../shared/wallet");
const { walletDefect } = require("../../shared/walletDefects"); const { walletDefect } = require("../../shared/walletDefects");
const { decryptWithPassword } = require("../../shared/vault"); 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); const blockie = blockieHtml(address);
return ( return (
`<div class="mb-1">${blockie}</div>` + `<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. // once its amount is re-derived from the fee estimate.
function renderAmount(txInfo) { function renderAmount(txInfo) {
const isErc20 = txInfo.token !== "ETH"; const isErc20 = txInfo.token !== "ETH";
const rawSymbol = isErc20 ? txInfo.tokenSymbol || "?" : nativeCurrency(); const rawSymbol = isErc20
const price = isErc20 ? getPrice(rawSymbol) : getPrice("ETH"); ? txInfo.tokenSymbol || "?"
: nativeCurrencyByChainId(txInfo.chainId);
const price = getPrice(isErc20 ? rawSymbol : "ETH", txInfo.chainId);
const amountUsd = price ? parseFloat(txInfo.amount) * price : null; const amountUsd = price ? parseFloat(txInfo.amount) * price : null;
$("confirm-amount").textContent = valueWithUsd( $("confirm-amount").textContent = valueWithUsd(
txInfo.amount + " " + displaySymbol(rawSymbol), txInfo.amount + " " + displaySymbol(rawSymbol),
@@ -101,16 +106,22 @@ function show(txInfo) {
feeStatus = FEE_PENDING; feeStatus = FEE_PENDING;
feeWei = null; feeWei = null;
maxSendFees = null; maxSendFees = null;
// The network this screen is drawn for, and the only one it sends on: // The network this screen is drawn for, and the only one it sends on: the
// Settings can switch the active network while the screen is open. The // one active when Send handed the transaction over. Drawn again from the
// wait, success and error screens label the amount by it too. // saved transaction, as when the popup reopens onto this screen, it keeps
txInfo.chainId = currentNetwork().chainId; // 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"; const isErc20 = txInfo.token !== "ETH";
// The raw symbol is the price-table key; the capped one is what the // The raw symbol is the price-table key; the capped one is what the
// screen says. Truncating before the lookup would silently drop the // screen says. Truncating before the lookup would silently drop the
// price of any token whose symbol is long enough to be capped. // 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); const symbol = displaySymbol(rawSymbol);
// Transaction type // Transaction type
@@ -126,7 +137,7 @@ function show(txInfo) {
if (isErc20) { if (isErc20) {
$("confirm-token-contract").innerHTML = renderAddressHtml( $("confirm-token-contract").innerHTML = renderAddressHtml(
txInfo.token, txInfo.token,
{}, { chainId: txInfo.chainId },
); );
tokenSection.classList.remove("hidden"); tokenSection.classList.remove("hidden");
attachCopyHandlers(tokenSection); attachCopyHandlers(tokenSection);
@@ -140,6 +151,7 @@ function show(txInfo) {
txInfo.from, txInfo.from,
null, null,
fromTitle, fromTitle,
txInfo.chainId,
); );
// To (with blockie) // To (with blockie)
@@ -148,14 +160,15 @@ function show(txInfo) {
txInfo.to, txInfo.to,
txInfo.ensName, txInfo.ensName,
toTitle, toTitle,
txInfo.chainId,
); );
$("confirm-to-ens").classList.add("hidden"); $("confirm-to-ens").classList.add("hidden");
renderAmount(txInfo); renderAmount(txInfo);
// Balance (with inline USD) // Balance (with inline USD)
const ethPrice = getPrice("ETH"); const ethPrice = getPrice("ETH", txInfo.chainId);
const tokenPrice = getPrice(rawSymbol); const tokenPrice = getPrice(rawSymbol, txInfo.chainId);
if (isErc20) { if (isErc20) {
// null is a balance whose scale nothing knows, not a balance of zero // null is a balance whose scale nothing knows, not a balance of zero
// (https://git.eeqj.de/sneak/AutistMask/issues/349). The send is // (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").classList.toggle("hidden", !isErc20);
$("confirm-gas-error").textContent = $("confirm-gas-error").textContent =
"You do not have enough " + "You do not have enough " +
nativeCurrency() + nativeCurrencyByChainId(txInfo.chainId) +
" to pay the network fee for this transfer. Please add " + " to pay the network fee for this transfer. Please add " +
nativeCurrency() + nativeCurrencyByChainId(txInfo.chainId) +
" to this address and try again."; " to this address and try again.";
// Both shown later, by checkRecipientHistory(). The first sentence says // Both shown later, by checkRecipientHistory(). The first sentence says
@@ -222,12 +235,12 @@ function show(txInfo) {
$("confirm-recipient-warning").textContent = $("confirm-recipient-warning").textContent =
"WARNING: The recipient address has never sent a transaction and" + "WARNING: The recipient address has never sent a transaction and" +
" holds no " + " holds no " +
nativeCurrency() + nativeCurrencyByChainId(txInfo.chainId) +
". This may indicate a fresh or unused address. Double-check the" + ". This may indicate a fresh or unused address. Double-check the" +
" address before sending."; " address before sending.";
$("confirm-contract-warning").textContent = $("confirm-contract-warning").textContent =
"WARNING: The recipient is a smart contract. Sending " + "WARNING: The recipient is a smart contract. Sending " +
nativeCurrency() + nativeCurrencyByChainId(txInfo.chainId) +
" or tokens directly to a contract may result in permanent loss of" + " or tokens directly to a contract may result in permanent loss of" +
" funds."; " funds.";
@@ -280,7 +293,7 @@ function renderValidation(txInfo) {
const isErc20 = txInfo.token !== "ETH"; const isErc20 = txInfo.token !== "ETH";
const symbol = isErc20 const symbol = isErc20
? displaySymbol(txInfo.tokenSymbol || "?") ? displaySymbol(txInfo.tokenSymbol || "?")
: nativeCurrency(); : nativeCurrencyByChainId(txInfo.chainId);
const { canSend, codes } = validateTransfer({ const { canSend, codes } = validateTransfer({
isErc20, isErc20,
@@ -369,9 +382,23 @@ function setVisible(id, visible) {
$(id).style.visibility = visible ? "visible" : "hidden"; $(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) { async function estimateGas(txInfo) {
try { try {
const provider = getProvider(state.rpcUrl, state.networkId); const provider = reviewedNetworkProvider(txInfo);
const feeData = await provider.getFeeData(); const feeData = await provider.getFeeData();
let gasLimit; let gasLimit;
@@ -419,12 +446,17 @@ async function estimateGas(txInfo) {
// does, so the same fee reads the same on both. // does, so the same fee reads the same on both.
if (estimateWei !== null && estimateWei < gasCostWei) { if (estimateWei !== null && estimateWei < gasCostWei) {
$("confirm-fee-amount").textContent = $("confirm-fee-amount").textContent =
"~" + formatFee(estimateWei, nativeCurrency()); "~" +
formatFee(
estimateWei,
nativeCurrencyByChainId(txInfo.chainId),
txInfo.chainId,
);
$("confirm-fee-reserve").textContent = $("confirm-fee-reserve").textContent =
"up to " + "up to " +
truncateAmountNeverZero(formatEther(gasCostWei)) + truncateAmountNeverZero(formatEther(gasCostWei)) +
" " + " " +
nativeCurrency() + nativeCurrencyByChainId(txInfo.chainId) +
" reserved"; " reserved";
setVisible("confirm-fee-reserve", true); setVisible("confirm-fee-reserve", true);
} else { } else {
@@ -433,7 +465,8 @@ async function estimateGas(txInfo) {
// cost is not below the reserve. Show the reserve alone. // cost is not below the reserve. Show the reserve alone.
$("confirm-fee-amount").textContent = formatFee( $("confirm-fee-amount").textContent = formatFee(
gasCostWei, gasCostWei,
nativeCurrency(), nativeCurrencyByChainId(txInfo.chainId),
txInfo.chainId,
); );
setVisible("confirm-fee-reserve", false); setVisible("confirm-fee-reserve", false);
} }
@@ -523,9 +556,10 @@ function showSendError(message) {
async function checkRecipientHistory(txInfo) { async function checkRecipientHistory(txInfo) {
try { try {
const provider = getProvider(state.rpcUrl, state.networkId); const provider = reviewedNetworkProvider(txInfo);
const asyncWarnings = await getFullWarnings(txInfo.to, provider, { const asyncWarnings = await getFullWarnings(txInfo.to, provider, {
fromAddress: txInfo.from, fromAddress: txInfo.from,
chainId: txInfo.chainId,
}); });
for (const w of asyncWarnings) { for (const w of asyncWarnings) {
if (w.type === "contract") { if (w.type === "contract") {
@@ -564,8 +598,10 @@ function init(_ctx) {
return; return;
} }
// Back from Settings only unhides this screen, still drawn for the // The screen describes the network the transaction was reviewed for,
// network that was active when it was shown. // 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) { if (currentNetwork().chainId !== pendingTx.chainId) {
showSendError( showSendError(
"The active network changed after this transaction was" + "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 { DEBUG } = require("../../shared/constants");
const { escapeHtml } = require("../../shared/html"); const { escapeHtml } = require("../../shared/html");
const { isDebug } = require("../../shared/log"); const { isDebug } = require("../../shared/log");
const { networkByChainId } = require("../../shared/networks");
const { formatUsd, getPrice } = require("../../shared/prices"); const { formatUsd, getPrice } = require("../../shared/prices");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { displaySymbol } = require("../../shared/symbolDisplay"); const { displaySymbol } = require("../../shared/symbolDisplay");
@@ -284,11 +285,12 @@ function unknownableAmount(balance) {
// The active network's native token symbol, `ETH` on mainnet and `SepoliaETH` // 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 // 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 // the Send screen, which sends on the active network, label a native amount
// native amount with this; a transaction already made or requested is labelled // with this; the confirmation screen, and a transaction already made or
// by its own chain id, through nativeCurrencyByChainId() in networks.js. The // requested, label it by the transaction's own chain id, through
// "ETH" that state.selectedToken and txInfo.token hold is the native token's // nativeCurrencyByChainId() in networks.js. The "ETH" that state.selectedToken
// id, not its label, and stays "ETH" on every network. // and txInfo.token hold is the native token's id, not its label, and stays
// "ETH" on every network.
function nativeCurrency() { function nativeCurrency() {
return currentNetwork().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: // A network fee in wei as the confirmation and approval screens both show it:
// the ETH figure through truncateAmountNeverZero() and labelled `symbol`, the // the ETH figure through truncateAmountNeverZero() and labelled `symbol`, the
// native currency of the network the fee is paid on, then its USD value when // 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 // the ETH price is known on the network with `chainId`, or on the active
// truncated figure. // network when no chain id is given. The USD value is of the exact fee, not of
function formatFee(wei, symbol) { // the truncated figure.
function formatFee(wei, symbol, chainId) {
const eth = formatEther(wei); const eth = formatEther(wei);
const ethPrice = getPrice("ETH"); const ethPrice = getPrice("ETH", chainId);
const usd = ethPrice ? formatUsd(parseFloat(eth) * ethPrice) : ""; const usd = ethPrice ? formatUsd(parseFloat(eth) * ethPrice) : "";
return ( return (
truncateAmountNeverZero(eth) + truncateAmountNeverZero(eth) +
@@ -604,17 +607,19 @@ const EXT_ICON =
`</svg></span>`; `</svg></span>`;
// Block-explorer URLs. The origin is a per-network constant from // 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 // src/shared/networks.js, the explorer of the network with `chainId`, or of
// of explorer JSON (a transaction's from/to, a token's address_hash), which // the active network when no chain id is given; only the path segment is
// nothing upstream validates as hex. percent-encoding it keeps a segment // data, and it comes out of explorer JSON (a transaction's from/to, a token's
// that contains a slash, a query or a fragment from re-pointing the link // address_hash), which nothing upstream validates as hex. percent-encoding it
// somewhere else in the explorer. // keeps a segment that contains a slash, a query or a fragment from
function explorerUrl(kind, value) { // re-pointing the link somewhere else in the explorer.
return `${currentNetwork().explorerUrl}/${kind}/${encodeURIComponent(value)}`; function explorerUrl(kind, value, chainId) {
const network = chainId ? networkByChainId(chainId) : currentNetwork();
return `${network.explorerUrl}/${kind}/${encodeURIComponent(value)}`;
} }
function etherscanAddressUrl(address) { function etherscanAddressUrl(address, chainId) {
return explorerUrl("address", address); return explorerUrl("address", address, chainId);
} }
// The URL still has to be escaped on the way into href="...": encoding // 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 // with them, and the "at least 32 characters" guarantee has
// to survive having no current callers to be a guarantee. // to survive having no current callers to be a guarantee.
// noLink — if true, omit etherscan link // 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 // After inserting the returned HTML into the DOM, call
// attachCopyHandlers() on the parent to wire up click-to-copy. // attachCopyHandlers() on the parent to wire up click-to-copy.
function renderAddressHtml(address, opts) { function renderAddressHtml(address, opts) {
const { title, ensName, maxLen, noLink } = opts || {}; const { title, ensName, maxLen, noLink, chainId } = opts || {};
const dot = addressDotHtml(address); const dot = addressDotHtml(address);
const displayAddr = maxLen const displayAddr = maxLen
? truncateMiddle(address, Math.max(ADDRESS_MIN_DISPLAY_LEN, maxLen)) ? truncateMiddle(address, Math.max(ADDRESS_MIN_DISPLAY_LEN, maxLen))
: address; : address;
const link = etherscanAddressUrl(address); const link = etherscanAddressUrl(address, chainId);
const extLink = noLink ? "" : etherscanLinkHtml(link); const extLink = noLink ? "" : etherscanLinkHtml(link);
let html = ""; let html = "";
+6 -1
View File
@@ -58,6 +58,8 @@ function getLocalWarnings(address, options = {}) {
* @param {object} provider - An ethers.js provider instance. * @param {object} provider - An ethers.js provider instance.
* @param {object} [options] - Optional context. * @param {object} [options] - Optional context.
* @param {string} [options.fromAddress] - Sender address (for self-send check). * @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}>>} * @returns {Promise<Array<{type: string, message: string, severity: string}>>}
*/ */
async function getFullWarnings(address, provider, options = {}) { 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 // Runs for ALL addresses including contracts, since many dangerous
// flagged addresses on Etherscan (drainers, phishing contracts) are contracts. // flagged addresses on Etherscan (drainers, phishing contracts) are contracts.
try { try {
const etherscanWarning = await checkEtherscanLabel(address); const etherscanWarning = await checkEtherscanLabel(
address,
options.chainId,
);
if (etherscanWarning) { if (etherscanWarning) {
warnings.push(etherscanWarning); warnings.push(etherscanWarning);
} }
+9 -4
View File
@@ -2,6 +2,8 @@
// Extension users make the requests directly to Etherscan — no proxy needed. // Extension users make the requests directly to Etherscan — no proxy needed.
// This is a best-effort enrichment: network failures return null silently. // 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. // Patterns in the page title that indicate a flagged address.
// Title format: "Fake_Phishing184810 | Address: 0x... | Etherscan" // Title format: "Fake_Phishing184810 | Address: 0x... | Etherscan"
const PHISHING_LABEL_PATTERNS = [/^Fake_Phishing/i, /^Phish:/i, /^Exploiter/i]; 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. * Returns a warning object if the address is flagged, or null.
* Network failures return null silently (best-effort check). * Network failures return null silently (best-effort check).
* *
* Uses the current network's explorer URL so the lookup works on both * Uses the explorer of the network with `chainId`, or of the active network
* mainnet (etherscan.io) and Sepolia (sepolia.etherscan.io). * when it is omitted: etherscan.io on mainnet, sepolia.etherscan.io on
* Sepolia.
* *
* @param {string} address - Ethereum address to check. * @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>} * @returns {Promise<{type: string, message: string, severity: string}|null>}
*/ */
async function checkEtherscanLabel(address) { async function checkEtherscanLabel(address, chainId) {
try { try {
// Lazy require to avoid pulling in chrome.storage at module scope // Lazy require to avoid pulling in chrome.storage at module scope
// (which breaks unit tests that only exercise parseEtherscanPage). // (which breaks unit tests that only exercise parseEtherscanPage).
const { currentNetwork } = require("./state"); 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, { const resp = await fetch(etherscanBase + address, {
headers: { Accept: "text/html" }, headers: { Accept: "text/html" },
}); });
+6 -3
View File
@@ -2,6 +2,7 @@
const { getTopTokenPrices } = require("./tokenList"); const { getTopTokenPrices } = require("./tokenList");
const { isBelowOneMillionth } = require("./amountDisplay"); const { isBelowOneMillionth } = require("./amountDisplay");
const { networkByChainId } = require("./networks");
const PRICE_CACHE_TTL = 300000; // 5 minutes const PRICE_CACHE_TTL = 300000; // 5 minutes
@@ -36,10 +37,12 @@ function clearPrices() {
lastFetchedAt = 0; lastFetchedAt = 0;
} }
// Return the USD price for a symbol, or null on testnet / unknown. // Return the USD price for a symbol on the network with `chainId`, or on the
function getPrice(symbol) { // active network when no chain id is given; null on testnet / unknown.
function getPrice(symbol, chainId) {
const { currentNetwork } = require("./state"); const { currentNetwork } = require("./state");
if (currentNetwork().isTestnet) return null; const network = chainId ? networkByChainId(chainId) : currentNetwork();
if (network.isTestnet) return null;
return prices[symbol] || null; return prices[symbol] || null;
} }
+288 -9
View File
@@ -7,25 +7,52 @@
// reviewed on Sepolia went out as 1 ETH on mainnet once mainnet was chosen in // reviewed on Sepolia went out as 1 ETH on mainnet once mainnet was chosen in
// Settings in between. // Settings in between.
// //
// The same holds when the popup was closed after the switch and reopened, and
// Back drew the screen again from the transaction it saved
// (https://git.eeqj.de/sneak/AutistMask/issues/586): the screen was drawn for
// the new network, and Sign & Send sent the reviewed transfer there. Drawn
// again, the screen describes the transaction entirely on the network it was
// reviewed for, because choosing that network again in Settings and pressing
// Back only unhides it, and Sign & Send then sends from it.
//
// Driven through the real confirmation screen, signer and chain switch, with // Driven through the real confirmation screen, signer and chain switch, with
// only the node and the DOM stubbed. The stub node is on the network its // only the node, fetch and the DOM stubbed. The stub node is on the network
// provider was built for, so a broadcast records the network it went to. // its provider was built for, so a broadcast records the network it went to.
"use strict"; "use strict";
// The signed transactions the stub node was handed, with its network's chain id. // The questions the stub node was asked for the fee and the recipient checks,
const mockNode = { broadcast: [] }; // by method, network name and RPC URL, in order. The signed transactions it
// was handed, with its network's chain id and RPC URL. And whether it quotes
// fees as a network with no type-2 pricing does.
const mockNode = { asked: [], broadcast: [], legacyFees: false };
jest.mock("ethers", () => { jest.mock("ethers", () => {
const actual = jest.requireActual("ethers"); const actual = jest.requireActual("ethers");
class StubProvider { class StubProvider {
constructor(url, network) { constructor(url, network) {
this.url = url;
this.network = network; this.network = network;
} }
record(method) {
mockNode.asked.push({
method,
network: this.network.name,
url: this.url,
});
}
async getNetwork() { async getNetwork() {
return this.network; return this.network;
} }
async getFeeData() { async getFeeData() {
this.record("getFeeData");
if (mockNode.legacyFees) {
return {
maxFeePerGas: null,
maxPriorityFeePerGas: null,
gasPrice: 10000000000n,
};
}
return { return {
maxFeePerGas: 20000000000n, maxFeePerGas: 20000000000n,
maxPriorityFeePerGas: 1000000000n, maxPriorityFeePerGas: 1000000000n,
@@ -36,16 +63,22 @@ jest.mock("ethers", () => {
return 21000n; return 21000n;
} }
async getCode() { async getCode() {
this.record("getCode");
return "0x"; return "0x";
} }
async getTransactionCount() { async getTransactionCount() {
this.record("getTransactionCount");
return 1; return 1;
} }
async lookupAddress() { async lookupAddress() {
return null; return null;
} }
async broadcastTransaction(signed) { async broadcastTransaction(signed) {
mockNode.broadcast.push({ chainId: this.network.chainId, signed }); mockNode.broadcast.push({
chainId: this.network.chainId,
url: this.url,
signed,
});
return { hash: "0x" + "ab".repeat(32) }; return { hash: "0x" + "ab".repeat(32) };
} }
} }
@@ -75,6 +108,8 @@ jest.mock("../src/popup/views/txStatus", () => ({
showError: jest.fn(), showError: jest.fn(),
})); }));
// The Etherscan label check is its only caller here: the explorer pages it
// asks for are recorded, and nothing is fetched.
global.fetch = jest.fn(() => { global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests"); throw new Error("tests must not perform network requests");
}); });
@@ -125,15 +160,27 @@ global.navigator = { clipboard: { writeText() {} } };
const { BaseWallet, Transaction, Wallet } = require("ethers"); const { BaseWallet, Transaction, Wallet } = require("ethers");
const { encryptWithPassword } = require("../src/shared/vault"); const { encryptWithPassword } = require("../src/shared/vault");
const { state, loadState } = require("../src/shared/state"); const { state, loadState, saveState } = require("../src/shared/state");
const { onChainSwitch } = require("../src/shared/chainSwitch"); const { onChainSwitch } = require("../src/shared/chainSwitch");
const { NETWORKS } = require("../src/shared/networks"); const { NETWORKS } = require("../src/shared/networks");
const { prices } = require("../src/shared/prices");
const {
goBack,
pushCurrentView,
setBackRenderer,
showView,
} = require("../src/popup/views/helpers");
const {
makeBackRenderer,
resetRenderedViews,
} = require("../src/popup/viewRouter");
const txStatus = require("../src/popup/views/txStatus"); const txStatus = require("../src/popup/views/txStatus");
const confirmTx = require("../src/popup/views/confirmTx"); const confirmTx = require("../src/popup/views/confirmTx");
const PRIVATE_KEY = "0x" + "11".repeat(32); const PRIVATE_KEY = "0x" + "11".repeat(32);
const HOLDER = new Wallet(PRIVATE_KEY).address; const HOLDER = new Wallet(PRIVATE_KEY).address;
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe"; const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
const TOKEN = "0x7070707070707070707070707070707070707070";
const PASSWORD = "correct horse battery staple"; const PASSWORD = "correct horse battery staple";
function el(id) { function el(id) {
@@ -150,7 +197,10 @@ beforeAll(async () => {
beforeEach(() => { beforeEach(() => {
elements.clear(); elements.clear();
mockNode.asked = [];
mockNode.broadcast = []; mockNode.broadcast = [];
mockNode.legacyFees = false;
global.fetch.mockClear();
txStatus.showWait.mockClear(); txStatus.showWait.mockClear();
txStatus.showError.mockClear(); txStatus.showError.mockClear();
signTransaction = jest.spyOn(BaseWallet.prototype, "signTransaction"); signTransaction = jest.spyOn(BaseWallet.prototype, "signTransaction");
@@ -160,10 +210,16 @@ afterEach(() => {
signTransaction.mockRestore(); signTransaction.mockRestore();
}); });
// Show the confirmation screen for 1 of the native token on `networkId`, as // Show the confirmation screen for 1 of the native token on `networkId`, or
// Review on the Send screen does, with its fee estimate settled. // for the transfer `changes` makes of it, as Review on the Send screen does,
async function review(networkId) { // with its fee estimate settled.
async function review(networkId, changes = {}) {
await onChainSwitch(networkId); await onChainSwitch(networkId);
await reviewOnActiveNetwork(changes);
}
// review() on the network already active, without switching to it again.
async function reviewOnActiveNetwork(changes = {}) {
state.wallets = [ state.wallets = [
{ {
type: "key", type: "key",
@@ -186,7 +242,90 @@ async function review(networkId) {
tokenBalance: null, tokenBalance: null,
tokenDecimals: null, tokenDecimals: null,
max: false, max: false,
...changes,
}); });
await settle();
}
// The user's own node for a network, set in Settings.
function ownNode(networkId) {
return "https://" + networkId + ".node.example.invalid";
}
// After review() on `reviewedOn`: the user's own node set for it, the Settings
// gear, `switchedTo` chosen there, the popup closed and reopened onto
// Settings, and Back, which draws the confirmation screen again from the
// transaction it saved. The reopened page holds an ETH price of $2,000 and a
// USDC price of $1 whichever network is active, so each USD value shows which
// network it was priced for.
async function switchReopenAndGoBack(reviewedOn, switchedTo) {
state.rpcUrl = ownNode(reviewedOn);
pushCurrentView();
showView("settings");
await onChainSwitch(switchedTo);
await loadState();
elements.clear();
resetRenderedViews();
setBackRenderer(makeBackRenderer(state, { confirmTx }));
confirmTx.init({});
prices.ETH = 2000;
prices.USDC = 1;
mockNode.asked = [];
global.fetch.mockClear();
goBack();
await settle();
}
// The USD value `text` as the screen appends it for `networkId`: nothing on a
// testnet, which has no USD values.
function usdOn(networkId, text) {
return NETWORKS[networkId].isTestnet ? "" : " (" + text + ")";
}
// The screen for the transfer review() shows by default, described on
// `networkId`: in its native currency, priced by it, linking to its explorer.
function expectNativeTransferOn(networkId) {
const { nativeCurrency: symbol, explorerUrl } = NETWORKS[networkId];
expect(el("confirm-type").textContent).toBe(
"Native " + symbol + " transfer",
);
expect(el("confirm-amount").textContent).toBe(
"1 " + symbol + usdOn(networkId, "$2,000.00"),
);
expect(el("confirm-balance").textContent).toBe(
"5.0000 " + symbol + usdOn(networkId, "$10,000.00"),
);
expect(el("confirm-fee-amount").textContent).toBe(
"~0.0002 " + symbol + usdOn(networkId, "$0.42"),
);
expect(el("confirm-fee-reserve").textContent).toBe(
"up to 0.0004 " + symbol + " reserved",
);
expect(el("confirm-gas-error").textContent).toBe(
"You do not have enough " +
symbol +
" to pay the network fee for this transfer. Please add " +
symbol +
" to this address and try again.",
);
expect(el("confirm-recipient-warning").textContent).toContain(
" holds no " + symbol + ". ",
);
expect(el("confirm-contract-warning").textContent).toContain(
" Sending " + symbol + " or tokens ",
);
expect(el("confirm-from").innerHTML).toContain(
`href="${explorerUrl}/address/${HOLDER}"`,
);
expect(el("confirm-to").innerHTML).toContain(
`href="${explorerUrl}/address/${RECIPIENT}"`,
);
}
// show() estimates the fee and checks the recipient without awaiting either;
// this lets both finish.
async function settle() {
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0)); for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
} }
@@ -208,6 +347,24 @@ test("a transfer reviewed on Sepolia and sent goes out on Sepolia", async () =>
expect(txStatus.showWait).toHaveBeenCalledTimes(1); expect(txStatus.showWait).toHaveBeenCalledTimes(1);
}); });
// Saving an RPC endpoint in Settings (src/popup/views/settings.js) sets only
// state.rpcUrl: the endpoint remembered for the active network keeps its old
// value until the next switch away from it.
test("a transfer reviewed after the active network's RPC endpoint was changed in Settings asks the new endpoint", async () => {
const newNode = "https://new.node.example.invalid";
await onChainSwitch("sepolia");
state.rpcUrl = newNode;
await saveState();
await reviewOnActiveNetwork();
expect(mockNode.asked).toEqual([
{ method: "getFeeData", network: "sepolia", url: newNode },
{ method: "getCode", network: "sepolia", url: newNode },
{ method: "getTransactionCount", network: "sepolia", url: newNode },
]);
});
describe.each([ describe.each([
["sepolia", "mainnet", "SepoliaETH"], ["sepolia", "mainnet", "SepoliaETH"],
["mainnet", "sepolia", "ETH"], ["mainnet", "sepolia", "ETH"],
@@ -238,6 +395,128 @@ describe.each([
}, },
); );
describe.each([
["sepolia", "mainnet"],
["mainnet", "sepolia"],
])(
"a transfer reviewed on %s, with %s chosen in Settings, the popup reopened and Back pressed",
(reviewedOn, switchedTo) => {
const { nativeCurrency: symbol, explorerUrl } = NETWORKS[reviewedOn];
test("is described on the network it was reviewed for, and neither signed nor sent", async () => {
await review(reviewedOn);
await switchReopenAndGoBack(reviewedOn, switchedTo);
expect(state.currentView).toBe("confirm-tx");
expectNativeTransferOn(reviewedOn);
expect(mockNode.asked).toEqual([
{
method: "getFeeData",
network: reviewedOn,
url: ownNode(reviewedOn),
},
{
method: "getCode",
network: reviewedOn,
url: ownNode(reviewedOn),
},
{
method: "getTransactionCount",
network: reviewedOn,
url: ownNode(reviewedOn),
},
]);
expect(global.fetch.mock.calls.map(([url]) => url)).toEqual([
explorerUrl + "/address/" + RECIPIENT,
]);
expect(el("btn-confirm-send").disabled).toBe(false);
await signAndSend();
expect(signTransaction).not.toHaveBeenCalled();
expect(mockNode.broadcast).toEqual([]);
expect(txStatus.showWait).not.toHaveBeenCalled();
expect(txStatus.showError).not.toHaveBeenCalled();
expect(el("confirm-errors").innerHTML).toContain(
"The active network changed after this transaction was" +
" reviewed, so it was not sent.",
);
});
test("is sent on that network, as described, once it is chosen in Settings again and Back pressed", async () => {
await review(reviewedOn);
await switchReopenAndGoBack(reviewedOn, switchedTo);
// The Settings gear, the reviewed network chosen, and Back, which
// only unhides the screen as it was drawn.
pushCurrentView();
showView("settings");
await onChainSwitch(reviewedOn);
goBack();
expect(state.currentView).toBe("confirm-tx");
expectNativeTransferOn(reviewedOn);
await signAndSend();
const chainId = BigInt(NETWORKS[reviewedOn].chainId);
expect(mockNode.broadcast).toHaveLength(1);
expect(mockNode.broadcast[0].chainId).toBe(chainId);
expect(mockNode.broadcast[0].url).toBe(ownNode(reviewedOn));
expect(Transaction.from(mockNode.broadcast[0].signed).chainId).toBe(
chainId,
);
expect(txStatus.showWait).toHaveBeenCalledTimes(1);
});
test("for more than the balance, says so in that network's native currency", async () => {
await review(reviewedOn, { amount: "10" });
await switchReopenAndGoBack(reviewedOn, switchedTo);
expect(el("confirm-errors").innerHTML).toContain(
"Insufficient balance. You have 5.0000 " +
symbol +
" but are trying to send 10 " +
symbol +
".",
);
});
test("with a fee quoted by a node with no type-2 pricing, shows the fee in that network's native currency and price", async () => {
await review(reviewedOn);
mockNode.legacyFees = true;
await switchReopenAndGoBack(reviewedOn, switchedTo);
expect(el("confirm-fee-amount").textContent).toBe(
"0.0002 " + symbol + usdOn(reviewedOn, "$0.42"),
);
});
test("of an ERC-20 token, links the token contract to that network's explorer and prices the token by that network", async () => {
await review(reviewedOn, {
token: TOKEN,
tokenSymbol: "USDC",
tokenBalance: "50",
tokenDecimals: 6,
amount: "10",
});
await switchReopenAndGoBack(reviewedOn, switchedTo);
expect(el("confirm-type").textContent).toBe(
"ERC-20 token transfer (USDC)",
);
expect(el("confirm-token-contract").innerHTML).toContain(
`href="${explorerUrl}/address/${TOKEN}"`,
);
expect(el("confirm-amount").textContent).toBe(
"10 USDC" + usdOn(reviewedOn, "$10.00"),
);
expect(el("confirm-balance").textContent).toBe(
"50.0000 USDC" + usdOn(reviewedOn, "$50.00"),
);
});
},
);
// The check in the click handler tells the user; the send itself carries the // The check in the click handler tells the user; the send itself carries the
// chain id the screen was drawn for, so a signer on another network's node // chain id the screen was drawn for, so a signer on another network's node
// cannot sign it even if the network changes after that check. // cannot sign it even if the network changes after that check.
+14
View File
@@ -453,6 +453,20 @@ const CONTRACT = [
views: ["confirm-tx"], views: ["confirm-tx"],
restored: false, restored: false,
}, },
// A chain id naming no network has no explorer to link to.
{
value: {
pendingTx: {
token: "ETH",
from: ADDRESS,
to: ADDRESS,
amount: "1",
chainId: "0x5",
},
},
views: ["confirm-tx"],
restored: false,
},
// wait-tx passes on `pendingWait.hash`; restoreWait() has checked // wait-tx passes on `pendingWait.hash`; restoreWait() has checked
// the fields below it since it was written, and this is the // the fields below it since it was written, and this is the
// regression guard. // regression guard.