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