ConfirmTx showed and checked the balance Send handed it, so a popup reopened onto a confirmation saved before a network switch stated the other network's balance and checked the amount against it. Each time it is shown, ConfirmTx now reads the From address's ETH balance, and for a token send its token balance at the scale the amount is shown with, from the node of the network it is drawn for. Until the read returns the balance line says "Loading..." and Sign & Send stays disabled; a read that fails is stated as unknown and blocks the send. The fee estimate starts once the read is done, since a max ETH send's amount comes from the balance. Send no longer hands balances over. Model: opus-5-5
221 lines
6.7 KiB
JavaScript
221 lines
6.7 KiB
JavaScript
// The Send screen refuses every amount the confirmation screen would refuse,
|
|
// by the same check (https://git.eeqj.de/sneak/AutistMask/issues/532).
|
|
//
|
|
// Send used to accept anything parseFloat() read as above zero, so "1e-3",
|
|
// "1abc" and an amount with more decimal places than the token has went on to
|
|
// a confirmation screen that then refused them. Both screens now call
|
|
// isValidAmount() in src/shared/txValidation.js.
|
|
//
|
|
// Driven through the real Send review handler and the real confirmation
|
|
// screen, with only the node and the DOM stubbed.
|
|
|
|
"use strict";
|
|
|
|
jest.mock("../src/shared/log", () => ({
|
|
log: {
|
|
debugf: () => {},
|
|
infof: () => {},
|
|
warnf: () => {},
|
|
errorf: () => {},
|
|
},
|
|
debugFetch: jest.fn(),
|
|
setRuntimeDebug: () => {},
|
|
isDebug: () => false,
|
|
}));
|
|
|
|
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,
|
|
gasPrice: 1000000000n,
|
|
}),
|
|
estimateGas: async () => 21000n,
|
|
getCode: async () => "0x",
|
|
getTransactionCount: async () => 1,
|
|
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", () => {
|
|
const actual = jest.requireActual("../src/shared/balances");
|
|
return { ...actual, getProvider: () => mockProvider };
|
|
});
|
|
|
|
// The confirmation screen's Etherscan label lookup fails, as it does offline.
|
|
global.fetch = jest.fn(() => {
|
|
throw new Error("tests must not perform network requests");
|
|
});
|
|
|
|
const { makeStorageStub } = require("./support/storageStub");
|
|
global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
|
|
|
|
// A stub DOM: every id resolves to a recording element.
|
|
const elements = new Map();
|
|
|
|
function makeEl(id) {
|
|
const handlers = new Map();
|
|
return {
|
|
id,
|
|
textContent: "",
|
|
innerHTML: "",
|
|
value: "",
|
|
disabled: false,
|
|
style: {},
|
|
dataset: {},
|
|
classList: {
|
|
add() {},
|
|
remove() {},
|
|
toggle() {},
|
|
contains: () => false,
|
|
},
|
|
handlers,
|
|
addEventListener(name, fn) {
|
|
handlers.set(name, fn);
|
|
},
|
|
appendChild(child) {
|
|
return child;
|
|
},
|
|
querySelectorAll: () => [],
|
|
querySelector: () => null,
|
|
remove() {},
|
|
focus() {},
|
|
};
|
|
}
|
|
|
|
global.document = {
|
|
getElementById(id) {
|
|
if (!elements.has(id)) elements.set(id, makeEl(id));
|
|
return elements.get(id);
|
|
},
|
|
createElement: (tag) => makeEl(tag),
|
|
body: { prepend() {}, appendChild() {} },
|
|
addEventListener() {},
|
|
};
|
|
global.navigator = { clipboard: { writeText() {} } };
|
|
|
|
const { state } = require("../src/shared/state");
|
|
const send = require("../src/popup/views/send");
|
|
const confirmTx = require("../src/popup/views/confirmTx");
|
|
|
|
const HOLDER = "0x" + "a".repeat(40);
|
|
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
|
|
// A token with 6 decimal places, as USDC has.
|
|
const TOKEN = "0x" + "d".repeat(40);
|
|
const TOKEN_BY_SYMBOL = { ETH: "ETH", TOK: TOKEN };
|
|
|
|
function el(id) {
|
|
return global.document.getElementById(id);
|
|
}
|
|
|
|
// Press Review on the Send screen with `amount` entered, for a send of `token`
|
|
// ("ETH" or TOKEN), and return what it handed the confirmation screen, or null
|
|
// when it handed nothing over.
|
|
async function review(amount, token) {
|
|
let handed = null;
|
|
send.init({ showConfirmTx: (info) => (handed = info) });
|
|
state.selectedToken = token;
|
|
el("send-to").value = RECIPIENT;
|
|
el("send-amount").value = amount;
|
|
await el("btn-send-review").handlers.get("click")();
|
|
return handed;
|
|
}
|
|
|
|
// Show the confirmation screen for the send Review hands over for 0.5, with
|
|
// `amount` in its place, and let its fee estimate settle.
|
|
async function confirm(amount, token) {
|
|
const txInfo = await review("0.5", token);
|
|
confirmTx.show({ ...txInfo, amount });
|
|
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
|
|
}
|
|
|
|
beforeEach(() => {
|
|
elements.clear();
|
|
state.wallets = [
|
|
{
|
|
name: "Wallet 1",
|
|
addresses: [
|
|
{
|
|
address: HOLDER,
|
|
balance: "1.0",
|
|
tokenBalances: [
|
|
{
|
|
address: TOKEN,
|
|
name: "Token",
|
|
symbol: "TOK",
|
|
decimals: 6,
|
|
balance: "250.0",
|
|
holders: 50000,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
];
|
|
state.selectedWallet = 0;
|
|
state.selectedAddress = 0;
|
|
state.trackedTokens = [
|
|
{ address: TOKEN, symbol: "TOK", name: "Token", decimals: 6 },
|
|
];
|
|
state.fraudContracts = [];
|
|
state.selectedToken = null;
|
|
state.currentView = null;
|
|
});
|
|
|
|
describe.each([
|
|
["1e-3", "ETH"],
|
|
["1e-3", "TOK"],
|
|
["1abc", "ETH"],
|
|
// 19 decimal places; ETH has 18.
|
|
["0.0000000000000000001", "ETH"],
|
|
// 7 decimal places; the token has 6.
|
|
["0.0000001", "TOK"],
|
|
["0", "ETH"],
|
|
["-1", "ETH"],
|
|
])("%s %s", (amount, symbol) => {
|
|
const token = TOKEN_BY_SYMBOL[symbol];
|
|
|
|
test("Send refuses it with a flash message and opens nothing", async () => {
|
|
expect(await review(amount, token)).toBe(null);
|
|
expect(el("flash-msg").textContent).toBe(
|
|
"Please enter a valid amount.",
|
|
);
|
|
});
|
|
|
|
test("the confirmation screen refuses it too", async () => {
|
|
await confirm(amount, token);
|
|
expect(el("confirm-errors").innerHTML).toContain(
|
|
"Please enter a valid amount to send.",
|
|
);
|
|
expect(el("btn-confirm-send").disabled).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe.each([
|
|
["0.5", "ETH"],
|
|
["0.5", "TOK"],
|
|
// Exactly the 6 decimal places the token has.
|
|
["0.000001", "TOK"],
|
|
// Only zeros past the token's 6 places, and past ETH's 18.
|
|
["1.0000000", "TOK"],
|
|
["0.5000000000000000000", "ETH"],
|
|
])("%s %s", (amount, symbol) => {
|
|
const token = TOKEN_BY_SYMBOL[symbol];
|
|
|
|
test("Send hands it to the confirmation screen", async () => {
|
|
expect((await review(amount, token)).amount).toBe(amount);
|
|
expect(el("flash-msg").textContent).toBe("");
|
|
});
|
|
|
|
test("the confirmation screen lets it be sent", async () => {
|
|
await confirm(amount, token);
|
|
expect(el("confirm-errors").innerHTML).toBe("");
|
|
expect(el("btn-confirm-send").disabled).toBe(false);
|
|
});
|
|
});
|