fix: the confirmation screen reads its balance on the network it is drawn for (closes #558)
ConfirmTx showed and checked the balance Send handed it, so a screen the popup reopened onto stated the balance as Send knew it, not as it is on the network the screen is drawn for. 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 reviewedNetworkProvider() picks for the network the transaction was reviewed for, the node the fee estimate and recipient checks ask. The fee estimate starts once the read returns. 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 was merged in pull request #569.
This commit is contained in:
@@ -15,20 +15,37 @@
|
||||
// reviewed for, because choosing that network again in Settings and pressing
|
||||
// Back only unhides it, and Sign & Send then sends from it.
|
||||
//
|
||||
// Its balance line and balance check are that network's balance, read from
|
||||
// that network's node each time the screen is drawn
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/558), not the balance Send
|
||||
// handed over. The fee estimate after the read stays on that network when
|
||||
// the network is switched while the read runs.
|
||||
//
|
||||
// Driven through the real confirmation screen, signer and chain switch, with
|
||||
// only the node, fetch 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.
|
||||
// its 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 questions the stub node was asked for the fee and the recipient checks,
|
||||
// by method, network name and RPC URL, in order. The signed transactions it
|
||||
// was handed, with its network's chain id and RPC URL. And whether it quotes
|
||||
// fees as a network with no type-2 pricing does.
|
||||
const mockNode = { asked: [], broadcast: [], legacyFees: false };
|
||||
// The questions the stub node was asked for the balance, the fee and the
|
||||
// recipient checks, by method, network name and RPC URL, in order. The
|
||||
// balances it reports on each network, by network name: ETH in wei and the
|
||||
// token in its base units. The signed transactions it was handed, with its
|
||||
// network's chain id and RPC URL. And whether it quotes fees as a network
|
||||
// with no type-2 pricing does.
|
||||
const mockNode = {
|
||||
asked: [],
|
||||
balanceWei: {},
|
||||
tokenUnits: {},
|
||||
broadcast: [],
|
||||
legacyFees: false,
|
||||
};
|
||||
|
||||
jest.mock("ethers", () => {
|
||||
const actual = jest.requireActual("ethers");
|
||||
// The selector of balanceOf(address).
|
||||
const BALANCE_OF = "0x70a08231";
|
||||
class StubProvider {
|
||||
constructor(url, network) {
|
||||
this.url = url;
|
||||
@@ -44,6 +61,22 @@ jest.mock("ethers", () => {
|
||||
async getNetwork() {
|
||||
return this.network;
|
||||
}
|
||||
async getBalance() {
|
||||
this.record("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()");
|
||||
}
|
||||
this.record("balanceOf");
|
||||
return actual.AbiCoder.defaultAbiCoder().encode(
|
||||
["uint256"],
|
||||
[mockNode.tokenUnits[this.network.name]],
|
||||
);
|
||||
}
|
||||
async getFeeData() {
|
||||
this.record("getFeeData");
|
||||
if (mockNode.legacyFees) {
|
||||
@@ -158,7 +191,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, saveState } = require("../src/shared/state");
|
||||
const { onChainSwitch } = require("../src/shared/chainSwitch");
|
||||
@@ -198,6 +237,11 @@ beforeAll(async () => {
|
||||
beforeEach(() => {
|
||||
elements.clear();
|
||||
mockNode.asked = [];
|
||||
mockNode.balanceWei = {
|
||||
mainnet: parseEther("5"),
|
||||
sepolia: parseEther("5"),
|
||||
};
|
||||
mockNode.tokenUnits = { mainnet: 0n, sepolia: 0n };
|
||||
mockNode.broadcast = [];
|
||||
mockNode.legacyFees = false;
|
||||
global.fetch.mockClear();
|
||||
@@ -212,7 +256,7 @@ afterEach(() => {
|
||||
|
||||
// Show the confirmation screen for 1 of the native token on `networkId`, or
|
||||
// for the transfer `changes` makes of it, as Review on the Send screen does,
|
||||
// with its fee estimate settled.
|
||||
// with its balance read and its fee estimate settled.
|
||||
async function review(networkId, changes = {}) {
|
||||
await onChainSwitch(networkId);
|
||||
await reviewOnActiveNetwork(changes);
|
||||
@@ -225,7 +269,7 @@ async function reviewOnActiveNetwork(changes = {}) {
|
||||
type: "key",
|
||||
name: "Wallet 1",
|
||||
encryptedSecret,
|
||||
addresses: [{ address: HOLDER, balance: "5.0" }],
|
||||
addresses: [{ address: HOLDER }],
|
||||
},
|
||||
];
|
||||
state.selectedWallet = 0;
|
||||
@@ -237,9 +281,7 @@ async function reviewOnActiveNetwork(changes = {}) {
|
||||
ensName: null,
|
||||
amount: "1",
|
||||
token: "ETH",
|
||||
balance: "5.0",
|
||||
tokenSymbol: null,
|
||||
tokenBalance: null,
|
||||
tokenDecimals: null,
|
||||
max: false,
|
||||
...changes,
|
||||
@@ -323,12 +365,24 @@ function expectNativeTransferOn(networkId) {
|
||||
);
|
||||
}
|
||||
|
||||
// show() estimates the fee and checks the recipient without awaiting either;
|
||||
// this lets both finish.
|
||||
// show() reads the balance and then estimates the fee, and checks the
|
||||
// recipient, without awaiting any of them; this lets all of them finish.
|
||||
async function settle() {
|
||||
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
|
||||
}
|
||||
|
||||
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() {
|
||||
el("confirm-tx-password").value = PASSWORD;
|
||||
await el("btn-confirm-send").handlers.get("click")();
|
||||
@@ -359,9 +413,10 @@ test("a transfer reviewed after the active network's RPC endpoint was changed in
|
||||
await reviewOnActiveNetwork();
|
||||
|
||||
expect(mockNode.asked).toEqual([
|
||||
{ method: "getFeeData", network: "sepolia", url: newNode },
|
||||
{ method: "getBalance", network: "sepolia", url: newNode },
|
||||
{ method: "getCode", network: "sepolia", url: newNode },
|
||||
{ method: "getTransactionCount", network: "sepolia", url: newNode },
|
||||
{ method: "getFeeData", network: "sepolia", url: newNode },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -411,7 +466,7 @@ describe.each([
|
||||
expectNativeTransferOn(reviewedOn);
|
||||
expect(mockNode.asked).toEqual([
|
||||
{
|
||||
method: "getFeeData",
|
||||
method: "getBalance",
|
||||
network: reviewedOn,
|
||||
url: ownNode(reviewedOn),
|
||||
},
|
||||
@@ -425,6 +480,11 @@ describe.each([
|
||||
network: reviewedOn,
|
||||
url: ownNode(reviewedOn),
|
||||
},
|
||||
{
|
||||
method: "getFeeData",
|
||||
network: reviewedOn,
|
||||
url: ownNode(reviewedOn),
|
||||
},
|
||||
]);
|
||||
expect(global.fetch.mock.calls.map(([url]) => url)).toEqual([
|
||||
explorerUrl + "/address/" + RECIPIENT,
|
||||
@@ -472,12 +532,106 @@ describe.each([
|
||||
await review(reviewedOn, { amount: "10" });
|
||||
await switchReopenAndGoBack(reviewedOn, switchedTo);
|
||||
|
||||
expect(el("confirm-errors").innerHTML).toContain(
|
||||
"Insufficient balance. You have 5.0000 " +
|
||||
expect(shown("confirm-balance-error")).toBe(true);
|
||||
expect(el("confirm-balance-error").textContent).toBe(
|
||||
"Your " +
|
||||
symbol +
|
||||
" but are trying to send 10 " +
|
||||
symbol +
|
||||
".",
|
||||
" balance does not cover this amount. Please go back and" +
|
||||
" send a smaller amount.",
|
||||
);
|
||||
});
|
||||
|
||||
test("states and checks that network's balance as it is then, which no longer covers the send", async () => {
|
||||
await review(reviewedOn);
|
||||
expect(sendDisabled()).toBe(false);
|
||||
mockNode.balanceWei = {
|
||||
[reviewedOn]: parseEther("0.5"),
|
||||
[switchedTo]: parseEther("5"),
|
||||
};
|
||||
await switchReopenAndGoBack(reviewedOn, switchedTo);
|
||||
|
||||
expect(el("confirm-balance").textContent).toBe(
|
||||
"0.5000 " + symbol + usdOn(reviewedOn, "$1,000.00"),
|
||||
);
|
||||
expect(shown("confirm-balance-error")).toBe(true);
|
||||
expect(sendDisabled()).toBe(true);
|
||||
});
|
||||
|
||||
test("states and checks that network's balance as it is then, which now covers the send, and refuses to send it", async () => {
|
||||
mockNode.balanceWei[reviewedOn] = parseEther("0.5");
|
||||
await review(reviewedOn);
|
||||
expect(sendDisabled()).toBe(true);
|
||||
mockNode.balanceWei = {
|
||||
[reviewedOn]: parseEther("5"),
|
||||
[switchedTo]: parseEther("0.5"),
|
||||
};
|
||||
await switchReopenAndGoBack(reviewedOn, switchedTo);
|
||||
|
||||
expect(el("confirm-balance").textContent).toBe(
|
||||
"5.0000 " + symbol + usdOn(reviewedOn, "$10,000.00"),
|
||||
);
|
||||
expect(errors()).toBe("");
|
||||
expect(shown("confirm-balance-error")).toBe(false);
|
||||
expect(shown("confirm-amount-fee-error")).toBe(false);
|
||||
expect(sendDisabled()).toBe(false);
|
||||
|
||||
await signAndSend();
|
||||
|
||||
expect(signTransaction).not.toHaveBeenCalled();
|
||||
expect(mockNode.broadcast).toEqual([]);
|
||||
expect(errors()).toContain(
|
||||
"The active network changed after this transaction was" +
|
||||
" reviewed, so it was not sent.",
|
||||
);
|
||||
});
|
||||
|
||||
const tokenSend = {
|
||||
token: TOKEN,
|
||||
tokenSymbol: "TOK",
|
||||
tokenDecimals: 6,
|
||||
};
|
||||
|
||||
test("of a token, states and checks that network's token balance as it is then, which no longer covers the send", async () => {
|
||||
mockNode.tokenUnits[reviewedOn] = 10000000n;
|
||||
await review(reviewedOn, tokenSend);
|
||||
expect(sendDisabled()).toBe(false);
|
||||
mockNode.tokenUnits = {
|
||||
[reviewedOn]: 500000n,
|
||||
[switchedTo]: 10000000n,
|
||||
};
|
||||
await switchReopenAndGoBack(reviewedOn, switchedTo);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
test("of a token, states and checks that network's token balance as it is then, which now covers the send, and refuses to send it", async () => {
|
||||
await review(reviewedOn, tokenSend);
|
||||
expect(sendDisabled()).toBe(true);
|
||||
mockNode.tokenUnits = {
|
||||
[reviewedOn]: 10000000n,
|
||||
[switchedTo]: 0n,
|
||||
};
|
||||
await switchReopenAndGoBack(reviewedOn, switchedTo);
|
||||
|
||||
expect(el("confirm-balance").textContent).toBe("10.0000 TOK");
|
||||
expect(errors()).toBe("");
|
||||
expect(shown("confirm-balance-error")).toBe(false);
|
||||
expect(shown("confirm-gas-error")).toBe(false);
|
||||
expect(sendDisabled()).toBe(false);
|
||||
|
||||
await signAndSend();
|
||||
|
||||
expect(signTransaction).not.toHaveBeenCalled();
|
||||
expect(mockNode.broadcast).toEqual([]);
|
||||
expect(errors()).toContain(
|
||||
"The active network changed after this transaction was" +
|
||||
" reviewed, so it was not sent.",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -492,10 +646,10 @@ describe.each([
|
||||
});
|
||||
|
||||
test("of an ERC-20 token, links the token contract to that network's explorer and prices the token by that network", async () => {
|
||||
mockNode.tokenUnits[reviewedOn] = 50000000n;
|
||||
await review(reviewedOn, {
|
||||
token: TOKEN,
|
||||
tokenSymbol: "USDC",
|
||||
tokenBalance: "50",
|
||||
tokenDecimals: 6,
|
||||
amount: "10",
|
||||
});
|
||||
@@ -536,3 +690,101 @@ test("the send is refused by a signer on another network's node", async () => {
|
||||
expect(signTransaction).not.toHaveBeenCalled();
|
||||
expect(mockNode.broadcast).toEqual([]);
|
||||
});
|
||||
|
||||
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, after the switch, and still
|
||||
// asks the node of the network the screen was drawn for.
|
||||
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();
|
||||
}
|
||||
const feeAskedOn = () =>
|
||||
mockNode.asked
|
||||
.filter((q) => q.method === "getFeeData")
|
||||
.map((q) => q.network);
|
||||
expect(feeAskedOn()).toEqual([]);
|
||||
|
||||
await onChainSwitch("sepolia");
|
||||
answer(parseEther("5"));
|
||||
await settle();
|
||||
|
||||
expect(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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user