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
This commit is contained in:
2026-10-18 22:33:53 +00:00
parent 1b9e14a4e9
commit fde8d1cdf0
3 changed files with 87 additions and 37 deletions
+40 -4
View File
@@ -11,7 +11,8 @@
// (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 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
@@ -21,9 +22,15 @@
"use strict";
// The balances the stub node reports on each network, by network name: ETH
// in wei and the token in its base units. And the signed transactions it was
// handed, with its network's chain id.
const mockNode = { balanceWei: {}, tokenUnits: {}, 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", () => {
const actual = jest.requireActual("ethers");
@@ -51,6 +58,7 @@ jest.mock("ethers", () => {
);
}
async getFeeData() {
mockNode.feeAskedOn.push(this.network.name);
return {
maxFeePerGas: 20000000000n,
maxPriorityFeePerGas: 1000000000n,
@@ -188,6 +196,7 @@ beforeEach(() => {
sepolia: parseEther("5"),
};
mockNode.tokenUnits = { mainnet: 0n, sepolia: 0n };
mockNode.feeAskedOn = [];
mockNode.broadcast = [];
txStatus.showWait.mockClear();
txStatus.showError.mockClear();
@@ -417,6 +426,33 @@ describe("the balance read", () => {
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")