1 Commits
Author SHA1 Message Date
sneak fe1e84b071 fix: a confirmation screen drawn again keeps the network it was reviewed for (closes #586)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
ConfirmTx set the transaction's chain id to the active network's every
time it drew the screen, so a popup reopened onto a saved confirmation
after a network switch in Settings described the reviewed transfer as
the new network's and sent it there.

show() now sets the chain id only on a transaction Send hands over. One
drawn again from the saved screen keeps its own, so Sign & Send refuses
it while another network is active. The screen labels its amounts in
that network's native currency and asks that network's RPC endpoint for
the fee and the recipient checks.

Model: opus-5-5
2026-10-19 02:55:38 +00:00
13 changed files with 102 additions and 378 deletions
+9 -17
View File
@@ -1790,13 +1790,9 @@ view would leave a wallet one click from deletion.
review it again." in the errors box, no screen change. The transaction review it again." in the errors box, no screen change. The transaction
carries the chain id of the network active when Send showed this screen, 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 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, 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 its amounts are in that network's native currency, and the fee estimate
currency, its USD values are that network's, so a testnet transaction and the recipient checks ask that network's RPC endpoint
shows none, its From, To and token contract links open that network's
explorer, the fee estimate and the contract and never-sent recipient
checks ask that network's RPC endpoint, and the Etherscan label check asks
that network's explorer
- "Sign & Send" (wrong password) → "Wrong password." on the password error - "Sign & Send" (wrong password) → "Wrong password." on the password error
line, no screen change line, no screen change
- "Sign & Send" signs as the From address above, not as whichever address is - "Sign & Send" signs as the From address above, not as whichever address is
@@ -2046,9 +2042,7 @@ view would leave a wallet one click from deletion.
- **Elements**: - **Elements**:
- "Back" button, "Delete Wallet" heading - "Back" button, "Delete Wallet" heading
- Warning naming the wallet and stating that deletion is permanent and any - Warning naming the wallet and stating that deletion is permanent and any
funds are unrecoverable without the recovery phrase, the private key or funds are unrecoverable without the recovery phrase
the extended private key, according to the wallet's kind
(`keyMaterialName()` in `src/popup/views/helpers.js`)
- Password input - Password input
- Error line - Error line
- "Confirm Delete" button - "Confirm Delete" button
@@ -2089,10 +2083,9 @@ view would leave a wallet one click from deletion.
cannot be unlocked again, and that no password is needed to delete it cannot be unlocked again, and that no password is needed to delete it
- What deletion does and does not do: it erases the copy of the key stored - What deletion does and does not do: it erases the copy of the key stored
on this device; nothing on chain changes and no money is moved on this device; nothing on chain changes and no money is moved
- The route back — adding the wallet again, with a new password, from a kept - The route back — adding the wallet again with the recovery phrase and a
copy of its recovery phrase, private key or extended private key (named as new password — and, in bold, that without that phrase written down the
on DeleteWallet) — and, in bold, that without that copy the deletion loses deletion loses everything the wallet holds, forever
everything the wallet holds, forever
- That the other wallets are not touched - That the other wallets are not touched
- The wallet's name, and a text input asking for it to be typed back. A name - The wallet's name, and a text input asking for it to be typed back. A name
that shows nothing at all (only spaces, or only characters that paint that shows nothing at all (only spaces, or only characters that paint
@@ -2150,9 +2143,8 @@ view would leave a wallet one click from deletion.
the address **only if it has on-chain activity**. An address that was the address **only if it has on-chain activity**. An address that was
never used is not found by that scan. The text is written by never used is not found by that scan. The text is written by
`recoveryPathText()` rather than sitting in `index.html`, so it can name `recoveryPathText()` rather than sitting in `index.html`, so it can name
the wallet's own kind of key material through `keyMaterialName()`, as the wallet's own kind of key material: an xprv wallet has no recovery
DeleteWallet and DeleteWalletLostPassword do: an xprv wallet has no phrase to re-import.
recovery phrase to re-import.
- A warning when the address holds anything, ETH or any tracked ERC-20, - A warning when the address holds anything, ETH or any tracked ERC-20,
followed by the holdings themselves via `balanceLinesForAddress()` and the followed by the holdings themselves via `balanceLinesForAddress()` and the
USD total via `formatAddressTotal()` (see USD total via `formatAddressTotal()` (see
+10 -11
View File
@@ -924,9 +924,9 @@
<div class="bg-well p-3 mx-1 mb-3"> <div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">Network</h3> <h3 class="font-bold mb-1">Network</h3>
<p class="text-xs text-muted mb-1"> <p class="text-xs text-muted mb-1">
Select the Ethereum network. Switching restores the RPC Select the Ethereum network. Switching networks will
and Blockscout endpoints last used on that network, or update the RPC and Blockscout endpoints to their
that network's defaults if it has none. defaults.
</p> </p>
<div class="text-xs flex items-center gap-1"> <div class="text-xs flex items-center gap-1">
<select <select
@@ -1123,8 +1123,7 @@
<p class="text-xs mb-3"> <p class="text-xs mb-3">
Deleting Deleting
<strong id="delete-wallet-name"></strong> is permanent. Any <strong id="delete-wallet-name"></strong> is permanent. Any
funds will be unrecoverable without your funds will be unrecoverable without your recovery phrase.
<span id="delete-wallet-key-material"></span>.
</p> </p>
<div class="mb-2"> <div class="mb-2">
<label class="block mb-1">Password</label> <label class="block mb-1">Password</label>
@@ -1175,13 +1174,13 @@
money at its addresses is not moved or destroyed. money at its addresses is not moved or destroyed.
</p> </p>
<p class="text-xs mb-2"> <p class="text-xs mb-2">
If you have kept a copy of this wallet's If you have the recovery phrase for this wallet written
<span id="delete-wallet-lost-key-material"></span>, add the down, add the wallet again afterwards with a new password
wallet again afterwards with a new password and you will and you will have it back.
have it back.
<strong <strong
>If you have not, deleting this wallet means losing >If you do not have it written down, deleting this
everything it holds, forever.</strong wallet means losing everything it holds,
forever.</strong
> >
</p> </p>
<p class="text-xs mb-3">Your other wallets are not touched.</p> <p class="text-xs mb-3">Your other wallets are not touched.</p>
+2 -6
View File
@@ -13,7 +13,6 @@
// cannot be required outside a browser. // cannot be required outside a browser.
const { RESTORABLE_VIEWS } = require("../shared/restorableViews"); const { RESTORABLE_VIEWS } = require("../shared/restorableViews");
const { SUPPORTED_CHAIN_IDS } = require("../shared/networks");
// The views this page load has rendered. // The views this page load has rendered.
// //
@@ -126,16 +125,13 @@ function isRenderableDecoded(value) {
// The pending transaction confirmTx.show() renders: `token` reaches // The pending transaction confirmTx.show() renders: `token` reaches
// renderAddressHtml() when it is not "ETH", and `from`/`to` reach // renderAddressHtml() when it is not "ETH", and `from`/`to` reach
// addressTitle(), makeBlockie() and getLocalWarnings(). `chainId`, which // addressTitle(), makeBlockie() and getLocalWarnings().
// show() fills in when it is absent, picks the network whose explorer the
// address links open, so it has to name one.
function isRenderablePendingTx(value) { function isRenderablePendingTx(value) {
return ( return (
isRecord(value) && isRecord(value) &&
isText(value.token) && isText(value.token) &&
isAddressText(value.from) && isAddressText(value.from) &&
isAddressText(value.to) && isAddressText(value.to)
(!value.chainId || SUPPORTED_CHAIN_IDS.has(value.chainId))
); );
} }
+9 -18
View File
@@ -71,11 +71,11 @@ function restore() {
} }
} }
function confirmAddressHtml(address, ensName, title, chainId) { function confirmAddressHtml(address, ensName, title) {
const blockie = blockieHtml(address); const blockie = blockieHtml(address);
return ( return (
`<div class="mb-1">${blockie}</div>` + `<div class="mb-1">${blockie}</div>` +
renderAddressHtml(address, { title, ensName, chainId }) renderAddressHtml(address, { title, ensName })
); );
} }
@@ -93,7 +93,7 @@ function renderAmount(txInfo) {
const rawSymbol = isErc20 const rawSymbol = isErc20
? txInfo.tokenSymbol || "?" ? txInfo.tokenSymbol || "?"
: nativeCurrencyByChainId(txInfo.chainId); : nativeCurrencyByChainId(txInfo.chainId);
const price = getPrice(isErc20 ? rawSymbol : "ETH", txInfo.chainId); const price = isErc20 ? getPrice(rawSymbol) : getPrice("ETH");
const amountUsd = price ? parseFloat(txInfo.amount) * price : null; const amountUsd = price ? parseFloat(txInfo.amount) * price : null;
$("confirm-amount").textContent = valueWithUsd( $("confirm-amount").textContent = valueWithUsd(
txInfo.amount + " " + displaySymbol(rawSymbol), txInfo.amount + " " + displaySymbol(rawSymbol),
@@ -110,9 +110,8 @@ function show(txInfo) {
// one active when Send handed the transaction over. Drawn again from 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 // saved transaction, as when the popup reopens onto this screen, it keeps
// that network, so Sign & Send refuses once Settings has switched to // that network, so Sign & Send refuses once Settings has switched to
// another. Everything this screen shows is that network's: native // another. This screen and the wait, success and error screens label
// currency, USD values, explorer links, and the node and explorer its // amounts by it.
// checks ask. The wait, success and error screens label amounts by it.
if (!txInfo.chainId) txInfo.chainId = currentNetwork().chainId; if (!txInfo.chainId) txInfo.chainId = currentNetwork().chainId;
const isErc20 = txInfo.token !== "ETH"; const isErc20 = txInfo.token !== "ETH";
@@ -137,7 +136,7 @@ function show(txInfo) {
if (isErc20) { if (isErc20) {
$("confirm-token-contract").innerHTML = renderAddressHtml( $("confirm-token-contract").innerHTML = renderAddressHtml(
txInfo.token, txInfo.token,
{ chainId: txInfo.chainId }, {},
); );
tokenSection.classList.remove("hidden"); tokenSection.classList.remove("hidden");
attachCopyHandlers(tokenSection); attachCopyHandlers(tokenSection);
@@ -151,7 +150,6 @@ function show(txInfo) {
txInfo.from, txInfo.from,
null, null,
fromTitle, fromTitle,
txInfo.chainId,
); );
// To (with blockie) // To (with blockie)
@@ -160,15 +158,14 @@ function show(txInfo) {
txInfo.to, txInfo.to,
txInfo.ensName, txInfo.ensName,
toTitle, toTitle,
txInfo.chainId,
); );
$("confirm-to-ens").classList.add("hidden"); $("confirm-to-ens").classList.add("hidden");
renderAmount(txInfo); renderAmount(txInfo);
// Balance (with inline USD) // Balance (with inline USD)
const ethPrice = getPrice("ETH", txInfo.chainId); const ethPrice = getPrice("ETH");
const tokenPrice = getPrice(rawSymbol, txInfo.chainId); const tokenPrice = getPrice(rawSymbol);
if (isErc20) { if (isErc20) {
// null is a balance whose scale nothing knows, not a balance of zero // null is a balance whose scale nothing knows, not a balance of zero
// (https://git.eeqj.de/sneak/AutistMask/issues/349). The send is // (https://git.eeqj.de/sneak/AutistMask/issues/349). The send is
@@ -447,11 +444,7 @@ async function estimateGas(txInfo) {
if (estimateWei !== null && estimateWei < gasCostWei) { if (estimateWei !== null && estimateWei < gasCostWei) {
$("confirm-fee-amount").textContent = $("confirm-fee-amount").textContent =
"~" + "~" +
formatFee( formatFee(estimateWei, nativeCurrencyByChainId(txInfo.chainId));
estimateWei,
nativeCurrencyByChainId(txInfo.chainId),
txInfo.chainId,
);
$("confirm-fee-reserve").textContent = $("confirm-fee-reserve").textContent =
"up to " + "up to " +
truncateAmountNeverZero(formatEther(gasCostWei)) + truncateAmountNeverZero(formatEther(gasCostWei)) +
@@ -466,7 +459,6 @@ async function estimateGas(txInfo) {
$("confirm-fee-amount").textContent = formatFee( $("confirm-fee-amount").textContent = formatFee(
gasCostWei, gasCostWei,
nativeCurrencyByChainId(txInfo.chainId), nativeCurrencyByChainId(txInfo.chainId),
txInfo.chainId,
); );
setVisible("confirm-fee-reserve", false); setVisible("confirm-fee-reserve", false);
} }
@@ -559,7 +551,6 @@ async function checkRecipientHistory(txInfo) {
const provider = reviewedNetworkProvider(txInfo); const provider = reviewedNetworkProvider(txInfo);
const asyncWarnings = await getFullWarnings(txInfo.to, provider, { const asyncWarnings = await getFullWarnings(txInfo.to, provider, {
fromAddress: txInfo.from, fromAddress: txInfo.from,
chainId: txInfo.chainId,
}); });
for (const w of asyncWarnings) { for (const w of asyncWarnings) {
if (w.type === "contract") { if (w.type === "contract") {
+4 -2
View File
@@ -17,9 +17,9 @@ const {
attachCopyHandlers, attachCopyHandlers,
addressHoldsFunds, addressHoldsFunds,
balanceLinesForAddress, balanceLinesForAddress,
keyMaterialName,
} = require("./helpers"); } = require("./helpers");
const { formatAddressTotal, getAddressValue } = require("../../shared/prices"); const { formatAddressTotal, getAddressValue } = require("../../shared/prices");
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
const { state, saveState } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
const { const {
canRemoveAddress, canRemoveAddress,
@@ -54,7 +54,9 @@ function setFlash(msg) {
// The noun follows the wallet: an xprv wallet holds no recovery phrase, and // The noun follows the wallet: an xprv wallet holds no recovery phrase, and
// this screen is offered on xprv wallets too. // this screen is offered on xprv wallets too.
function recoveryPathText(wallet) { function recoveryPathText(wallet) {
const secret = keyMaterialName(wallet); const secret = walletHasRecoveryPhrase(wallet)
? "recovery phrase"
: "extended private key";
return ( return (
"Getting the address back into this list is not easy, so be sure. " + "Getting the address back into this list is not easy, so be sure. " +
"Adding an address derives the next unused one, not this one, and " + "Adding an address derives the next unused one, not this one, and " +
-7
View File
@@ -7,7 +7,6 @@ const {
goBack, goBack,
clearViewStack, clearViewStack,
onViewLeave, onViewLeave,
keyMaterialName,
} = require("./helpers"); } = require("./helpers");
const { state, saveState } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
const { decryptWithPassword } = require("../../shared/vault"); const { decryptWithPassword } = require("../../shared/vault");
@@ -76,9 +75,6 @@ function show(walletIdx) {
clear(); clear();
deleteWalletIndex = walletIdx; deleteWalletIndex = walletIdx;
$("delete-wallet-name").textContent = displayName(walletIdx); $("delete-wallet-name").textContent = displayName(walletIdx);
$("delete-wallet-key-material").textContent = keyMaterialName(
state.wallets[walletIdx],
);
showView("delete-wallet-confirm"); showView("delete-wallet-confirm");
} }
@@ -98,9 +94,6 @@ function showLostPassword() {
clearLostPassword(); clearLostPassword();
$("delete-wallet-lost-name").textContent = name; $("delete-wallet-lost-name").textContent = name;
$("delete-wallet-lost-name-echo").textContent = name; $("delete-wallet-lost-name-echo").textContent = name;
$("delete-wallet-lost-key-material").textContent = keyMaterialName(
state.wallets[walletIdx],
);
// showView() runs the leave hook of delete-wallet-confirm, which nulls // showView() runs the leave hook of delete-wallet-confirm, which nulls
// deleteWalletIndex, so this screen's own selection is recorded after // deleteWalletIndex, so this screen's own selection is recorded after
// it and not before. // it and not before.
+15 -33
View File
@@ -22,11 +22,9 @@ const { resolveTokenSymbol } = require("../../shared/approvalAmount");
const { DEBUG } = require("../../shared/constants"); const { DEBUG } = require("../../shared/constants");
const { escapeHtml } = require("../../shared/html"); const { escapeHtml } = require("../../shared/html");
const { isDebug } = require("../../shared/log"); const { isDebug } = require("../../shared/log");
const { networkByChainId } = require("../../shared/networks");
const { formatUsd, getPrice } = require("../../shared/prices"); const { formatUsd, getPrice } = require("../../shared/prices");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { displaySymbol } = require("../../shared/symbolDisplay"); const { displaySymbol } = require("../../shared/symbolDisplay");
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
const { markViewRendered } = require("../viewRouter"); const { markViewRendered } = require("../viewRouter");
// When views are added, removed, or transitions between them change, // When views are added, removed, or transitions between them change,
@@ -308,12 +306,11 @@ function tokenLabel(address) {
// A network fee in wei as the confirmation and approval screens both show it: // A network fee in wei as the confirmation and approval screens both show it:
// the ETH figure through truncateAmountNeverZero() and labelled `symbol`, the // the ETH figure through truncateAmountNeverZero() and labelled `symbol`, the
// native currency of the network the fee is paid on, then its USD value when // native currency of the network the fee is paid on, then its USD value when
// the ETH price is known on the network with `chainId`, or on the active // the ETH price is known. The USD value is of the exact fee, not of the
// network when no chain id is given. The USD value is of the exact fee, not of // truncated figure.
// the truncated figure. function formatFee(wei, symbol) {
function formatFee(wei, symbol, chainId) {
const eth = formatEther(wei); const eth = formatEther(wei);
const ethPrice = getPrice("ETH", chainId); const ethPrice = getPrice("ETH");
const usd = ethPrice ? formatUsd(parseFloat(eth) * ethPrice) : ""; const usd = ethPrice ? formatUsd(parseFloat(eth) * ethPrice) : "";
return ( return (
truncateAmountNeverZero(eth) + truncateAmountNeverZero(eth) +
@@ -421,16 +418,6 @@ function addressHoldsFunds(addr) {
return false; return false;
} }
// The name of a wallet's key material, which is also its only backup: the
// recovery phrase of an HD wallet, the private key of a key wallet, the
// extended private key of an xprv wallet. DeleteWallet,
// DeleteWalletLostPassword and DeleteAddress name the backup with it.
function keyMaterialName(wallet) {
if (walletHasRecoveryPhrase(wallet)) return "recovery phrase";
if (wallet.type === "key") return "private key";
return "extended private key";
}
// The fewest characters of an address any caller may ask to display. The // The fewest characters of an address any caller may ask to display. The
// 10-character cap inside truncateMiddle() is the other half of the same // 10-character cap inside truncateMiddle() is the other half of the same
// guarantee; this is the half that used to be spelled out at each call // guarantee; this is the half that used to be spelled out at each call
@@ -606,19 +593,17 @@ const EXT_ICON =
`</svg></span>`; `</svg></span>`;
// Block-explorer URLs. The origin is a per-network constant from // Block-explorer URLs. The origin is a per-network constant from
// src/shared/networks.js, the explorer of the network with `chainId`, or of // src/shared/networks.js; only the path segment is data, and it comes out
// the active network when no chain id is given; only the path segment is // of explorer JSON (a transaction's from/to, a token's address_hash), which
// data, and it comes out of explorer JSON (a transaction's from/to, a token's // nothing upstream validates as hex. percent-encoding it keeps a segment
// address_hash), which nothing upstream validates as hex. percent-encoding it // that contains a slash, a query or a fragment from re-pointing the link
// keeps a segment that contains a slash, a query or a fragment from // somewhere else in the explorer.
// re-pointing the link somewhere else in the explorer. function explorerUrl(kind, value) {
function explorerUrl(kind, value, chainId) { return `${currentNetwork().explorerUrl}/${kind}/${encodeURIComponent(value)}`;
const network = chainId ? networkByChainId(chainId) : currentNetwork();
return `${network.explorerUrl}/${kind}/${encodeURIComponent(value)}`;
} }
function etherscanAddressUrl(address, chainId) { function etherscanAddressUrl(address) {
return explorerUrl("address", address, chainId); return explorerUrl("address", address);
} }
// The URL still has to be escaped on the way into href="...": encoding // The URL still has to be escaped on the way into href="...": encoding
@@ -684,18 +669,16 @@ function attachCopyHandlers(container) {
// with them, and the "at least 32 characters" guarantee has // with them, and the "at least 32 characters" guarantee has
// to survive having no current callers to be a guarantee. // to survive having no current callers to be a guarantee.
// noLink — if true, omit etherscan link // noLink — if true, omit etherscan link
// chainId — the network whose explorer the link opens; the active
// network's when absent
// //
// After inserting the returned HTML into the DOM, call // After inserting the returned HTML into the DOM, call
// attachCopyHandlers() on the parent to wire up click-to-copy. // attachCopyHandlers() on the parent to wire up click-to-copy.
function renderAddressHtml(address, opts) { function renderAddressHtml(address, opts) {
const { title, ensName, maxLen, noLink, chainId } = opts || {}; const { title, ensName, maxLen, noLink } = opts || {};
const dot = addressDotHtml(address); const dot = addressDotHtml(address);
const displayAddr = maxLen const displayAddr = maxLen
? truncateMiddle(address, Math.max(ADDRESS_MIN_DISPLAY_LEN, maxLen)) ? truncateMiddle(address, Math.max(ADDRESS_MIN_DISPLAY_LEN, maxLen))
: address; : address;
const link = etherscanAddressUrl(address, chainId); const link = etherscanAddressUrl(address);
const extLink = noLink ? "" : etherscanLinkHtml(link); const extLink = noLink ? "" : etherscanLinkHtml(link);
let html = ""; let html = "";
@@ -742,7 +725,6 @@ module.exports = {
balanceLine, balanceLine,
balanceLinesForAddress, balanceLinesForAddress,
addressHoldsFunds, addressHoldsFunds,
keyMaterialName,
unknownableAmount, unknownableAmount,
nativeCurrency, nativeCurrency,
tokenLabel, tokenLabel,
+1 -6
View File
@@ -58,8 +58,6 @@ function getLocalWarnings(address, options = {}) {
* @param {object} provider - An ethers.js provider instance. * @param {object} provider - An ethers.js provider instance.
* @param {object} [options] - Optional context. * @param {object} [options] - Optional context.
* @param {string} [options.fromAddress] - Sender address (for self-send check). * @param {string} [options.fromAddress] - Sender address (for self-send check).
* @param {string} [options.chainId] - Chain id of the network whose explorer
* the Etherscan label check asks; the active network's when omitted.
* @returns {Promise<Array<{type: string, message: string, severity: string}>>} * @returns {Promise<Array<{type: string, message: string, severity: string}>>}
*/ */
async function getFullWarnings(address, provider, options = {}) { async function getFullWarnings(address, provider, options = {}) {
@@ -112,10 +110,7 @@ async function getFullWarnings(address, provider, options = {}) {
// Runs for ALL addresses including contracts, since many dangerous // Runs for ALL addresses including contracts, since many dangerous
// flagged addresses on Etherscan (drainers, phishing contracts) are contracts. // flagged addresses on Etherscan (drainers, phishing contracts) are contracts.
try { try {
const etherscanWarning = await checkEtherscanLabel( const etherscanWarning = await checkEtherscanLabel(address);
address,
options.chainId,
);
if (etherscanWarning) { if (etherscanWarning) {
warnings.push(etherscanWarning); warnings.push(etherscanWarning);
} }
+4 -9
View File
@@ -2,8 +2,6 @@
// Extension users make the requests directly to Etherscan — no proxy needed. // Extension users make the requests directly to Etherscan — no proxy needed.
// This is a best-effort enrichment: network failures return null silently. // This is a best-effort enrichment: network failures return null silently.
const { networkByChainId } = require("./networks");
// Patterns in the page title that indicate a flagged address. // Patterns in the page title that indicate a flagged address.
// Title format: "Fake_Phishing184810 | Address: 0x... | Etherscan" // Title format: "Fake_Phishing184810 | Address: 0x... | Etherscan"
const PHISHING_LABEL_PATTERNS = [/^Fake_Phishing/i, /^Phish:/i, /^Exploiter/i]; const PHISHING_LABEL_PATTERNS = [/^Fake_Phishing/i, /^Phish:/i, /^Exploiter/i];
@@ -74,21 +72,18 @@ function parseEtherscanPage(html) {
* Returns a warning object if the address is flagged, or null. * Returns a warning object if the address is flagged, or null.
* Network failures return null silently (best-effort check). * Network failures return null silently (best-effort check).
* *
* Uses the explorer of the network with `chainId`, or of the active network * Uses the current network's explorer URL so the lookup works on both
* when it is omitted: etherscan.io on mainnet, sepolia.etherscan.io on * mainnet (etherscan.io) and Sepolia (sepolia.etherscan.io).
* Sepolia.
* *
* @param {string} address - Ethereum address to check. * @param {string} address - Ethereum address to check.
* @param {string} [chainId] - Chain id of the network to look it up on.
* @returns {Promise<{type: string, message: string, severity: string}|null>} * @returns {Promise<{type: string, message: string, severity: string}|null>}
*/ */
async function checkEtherscanLabel(address, chainId) { async function checkEtherscanLabel(address) {
try { try {
// Lazy require to avoid pulling in chrome.storage at module scope // Lazy require to avoid pulling in chrome.storage at module scope
// (which breaks unit tests that only exercise parseEtherscanPage). // (which breaks unit tests that only exercise parseEtherscanPage).
const { currentNetwork } = require("./state"); const { currentNetwork } = require("./state");
const network = chainId ? networkByChainId(chainId) : currentNetwork(); const etherscanBase = currentNetwork().explorerUrl + "/address/";
const etherscanBase = network.explorerUrl + "/address/";
const resp = await fetch(etherscanBase + address, { const resp = await fetch(etherscanBase + address, {
headers: { Accept: "text/html" }, headers: { Accept: "text/html" },
}); });
+3 -6
View File
@@ -2,7 +2,6 @@
const { getTopTokenPrices } = require("./tokenList"); const { getTopTokenPrices } = require("./tokenList");
const { isBelowOneMillionth } = require("./amountDisplay"); const { isBelowOneMillionth } = require("./amountDisplay");
const { networkByChainId } = require("./networks");
const PRICE_CACHE_TTL = 300000; // 5 minutes const PRICE_CACHE_TTL = 300000; // 5 minutes
@@ -37,12 +36,10 @@ function clearPrices() {
lastFetchedAt = 0; lastFetchedAt = 0;
} }
// Return the USD price for a symbol on the network with `chainId`, or on the // Return the USD price for a symbol, or null on testnet / unknown.
// active network when no chain id is given; null on testnet / unknown. function getPrice(symbol) {
function getPrice(symbol, chainId) {
const { currentNetwork } = require("./state"); const { currentNetwork } = require("./state");
const network = chainId ? networkByChainId(chainId) : currentNetwork(); if (currentNetwork().isTestnet) return null;
if (network.isTestnet) return null;
return prices[symbol] || null; return prices[symbol] || null;
} }
+45 -219
View File
@@ -10,22 +10,17 @@
// The same holds when the popup was closed after the switch and reopened, and // The same holds when the popup was closed after the switch and reopened, and
// Back drew the screen again from the transaction it saved // Back drew the screen again from the transaction it saved
// (https://git.eeqj.de/sneak/AutistMask/issues/586): the screen was drawn for // (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 // the new network, and Sign & Send sent the reviewed transfer there.
// again, the screen describes the transaction entirely on the network it was
// reviewed for, because choosing that network again in Settings and pressing
// Back only unhides it, and Sign & Send then sends from it.
// //
// Driven through the real confirmation screen, signer and chain switch, with // Driven through the real confirmation screen, signer and chain switch, with
// only the node, fetch and the DOM stubbed. The stub node is on the network // only the node and the DOM stubbed. The stub node is on the network its
// its provider was built for, so a broadcast records the network it went to. // provider was built for, so a broadcast records the network it went to.
"use strict"; "use strict";
// The questions the stub node was asked for the fee and the recipient checks, // The nodes asked for the fee, by network name and RPC URL, in order. And the
// by method, network name and RPC URL, in order. The signed transactions it // signed transactions the stub node was handed, with its network's chain id.
// was handed, with its network's chain id and RPC URL. And whether it quotes const mockNode = { feeAsked: [], broadcast: [] };
// fees as a network with no type-2 pricing does.
const mockNode = { asked: [], broadcast: [], legacyFees: false };
jest.mock("ethers", () => { jest.mock("ethers", () => {
const actual = jest.requireActual("ethers"); const actual = jest.requireActual("ethers");
@@ -34,25 +29,14 @@ jest.mock("ethers", () => {
this.url = url; this.url = url;
this.network = network; this.network = network;
} }
record(method) {
mockNode.asked.push({
method,
network: this.network.name,
url: this.url,
});
}
async getNetwork() { async getNetwork() {
return this.network; return this.network;
} }
async getFeeData() { async getFeeData() {
this.record("getFeeData"); mockNode.feeAsked.push({
if (mockNode.legacyFees) { network: this.network.name,
return { url: this.url,
maxFeePerGas: null, });
maxPriorityFeePerGas: null,
gasPrice: 10000000000n,
};
}
return { return {
maxFeePerGas: 20000000000n, maxFeePerGas: 20000000000n,
maxPriorityFeePerGas: 1000000000n, maxPriorityFeePerGas: 1000000000n,
@@ -63,22 +47,16 @@ jest.mock("ethers", () => {
return 21000n; return 21000n;
} }
async getCode() { async getCode() {
this.record("getCode");
return "0x"; return "0x";
} }
async getTransactionCount() { async getTransactionCount() {
this.record("getTransactionCount");
return 1; return 1;
} }
async lookupAddress() { async lookupAddress() {
return null; return null;
} }
async broadcastTransaction(signed) { async broadcastTransaction(signed) {
mockNode.broadcast.push({ mockNode.broadcast.push({ chainId: this.network.chainId, signed });
chainId: this.network.chainId,
url: this.url,
signed,
});
return { hash: "0x" + "ab".repeat(32) }; return { hash: "0x" + "ab".repeat(32) };
} }
} }
@@ -108,8 +86,6 @@ jest.mock("../src/popup/views/txStatus", () => ({
showError: jest.fn(), showError: jest.fn(),
})); }));
// The Etherscan label check is its only caller here: the explorer pages it
// asks for are recorded, and nothing is fetched.
global.fetch = jest.fn(() => { global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests"); throw new Error("tests must not perform network requests");
}); });
@@ -163,7 +139,6 @@ const { encryptWithPassword } = require("../src/shared/vault");
const { state, loadState } = require("../src/shared/state"); const { state, loadState } = require("../src/shared/state");
const { onChainSwitch } = require("../src/shared/chainSwitch"); const { onChainSwitch } = require("../src/shared/chainSwitch");
const { NETWORKS } = require("../src/shared/networks"); const { NETWORKS } = require("../src/shared/networks");
const { prices } = require("../src/shared/prices");
const { const {
goBack, goBack,
pushCurrentView, pushCurrentView,
@@ -180,7 +155,6 @@ const confirmTx = require("../src/popup/views/confirmTx");
const PRIVATE_KEY = "0x" + "11".repeat(32); const PRIVATE_KEY = "0x" + "11".repeat(32);
const HOLDER = new Wallet(PRIVATE_KEY).address; const HOLDER = new Wallet(PRIVATE_KEY).address;
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe"; const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
const TOKEN = "0x7070707070707070707070707070707070707070";
const PASSWORD = "correct horse battery staple"; const PASSWORD = "correct horse battery staple";
function el(id) { function el(id) {
@@ -197,10 +171,8 @@ beforeAll(async () => {
beforeEach(() => { beforeEach(() => {
elements.clear(); elements.clear();
mockNode.asked = []; mockNode.feeAsked = [];
mockNode.broadcast = []; mockNode.broadcast = [];
mockNode.legacyFees = false;
global.fetch.mockClear();
txStatus.showWait.mockClear(); txStatus.showWait.mockClear();
txStatus.showError.mockClear(); txStatus.showError.mockClear();
signTransaction = jest.spyOn(BaseWallet.prototype, "signTransaction"); signTransaction = jest.spyOn(BaseWallet.prototype, "signTransaction");
@@ -210,10 +182,9 @@ afterEach(() => {
signTransaction.mockRestore(); signTransaction.mockRestore();
}); });
// Show the confirmation screen for 1 of the native token on `networkId`, or // Show the confirmation screen for 1 of the native token on `networkId`, as
// for the transfer `changes` makes of it, as Review on the Send screen does, // Review on the Send screen does, with its fee estimate settled.
// with its fee estimate settled. async function review(networkId) {
async function review(networkId, changes = {}) {
await onChainSwitch(networkId); await onChainSwitch(networkId);
state.wallets = [ state.wallets = [
{ {
@@ -237,87 +208,10 @@ async function review(networkId, changes = {}) {
tokenBalance: null, tokenBalance: null,
tokenDecimals: null, tokenDecimals: null,
max: false, max: false,
...changes,
}); });
await settle(); 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; // show() estimates the fee and checks the recipient without awaiting either;
// this lets both finish. // this lets both finish.
async function settle() { async function settle() {
@@ -373,38 +267,42 @@ describe.each([
); );
describe.each([ describe.each([
["sepolia", "mainnet"], ["sepolia", "mainnet", "SepoliaETH"],
["mainnet", "sepolia"], ["mainnet", "sepolia", "ETH"],
])( ])(
"a transfer reviewed on %s, with %s chosen in Settings, the popup reopened and Back pressed", "a transfer reviewed on %s, with %s chosen in Settings, the popup reopened and Back pressed",
(reviewedOn, switchedTo) => { (reviewedOn, switchedTo, symbol) => {
const { nativeCurrency: symbol, explorerUrl } = NETWORKS[reviewedOn];
test("is described on the network it was reviewed for, and neither signed nor sent", async () => { test("is described on the network it was reviewed for, and neither signed nor sent", async () => {
await review(reviewedOn); await review(reviewedOn);
await switchReopenAndGoBack(reviewedOn, switchedTo); // The user's own node for that network, set in Settings.
const ownNode = "https://" + reviewedOn + ".node.example.invalid";
state.rpcUrl = ownNode;
// The Settings gear, then its network select.
pushCurrentView();
showView("settings");
await onChainSwitch(switchedTo);
// The reopened popup loads what was saved onto a blank page, and
// Back draws the confirmation screen again from it.
await loadState();
elements.clear();
resetRenderedViews();
setBackRenderer(makeBackRenderer(state, { confirmTx }));
confirmTx.init({});
mockNode.feeAsked = [];
goBack();
await settle();
expect(state.currentView).toBe("confirm-tx"); expect(state.currentView).toBe("confirm-tx");
expectNativeTransferOn(reviewedOn); expect(el("confirm-type").textContent).toBe(
expect(mockNode.asked).toEqual([ "Native " + symbol + " transfer",
{ );
method: "getFeeData", expect(el("confirm-balance").textContent).toBe("5.0000 " + symbol);
network: reviewedOn, expect(el("confirm-fee-amount").textContent).toBe(
url: ownNode(reviewedOn), "~0.0002 " + symbol,
}, );
{ expect(mockNode.feeAsked).toEqual([
method: "getCode", { network: reviewedOn, url: ownNode },
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); expect(el("btn-confirm-send").disabled).toBe(false);
@@ -419,78 +317,6 @@ describe.each([
" reviewed, so it was not sent.", " 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"),
);
});
}, },
); );
-30
View File
@@ -259,36 +259,6 @@ describe("reaching the screen", () => {
}); });
}); });
// Both screens tell the user what they need to get the wallet back, and a
// key or xprv wallet has no recovery phrase: telling its owner to rely on
// one sends them looking for words that do not exist
// (https://git.eeqj.de/sneak/AutistMask/issues/529).
describe("what the screens say the wallet's backup is", () => {
test.each([
["hd", "recovery phrase"],
["key", "private key"],
["xprv", "extended private key"],
])(
"a wallet of type %s is told about its %s",
async (type, keyMaterial) => {
const { deleteWallet, state } = load();
state.wallets[1].type = type;
deleteWallet.show(1);
expect(state.currentView).toBe("delete-wallet-confirm");
expect(node("delete-wallet-key-material").textContent).toBe(
keyMaterial,
);
await click("btn-delete-wallet-lost-password");
expect(state.currentView).toBe(VIEW);
expect(node("delete-wallet-lost-key-material").textContent).toBe(
keyMaterial,
);
},
);
});
describe("the typed confirmation", () => { describe("the typed confirmation", () => {
test("a name that is not the wallet's deletes nothing", async () => { test("a name that is not the wallet's deletes nothing", async () => {
const { deleteWallet, state, storage } = load(); const { deleteWallet, state, storage } = load();
-14
View File
@@ -453,20 +453,6 @@ const CONTRACT = [
views: ["confirm-tx"], views: ["confirm-tx"],
restored: false, restored: false,
}, },
// A chain id naming no network has no explorer to link to.
{
value: {
pendingTx: {
token: "ETH",
from: ADDRESS,
to: ADDRESS,
amount: "1",
chainId: "0x5",
},
},
views: ["confirm-tx"],
restored: false,
},
// wait-tx passes on `pendingWait.hash`; restoreWait() has checked // wait-tx passes on `pendingWait.hash`; restoreWait() has checked
// the fields below it since it was written, and this is the // the fields below it since it was written, and this is the
// regression guard. // regression guard.