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.