diff --git a/README.md b/README.md
index e152857..709333c 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/src/popup/viewRouter.js b/src/popup/viewRouter.js
index ed7def4..43ee2e0 100644
--- a/src/popup/viewRouter.js
+++ b/src/popup/viewRouter.js
@@ -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))
);
}
diff --git a/src/popup/views/confirmTx.js b/src/popup/views/confirmTx.js
index e26811a..6cad009 100644
--- a/src/popup/views/confirmTx.js
+++ b/src/popup/views/confirmTx.js
@@ -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 (
`
${blockie}
` +
- 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" +
diff --git a/src/popup/views/helpers.js b/src/popup/views/helpers.js
index 4f735d2..dba54d6 100644
--- a/src/popup/views/helpers.js
+++ b/src/popup/views/helpers.js
@@ -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 =
``;
// 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 = "";
diff --git a/src/shared/addressWarnings.js b/src/shared/addressWarnings.js
index 61595cc..43fa951 100644
--- a/src/shared/addressWarnings.js
+++ b/src/shared/addressWarnings.js
@@ -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>}
*/
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);
}
diff --git a/src/shared/etherscanLabels.js b/src/shared/etherscanLabels.js
index e74e884..9f19345 100644
--- a/src/shared/etherscanLabels.js
+++ b/src/shared/etherscanLabels.js
@@ -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" },
});
diff --git a/src/shared/prices.js b/src/shared/prices.js
index ebc1944..0c010cd 100644
--- a/src/shared/prices.js
+++ b/src/shared/prices.js
@@ -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;
}
diff --git a/tests/confirmTxNetworkSwitch.test.js b/tests/confirmTxNetworkSwitch.test.js
index a612744..cf1fecf 100644
--- a/tests/confirmTxNetworkSwitch.test.js
+++ b/tests/confirmTxNetworkSwitch.test.js
@@ -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.
diff --git a/tests/persistedFieldContract.test.js b/tests/persistedFieldContract.test.js
index 637d2ab..1246034 100644
--- a/tests/persistedFieldContract.test.js
+++ b/tests/persistedFieldContract.test.js
@@ -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.