The popup's saveState() and the background's updateState() each read the stored record, change it and write it back, and each was ordered only within its own context, so a write by one could land between the other's read and write and be lost. Both now hold the browser's lock (navigator.locks, shared by every page and the background) from the read to the write, through withLock() in src/shared/browserApi.js; the recovery screen's reset takes it too. The lock also orders a page's own saves, so both per-context queues are gone. The unit tests get a small lock from a Jest setup file. 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);
|
|
});
|
|
});
|