diff --git a/README.md b/README.md
index dd58982..11527fc 100644
--- a/README.md
+++ b/README.md
@@ -1013,8 +1013,10 @@ rendered differently on two screens.
The native token's label is a network's `nativeCurrency` in
`src/shared/networks.js`: `ETH` on mainnet, `SepoliaETH` on Sepolia. The
-wallet's balances and the Send and confirmation screens, which send on the
-active network, use the active network's. A transaction's figures use the one of
+wallet's balances and the Send screen, which sends on the active network, use
+the active network's. The confirmation screen uses the one of the network 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
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
@@ -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 fee, the amount is left as it was and the amount-plus-fee error below
blocks the send
- - Your balance: value + symbol (USD in parentheses), or `unknown (SYMBOL)`
- for a token whose scale is unknown
+ - Your balance: the From address's balance on the network the screen is
+ 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",
- 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 * maxFeePerGas` reserve the node requires, which is what the
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 —
recipient with no transaction history, recipient is a contract, burn
address, and an Etherscan phishing/scam label
- - Errors (insufficient balance), plus three reserved error boxes — the
- amount plus the fee exceeds the balance (ETH transfers), not enough ETH to
- pay the fee for the transfer (ERC-20 transfers), and the fee could not be
- estimated. The first two are mutually exclusive per transfer type, so only
- the applicable one holds space. The last names 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
+ - Errors (an invalid amount), plus five reserved error boxes, so the balance
+ and the fee arriving later move nothing — the amount exceeds the balance
+ (for a token whose scale is unknown it says the token's balance is
+ unknown), the balance could not be read (it asks the user to go back and
+ try again), the amount plus the fee exceeds the balance (ETH transfers),
+ not enough ETH to pay the fee for the transfer (ERC-20 transfers), and the
+ fee could not be estimated. The third and fourth are mutually exclusive
+ per transfer type, so only the applicable one holds space. The last names
+ 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
line
- - "Sign & Send" button (disabled if errors, and while the network fee
- estimate is pending or unavailable)
+ - "Sign & Send" button (disabled if errors, while the balance is being read,
+ and while the network fee estimate is pending or unavailable)
- **Transitions**:
- "Sign & Send" (correct password) → broadcast tx → **WaitTx**
- "Sign & Send" (correct password) → broadcast fails → **ErrorTx**
diff --git a/src/popup/index.html b/src/popup/index.html
index a433783..c666ac0 100644
--- a/src/popup/index.html
+++ b/src/popup/index.html
@@ -669,6 +669,20 @@
id="confirm-errors"
class="mb-2 border border-border border-dashed p-2 invisible min-h-[1.25rem]"
>
+
+
+
+ Your balance could not be read from the network, so this
+ transaction cannot be checked against it. Please go back and
+ try again.
+
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
-// show() and again when the fee estimate resolves or fails. Every element it
-// touches already occupies its space, so re-running it never moves anything.
+// show(), again when the balances are read, and again when the fee estimate
+// resolves or fails. Every element it touches already occupies its space, so
+// re-running it never moves anything.
function renderValidation(txInfo) {
const isErc20 = txInfo.token !== "ETH";
- const symbol = isErc20
- ? displaySymbol(txInfo.tokenSymbol || "?")
- : nativeCurrency();
+
+ // 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 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({
isErc20,
amount: txInfo.amount,
tokenDecimals: txInfo.tokenDecimals,
- ethBalance: txInfo.balance,
- tokenBalance: txInfo.tokenBalance,
+ ethBalance,
+ tokenBalance,
feeStatus,
feeWei,
});
- // Messages carrying the user's own numbers are built here; the fixed
- // sentences live in the reserved elements in index.html, except the
- // gas and fee-unknown ones, which show() sets.
+ // An invalid amount is known before the first paint, so its message is
+ // written here. Every message a later result brings has a reserved
+ // 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 = [];
if (codes.includes(CODES.AMOUNT_INVALID)) {
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");
if (messages.length > 0) {
@@ -337,13 +385,20 @@ function renderValidation(txInfo) {
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(
"confirm-amount-fee-error",
- codes.includes(CODES.INSUFFICIENT_ETH_WITH_FEE),
+ balanceRead && codes.includes(CODES.INSUFFICIENT_ETH_WITH_FEE),
);
setVisible(
"confirm-gas-error",
- codes.includes(CODES.INSUFFICIENT_ETH_FOR_FEE),
+ balanceRead && codes.includes(CODES.INSUFFICIENT_ETH_FOR_FEE),
);
setVisible(
"confirm-fee-unknown-error",
@@ -362,9 +417,10 @@ function setVisible(id, visible) {
$(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 {
- const provider = getProvider(state.rpcUrl, state.networkId);
const feeData = await provider.getFeeData();
let gasLimit;
@@ -412,12 +468,13 @@ async function estimateGas(txInfo) {
// does, so the same fee reads the same on both.
if (estimateWei !== null && estimateWei < gasCostWei) {
$("confirm-fee-amount").textContent =
- "~" + formatFee(estimateWei, nativeCurrency());
+ "~" +
+ formatFee(estimateWei, nativeCurrencyByChainId(txInfo.chainId));
$("confirm-fee-reserve").textContent =
"up to " +
truncateAmountNeverZero(formatEther(gasCostWei)) +
" " +
- nativeCurrency() +
+ nativeCurrencyByChainId(txInfo.chainId) +
" reserved";
setVisible("confirm-fee-reserve", true);
} else {
@@ -426,20 +483,20 @@ async function estimateGas(txInfo) {
// cost is not below the reserve. Show the reserve alone.
$("confirm-fee-amount").textContent = formatFee(
gasCostWei,
- nativeCurrency(),
+ nativeCurrencyByChainId(txInfo.chainId),
);
setVisible("confirm-fee-reserve", false);
}
feeStatus = FEE_KNOWN;
feeWei = gasCostWei;
- // A max ETH send is the balance minus this estimate's reserve, not the
- // Send screen's, and is signed with this estimate's fee fields: fees
- // fetched again at signing could exceed the reserve it leaves, and the
- // node would refuse it for want of funds. Where the balance no longer
- // covers the fee, the amount is left as it is and the balance check
- // below says so.
+ // A max ETH send is the balance readBalances() read minus this
+ // estimate's reserve, not the Send screen's, and is signed with this
+ // estimate's fee fields: fees fetched again at signing could exceed
+ // the reserve it leaves, and the node would refuse it for want of
+ // funds. Where the balance is unknown or no longer covers the fee, the
+ // amount is left as it is and the balance check below says so.
if (txInfo.max && txInfo.token === "ETH") {
- const amount = maxEthAmount(txInfo.balance, gasCostWei);
+ const amount = maxEthAmount(balances?.eth, gasCostWei);
if (amount !== null) {
txInfo.amount = amount;
renderAmount(txInfo);
@@ -514,9 +571,8 @@ function showSendError(message) {
el.style.visibility = "visible";
}
-async function checkRecipientHistory(txInfo) {
+async function checkRecipientHistory(txInfo, provider) {
try {
- const provider = getProvider(state.rpcUrl, state.networkId);
const asyncWarnings = await getFullWarnings(txInfo.to, provider, {
fromAddress: txInfo.from,
});
diff --git a/src/popup/views/send.js b/src/popup/views/send.js
index 29350d6..6556c9f 100644
--- a/src/popup/views/send.js
+++ b/src/popup/views/send.js
@@ -164,8 +164,9 @@ function renderSendTokenSelect(addr) {
}
}
-// The token balance and scale the Send screen states and hands the
-// confirmation screen, so the two screens describe the holding the same way.
+// The token balance the Send screen states, and the scale it hands the
+// 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
// 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
// `balance` is a quantity computed at a scale this screen has just declined to
-// stand behind. Stating it would leave validateTransfer() checking the amount
-// against a number the wallet does not vouch for, so it is withdrawn: unknown
-// scale means unknown balance. It is null rather than "0": both screens state
-// an unknown balance as unknown, and validateTransfer() treats it as no balance
-// to spend from, which is the fail-closed side of an amount nobody can check.
-// 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.
+// stand behind, so it is withdrawn: unknown scale means unknown balance. It is
+// null rather than "0": this screen states it as unknown, as the confirmation
+// screen does, and Max fills nothing in for it. 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) {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === token.toLowerCase(),
@@ -378,7 +377,6 @@ function init(_ctx) {
const addr = currentAddress();
let tokenSymbol = null;
- let tokenBalance = null;
let tokenDecimals = null;
if (token !== "ETH") {
tokenSymbol = resolveSymbol(
@@ -386,10 +384,7 @@ function init(_ctx) {
addr.tokenBalances,
state.trackedTokens,
);
- ({ tokenBalance, tokenDecimals } = tokenBalanceAndDecimals(
- addr,
- token,
- ));
+ ({ tokenDecimals } = tokenBalanceAndDecimals(addr, token));
}
// The confirmation screen's own amount check, so it is never shown
@@ -424,9 +419,7 @@ function init(_ctx) {
ensName: ensName,
amount: amount,
token: token,
- balance: addr.balance,
tokenSymbol: tokenSymbol,
- tokenBalance: tokenBalance,
tokenDecimals: tokenDecimals,
max: amountIsMax,
});
diff --git a/src/shared/txValidation.js b/src/shared/txValidation.js
index 4c9d5df..ed256cc 100644
--- a/src/shared/txValidation.js
+++ b/src/shared/txValidation.js
@@ -111,8 +111,8 @@ function isValidAmount(amount, tokenDecimals = null) {
// 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
-// exactly that reserve left behind. `ethBalance` is the exact decimal string
-// balances.js stores, never a rounded one. Null when the balance does not
+// exactly that reserve left behind. `ethBalance` is an exact decimal string as
+// 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
// unusable.
function maxEthAmount(ethBalance, feeWei) {
diff --git a/tests/addressKey.test.js b/tests/addressKey.test.js
index 631cfe5..89e779f 100644
--- a/tests/addressKey.test.js
+++ b/tests/addressKey.test.js
@@ -291,7 +291,6 @@ async function confirmSend(from, selected) {
to: RECIPIENT,
amount: "0.5",
token: "ETH",
- balance: "1.0",
});
await settle();
node("confirm-tx-password").value = PASSWORD;
diff --git a/tests/confirmTxNetworkSwitch.test.js b/tests/confirmTxNetworkSwitch.test.js
index a612744..48e3fea 100644
--- a/tests/confirmTxNetworkSwitch.test.js
+++ b/tests/confirmTxNetworkSwitch.test.js
@@ -7,17 +7,35 @@
// reviewed on Sepolia went out as 1 ETH on mainnet once mainnet was chosen in
// 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
// 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";
-// The signed transactions the stub node was handed, with its network's chain id.
-const mockNode = { broadcast: [] };
+// The balances the stub node reports on each network, by network name: ETH
+// 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", () => {
const actual = jest.requireActual("ethers");
+ // The selector of balanceOf(address).
+ const BALANCE_OF = "0x70a08231";
class StubProvider {
constructor(url, network) {
this.network = network;
@@ -25,7 +43,22 @@ jest.mock("ethers", () => {
async getNetwork() {
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() {
+ mockNode.feeAskedOn.push(this.network.name);
return {
maxFeePerGas: 20000000000n,
maxPriorityFeePerGas: 1000000000n,
@@ -123,7 +156,13 @@ global.document = {
};
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 { state, loadState } = require("../src/shared/state");
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 RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
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) {
return global.document.getElementById(id);
@@ -150,6 +191,12 @@ beforeAll(async () => {
beforeEach(() => {
elements.clear();
+ mockNode.balanceWei = {
+ mainnet: parseEther("5"),
+ sepolia: parseEther("5"),
+ };
+ mockNode.tokenUnits = { mainnet: 0n, sepolia: 0n };
+ mockNode.feeAskedOn = [];
mockNode.broadcast = [];
txStatus.showWait.mockClear();
txStatus.showError.mockClear();
@@ -160,16 +207,23 @@ afterEach(() => {
signTransaction.mockRestore();
});
-// Show the confirmation screen for 1 of the native token on `networkId`, as
-// Review on the Send screen does, with its fee estimate settled.
-async function review(networkId) {
+// show() reads the balances and estimates the fee without awaiting either;
+// this lets both settle.
+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);
state.wallets = [
{
type: "key",
name: "Wallet 1",
encryptedSecret,
- addresses: [{ address: HOLDER, balance: "5.0" }],
+ addresses: [{ address: HOLDER }],
},
];
state.selectedWallet = 0;
@@ -181,13 +235,36 @@ async function review(networkId) {
ensName: null,
amount: "1",
token: "ETH",
- balance: "5.0",
tokenSymbol: null,
- tokenBalance: null,
tokenDecimals: null,
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() {
@@ -257,3 +334,174 @@ test("the send is refused by a signer on another network's node", async () => {
expect(signTransaction).not.toHaveBeenCalled();
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);
+ });
+});
diff --git a/tests/e2e/network.js b/tests/e2e/network.js
index 123f6cd..e2d2443 100644
--- a/tests/e2e/network.js
+++ b/tests/e2e/network.js
@@ -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_SYMBOL = "0x95d89b41";
const SELECTOR_NAME = "0x06fdde03";
+const SELECTOR_BALANCE_OF = "0x70a08231";
-// 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
-// time to compare with the scale the confirmation screen rendered (issue
-// #305). Adding the token by its contract address reads all three (issue
-// #295); symbol() and name() answer what the explorer reports for it.
+// Every eth_call still answers with a zero word except decimals(), symbol(),
+// name() and balanceOf() on the stub token. The wallet reads decimals() back
+// at signing time to compare with the scale the confirmation screen rendered
+// (issue #305). Adding the token by its contract address reads the first three
+// (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()
// answers something other than the value this same fixture reports through
@@ -278,6 +283,9 @@ function ethCallResult(req, opts) {
if (data.startsWith(SELECTOR_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;
}
@@ -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.
//
-// The switch (holdGasEstimate, holdTransactionCount or holdBlockscout) is read
-// here rather than captured, so a test flips it on the same options object the
-// route was registered with — the same pattern as seedTokenTransfer. This is
+// The switch (holdBalance, holdGasEstimate, holdTransactionCount or
+// holdBlockscout) is read here rather than captured, so a test flips it on the
+// 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;
// sampling the screen and hoping to win a race against the network would
// assert nothing on a slow machine.
@@ -557,6 +566,9 @@ async function handleRpc(route, postData, opts, report) {
report("unstubbed request: POST " + route.request().url());
return route.abort();
}
+ if (batch.some((req) => req.method === "eth_getBalance")) {
+ await awaitRelease(opts, "holdBalance", report);
+ }
if (batch.some((req) => req.method === "eth_estimateGas")) {
await awaitRelease(opts, "holdGasEstimate", report);
}
@@ -615,6 +627,8 @@ function traceEnabled(raw) {
* fixture expects.
* @param {boolean} [opts.failGasEstimate] answer eth_estimateGas with a
* 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
* eth_estimateGas until this is cleared again.
* @param {boolean} [opts.holdTransactionCount] hold every batch containing an
diff --git a/tests/e2e/run.js b/tests/e2e/run.js
index c5e221f..5f23f4f 100644
--- a/tests/e2e/run.js
+++ b/tests/e2e/run.js
@@ -2232,7 +2232,7 @@ function feeEth(wei) {
// 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
-// 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) {
return page.evaluate(() => {
const el = (id) => document.getElementById(id);
@@ -2245,6 +2245,9 @@ async function confirmState(page) {
};
return {
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(),
balance: el("confirm-balance").textContent.trim(),
fee: el("confirm-fee-amount").textContent.trim(),
@@ -2253,6 +2256,9 @@ async function confirmState(page) {
errors: shown("confirm-errors")
? el("confirm-errors").textContent.trim()
: "",
+ balanceError: shown("confirm-balance-error")
+ ? el("confirm-balance-error").textContent.trim()
+ : "",
amountFeeError: shown("confirm-amount-fee-error"),
gasError: shown("confirm-gas-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) {
if (await page.isVisible("#view-confirm-tx")) {
await page.click("#btn-confirm-back");
@@ -2282,13 +2298,14 @@ async function backToAddress(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
// 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
// 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 page.click("#btn-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.click("#btn-send-review");
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");
}
@@ -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",
);
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",
);
});
@@ -2409,6 +2437,7 @@ test("ConfirmTx enables Send once the estimate lands, quoting both numbers (#238
);
assert(
st.errors === "" &&
+ st.balanceError === "" &&
!st.amountFeeError &&
!st.gasError &&
!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) => {
- await goToConfirm(env.page, {
- token: "ETH",
- balance: FUNDED_ETH_TEXT + " ETH",
- amount: OVER_BALANCE_AMOUNT,
- });
+// The screen is first measured with its balance read held, so the message the
+// 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",
+ balance: FUNDED_ETH_TEXT + " ETH",
+ 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);
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(
- st.errors === want,
- "wrong over-balance message: " + JSON.stringify(st.errors),
+ st.balance === FUNDED_ETH_TEXT + " ETH",
+ "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.amountFeeError,
"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) => {
@@ -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",
);
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",
);
assert(
@@ -2633,21 +2689,19 @@ test("ConfirmTx refuses an ERC-20 send that exceeds the token balance (#238)", a
await waitForEstimate(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(
- st.errors === want,
- "wrong over-token-balance message: " + JSON.stringify(st.errors),
+ st.balance === TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol,
+ "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(
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",
);
assert(
- st.errors === "",
+ st.errors === "" && st.balanceError === "",
"a token-balance message is shown for a transfer the token balance covers: " +
- JSON.stringify(st.errors),
+ JSON.stringify(st.errors + st.balanceError),
);
assert(
st.height === env.erc20PendingHeight,
@@ -4859,6 +4913,9 @@ async function main() {
seedTokenBalance: false,
ethBalanceWei: null,
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,
// Whether a JSON-RPC batch asking for a transaction count is held
// unanswered: the address scan after creating a wallet (#475).
diff --git a/tests/nativeCurrency.test.js b/tests/nativeCurrency.test.js
index 8bd8bb7..d09133c 100644
--- a/tests/nativeCurrency.test.js
+++ b/tests/nativeCurrency.test.js
@@ -17,6 +17,10 @@ jest.mock("ethers", () => {
async lookupAddress() {
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.
async getFeeData() {
return { maxFeePerGas: 20000000000n, gasPrice: 10000000000n };
@@ -332,14 +336,11 @@ describe.each([
test("the insufficient-balance error", async () => {
await confirmSend("2");
- expect(
- global.document.getElementById("confirm-errors").innerHTML,
- ).toContain(
- "You have 1.5000 " +
+ expect(text("confirm-balance-error")).toBe(
+ "Your " +
symbol +
- " but are trying to send 2 " +
- symbol +
- ".",
+ " balance does not cover this amount. Please go back and send" +
+ " a smaller amount.",
);
});
});
diff --git a/tests/persistedFieldContract.test.js b/tests/persistedFieldContract.test.js
index 637d2ab..6d45119 100644
--- a/tests/persistedFieldContract.test.js
+++ b/tests/persistedFieldContract.test.js
@@ -767,7 +767,6 @@ const WELL_FORMED_DATA = {
from: ADDRESS,
to: ADDRESS,
amount: "1",
- balance: "2",
},
pendingWait: {
hash: "0x1",
diff --git a/tests/sendAmountRule.test.js b/tests/sendAmountRule.test.js
index 407b13c..224ff4a 100644
--- a/tests/sendAmountRule.test.js
+++ b/tests/sendAmountRule.test.js
@@ -23,7 +23,10 @@ jest.mock("../src/shared/log", () => ({
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 = {
getFeeData: async () => ({
maxFeePerGas: 2000000000n,
@@ -32,7 +35,11 @@ const mockProvider = {
estimateGas: async () => 21000n,
getCode: async () => "0x",
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", () => {
diff --git a/tests/sendDisplayFloor.test.js b/tests/sendDisplayFloor.test.js
index 1e0b885..d7a37d6 100644
--- a/tests/sendDisplayFloor.test.js
+++ b/tests/sendDisplayFloor.test.js
@@ -18,6 +18,7 @@
// What the stub node answers. Each test sets what it needs.
const mockNode = {
balanceWei: 0n,
+ tokenUnits: 0n,
feeData: { maxFeePerGas: 1n, gasPrice: 1n },
};
@@ -30,6 +31,13 @@ jest.mock("ethers", () => {
async getBalance() {
return mockNode.balanceWei;
}
+ // balanceOf(), the one contract call these screens make.
+ async call() {
+ return actual.AbiCoder.defaultAbiCoder().encode(
+ ["uint256"],
+ [mockNode.tokenUnits],
+ );
+ }
async lookupAddress() {
return null;
}
@@ -195,10 +203,19 @@ function errors() {
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,
-// 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 = []) {
mockNode.balanceWei = balanceWei;
+ mockNode.tokenUnits =
+ tokenItems.length > 0 ? BigInt(tokenItems[0].value) : 0n;
mockExplorer.items = tokenItems;
state.wallets = [{ name: "Wallet 1", addresses: [{ address: HOLDER }] }];
state.selectedWallet = 0;
@@ -300,9 +317,10 @@ describe("a tracked token holding below 0.000001 never renders as zero", () => {
await confirmSend("0.0000005", TOKEN);
expect(text("confirm-balance")).toBe("0.0000005 TOK");
expect(errors()).toBe("");
+ expect(balanceError()).toBe("");
await confirmSend("0.0000006", TOKEN);
- expect(errors()).toContain(
- "You have 0.0000005 TOK but are trying to send 0.0000006 TOK.",
+ expect(balanceError()).toContain(
+ "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);
expect(text("confirm-balance")).toBe("1.5000 TOK");
expect(errors()).toBe("");
+ expect(balanceError()).toBe("");
});
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);
expect(text("confirm-balance")).toBe("0.000000000000000000000001 TOK");
- expect(errors()).toContain(
- "You have 0.000000000000000000000001 TOK but are trying to send" +
- " 0.000000000000000001 TOK.",
+ expect(balanceError()).toContain(
+ "Your TOK balance does not cover this amount.",
);
});
});
@@ -356,9 +374,10 @@ describe("an untracked token holding below 0.000001 never renders as zero", () =
await confirmSend("0.0000005", TOKEN);
expect(text("confirm-balance")).toBe("0.0000005 TOK");
expect(errors()).toBe("");
+ expect(balanceError()).toBe("");
await confirmSend("0.0000006", TOKEN);
- expect(errors()).toContain(
- "You have 0.0000005 TOK but are trying to send 0.0000006 TOK.",
+ expect(balanceError()).toContain(
+ "Your TOK balance does not cover this amount.",
);
});
});
diff --git a/tests/sendMax.test.js b/tests/sendMax.test.js
index 1c3f0b9..997e43e 100644
--- a/tests/sendMax.test.js
+++ b/tests/sendMax.test.js
@@ -17,6 +17,7 @@
// What the stub node answers, and the signed transactions it was handed.
const mockNode = {
balanceWei: 0n,
+ tokenUnits: 0n,
feeData: null,
broadcast: [],
};
@@ -30,6 +31,14 @@ jest.mock("ethers", () => {
async getBalance() {
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() {
return null;
}
@@ -187,9 +196,12 @@ function text(id) {
}
// 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 = []) {
mockNode.balanceWei = balanceWei;
+ mockNode.tokenUnits =
+ tokenItems.length > 0 ? BigInt(tokenItems[0].value) : 0n;
mockExplorer.items = tokenItems;
state.wallets = [
{
diff --git a/tests/unknownScaleSend.test.js b/tests/unknownScaleSend.test.js
index e264584..c65a9b3 100644
--- a/tests/unknownScaleSend.test.js
+++ b/tests/unknownScaleSend.test.js
@@ -39,7 +39,8 @@ jest.mock("../src/shared/log", () => ({
// 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
-// 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 = {
getFeeData: async () => ({
maxFeePerGas: 2000000000n,
@@ -48,7 +49,14 @@ const mockProvider = {
estimateGas: async () => 21000n,
getCode: async () => "0x",
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", () => {
@@ -114,7 +122,7 @@ global.document = {
};
global.navigator = { clipboard: { writeText() {} } };
-const { parseUnits } = require("ethers");
+const { AbiCoder, parseUnits } = require("ethers");
const { fetchTokenBalances } = require("../src/shared/balances");
const { debugFetch } = require("../src/shared/log");
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
-// refreshBalances() puts them.
+// refreshBalances() puts them. The node reports the holding the explorer does.
async function fetchOnto(items) {
+ mockProvider.tokenUnits = BigInt(items[0].value);
debugFetch.mockImplementation(async () => ({
ok: true,
status: 200,
@@ -181,6 +190,7 @@ async function fetchOnto(items) {
// is from the first. Two addresses is what it takes to reach
// explorerDecimals()'s disagreement check, which is only reachable across rows.
async function fetchOntoBoth(itemsA, itemsB) {
+ mockProvider.tokenUnits = BigInt(itemsA[0].value);
debugFetch.mockImplementation(async () => ({
ok: true,
status: 200,
@@ -239,6 +249,12 @@ function errors() {
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() {
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");
expect(txInfo.tokenDecimals).toBe(18);
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 () => {
@@ -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
// null is what goes forward.
expect(txInfo.tokenDecimals).toBeNull();
- expect(txInfo.tokenBalance).toBeNull();
confirmTx.show(txInfo);
await settle();
expect(text("confirm-balance")).toBe("unknown (NOVEL)");
- expect(errors()).toContain("This token's balance is unknown");
+ expect(balanceError()).toContain("This token's balance is unknown");
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");
expect(txInfo.tokenDecimals).toBeNull();
// 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 () => {
@@ -367,8 +382,7 @@ describe("a scale the explorer's own rows disagree about", () => {
// confirm-fee-unknown-error — "the network fee could not be
// estimated... please go back and try again" — as the only explanation
// for a screen that can never proceed.
- expect(errors()).not.toBe("");
- expect(errors()).toContain("This token's balance is unknown");
+ expect(balanceError()).toContain("This token's balance is unknown");
expect(text("confirm-balance")).toBe("unknown (NOVEL)");
expect(sendDisabled()).toBe(true);
// 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");
const txInfo = await reviewSend(NOVEL, "1.5");
expect(txInfo.tokenDecimals).toBe(6);
- expect(txInfo.tokenBalance).toBe("5.0");
confirmTx.show(txInfo);
await settle();
expect(text("confirm-balance")).toBe("5.0000 NOVEL");
expect(errors()).toBe("");
+ expect(balanceError()).toBe("");
expect(sendDisabled()).toBe(false);
});
});
describe("the confirmation screen tells an unknown balance from a zero one", () => {
- function txInfo(tokenBalance) {
- return {
- from: HOLDER,
- to: RECIPIENT,
- ensName: null,
- amount: "1.5",
- token: NOVEL,
- balance: "1.0",
- tokenSymbol: "NOVEL",
- tokenBalance,
- tokenDecimals: tokenBalance === null ? null : 18,
- };
- }
-
- async function render(tokenBalance) {
+ // A send of 1.5 NOVEL from an address the node reports none of, with the
+ // token's scale `tokenDecimals`, or null for a scale nobody knows.
+ async function render(tokenDecimals) {
+ mockProvider.tokenUnits = 0n;
state.wallets = [
{
name: "Wallet 1",
@@ -469,30 +472,40 @@ describe("the confirmation screen tells an unknown balance from a zero one", ()
];
state.selectedWallet = 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();
- 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 () => {
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).toBe("unknown (NOVEL)");
expect(zero.balance).toBe("0.0000 NOVEL");
});
// 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
- // about the holding, and this one has no established quantity to claim.
- test("the insufficient-balance message names the reason, not a figure", async () => {
+ // spend from, which is the fail-closed side — but "your balance does not
+ // cover this amount" is a claim about the holding, and this one has no
+ // established quantity to claim.
+ test("the insufficient-balance message names the reason, not a shortfall", async () => {
const unknown = await render(null);
- const zero = await render("0.0");
- expect(unknown.errors).not.toBe(zero.errors);
- expect(unknown.errors).toContain("This token's balance is unknown");
- expect(unknown.errors).not.toContain("You have");
- expect(zero.errors).toContain("You have 0.0000 NOVEL");
- expect(zero.errors).not.toContain("balance is unknown");
+ const zero = await render(18);
+ expect(unknown.message).toContain("This token's balance is unknown");
+ expect(unknown.message).not.toContain("does not cover");
+ expect(zero.message).toBe(
+ "Your NOVEL balance does not cover this amount. Please go back and" +
+ " send a smaller amount.",
+ );
});
});