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

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

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

Model: opus-5-5
This commit is contained in:
2026-10-19 01:05:50 +00:00
parent b923b2c19c
commit 8098402d83
15 changed files with 690 additions and 245 deletions
-1
View File
@@ -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;
+259 -11
View File
@@ -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);
});
});
+23 -9
View File
@@ -244,16 +244,21 @@ function latestBlock() {
};
}
// keccak("decimals()")[0:4], and the same for symbol() and name().
// keccak("decimals()")[0:4], and the same for symbol(), name() and
// balanceOf(address).
const SELECTOR_DECIMALS = "0x313ce567";
const SELECTOR_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
+92 -35
View File
@@ -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).
+8 -7
View File
@@ -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 };
@@ -339,14 +343,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.",
);
});
});
-1
View File
@@ -767,7 +767,6 @@ const WELL_FORMED_DATA = {
from: ADDRESS,
to: ADDRESS,
amount: "1",
balance: "2",
},
pendingWait: {
hash: "0x1",
+9 -2
View File
@@ -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", () => {
+27 -8
View File
@@ -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.",
);
});
});
+13 -1
View File
@@ -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 = [
{
+51 -38
View File
@@ -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&#39;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&#39;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&#39;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.",
);
});
});