fix: a confirmation screen drawn again keeps the network it was reviewed for (closes #586) #587

Merged
clawbot merged 1 commits from issue-586-confirm-keeps-chain-id into next 2026-10-19 07:38:16 +02:00
9 changed files with 435 additions and 72 deletions
Showing only changes of commit 640f31e0a1 - Show all commits
+18 -8
View File
@@ -1013,10 +1013,12 @@ rendered differently on two screens.
The native token's label is a network's `nativeCurrency` in
`src/shared/networks.js`: `ETH` on mainnet, `SepoliaETH` on Sepolia. The
wallet's balances and the Send and confirmation screens, which send on the
active network, use the active network's. 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
wallet's balances and the Send screen, which sends on the active network, use
the active network's. The confirmation screen uses the one of the network its
transaction was reviewed on, the network active when Send showed it, even after
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
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
@@ -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
it, and a disagreement is a refusal rather than a preference for either
value (`src/shared/transferAmount.js`)
- "Sign & Send" after the active network was switched in Settings while this
screen was open → nothing is signed; "The active network changed after
- "Sign & Send" after the active network was switched in Settings since this
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
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
be signed for another
carries the chain id of the network active when Send showed this screen,
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
line, no screen change
- "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.
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;
}
+288 -9
View File
@@ -7,25 +7,52 @@
// reviewed on Sepolia went out as 1 ETH on mainnet once mainnet was chosen in
// 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
// only the node and the DOM stubbed. The stub node is on the network its
// provider was built for, so a broadcast records the network it went to.
// only the node, fetch and the DOM stubbed. The stub node is on the network
// its provider was built for, so a broadcast records the network it went to.
"use strict";
// The signed transactions the stub node was handed, with its network's chain id.
const mockNode = { broadcast: [] };
// The questions the stub node was asked for the fee and the recipient checks,
// 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", () => {
const actual = jest.requireActual("ethers");
class StubProvider {
constructor(url, network) {
this.url = url;
this.network = network;
}
record(method) {
mockNode.asked.push({
method,
network: this.network.name,
url: this.url,
});
}
async getNetwork() {
return this.network;
}
async getFeeData() {
this.record("getFeeData");
if (mockNode.legacyFees) {
return {
maxFeePerGas: null,
maxPriorityFeePerGas: null,
gasPrice: 10000000000n,
};
}
return {
maxFeePerGas: 20000000000n,
maxPriorityFeePerGas: 1000000000n,
@@ -36,16 +63,22 @@ jest.mock("ethers", () => {
return 21000n;
}
async getCode() {
this.record("getCode");
return "0x";
}
async getTransactionCount() {
this.record("getTransactionCount");
return 1;
}
async lookupAddress() {
return null;
}
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) };
}
}
@@ -75,6 +108,8 @@ jest.mock("../src/popup/views/txStatus", () => ({
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(() => {
throw new Error("tests must not perform network requests");
});
@@ -125,15 +160,27 @@ global.navigator = { clipboard: { writeText() {} } };
const { BaseWallet, Transaction, Wallet } = require("ethers");
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 { 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 confirmTx = require("../src/popup/views/confirmTx");
const PRIVATE_KEY = "0x" + "11".repeat(32);
const HOLDER = new Wallet(PRIVATE_KEY).address;
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
const TOKEN = "0x7070707070707070707070707070707070707070";
const PASSWORD = "correct horse battery staple";
function el(id) {
@@ -150,7 +197,10 @@ beforeAll(async () => {
beforeEach(() => {
elements.clear();
mockNode.asked = [];
mockNode.broadcast = [];
mockNode.legacyFees = false;
global.fetch.mockClear();
txStatus.showWait.mockClear();
txStatus.showError.mockClear();
signTransaction = jest.spyOn(BaseWallet.prototype, "signTransaction");
@@ -160,10 +210,16 @@ afterEach(() => {
signTransaction.mockRestore();
});
// Show the confirmation screen for 1 of the native token on `networkId`, as
// Review on the Send screen does, with its fee estimate settled.
async function review(networkId) {
// Show the confirmation screen for 1 of the native token on `networkId`, or
// for the transfer `changes` makes of it, as Review on the Send screen does,
// with its fee estimate settled.
async function review(networkId, changes = {}) {
await onChainSwitch(networkId);
await reviewOnActiveNetwork(changes);
}
// review() on the network already active, without switching to it again.
async function reviewOnActiveNetwork(changes = {}) {
state.wallets = [
{
type: "key",
@@ -186,7 +242,90 @@ async function review(networkId) {
tokenBalance: null,
tokenDecimals: null,
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));
}
@@ -208,6 +347,24 @@ test("a transfer reviewed on Sepolia and sent goes out on Sepolia", async () =>
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([
["sepolia", "mainnet", "SepoliaETH"],
["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
// 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.
+14
View File
@@ -453,6 +453,20 @@ const CONTRACT = [
views: ["confirm-tx"],
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
// the fields below it since it was written, and this is the
// regression guard.