fix: the confirmation screen reads its balance on the network it is drawn for (closes #558)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run

ConfirmTx showed and checked the balance Send handed it, so a popup reopened onto a confirmation saved before a network switch stated the other network's balance and checked the amount against it.

Each time it is shown, ConfirmTx now reads the From address's ETH balance, and for a token send its token balance, from the node of the network it is drawn for, and gives that node to the fee estimate, which starts once the read is done. Until then the balance line says "Loading..." and Sign & Send stays disabled; a failed read is stated as unknown. Each message the read can bring has a box reserved from the first paint, so nothing moves when it returns.

Model: opus-5-5
This commit is contained in:
2026-10-19 00:17:07 +00:00
parent fc042c3191
commit c1ac51d514
15 changed files with 689 additions and 244 deletions
+29 -16
View File
@@ -1013,8 +1013,10 @@ 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 it is
drawn for, the network active when it was shown, even after the network is
switched in Settings while it is open. A transaction's figures use the one of
the network its chain id names, whichever network is active: the value and fee 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 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
@@ -1719,10 +1721,18 @@ view would leave a wallet one click from deletion.
the reserve the transaction does not use. If the balance no longer covers the reserve the transaction does not use. If the balance no longer covers
the fee, the amount is left as it was and the amount-plus-fee error below the fee, the amount is left as it was and the amount-plus-fee error below
blocks the send blocks the send
- Your balance: value + symbol (USD in parentheses), or `unknown (SYMBOL)` - Your balance: the From address's balance on the network the screen is
for a token whose scale is unknown drawn for, read from that network's RPC endpoint each time the screen is
shown, so a screen the popup reopens onto after the network was switched
states and checks the balance on the new network. "Loading..." until the
read returns, then value + symbol (USD in parentheses), or
`unknown (SYMBOL)` for a token whose scale is unknown or when the read
failed. The amount check uses the same balances: the ETH balance, and for
a token transfer the token balance as well
- Network fee: "Estimating..." then two lines, or "Unable to estimate", - Network fee: "Estimating..." then two lines, or "Unable to estimate",
fetched async. The first line is what the transfer is expected to cost, fetched async once the balance is read, from the same RPC endpoint as the
balance, even if the network is switched in Settings while the balance is
being read. The first line is what the transfer is expected to cost,
`gasLimit * gasPrice` (USD in parentheses); the second is the `gasLimit * gasPrice` (USD in parentheses); the second is the
`gasLimit * maxFeePerGas` reserve the node requires, which is what the `gasLimit * maxFeePerGas` reserve the node requires, which is what the
balance check gates on. The second line is omitted on a network with no balance check gates on. The second line is omitted on a network with no
@@ -1732,19 +1742,22 @@ view would leave a wallet one click from deletion.
plus four reserved warning boxes made visible by the async checks — plus four reserved warning boxes made visible by the async checks —
recipient with no transaction history, recipient is a contract, burn recipient with no transaction history, recipient is a contract, burn
address, and an Etherscan phishing/scam label address, and an Etherscan phishing/scam label
- Errors (insufficient balance), plus three reserved error boxes — the - Errors (an invalid amount), plus five reserved error boxes, so the balance
amount plus the fee exceeds the balance (ETH transfers), not enough ETH to and the fee arriving later move nothing — the amount exceeds the balance
pay the fee for the transfer (ERC-20 transfers), and the fee could not be (for a token whose scale is unknown it says the token's balance is
estimated. The first two are mutually exclusive per transfer type, so only unknown), the balance could not be read (it asks the user to go back and
the applicable one holds space. The last names its cause: for a token try again), the amount plus the fee exceeds the balance (ETH transfers),
whose scale is unknown the fee can never be estimated, and it says the not enough ETH to pay the fee for the transfer (ERC-20 transfers), and the
wallet does not know how many decimal places the token uses and that the fee could not be estimated. The third and fourth are mutually exclusive
transaction cannot be sent; for any other failure it asks the user to go per transfer type, so only the applicable one holds space. The last names
back and try again its cause: for a token whose scale is unknown the fee can never be
estimated, and it says the wallet does not know how many decimal places
the token uses and that the transaction cannot be sent; for any other
failure it asks the user to go back and try again
- Password: an inline field on this screen, not a modal, with its own error - Password: an inline field on this screen, not a modal, with its own error
line line
- "Sign & Send" button (disabled if errors, and while the network fee - "Sign & Send" button (disabled if errors, while the balance is being read,
estimate is pending or unavailable) and while the network fee estimate is pending or unavailable)
- **Transitions**: - **Transitions**:
- "Sign & Send" (correct password) → broadcast tx → **WaitTx** - "Sign & Send" (correct password) → broadcast tx → **WaitTx**
- "Sign & Send" (correct password) → broadcast fails → **ErrorTx** - "Sign & Send" (correct password) → broadcast fails → **ErrorTx**
+14
View File
@@ -669,6 +669,20 @@
id="confirm-errors" id="confirm-errors"
class="mb-2 border border-border border-dashed p-2 invisible min-h-[1.25rem]" class="mb-2 border border-border border-dashed p-2 invisible min-h-[1.25rem]"
></div> ></div>
<!-- Its sentence names the token, or why the token's balance
is unknown, so show() in confirmTx.js sets it. -->
<div
id="confirm-balance-error"
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
></div>
<div
id="confirm-balance-read-error"
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
>
Your balance could not be read from the network, so this
transaction cannot be checked against it. Please go back and
try again.
</div>
<div <div
id="confirm-amount-fee-error" id="confirm-amount-fee-error"
class="mb-2 border border-border border-dashed p-2 text-xs invisible" class="mb-2 border border-border border-dashed p-2 text-xs invisible"
+153 -97
View File
@@ -2,7 +2,13 @@
// Shows transaction details, warnings, errors. On Sign & Send, // Shows transaction details, warnings, errors. On Sign & Send,
// reads inline password, decrypts secret, signs and broadcasts. // reads inline password, decrypts secret, signs and broadcasts.
const { parseEther, parseUnits, formatEther, Contract } = require("ethers"); const {
parseEther,
parseUnits,
formatEther,
formatUnits,
Contract,
} = require("ethers");
const { const {
$, $,
showError, showError,
@@ -11,7 +17,6 @@ const {
addressTitle, addressTitle,
escapeHtml, escapeHtml,
displaySymbol, displaySymbol,
nativeCurrency,
renderAddressHtml, renderAddressHtml,
blockieHtml, blockieHtml,
attachCopyHandlers, attachCopyHandlers,
@@ -20,6 +25,7 @@ const {
formatFee, formatFee,
} = require("./helpers"); } = require("./helpers");
const { state, currentNetwork } = require("../../shared/state"); const { state, currentNetwork } = require("../../shared/state");
const { 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");
@@ -32,6 +38,7 @@ const {
const { ERC20_ABI, isBurnAddress } = require("../../shared/constants"); const { ERC20_ABI, isBurnAddress } = require("../../shared/constants");
const { const {
displayedDecimals, displayedDecimals,
toDecimals,
transferAmountUnits, transferAmountUnits,
} = require("../../shared/transferAmount"); } = require("../../shared/transferAmount");
const { assertWithinCeilings } = require("../../shared/approvalVerify"); const { assertWithinCeilings } = require("../../shared/approvalVerify");
@@ -60,6 +67,14 @@ let feeWei = null;
// amount was derived from. Null for any other send, which ethers prices from // amount was derived from. Null for any other send, which ethers prices from
// the node at signing time. // the node at signing time.
let maxSendFees = null; let maxSendFees = null;
// The From address's balances on the network this screen is drawn for, read
// from that network's node by readBalances() each time the screen is shown.
// The popup can reopen onto this screen after the network was switched, so
// any balance Send knew may be another network's. Null until the read
// returns; then `eth` and `token` are decimal strings, `token` null for an
// ETH send or a token whose scale is unknown, and both null if the read
// failed.
let balances = null;
function restore() { function restore() {
const d = state.viewData; const d = state.viewData;
@@ -87,7 +102,9 @@ 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
? txInfo.tokenSymbol || "?"
: nativeCurrencyByChainId(txInfo.chainId);
const price = isErc20 ? getPrice(rawSymbol) : getPrice("ETH"); 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(
@@ -96,21 +113,56 @@ function renderAmount(txInfo) {
); );
} }
// The balance line, with its USD value: "Loading..." until readBalances()
// returns, then the balance it read.
function renderBalance(txInfo) {
const isErc20 = txInfo.token !== "ETH";
const rawSymbol = isErc20
? txInfo.tokenSymbol || "?"
: nativeCurrencyByChainId(txInfo.chainId);
const symbol = displaySymbol(rawSymbol);
if (balances === null) {
$("confirm-balance").textContent = "Loading...";
return;
}
// A null balance is one nobody established, for a token whose scale is
// unknown or after a read that failed, and is stated as unknown, never as
// zero (https://git.eeqj.de/sneak/AutistMask/issues/349).
const bal = isErc20 ? balances.token : balances.eth;
if (bal === null) {
$("confirm-balance").textContent = "unknown (" + symbol + ")";
return;
}
const price = isErc20 ? getPrice(rawSymbol) : getPrice("ETH");
const balUsd = price ? parseFloat(bal) * price : null;
$("confirm-balance").textContent = valueWithUsd(
truncateAmountNeverZero(bal) + " " + symbol,
balUsd,
);
}
function show(txInfo) { function show(txInfo) {
pendingTx = txInfo; pendingTx = txInfo;
feeStatus = FEE_PENDING; feeStatus = FEE_PENDING;
feeWei = null; feeWei = null;
maxSendFees = null; maxSendFees = null;
balances = 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:
// Settings can switch the active network while the screen is open. The // Settings can switch the active network while the screen is open. This
// wait, success and error screens label the amount by it too. // screen and the wait, success and error screens label amounts by it.
txInfo.chainId = currentNetwork().chainId; txInfo.chainId = currentNetwork().chainId;
// That network's node, read here once and passed to every read below, so
// a read still in flight when the network is switched, and the fee
// estimate that follows the balance read, stay on it.
const provider = getProvider(state.rpcUrl, state.networkId);
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
@@ -152,33 +204,7 @@ function show(txInfo) {
$("confirm-to-ens").classList.add("hidden"); $("confirm-to-ens").classList.add("hidden");
renderAmount(txInfo); renderAmount(txInfo);
renderBalance(txInfo);
// Balance (with inline USD)
const ethPrice = getPrice("ETH");
const tokenPrice = getPrice(rawSymbol);
if (isErc20) {
// null is a balance whose scale nothing knows, not a balance of zero
// (https://git.eeqj.de/sneak/AutistMask/issues/349). The send is
// refused at encode time for the same missing scale; what this line
// must not do is state a quantity nobody established.
const bal = txInfo.tokenBalance;
const balUsd =
tokenPrice && bal != null ? parseFloat(bal) * tokenPrice : null;
$("confirm-balance").textContent =
bal == null
? "unknown (" + symbol + ")"
: valueWithUsd(
truncateAmountNeverZero(bal) + " " + symbol,
balUsd,
);
} else {
const bal = txInfo.balance || "0";
const balUsd = ethPrice ? parseFloat(bal) * ethPrice : null;
$("confirm-balance").textContent = valueWithUsd(
truncateAmountNeverZero(bal) + " " + symbol,
balUsd,
);
}
// Check for warnings (synchronous local checks) // Check for warnings (synchronous local checks)
const localWarnings = getLocalWarnings(txInfo.to, { const localWarnings = getLocalWarnings(txInfo.to, {
@@ -212,15 +238,15 @@ 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.";
// Shown later, once checkRecipientHistory() finds a contract. // Shown later, once checkRecipientHistory() finds a contract.
$("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.";
@@ -237,6 +263,20 @@ function show(txInfo) {
" cannot be checked against your balance. Please go back and" + " cannot be checked against your balance. Please go back and" +
" try again."; " try again.";
// So does the message for an amount the balance does not cover. It is
// set here, before the first paint, so the balance read returning only
// makes it visible and moves nothing, which is why it quotes no balance.
// Without the token's scale readBalances() reads no token balance.
$("confirm-balance-error").textContent =
isErc20 && toDecimals(txInfo.tokenDecimals) === null
? "This token's balance is unknown, because nothing this wallet" +
" can consult reports how many decimal places it uses, so the" +
" amount you are trying to send cannot be checked against it."
: "Your " +
symbol +
" balance does not cover this amount. Please go back and send" +
" a smaller amount.";
renderValidation(txInfo); renderValidation(txInfo);
// Reset password field and error // Reset password field and error
@@ -262,69 +302,77 @@ function show(txInfo) {
$("confirm-burn-warning").style.visibility = "visible"; $("confirm-burn-warning").style.visibility = "visible";
} }
estimateGas(txInfo); // The fee is estimated once the balances are read, because a max ETH
checkRecipientHistory(txInfo); // send's amount is the ETH balance minus the estimate's reserve.
readBalances(txInfo, provider).then(() => estimateGas(txInfo, provider));
checkRecipientHistory(txInfo, provider);
}
// Read the From address's balances from `provider`, the node of the network
// this screen is drawn for: ETH, and for a token send the token, at the scale
// the screen renders the amount with. A token whose scale is unknown has no
// balance to read. Never rejects.
async function readBalances(txInfo, provider) {
let read;
try {
const decimals = toDecimals(txInfo.tokenDecimals);
const readToken = txInfo.token !== "ETH" && decimals !== null;
const [ethWei, tokenUnits] = await Promise.all([
provider.getBalance(txInfo.from),
readToken
? new Contract(txInfo.token, ERC20_ABI, provider).balanceOf(
txInfo.from,
)
: null,
]);
read = {
eth: formatEther(ethWei),
token: readToken ? formatUnits(tokenUnits, decimals) : null,
};
} catch (e) {
log.errorf("balance read failed:", e.shortMessage || e.message);
read = { eth: null, token: null };
}
// The user may have left this transaction while the read was in flight.
if (pendingTx !== txInfo) return;
balances = read;
renderBalance(txInfo);
renderValidation(txInfo);
} }
// Render the balance check for the transaction on screen. Called once during // Render the balance check for the transaction on screen. Called once during
// show() and again when the fee estimate resolves or fails. Every element it // show(), again when the balances are read, and again when the fee estimate
// touches already occupies its space, so re-running it never moves anything. // resolves or fails. Every element it touches already occupies its space, so
// re-running it never moves anything.
function renderValidation(txInfo) { function renderValidation(txInfo) {
const isErc20 = txInfo.token !== "ETH"; const isErc20 = txInfo.token !== "ETH";
const symbol = isErc20
? displaySymbol(txInfo.tokenSymbol || "?") // Null while the balances are being read and after a read that failed.
: nativeCurrency(); // validateTransfer() takes a missing balance as nothing to spend from, so
// sending stays blocked either way, and the only message shown for it is
// the one saying the read failed.
const ethBalance = balances ? balances.eth : null;
const tokenBalance = balances ? balances.token : null;
const balanceRead = ethBalance !== null;
const { canSend, codes } = validateTransfer({ const { canSend, codes } = validateTransfer({
isErc20, isErc20,
amount: txInfo.amount, amount: txInfo.amount,
tokenDecimals: txInfo.tokenDecimals, tokenDecimals: txInfo.tokenDecimals,
ethBalance: txInfo.balance, ethBalance,
tokenBalance: txInfo.tokenBalance, tokenBalance,
feeStatus, feeStatus,
feeWei, feeWei,
}); });
// Messages carrying the user's own numbers are built here; the fixed // An invalid amount is known before the first paint, so its message is
// sentences live in the reserved elements in index.html, except the // written here. Every message a later result brings has a reserved
// gas and fee-unknown ones, which show() sets. // element, its sentence fixed in index.html or set by show(), and is only
// made visible below, so nothing moves when the result arrives.
const messages = []; const messages = [];
if (codes.includes(CODES.AMOUNT_INVALID)) { if (codes.includes(CODES.AMOUNT_INVALID)) {
messages.push("Please enter a valid amount to send."); messages.push("Please enter a valid amount to send.");
} }
if (codes.includes(CODES.INSUFFICIENT_TOKEN)) {
messages.push(
txInfo.tokenBalance == null
? "This token's balance is unknown, because nothing this" +
" wallet can consult reports how many decimal places it" +
" uses, so the amount you are trying to send cannot be" +
" checked against it."
: "Insufficient " +
symbol +
" balance. You have " +
truncateAmountNeverZero(txInfo.tokenBalance) +
" " +
symbol +
" but are trying to send " +
txInfo.amount +
" " +
symbol +
".",
);
}
if (codes.includes(CODES.INSUFFICIENT_ETH)) {
messages.push(
"Insufficient balance. You have " +
truncateAmountNeverZero(txInfo.balance || "0") +
" " +
symbol +
" but are trying to send " +
txInfo.amount +
" " +
symbol +
".",
);
}
const errorsEl = $("confirm-errors"); const errorsEl = $("confirm-errors");
if (messages.length > 0) { if (messages.length > 0) {
@@ -337,13 +385,20 @@ function renderValidation(txInfo) {
errorsEl.style.visibility = "hidden"; errorsEl.style.visibility = "hidden";
} }
setVisible("confirm-balance-read-error", balances !== null && !balanceRead);
setVisible(
"confirm-balance-error",
balanceRead &&
(codes.includes(CODES.INSUFFICIENT_TOKEN) ||
codes.includes(CODES.INSUFFICIENT_ETH)),
);
setVisible( setVisible(
"confirm-amount-fee-error", "confirm-amount-fee-error",
codes.includes(CODES.INSUFFICIENT_ETH_WITH_FEE), balanceRead && codes.includes(CODES.INSUFFICIENT_ETH_WITH_FEE),
); );
setVisible( setVisible(
"confirm-gas-error", "confirm-gas-error",
codes.includes(CODES.INSUFFICIENT_ETH_FOR_FEE), balanceRead && codes.includes(CODES.INSUFFICIENT_ETH_FOR_FEE),
); );
setVisible( setVisible(
"confirm-fee-unknown-error", "confirm-fee-unknown-error",
@@ -362,9 +417,10 @@ function setVisible(id, visible) {
$(id).style.visibility = visible ? "visible" : "hidden"; $(id).style.visibility = visible ? "visible" : "hidden";
} }
async function estimateGas(txInfo) { // Estimate the network fee on `provider`, the node of the network this screen
// is drawn for.
async function estimateGas(txInfo, provider) {
try { try {
const provider = getProvider(state.rpcUrl, state.networkId);
const feeData = await provider.getFeeData(); const feeData = await provider.getFeeData();
let gasLimit; let gasLimit;
@@ -412,12 +468,13 @@ 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));
$("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 {
@@ -426,20 +483,20 @@ 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),
); );
setVisible("confirm-fee-reserve", false); setVisible("confirm-fee-reserve", false);
} }
feeStatus = FEE_KNOWN; feeStatus = FEE_KNOWN;
feeWei = gasCostWei; feeWei = gasCostWei;
// A max ETH send is the balance minus this estimate's reserve, not the // A max ETH send is the balance readBalances() read minus this
// Send screen's, and is signed with this estimate's fee fields: fees // estimate's reserve, not the Send screen's, and is signed with this
// fetched again at signing could exceed the reserve it leaves, and the // estimate's fee fields: fees fetched again at signing could exceed
// node would refuse it for want of funds. Where the balance no longer // the reserve it leaves, and the node would refuse it for want of
// covers the fee, the amount is left as it is and the balance check // funds. Where the balance is unknown or no longer covers the fee, the
// below says so. // amount is left as it is and the balance check below says so.
if (txInfo.max && txInfo.token === "ETH") { if (txInfo.max && txInfo.token === "ETH") {
const amount = maxEthAmount(txInfo.balance, gasCostWei); const amount = maxEthAmount(balances?.eth, gasCostWei);
if (amount !== null) { if (amount !== null) {
txInfo.amount = amount; txInfo.amount = amount;
renderAmount(txInfo); renderAmount(txInfo);
@@ -514,9 +571,8 @@ function showSendError(message) {
el.style.visibility = "visible"; el.style.visibility = "visible";
} }
async function checkRecipientHistory(txInfo) { async function checkRecipientHistory(txInfo, provider) {
try { try {
const provider = getProvider(state.rpcUrl, state.networkId);
const asyncWarnings = await getFullWarnings(txInfo.to, provider, { const asyncWarnings = await getFullWarnings(txInfo.to, provider, {
fromAddress: txInfo.from, fromAddress: txInfo.from,
}); });
+9 -16
View File
@@ -164,8 +164,9 @@ function renderSendTokenSelect(addr) {
} }
} }
// The token balance and scale the Send screen states and hands the // The token balance the Send screen states, and the scale it hands the
// confirmation screen, so the two screens describe the holding the same way. // confirmation screen, which reads the balance itself from the network it is
// drawn for.
// //
// The scale is resolved the same way balances.js resolved the scale it // The scale is resolved the same way balances.js resolved the scale it
// DISPLAYED this token's balance at: bundled list, then the user's tracked // DISPLAYED this token's balance at: bundled list, then the user's tracked
@@ -188,13 +189,11 @@ function renderSendTokenSelect(addr) {
// //
// The two resolutions can therefore differ, and where they do, the stored // The two resolutions can therefore differ, and where they do, the stored
// `balance` is a quantity computed at a scale this screen has just declined to // `balance` is a quantity computed at a scale this screen has just declined to
// stand behind. Stating it would leave validateTransfer() checking the amount // stand behind, so it is withdrawn: unknown scale means unknown balance. It is
// against a number the wallet does not vouch for, so it is withdrawn: unknown // null rather than "0": this screen states it as unknown, as the confirmation
// scale means unknown balance. It is null rather than "0": both screens state // screen does, and Max fills nothing in for it. Only a stored quantity is
// an unknown balance as unknown, and validateTransfer() treats it as no balance // withdrawn: the "0" for a token that has no row at all is an absence of
// to spend from, which is the fail-closed side of an amount nobody can check. // holdings, which is true at every scale.
// Only a stored quantity is withdrawn: the "0" for a token that has no row at
// all is an absence of holdings, which is true at every scale.
function tokenBalanceAndDecimals(addr, token) { function tokenBalanceAndDecimals(addr, token) {
const tb = (addr.tokenBalances || []).find( const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === token.toLowerCase(), (t) => t.address.toLowerCase() === token.toLowerCase(),
@@ -378,7 +377,6 @@ function init(_ctx) {
const addr = currentAddress(); const addr = currentAddress();
let tokenSymbol = null; let tokenSymbol = null;
let tokenBalance = null;
let tokenDecimals = null; let tokenDecimals = null;
if (token !== "ETH") { if (token !== "ETH") {
tokenSymbol = resolveSymbol( tokenSymbol = resolveSymbol(
@@ -386,10 +384,7 @@ function init(_ctx) {
addr.tokenBalances, addr.tokenBalances,
state.trackedTokens, state.trackedTokens,
); );
({ tokenBalance, tokenDecimals } = tokenBalanceAndDecimals( ({ tokenDecimals } = tokenBalanceAndDecimals(addr, token));
addr,
token,
));
} }
// The confirmation screen's own amount check, so it is never shown // The confirmation screen's own amount check, so it is never shown
@@ -424,9 +419,7 @@ function init(_ctx) {
ensName: ensName, ensName: ensName,
amount: amount, amount: amount,
token: token, token: token,
balance: addr.balance,
tokenSymbol: tokenSymbol, tokenSymbol: tokenSymbol,
tokenBalance: tokenBalance,
tokenDecimals: tokenDecimals, tokenDecimals: tokenDecimals,
max: amountIsMax, max: amountIsMax,
}); });
+2 -2
View File
@@ -111,8 +111,8 @@ function isValidAmount(amount, tokenDecimals = null) {
// The most ETH a send can carry: the exact balance minus the fee reserve from // The most ETH a send can carry: the exact balance minus the fee reserve from
// feeReserveWei(), as a decimal string, so validateTransfer() passes it with // feeReserveWei(), as a decimal string, so validateTransfer() passes it with
// exactly that reserve left behind. `ethBalance` is the exact decimal string // exactly that reserve left behind. `ethBalance` is an exact decimal string as
// balances.js stores, never a rounded one. Null when the balance does not // formatEther() writes it, never a rounded one. Null when the balance does not
// leave anything to send once the fee is paid, or when either input is // leave anything to send once the fee is paid, or when either input is
// unusable. // unusable.
function maxEthAmount(ethBalance, feeWei) { function maxEthAmount(ethBalance, feeWei) {
-1
View File
@@ -291,7 +291,6 @@ async function confirmSend(from, selected) {
to: RECIPIENT, to: RECIPIENT,
amount: "0.5", amount: "0.5",
token: "ETH", token: "ETH",
balance: "1.0",
}); });
await settle(); await settle();
node("confirm-tx-password").value = PASSWORD; node("confirm-tx-password").value = PASSWORD;
+259 -11
View File
@@ -7,17 +7,35 @@
// 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.
// //
// Its balance line and balance check are that network's too
// (https://git.eeqj.de/sneak/AutistMask/issues/558). The popup can reopen onto
// a confirmation screen saved before the network was switched in another
// popup, and the screen, drawn again for the new network, showed and checked
// the balance Send had read on the old one. The balance read and the fee
// estimate after it stay on that network when it is switched while they run.
//
// 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 and the DOM stubbed. The stub node is on the network its
// provider was built for, so a broadcast records the network it went to. // provider was built for, so a broadcast records the network it went to, and
// a balance read answers with that network's balance.
"use strict"; "use strict";
// The signed transactions the stub node was handed, with its network's chain id. // The balances the stub node reports on each network, by network name: ETH
const mockNode = { broadcast: [] }; // in wei and the token in its base units. The networks whose node was asked
// for the fee, in order. And the signed transactions it was handed, with its
// network's chain id.
const mockNode = {
balanceWei: {},
tokenUnits: {},
feeAskedOn: [],
broadcast: [],
};
jest.mock("ethers", () => { jest.mock("ethers", () => {
const actual = jest.requireActual("ethers"); const actual = jest.requireActual("ethers");
// The selector of balanceOf(address).
const BALANCE_OF = "0x70a08231";
class StubProvider { class StubProvider {
constructor(url, network) { constructor(url, network) {
this.network = network; this.network = network;
@@ -25,7 +43,22 @@ jest.mock("ethers", () => {
async getNetwork() { async getNetwork() {
return this.network; return this.network;
} }
async getBalance() {
return mockNode.balanceWei[this.network.name];
}
// balanceOf() is the one contract call the screen makes before Sign
// & Send.
async call(tx) {
if (!tx.data.startsWith(BALANCE_OF)) {
throw new Error("the stub node answers only balanceOf()");
}
return actual.AbiCoder.defaultAbiCoder().encode(
["uint256"],
[mockNode.tokenUnits[this.network.name]],
);
}
async getFeeData() { async getFeeData() {
mockNode.feeAskedOn.push(this.network.name);
return { return {
maxFeePerGas: 20000000000n, maxFeePerGas: 20000000000n,
maxPriorityFeePerGas: 1000000000n, maxPriorityFeePerGas: 1000000000n,
@@ -123,7 +156,13 @@ global.document = {
}; };
global.navigator = { clipboard: { writeText() {} } }; global.navigator = { clipboard: { writeText() {} } };
const { BaseWallet, Transaction, Wallet } = require("ethers"); const {
BaseWallet,
JsonRpcProvider,
Transaction,
Wallet,
parseEther,
} = require("ethers");
const { encryptWithPassword } = require("../src/shared/vault"); 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");
@@ -135,6 +174,8 @@ 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 PASSWORD = "correct horse battery staple"; const PASSWORD = "correct horse battery staple";
// A token with 6 decimal places, which the bundled list does not know.
const TOKEN = "0x" + "d".repeat(40);
function el(id) { function el(id) {
return global.document.getElementById(id); return global.document.getElementById(id);
@@ -150,6 +191,12 @@ beforeAll(async () => {
beforeEach(() => { beforeEach(() => {
elements.clear(); elements.clear();
mockNode.balanceWei = {
mainnet: parseEther("5"),
sepolia: parseEther("5"),
};
mockNode.tokenUnits = { mainnet: 0n, sepolia: 0n };
mockNode.feeAskedOn = [];
mockNode.broadcast = []; mockNode.broadcast = [];
txStatus.showWait.mockClear(); txStatus.showWait.mockClear();
txStatus.showError.mockClear(); txStatus.showError.mockClear();
@@ -160,16 +207,23 @@ afterEach(() => {
signTransaction.mockRestore(); signTransaction.mockRestore();
}); });
// Show the confirmation screen for 1 of the native token on `networkId`, as // show() reads the balances and estimates the fee without awaiting either;
// Review on the Send screen does, with its fee estimate settled. // this lets both settle.
async function review(networkId) { async function settle() {
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
// Show the confirmation screen for 1 of the native token on `networkId`, or
// for what `send` describes instead, as Review on the Send screen does, with
// its balance read and its fee estimate settled.
async function review(networkId, send = {}) {
await onChainSwitch(networkId); await onChainSwitch(networkId);
state.wallets = [ state.wallets = [
{ {
type: "key", type: "key",
name: "Wallet 1", name: "Wallet 1",
encryptedSecret, encryptedSecret,
addresses: [{ address: HOLDER, balance: "5.0" }], addresses: [{ address: HOLDER }],
}, },
]; ];
state.selectedWallet = 0; state.selectedWallet = 0;
@@ -181,13 +235,36 @@ async function review(networkId) {
ensName: null, ensName: null,
amount: "1", amount: "1",
token: "ETH", token: "ETH",
balance: "5.0",
tokenSymbol: null, tokenSymbol: null,
tokenBalance: null,
tokenDecimals: null, tokenDecimals: null,
max: false, max: false,
...send,
}); });
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0)); await settle();
}
// Switch the network to `networkId` as Settings in another popup does, then
// open the popup again onto the confirmation screen it saved.
async function reopenOn(networkId) {
const saved = JSON.parse(JSON.stringify(state.viewData));
await onChainSwitch(networkId);
elements.clear();
state.viewData = saved;
confirmTx.init({});
confirmTx.restore();
await settle();
}
function errors() {
return el("confirm-errors").innerHTML;
}
function shown(id) {
return el(id).style.visibility === "visible";
}
function sendDisabled() {
return el("btn-confirm-send").disabled;
} }
async function signAndSend() { async function signAndSend() {
@@ -257,3 +334,174 @@ test("the send is refused by a signer on another network's node", async () => {
expect(signTransaction).not.toHaveBeenCalled(); expect(signTransaction).not.toHaveBeenCalled();
expect(mockNode.broadcast).toEqual([]); expect(mockNode.broadcast).toEqual([]);
}); });
describe("a confirmation screen saved on mainnet and reopened after a switch to Sepolia", () => {
test("states and checks the Sepolia balance, which covers the send", async () => {
mockNode.balanceWei = {
mainnet: parseEther("0.5"),
sepolia: parseEther("5"),
};
await review("mainnet");
expect(el("confirm-balance").textContent).toBe("0.5000 ETH");
expect(sendDisabled()).toBe(true);
await reopenOn("sepolia");
expect(el("confirm-type").textContent).toBe(
"Native SepoliaETH transfer",
);
expect(el("confirm-balance").textContent).toBe("5.0000 SepoliaETH");
expect(errors()).toBe("");
expect(shown("confirm-balance-error")).toBe(false);
expect(el("confirm-amount-fee-error").style.visibility).toBe("hidden");
expect(sendDisabled()).toBe(false);
});
test("states and checks the Sepolia balance, which does not cover the send", async () => {
mockNode.balanceWei = {
mainnet: parseEther("5"),
sepolia: parseEther("0.5"),
};
await review("mainnet");
expect(el("confirm-balance").textContent).toBe("5.0000 ETH");
expect(sendDisabled()).toBe(false);
await reopenOn("sepolia");
expect(el("confirm-balance").textContent).toBe("0.5000 SepoliaETH");
expect(shown("confirm-balance-error")).toBe(true);
expect(el("confirm-balance-error").textContent).toBe(
"Your SepoliaETH balance does not cover this amount. Please go" +
" back and send a smaller amount.",
);
expect(sendDisabled()).toBe(true);
});
const tokenSend = {
token: TOKEN,
tokenSymbol: "TOK",
tokenDecimals: 6,
};
test("states and checks the Sepolia token balance, which covers the send", async () => {
mockNode.tokenUnits = { mainnet: 0n, sepolia: 10000000n };
await review("mainnet", tokenSend);
expect(el("confirm-balance").textContent).toBe("0.0000 TOK");
expect(sendDisabled()).toBe(true);
await reopenOn("sepolia");
expect(el("confirm-balance").textContent).toBe("10.0000 TOK");
expect(errors()).toBe("");
expect(shown("confirm-balance-error")).toBe(false);
expect(el("confirm-gas-error").style.visibility).toBe("hidden");
expect(sendDisabled()).toBe(false);
});
test("states and checks the Sepolia token balance, which does not cover the send", async () => {
mockNode.tokenUnits = { mainnet: 10000000n, sepolia: 500000n };
await review("mainnet", tokenSend);
expect(el("confirm-balance").textContent).toBe("10.0000 TOK");
expect(sendDisabled()).toBe(false);
await reopenOn("sepolia");
expect(el("confirm-balance").textContent).toBe("0.5000 TOK");
expect(shown("confirm-balance-error")).toBe(true);
expect(el("confirm-balance-error").textContent).toBe(
"Your TOK balance does not cover this amount. Please go back and" +
" send a smaller amount.",
);
expect(sendDisabled()).toBe(true);
});
});
describe("the balance read", () => {
test("leaves the balance line Loading... and Sign & Send disabled until it returns", async () => {
let answer;
const getBalance = jest
.spyOn(JsonRpcProvider.prototype, "getBalance")
.mockImplementationOnce(() => new Promise((r) => (answer = r)));
try {
await review("sepolia");
} finally {
getBalance.mockRestore();
}
expect(el("confirm-balance").textContent).toBe("Loading...");
expect(el("confirm-fee-amount").textContent).toBe("Estimating...");
expect(el("confirm-errors").style.visibility).toBe("hidden");
expect(shown("confirm-balance-error")).toBe(false);
expect(shown("confirm-balance-read-error")).toBe(false);
expect(sendDisabled()).toBe(true);
answer(parseEther("5"));
await settle();
expect(el("confirm-balance").textContent).toBe("5.0000 SepoliaETH");
expect(sendDisabled()).toBe(false);
});
// The Chrome suite measures that nothing moves; this checks the message
// is already written, only hidden, before the read returns.
test("that finds the amount not covered only makes a message written before it visible", async () => {
let answer;
const getBalance = jest
.spyOn(JsonRpcProvider.prototype, "getBalance")
.mockImplementationOnce(() => new Promise((r) => (answer = r)));
try {
await review("sepolia");
} finally {
getBalance.mockRestore();
}
const message =
"Your SepoliaETH balance does not cover this amount. Please go" +
" back and send a smaller amount.";
expect(el("confirm-balance-error").textContent).toBe(message);
expect(shown("confirm-balance-error")).toBe(false);
answer(parseEther("0.5"));
await settle();
expect(el("confirm-balance-error").textContent).toBe(message);
expect(shown("confirm-balance-error")).toBe(true);
expect(errors()).toBe("");
expect(sendDisabled()).toBe(true);
});
// The fee is estimated once the read returns, so a node chosen then would
// be the new network's.
test("still in flight when Sepolia is chosen in Settings leaves the balance and the fee on mainnet", async () => {
let answer;
const getBalance = jest
.spyOn(JsonRpcProvider.prototype, "getBalance")
.mockImplementationOnce(() => new Promise((r) => (answer = r)));
try {
await review("mainnet");
} finally {
getBalance.mockRestore();
}
expect(mockNode.feeAskedOn).toEqual([]);
await onChainSwitch("sepolia");
answer(parseEther("5"));
await settle();
expect(mockNode.feeAskedOn).toEqual(["mainnet"]);
expect(el("confirm-balance").textContent).toBe("5.0000 ETH");
// 21000 gas at 10 gwei expected, at 20 gwei reserved.
expect(el("confirm-fee-amount").textContent).toBe("~0.0002 ETH");
expect(el("confirm-fee-reserve").textContent).toBe(
"up to 0.0004 ETH reserved",
);
});
test("that fails states the balance as unknown, not zero, and blocks sending", async () => {
const getBalance = jest
.spyOn(JsonRpcProvider.prototype, "getBalance")
.mockRejectedValueOnce(new Error("the node did not answer"));
try {
await review("sepolia");
} finally {
getBalance.mockRestore();
}
expect(el("confirm-balance").textContent).toBe("unknown (SepoliaETH)");
expect(shown("confirm-balance-read-error")).toBe(true);
expect(shown("confirm-balance-error")).toBe(false);
expect(errors()).toBe("");
expect(sendDisabled()).toBe(true);
});
});
+23 -9
View File
@@ -244,16 +244,21 @@ function latestBlock() {
}; };
} }
// keccak("decimals()")[0:4], and the same for symbol() and name(). // keccak("decimals()")[0:4], and the same for symbol(), name() and
// balanceOf(address).
const SELECTOR_DECIMALS = "0x313ce567"; const SELECTOR_DECIMALS = "0x313ce567";
const SELECTOR_SYMBOL = "0x95d89b41"; const SELECTOR_SYMBOL = "0x95d89b41";
const SELECTOR_NAME = "0x06fdde03"; const SELECTOR_NAME = "0x06fdde03";
const SELECTOR_BALANCE_OF = "0x70a08231";
// Every eth_call still answers with a zero word except decimals(), symbol() // Every eth_call still answers with a zero word except decimals(), symbol(),
// and name() on the stub token. The wallet reads decimals() back at signing // name() and balanceOf() on the stub token. The wallet reads decimals() back
// time to compare with the scale the confirmation screen rendered (issue // at signing time to compare with the scale the confirmation screen rendered
// #305). Adding the token by its contract address reads all three (issue // (issue #305). Adding the token by its contract address reads the first three
// #295); symbol() and name() answer what the explorer reports for it. // (issue #295); symbol() and name() answer what the explorer reports for it.
// The confirmation screen reads balanceOf()
// (https://git.eeqj.de/sneak/AutistMask/issues/558), which answers the holding
// the explorer reports, or zero when no holding is seeded.
// //
// opts.tokenDecimalsOverride is the lying contract: set it and decimals() // opts.tokenDecimalsOverride is the lying contract: set it and decimals()
// answers something other than the value this same fixture reports through // answers something other than the value this same fixture reports through
@@ -278,6 +283,9 @@ function ethCallResult(req, opts) {
if (data.startsWith(SELECTOR_NAME)) { if (data.startsWith(SELECTOR_NAME)) {
return abi.encode(["string"], [tokenObject(opts).name]); return abi.encode(["string"], [tokenObject(opts).name]);
} }
if (data.startsWith(SELECTOR_BALANCE_OF) && opts.seedTokenBalance) {
return word(tokenBalanceItems(opts)[0].value);
}
return ZERO_WORD; return ZERO_WORD;
} }
@@ -420,9 +428,10 @@ const HOLD_MAX_MS = 30000;
// Hold a reply open for as long as the test asks: until opts[name] is false. // Hold a reply open for as long as the test asks: until opts[name] is false.
// //
// The switch (holdGasEstimate, holdTransactionCount or holdBlockscout) is read // The switch (holdBalance, holdGasEstimate, holdTransactionCount or
// here rather than captured, so a test flips it on the same options object the // holdBlockscout) is read here rather than captured, so a test flips it on the
// route was registered with — the same pattern as seedTokenTransfer. This is // same options object the route was registered with — the same pattern as
// seedTokenTransfer. This is
// the only way to observe a screen while its request is genuinely in flight; // the only way to observe a screen while its request is genuinely in flight;
// sampling the screen and hoping to win a race against the network would // sampling the screen and hoping to win a race against the network would
// assert nothing on a slow machine. // assert nothing on a slow machine.
@@ -557,6 +566,9 @@ async function handleRpc(route, postData, opts, report) {
report("unstubbed request: POST " + route.request().url()); report("unstubbed request: POST " + route.request().url());
return route.abort(); return route.abort();
} }
if (batch.some((req) => req.method === "eth_getBalance")) {
await awaitRelease(opts, "holdBalance", report);
}
if (batch.some((req) => req.method === "eth_estimateGas")) { if (batch.some((req) => req.method === "eth_estimateGas")) {
await awaitRelease(opts, "holdGasEstimate", report); await awaitRelease(opts, "holdGasEstimate", report);
} }
@@ -615,6 +627,8 @@ function traceEnabled(raw) {
* fixture expects. * fixture expects.
* @param {boolean} [opts.failGasEstimate] answer eth_estimateGas with a * @param {boolean} [opts.failGasEstimate] answer eth_estimateGas with a
* node-side refusal. * node-side refusal.
* @param {boolean} [opts.holdBalance] hold every batch containing an
* eth_getBalance until this is cleared again.
* @param {boolean} [opts.holdGasEstimate] hold every batch containing an * @param {boolean} [opts.holdGasEstimate] hold every batch containing an
* eth_estimateGas until this is cleared again. * eth_estimateGas until this is cleared again.
* @param {boolean} [opts.holdTransactionCount] hold every batch containing an * @param {boolean} [opts.holdTransactionCount] hold every batch containing an
+88 -31
View File
@@ -2232,7 +2232,7 @@ function feeEth(wei) {
// What the confirmation screen is showing right now, read out of the DOM in // What the confirmation screen is showing right now, read out of the DOM in
// one pass: whether sending is allowed, which reason it is giving, what the // one pass: whether sending is allowed, which reason it is giving, what the
// fee block says, and how tall the whole view is. // fee block says, how tall the whole view is, and where Sign & Send sits in it.
async function confirmState(page) { async function confirmState(page) {
return page.evaluate(() => { return page.evaluate(() => {
const el = (id) => document.getElementById(id); const el = (id) => document.getElementById(id);
@@ -2245,6 +2245,9 @@ async function confirmState(page) {
}; };
return { return {
height: el("view-confirm-tx").getBoundingClientRect().height, height: el("view-confirm-tx").getBoundingClientRect().height,
sendTop:
el("btn-confirm-send").getBoundingClientRect().top -
el("view-confirm-tx").getBoundingClientRect().top,
type: el("confirm-type").textContent.trim(), type: el("confirm-type").textContent.trim(),
balance: el("confirm-balance").textContent.trim(), balance: el("confirm-balance").textContent.trim(),
fee: el("confirm-fee-amount").textContent.trim(), fee: el("confirm-fee-amount").textContent.trim(),
@@ -2253,6 +2256,9 @@ async function confirmState(page) {
errors: shown("confirm-errors") errors: shown("confirm-errors")
? el("confirm-errors").textContent.trim() ? el("confirm-errors").textContent.trim()
: "", : "",
balanceError: shown("confirm-balance-error")
? el("confirm-balance-error").textContent.trim()
: "",
amountFeeError: shown("confirm-amount-fee-error"), amountFeeError: shown("confirm-amount-fee-error"),
gasError: shown("confirm-gas-error"), gasError: shown("confirm-gas-error"),
feeUnknownError: shown("confirm-fee-unknown-error"), feeUnknownError: shown("confirm-fee-unknown-error"),
@@ -2271,6 +2277,16 @@ async function waitForEstimate(page) {
); );
} }
async function waitForBalance(page) {
await page.waitForFunction(
() =>
document.getElementById("confirm-balance").textContent.trim() !==
"Loading...",
null,
{ timeout: 60000 },
);
}
async function backToAddress(page) { async function backToAddress(page) {
if (await page.isVisible("#view-confirm-tx")) { if (await page.isVisible("#view-confirm-tx")) {
await page.click("#btn-confirm-back"); await page.click("#btn-confirm-back");
@@ -2282,13 +2298,14 @@ async function backToAddress(page) {
await openAddressDetail(page); await openAddressDetail(page);
} }
// Drive the popup to the confirmation screen for one send. // Drive the popup to the confirmation screen for one send, and return as soon
// as the screen is shown, its balance read still in flight.
// //
// It waits for the send screen to be showing `balance`, the fixture's balance // It waits for the send screen to be showing `balance`, the fixture's balance
// as that screen displays it, before filling anything in. Waiting for it — // as that screen displays it, before filling anything in. Waiting for it —
// rather than for a refresh to have probably landed — is what keeps every // rather than for a refresh to have probably landed — is what keeps every
// assertion below deterministic after a fixture change. // assertion below deterministic after a fixture change.
async function goToConfirm(page, { token, balance, amount }) { async function openConfirm(page, { token, balance, amount }) {
await backToAddress(page); await backToAddress(page);
await page.click("#btn-send"); await page.click("#btn-send");
await visible(page, "#view-send"); await visible(page, "#view-send");
@@ -2303,6 +2320,14 @@ async function goToConfirm(page, { token, balance, amount }) {
await page.fill("#send-amount", amount); await page.fill("#send-amount", amount);
await page.click("#btn-send-review"); await page.click("#btn-send-review");
await visible(page, "#view-confirm-tx"); await visible(page, "#view-confirm-tx");
}
// The same, returning once the screen has read the balance. It reads it
// before it estimates the fee, so every state below is read with the balance
// in place, the pending fee included.
async function goToConfirm(page, send) {
await openConfirm(page, send);
await waitForBalance(page);
await assertAddressesFit(page, "the confirmation screen"); await assertAddressesFit(page, "the confirmation screen");
} }
@@ -2378,7 +2403,10 @@ test("ConfirmTx blocks sending while the fee estimate is pending (#238)", async
"the estimate-failed message is shown for an estimate that is merely pending", "the estimate-failed message is shown for an estimate that is merely pending",
); );
assert( assert(
!st.amountFeeError && !st.gasError && st.errors === "", !st.amountFeeError &&
!st.gasError &&
st.errors === "" &&
st.balanceError === "",
"a balance message is shown before the fee is known", "a balance message is shown before the fee is known",
); );
}); });
@@ -2409,6 +2437,7 @@ test("ConfirmTx enables Send once the estimate lands, quoting both numbers (#238
); );
assert( assert(
st.errors === "" && st.errors === "" &&
st.balanceError === "" &&
!st.amountFeeError && !st.amountFeeError &&
!st.gasError && !st.gasError &&
!st.feeUnknownError, !st.feeUnknownError,
@@ -2475,30 +2504,54 @@ test("ConfirmTx gates on the fee RESERVE, not the displayed estimate (#238)", as
); );
}); });
test("ConfirmTx refuses a send that exceeds the balance outright (#238)", async (env) => { // The screen is first measured with its balance read held, so the message the
await goToConfirm(env.page, { // read brings must already hold its space when the read returns (#558).
test("ConfirmTx refuses a send that exceeds the balance outright, and nothing moves when the balance arrives (#238)", async (env) => {
env.routeOpts.holdBalance = true;
let reading;
try {
await openConfirm(env.page, {
token: "ETH", token: "ETH",
balance: FUNDED_ETH_TEXT + " ETH", balance: FUNDED_ETH_TEXT + " ETH",
amount: OVER_BALANCE_AMOUNT, amount: OVER_BALANCE_AMOUNT,
}); });
reading = await confirmState(env.page);
} finally {
env.routeOpts.holdBalance = false;
}
assert(
reading.balance === "Loading...",
"the balance read was not held: " + JSON.stringify(reading.balance),
);
await waitForBalance(env.page);
await waitForEstimate(env.page); await waitForEstimate(env.page);
const st = await confirmState(env.page); const st = await confirmState(env.page);
const want =
"Insufficient balance. You have " +
FUNDED_ETH_TEXT +
" ETH but are trying to send " +
OVER_BALANCE_AMOUNT +
" ETH.";
assert( assert(
st.errors === want, st.balance === FUNDED_ETH_TEXT + " ETH",
"wrong over-balance message: " + JSON.stringify(st.errors), "the confirmation screen shows the wrong balance: " +
JSON.stringify(st.balance),
);
const want =
"Your ETH balance does not cover this amount. Please go back and" +
" send a smaller amount.";
assert(
st.balanceError === want,
"wrong over-balance message: " + JSON.stringify(st.balanceError),
); );
assert(st.sendDisabled, "Send is enabled for a send that exceeds balance"); assert(st.sendDisabled, "Send is enabled for a send that exceeds balance");
assert( assert(
!st.amountFeeError, !st.amountFeeError,
"the amount-plus-fee message is shown for an amount that alone exceeds the balance", "the amount-plus-fee message is shown for an amount that alone exceeds the balance",
); );
assert(
st.sendTop === reading.sendTop,
"Sign & Send moved when the balance read returned: " +
reading.sendTop +
"px -> " +
st.sendTop +
"px",
);
}); });
test("ConfirmTx refuses to send when the fee estimate fails, with its own message (#238)", async (env) => { test("ConfirmTx refuses to send when the fee estimate fails, with its own message (#238)", async (env) => {
@@ -2542,7 +2595,10 @@ test("ConfirmTx refuses to send when the fee estimate fails, with its own messag
"the estimate-failed message is not shown after a failed estimate", "the estimate-failed message is not shown after a failed estimate",
); );
assert( assert(
!st.amountFeeError && !st.gasError && st.errors === "", !st.amountFeeError &&
!st.gasError &&
st.errors === "" &&
st.balanceError === "",
"a balance message is shown for an estimate that simply failed", "a balance message is shown for an estimate that simply failed",
); );
assert( assert(
@@ -2633,21 +2689,19 @@ test("ConfirmTx refuses an ERC-20 send that exceeds the token balance (#238)", a
await waitForEstimate(env.page); await waitForEstimate(env.page);
const st = await confirmState(env.page); const st = await confirmState(env.page);
const want =
"Insufficient " +
STUB_TOKEN.symbol +
" balance. You have " +
TOKEN_BALANCE_TEXT +
" " +
STUB_TOKEN.symbol +
" but are trying to send " +
OVER_TOKEN_AMOUNT +
" " +
STUB_TOKEN.symbol +
".";
assert( assert(
st.errors === want, st.balance === TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol,
"wrong over-token-balance message: " + JSON.stringify(st.errors), "the confirmation screen shows the wrong token balance: " +
JSON.stringify(st.balance),
);
const want =
"Your " +
STUB_TOKEN.symbol +
" balance does not cover this amount. Please go back and send a" +
" smaller amount.";
assert(
st.balanceError === want,
"wrong over-token-balance message: " + JSON.stringify(st.balanceError),
); );
assert( assert(
st.sendDisabled, st.sendDisabled,
@@ -2703,9 +2757,9 @@ test("ConfirmTx gates the ERC-20 fee on the RESERVE, with the ERC-20 message (#2
"the native-ETH over-budget message is shown on an ERC-20 transfer", "the native-ETH over-budget message is shown on an ERC-20 transfer",
); );
assert( assert(
st.errors === "", st.errors === "" && st.balanceError === "",
"a token-balance message is shown for a transfer the token balance covers: " + "a token-balance message is shown for a transfer the token balance covers: " +
JSON.stringify(st.errors), JSON.stringify(st.errors + st.balanceError),
); );
assert( assert(
st.height === env.erc20PendingHeight, st.height === env.erc20PendingHeight,
@@ -4859,6 +4913,9 @@ async function main() {
seedTokenBalance: false, seedTokenBalance: false,
ethBalanceWei: null, ethBalanceWei: null,
failGasEstimate: false, failGasEstimate: false,
// Whether a JSON-RPC batch asking for a balance is held unanswered:
// the confirmation screen's own balance read (#558).
holdBalance: false,
holdGasEstimate: false, holdGasEstimate: false,
// Whether a JSON-RPC batch asking for a transaction count is held // Whether a JSON-RPC batch asking for a transaction count is held
// unanswered: the address scan after creating a wallet (#475). // unanswered: the address scan after creating a wallet (#475).
+8 -7
View File
@@ -17,6 +17,10 @@ jest.mock("ethers", () => {
async lookupAddress() { async lookupAddress() {
return null; return null;
} }
// The 1.5 of the native token the address holds on either network.
async getBalance() {
return 1500000000000000000n;
}
// 10 gwei expected, 20 gwei reserved per gas. // 10 gwei expected, 20 gwei reserved per gas.
async getFeeData() { async getFeeData() {
return { maxFeePerGas: 20000000000n, gasPrice: 10000000000n }; return { maxFeePerGas: 20000000000n, gasPrice: 10000000000n };
@@ -332,14 +336,11 @@ describe.each([
test("the insufficient-balance error", async () => { test("the insufficient-balance error", async () => {
await confirmSend("2"); await confirmSend("2");
expect( expect(text("confirm-balance-error")).toBe(
global.document.getElementById("confirm-errors").innerHTML, "Your " +
).toContain(
"You have 1.5000 " +
symbol + symbol +
" but are trying to send 2 " + " balance does not cover this amount. Please go back and send" +
symbol + " a smaller amount.",
".",
); );
}); });
}); });
-1
View File
@@ -767,7 +767,6 @@ const WELL_FORMED_DATA = {
from: ADDRESS, from: ADDRESS,
to: ADDRESS, to: ADDRESS,
amount: "1", amount: "1",
balance: "2",
}, },
pendingWait: { pendingWait: {
hash: "0x1", hash: "0x1",
+9 -2
View File
@@ -23,7 +23,10 @@ jest.mock("../src/shared/log", () => ({
isDebug: () => false, isDebug: () => false,
})); }));
// What the confirmation screen asks the node for. const { AbiCoder } = require("ethers");
// What the confirmation screen asks the node for. The address holds the 1 ETH
// and 250 TOK the wallet below records.
const mockProvider = { const mockProvider = {
getFeeData: async () => ({ getFeeData: async () => ({
maxFeePerGas: 2000000000n, maxFeePerGas: 2000000000n,
@@ -32,7 +35,11 @@ const mockProvider = {
estimateGas: async () => 21000n, estimateGas: async () => 21000n,
getCode: async () => "0x", getCode: async () => "0x",
getTransactionCount: async () => 1, getTransactionCount: async () => 1,
getBalance: async () => 0n, getBalance: async () => 1000000000000000000n,
// The one contract call the screen makes, balanceOf(), answered at the
// token's 6 decimal places.
call: async () =>
AbiCoder.defaultAbiCoder().encode(["uint256"], [250000000n]),
}; };
jest.mock("../src/shared/balances", () => { jest.mock("../src/shared/balances", () => {
+27 -8
View File
@@ -18,6 +18,7 @@
// What the stub node answers. Each test sets what it needs. // What the stub node answers. Each test sets what it needs.
const mockNode = { const mockNode = {
balanceWei: 0n, balanceWei: 0n,
tokenUnits: 0n,
feeData: { maxFeePerGas: 1n, gasPrice: 1n }, feeData: { maxFeePerGas: 1n, gasPrice: 1n },
}; };
@@ -30,6 +31,13 @@ jest.mock("ethers", () => {
async getBalance() { async getBalance() {
return mockNode.balanceWei; return mockNode.balanceWei;
} }
// balanceOf(), the one contract call these screens make.
async call() {
return actual.AbiCoder.defaultAbiCoder().encode(
["uint256"],
[mockNode.tokenUnits],
);
}
async lookupAddress() { async lookupAddress() {
return null; return null;
} }
@@ -195,10 +203,19 @@ function errors() {
return global.document.getElementById("confirm-errors").innerHTML; return global.document.getElementById("confirm-errors").innerHTML;
} }
// The message for an amount the balance does not cover, while it is shown.
function balanceError() {
const box = global.document.getElementById("confirm-balance-error");
return box.style.visibility === "visible" ? box.textContent : "";
}
// The ETH balance the node reports and the token rows the explorer reports, // The ETH balance the node reports and the token rows the explorer reports,
// fetched and stored exactly where the popup stores them. // fetched and stored exactly where the popup stores them. The node reports the
// token holding the explorer does.
async function refreshWith(balanceWei, tokenItems = []) { async function refreshWith(balanceWei, tokenItems = []) {
mockNode.balanceWei = balanceWei; mockNode.balanceWei = balanceWei;
mockNode.tokenUnits =
tokenItems.length > 0 ? BigInt(tokenItems[0].value) : 0n;
mockExplorer.items = tokenItems; mockExplorer.items = tokenItems;
state.wallets = [{ name: "Wallet 1", addresses: [{ address: HOLDER }] }]; state.wallets = [{ name: "Wallet 1", addresses: [{ address: HOLDER }] }];
state.selectedWallet = 0; state.selectedWallet = 0;
@@ -300,9 +317,10 @@ describe("a tracked token holding below 0.000001 never renders as zero", () => {
await confirmSend("0.0000005", TOKEN); await confirmSend("0.0000005", TOKEN);
expect(text("confirm-balance")).toBe("0.0000005 TOK"); expect(text("confirm-balance")).toBe("0.0000005 TOK");
expect(errors()).toBe(""); expect(errors()).toBe("");
expect(balanceError()).toBe("");
await confirmSend("0.0000006", TOKEN); await confirmSend("0.0000006", TOKEN);
expect(errors()).toContain( expect(balanceError()).toContain(
"You have 0.0000005 TOK but are trying to send 0.0000006 TOK.", "Your TOK balance does not cover this amount.",
); );
}); });
@@ -316,6 +334,7 @@ describe("a tracked token holding below 0.000001 never renders as zero", () => {
await confirmSend("1.5", TOKEN); await confirmSend("1.5", TOKEN);
expect(text("confirm-balance")).toBe("1.5000 TOK"); expect(text("confirm-balance")).toBe("1.5000 TOK");
expect(errors()).toBe(""); expect(errors()).toBe("");
expect(balanceError()).toBe("");
}); });
test("with more than 18 decimals and a holding below 10^-18", async () => { test("with more than 18 decimals and a holding below 10^-18", async () => {
@@ -329,9 +348,8 @@ describe("a tracked token holding below 0.000001 never renders as zero", () => {
); );
await confirmSend("0.000000000000000001", TOKEN); await confirmSend("0.000000000000000001", TOKEN);
expect(text("confirm-balance")).toBe("0.000000000000000000000001 TOK"); expect(text("confirm-balance")).toBe("0.000000000000000000000001 TOK");
expect(errors()).toContain( expect(balanceError()).toContain(
"You have 0.000000000000000000000001 TOK but are trying to send" + "Your TOK balance does not cover this amount.",
" 0.000000000000000001 TOK.",
); );
}); });
}); });
@@ -356,9 +374,10 @@ describe("an untracked token holding below 0.000001 never renders as zero", () =
await confirmSend("0.0000005", TOKEN); await confirmSend("0.0000005", TOKEN);
expect(text("confirm-balance")).toBe("0.0000005 TOK"); expect(text("confirm-balance")).toBe("0.0000005 TOK");
expect(errors()).toBe(""); expect(errors()).toBe("");
expect(balanceError()).toBe("");
await confirmSend("0.0000006", TOKEN); await confirmSend("0.0000006", TOKEN);
expect(errors()).toContain( expect(balanceError()).toContain(
"You have 0.0000005 TOK but are trying to send 0.0000006 TOK.", "Your TOK balance does not cover this amount.",
); );
}); });
}); });
+13 -1
View File
@@ -17,6 +17,7 @@
// What the stub node answers, and the signed transactions it was handed. // What the stub node answers, and the signed transactions it was handed.
const mockNode = { const mockNode = {
balanceWei: 0n, balanceWei: 0n,
tokenUnits: 0n,
feeData: null, feeData: null,
broadcast: [], broadcast: [],
}; };
@@ -30,6 +31,14 @@ jest.mock("ethers", () => {
async getBalance() { async getBalance() {
return mockNode.balanceWei; return mockNode.balanceWei;
} }
// balanceOf(), the one contract call these screens make before Sign
// & Send.
async call() {
return actual.AbiCoder.defaultAbiCoder().encode(
["uint256"],
[mockNode.tokenUnits],
);
}
async lookupAddress() { async lookupAddress() {
return null; return null;
} }
@@ -187,9 +196,12 @@ function text(id) {
} }
// The ETH balance the node reports and the token rows the explorer reports, // The ETH balance the node reports and the token rows the explorer reports,
// fetched and stored exactly where the popup stores them. // fetched and stored exactly where the popup stores them. The node reports the
// token holding the explorer does.
async function refreshWith(balanceWei, tokenItems = []) { async function refreshWith(balanceWei, tokenItems = []) {
mockNode.balanceWei = balanceWei; mockNode.balanceWei = balanceWei;
mockNode.tokenUnits =
tokenItems.length > 0 ? BigInt(tokenItems[0].value) : 0n;
mockExplorer.items = tokenItems; mockExplorer.items = tokenItems;
state.wallets = [ state.wallets = [
{ {
+51 -38
View File
@@ -39,7 +39,8 @@ jest.mock("../src/shared/log", () => ({
// Everything the confirmation screen would reach the network for. The gas // Everything the confirmation screen would reach the network for. The gas
// estimate is the point: with a usable scale it must succeed, so that a failure // estimate is the point: with a usable scale it must succeed, so that a failure
// in these tests is a failure of the scale and not of the stub. // in these tests is a failure of the scale and not of the stub. The address
// holds the 1 ETH the wallets below record, and `tokenUnits` of the token.
const mockProvider = { const mockProvider = {
getFeeData: async () => ({ getFeeData: async () => ({
maxFeePerGas: 2000000000n, maxFeePerGas: 2000000000n,
@@ -48,7 +49,14 @@ const mockProvider = {
estimateGas: async () => 21000n, estimateGas: async () => 21000n,
getCode: async () => "0x", getCode: async () => "0x",
getTransactionCount: async () => 1, getTransactionCount: async () => 1,
getBalance: async () => 0n, getBalance: async () => 1000000000000000000n,
tokenUnits: 0n,
// The one contract call the screen makes, balanceOf().
call: async () =>
AbiCoder.defaultAbiCoder().encode(
["uint256"],
[mockProvider.tokenUnits],
),
}; };
jest.mock("../src/shared/balances", () => { jest.mock("../src/shared/balances", () => {
@@ -114,7 +122,7 @@ global.document = {
}; };
global.navigator = { clipboard: { writeText() {} } }; global.navigator = { clipboard: { writeText() {} } };
const { parseUnits } = require("ethers"); const { AbiCoder, parseUnits } = require("ethers");
const { fetchTokenBalances } = require("../src/shared/balances"); const { fetchTokenBalances } = require("../src/shared/balances");
const { debugFetch } = require("../src/shared/log"); const { debugFetch } = require("../src/shared/log");
const { state } = require("../src/shared/state"); const { state } = require("../src/shared/state");
@@ -155,8 +163,9 @@ function row(token = {}, value = FIVE_WETH) {
} }
// Fetch the explorer's rows through the real fetcher and put them exactly where // Fetch the explorer's rows through the real fetcher and put them exactly where
// refreshBalances() puts them. // refreshBalances() puts them. The node reports the holding the explorer does.
async function fetchOnto(items) { async function fetchOnto(items) {
mockProvider.tokenUnits = BigInt(items[0].value);
debugFetch.mockImplementation(async () => ({ debugFetch.mockImplementation(async () => ({
ok: true, ok: true,
status: 200, status: 200,
@@ -181,6 +190,7 @@ async function fetchOnto(items) {
// is from the first. Two addresses is what it takes to reach // is from the first. Two addresses is what it takes to reach
// explorerDecimals()'s disagreement check, which is only reachable across rows. // explorerDecimals()'s disagreement check, which is only reachable across rows.
async function fetchOntoBoth(itemsA, itemsB) { async function fetchOntoBoth(itemsA, itemsB) {
mockProvider.tokenUnits = BigInt(itemsA[0].value);
debugFetch.mockImplementation(async () => ({ debugFetch.mockImplementation(async () => ({
ok: true, ok: true,
status: 200, status: 200,
@@ -239,6 +249,12 @@ function errors() {
return el("confirm-errors").innerHTML; return el("confirm-errors").innerHTML;
} }
// The message for an amount the balance does not cover, while it is shown.
function balanceError() {
const box = el("confirm-balance-error");
return box.style.visibility === "visible" ? box.textContent : "";
}
function sendDisabled() { function sendDisabled() {
return el("btn-confirm-send").disabled; return el("btn-confirm-send").disabled;
} }
@@ -270,7 +286,6 @@ describe("the Send screen resolves the scale rather than reading the stored one"
const txInfo = await reviewSend(WETH, "1.5"); const txInfo = await reviewSend(WETH, "1.5");
expect(txInfo.tokenDecimals).toBe(18); expect(txInfo.tokenDecimals).toBe(18);
expect(txInfo.tokenDecimals).not.toBe(balances[0].decimals); expect(txInfo.tokenDecimals).not.toBe(balances[0].decimals);
expect(txInfo.tokenBalance).toBe("5.0");
}); });
test("that scale estimates a fee and leaves Send enabled", async () => { test("that scale estimates a fee and leaves Send enabled", async () => {
@@ -307,11 +322,10 @@ describe("the Send screen resolves the scale rather than reading the stored one"
// No fallback was introduced: resolution answers null here, and the // No fallback was introduced: resolution answers null here, and the
// null is what goes forward. // null is what goes forward.
expect(txInfo.tokenDecimals).toBeNull(); expect(txInfo.tokenDecimals).toBeNull();
expect(txInfo.tokenBalance).toBeNull();
confirmTx.show(txInfo); confirmTx.show(txInfo);
await settle(); await settle();
expect(text("confirm-balance")).toBe("unknown (NOVEL)"); expect(text("confirm-balance")).toBe("unknown (NOVEL)");
expect(errors()).toContain("This token&#39;s balance is unknown"); expect(balanceError()).toContain("This token's balance is unknown");
expect(sendDisabled()).toBe(true); expect(sendDisabled()).toBe(true);
}); });
}); });
@@ -355,7 +369,8 @@ describe("a scale the explorer's own rows disagree about", () => {
const txInfo = await reviewSend(NOVEL, "1.5"); const txInfo = await reviewSend(NOVEL, "1.5");
expect(txInfo.tokenDecimals).toBeNull(); expect(txInfo.tokenDecimals).toBeNull();
// The regression this closes: null scale alongside a non-null balance. // The regression this closes: null scale alongside a non-null balance.
expect(txInfo.tokenBalance).toBeNull(); send.updateSendBalance();
expect(text("send-balance")).toBe("Current balance: unknown (NOVEL)");
}); });
test("so the user is told the balance is unknown, not only that the fee failed", async () => { test("so the user is told the balance is unknown, not only that the fee failed", async () => {
@@ -367,8 +382,7 @@ describe("a scale the explorer's own rows disagree about", () => {
// confirm-fee-unknown-error — "the network fee could not be // confirm-fee-unknown-error — "the network fee could not be
// estimated... please go back and try again" — as the only explanation // estimated... please go back and try again" — as the only explanation
// for a screen that can never proceed. // for a screen that can never proceed.
expect(errors()).not.toBe(""); expect(balanceError()).toContain("This token's balance is unknown");
expect(errors()).toContain("This token&#39;s balance is unknown");
expect(text("confirm-balance")).toBe("unknown (NOVEL)"); expect(text("confirm-balance")).toBe("unknown (NOVEL)");
expect(sendDisabled()).toBe(true); expect(sendDisabled()).toBe(true);
// The fee line still reports the estimate as unavailable, because it // The fee line still reports the estimate as unavailable, because it
@@ -434,31 +448,20 @@ describe("a scale the explorer's own rows disagree about", () => {
expect(text("send-balance")).toBe("Current balance: 5.0000 NOVEL"); expect(text("send-balance")).toBe("Current balance: 5.0000 NOVEL");
const txInfo = await reviewSend(NOVEL, "1.5"); const txInfo = await reviewSend(NOVEL, "1.5");
expect(txInfo.tokenDecimals).toBe(6); expect(txInfo.tokenDecimals).toBe(6);
expect(txInfo.tokenBalance).toBe("5.0");
confirmTx.show(txInfo); confirmTx.show(txInfo);
await settle(); await settle();
expect(text("confirm-balance")).toBe("5.0000 NOVEL"); expect(text("confirm-balance")).toBe("5.0000 NOVEL");
expect(errors()).toBe(""); expect(errors()).toBe("");
expect(balanceError()).toBe("");
expect(sendDisabled()).toBe(false); expect(sendDisabled()).toBe(false);
}); });
}); });
describe("the confirmation screen tells an unknown balance from a zero one", () => { describe("the confirmation screen tells an unknown balance from a zero one", () => {
function txInfo(tokenBalance) { // A send of 1.5 NOVEL from an address the node reports none of, with the
return { // token's scale `tokenDecimals`, or null for a scale nobody knows.
from: HOLDER, async function render(tokenDecimals) {
to: RECIPIENT, mockProvider.tokenUnits = 0n;
ensName: null,
amount: "1.5",
token: NOVEL,
balance: "1.0",
tokenSymbol: "NOVEL",
tokenBalance,
tokenDecimals: tokenBalance === null ? null : 18,
};
}
async function render(tokenBalance) {
state.wallets = [ state.wallets = [
{ {
name: "Wallet 1", name: "Wallet 1",
@@ -469,30 +472,40 @@ describe("the confirmation screen tells an unknown balance from a zero one", ()
]; ];
state.selectedWallet = 0; state.selectedWallet = 0;
state.selectedAddress = 0; state.selectedAddress = 0;
confirmTx.show(txInfo(tokenBalance)); confirmTx.show({
from: HOLDER,
to: RECIPIENT,
ensName: null,
amount: "1.5",
token: NOVEL,
tokenSymbol: "NOVEL",
tokenDecimals,
});
await settle(); await settle();
return { balance: text("confirm-balance"), errors: errors() }; return { balance: text("confirm-balance"), message: balanceError() };
} }
test("the balance line states unknown rather than a quantity of zero", async () => { test("the balance line states unknown rather than a quantity of zero", async () => {
const unknown = await render(null); const unknown = await render(null);
const zero = await render("0.0"); const zero = await render(18);
expect(unknown.balance).not.toBe(zero.balance); expect(unknown.balance).not.toBe(zero.balance);
expect(unknown.balance).toBe("unknown (NOVEL)"); expect(unknown.balance).toBe("unknown (NOVEL)");
expect(zero.balance).toBe("0.0000 NOVEL"); expect(zero.balance).toBe("0.0000 NOVEL");
}); });
// Both hit INSUFFICIENT_TOKEN — an unknown balance is treated as nothing to // Both hit INSUFFICIENT_TOKEN — an unknown balance is treated as nothing to
// spend from, which is the fail-closed side — but "you have 0.0" is a claim // spend from, which is the fail-closed side — but "your balance does not
// about the holding, and this one has no established quantity to claim. // cover this amount" is a claim about the holding, and this one has no
test("the insufficient-balance message names the reason, not a figure", async () => { // established quantity to claim.
test("the insufficient-balance message names the reason, not a shortfall", async () => {
const unknown = await render(null); const unknown = await render(null);
const zero = await render("0.0"); const zero = await render(18);
expect(unknown.errors).not.toBe(zero.errors); expect(unknown.message).toContain("This token's balance is unknown");
expect(unknown.errors).toContain("This token&#39;s balance is unknown"); expect(unknown.message).not.toContain("does not cover");
expect(unknown.errors).not.toContain("You have"); expect(zero.message).toBe(
expect(zero.errors).toContain("You have 0.0000 NOVEL"); "Your NOVEL balance does not cover this amount. Please go back and" +
expect(zero.errors).not.toContain("balance is unknown"); " send a smaller amount.",
);
}); });
}); });