2 Commits
Author SHA1 Message Date
sneak fde8d1cdf0 fix: the confirmation screen's balance read and fee estimate stay on its network
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
The fee estimate starts once the balance is read, and both it and the balance
line took the active network at that moment, so a network switched in Settings
while the read was in flight put a mainnet balance under a SepoliaETH label and
estimated its fee on Sepolia.

show() now builds the node of the network it is drawn for once and passes it
to the balance read, the fee estimate and the recipient check, and every native
symbol on the screen comes from the chain id it is drawn for.

Model: opus-5-5
2026-10-18 22:33:53 +00:00
sneak 1b9e14a4e9 fix: the confirmation screen reads its balance on the network it is drawn for (closes #558)
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 at the scale the amount is shown with, from the node of the network it is drawn for. Until the read returns the balance line says "Loading..." and Sign & Send stays disabled; a read that fails is stated as unknown and blocks the send. The fee estimate starts once the read is done, since a max ETH send's amount comes from the balance. Send no longer hands balances over.

Model: opus-5-5
2026-10-18 22:29:39 +00:00
14 changed files with 496 additions and 147 deletions
+27 -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,20 @@ 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 (insufficient balance, or a balance that could not be read, which
amount plus the fee exceeds the balance (ETH transfers), not enough ETH to asks the user to go back and try again), plus three reserved error boxes —
pay the fee for the transfer (ERC-20 transfers), and the fee could not be the amount plus the fee exceeds the balance (ETH transfers), not enough
estimated. The first two are mutually exclusive per transfer type, so only ETH to pay the fee for the transfer (ERC-20 transfers), and the fee could
the applicable one holds space. The last names its cause: for a token not be estimated. The first two are mutually exclusive per transfer type,
whose scale is unknown the fee can never be estimated, and it says the so only the applicable one holds space. The last names its cause: for a
wallet does not know how many decimal places the token uses and that the token whose scale is unknown the fee can never be estimated, and it says
transaction cannot be sent; for any other failure it asks the user to go the wallet does not know how many decimal places the token uses and that
back and try again 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**
+140 -64
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.";
@@ -262,25 +288,67 @@ 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 const symbol = isErc20
? displaySymbol(txInfo.tokenSymbol || "?") ? displaySymbol(txInfo.tokenSymbol || "?")
: nativeCurrency(); : nativeCurrencyByChainId(txInfo.chainId);
// Null while the balances are being read and after a read that failed.
// validateTransfer() takes a missing balance as nothing to spend from, so
// sending stays blocked either way, and no message below quotes one.
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,
}); });
@@ -292,9 +360,16 @@ function renderValidation(txInfo) {
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)) { if (balances !== null && !balanceRead) {
messages.push( messages.push(
txInfo.tokenBalance == null "Your balance could not be read from the network, so this" +
" transaction cannot be checked against it. Please go back" +
" and try again.",
);
}
if (balanceRead && codes.includes(CODES.INSUFFICIENT_TOKEN)) {
messages.push(
tokenBalance == null
? "This token's balance is unknown, because nothing this" + ? "This token's balance is unknown, because nothing this" +
" wallet can consult reports how many decimal places it" + " wallet can consult reports how many decimal places it" +
" uses, so the amount you are trying to send cannot be" + " uses, so the amount you are trying to send cannot be" +
@@ -302,7 +377,7 @@ function renderValidation(txInfo) {
: "Insufficient " + : "Insufficient " +
symbol + symbol +
" balance. You have " + " balance. You have " +
truncateAmountNeverZero(txInfo.tokenBalance) + truncateAmountNeverZero(tokenBalance) +
" " + " " +
symbol + symbol +
" but are trying to send " + " but are trying to send " +
@@ -312,10 +387,10 @@ function renderValidation(txInfo) {
".", ".",
); );
} }
if (codes.includes(CODES.INSUFFICIENT_ETH)) { if (balanceRead && codes.includes(CODES.INSUFFICIENT_ETH)) {
messages.push( messages.push(
"Insufficient balance. You have " + "Insufficient balance. You have " +
truncateAmountNeverZero(txInfo.balance || "0") + truncateAmountNeverZero(ethBalance) +
" " + " " +
symbol + symbol +
" but are trying to send " + " but are trying to send " +
@@ -339,11 +414,11 @@ function renderValidation(txInfo) {
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 +437,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 +488,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 +503,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 +591,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;
+225 -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,32 @@ 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 sendDisabled() {
return el("btn-confirm-send").disabled;
} }
async function signAndSend() { async function signAndSend() {
@@ -257,3 +330,144 @@ 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(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(errors()).toContain(
"Insufficient balance. You have 0.5000 SepoliaETH but are trying" +
" to send 1 SepoliaETH.",
);
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(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(errors()).toContain(
"Insufficient TOK balance. You have 0.5000 TOK but are trying to" +
" send 1 TOK.",
);
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(sendDisabled()).toBe(true);
answer(parseEther("5"));
await settle();
expect(el("confirm-balance").textContent).toBe("5.0000 SepoliaETH");
expect(sendDisabled()).toBe(false);
});
// 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(errors()).toBe(
'<div class="text-xs">Your balance could not be read from the' +
" network, so this transaction cannot be checked against it." +
" Please go back and try again.</div>",
);
expect(sendDisabled()).toBe(true);
});
});
+14 -6
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;
} }
+10
View File
@@ -2303,6 +2303,16 @@ 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 screen reads the balance itself before it estimates the fee, so
// waiting for it here means every state below is read with the balance
// in place, the pending fee included.
await page.waitForFunction(
() =>
document.getElementById("confirm-balance").textContent.trim() !==
"Loading...",
null,
{ timeout: 60000 },
);
await assertAddressesFit(page, "the confirmation screen"); await assertAddressesFit(page, "the confirmation screen");
} }
+4
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 };
-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", () => {
+12 -1
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;
} }
@@ -196,9 +204,12 @@ function errors() {
} }
// 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;
+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 = [
{ {
+31 -26
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,
@@ -270,7 +280,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,7 +316,6 @@ 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)");
@@ -355,7 +363,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 () => {
@@ -434,7 +443,6 @@ 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");
@@ -444,21 +452,10 @@ describe("a scale the explorer's own rows disagree about", () => {
}); });
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,14 +466,22 @@ 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"), errors: errors() };
} }
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");
@@ -487,7 +492,7 @@ describe("the confirmation screen tells an unknown balance from a zero one", ()
// about the holding, and this one has no established quantity to claim. // about the holding, and this one has no established quantity to claim.
test("the insufficient-balance message names the reason, not a figure", async () => { test("the insufficient-balance message names the reason, not a figure", 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.errors).not.toBe(zero.errors);
expect(unknown.errors).toContain("This token&#39;s balance is unknown"); expect(unknown.errors).toContain("This token&#39;s balance is unknown");
expect(unknown.errors).not.toContain("You have"); expect(unknown.errors).not.toContain("You have");