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 except a page in a Firefox private window) 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
416 lines
13 KiB
JavaScript
416 lines
13 KiB
JavaScript
// The amount of the wallet's own send on the confirmation, wait, success and
|
|
// error screens (https://git.eeqj.de/sneak/AutistMask/issues/522).
|
|
//
|
|
// These screens printed the text from Send's amount field as it was typed or
|
|
// filled in: `0.5 ETH`, or `1.234147890123456789 ETH` after Max, where the
|
|
// approval screen shows a site's send as `0.5000 ETH`. They now show it to four
|
|
// decimal places with the nonzero floor, as the approval screen does, and the
|
|
// transaction is still signed for the exact amount.
|
|
//
|
|
// Driven through the real Send screen, confirmation screen, Sign & Send and
|
|
// wait screen, with only the node, the explorer and the DOM stubbed.
|
|
|
|
"use strict";
|
|
|
|
// What the stub node answers, and the signed transactions it was handed. The
|
|
// token's own decimals() answers 6, as the explorer does.
|
|
const mockNode = {
|
|
balanceWei: 0n,
|
|
tokenUnits: 0n,
|
|
tokenDecimals: 6n,
|
|
broadcast: [],
|
|
broadcastError: null,
|
|
receipt: null,
|
|
};
|
|
|
|
// The token rows the stub explorer reports for the address.
|
|
const mockExplorer = { items: [] };
|
|
|
|
jest.mock("ethers", () => {
|
|
const actual = jest.requireActual("ethers");
|
|
// The selector of decimals(); every other contract call is balanceOf().
|
|
const DECIMALS = "0x313ce567";
|
|
class StubProvider {
|
|
async getBalance() {
|
|
return mockNode.balanceWei;
|
|
}
|
|
async call(tx) {
|
|
return actual.AbiCoder.defaultAbiCoder().encode(
|
|
["uint256"],
|
|
[
|
|
tx.data.startsWith(DECIMALS)
|
|
? mockNode.tokenDecimals
|
|
: mockNode.tokenUnits,
|
|
],
|
|
);
|
|
}
|
|
async lookupAddress() {
|
|
return null;
|
|
}
|
|
// 20 gwei reserved per gas.
|
|
async getFeeData() {
|
|
return {
|
|
maxFeePerGas: 20000000000n,
|
|
maxPriorityFeePerGas: 1000000000n,
|
|
gasPrice: 10000000000n,
|
|
};
|
|
}
|
|
async estimateGas() {
|
|
return 21000n;
|
|
}
|
|
async getCode() {
|
|
return "0x";
|
|
}
|
|
async getTransactionCount() {
|
|
return 1;
|
|
}
|
|
async getNetwork() {
|
|
return { chainId: 1n };
|
|
}
|
|
async broadcastTransaction(signed) {
|
|
if (mockNode.broadcastError) throw mockNode.broadcastError;
|
|
mockNode.broadcast.push(signed);
|
|
return { hash: "0x" + "ab".repeat(32) };
|
|
}
|
|
async getTransactionReceipt() {
|
|
return mockNode.receipt;
|
|
}
|
|
}
|
|
return {
|
|
...actual,
|
|
JsonRpcProvider: StubProvider,
|
|
Network: { from: () => ({}) },
|
|
};
|
|
});
|
|
|
|
jest.mock("../src/shared/log", () => ({
|
|
log: {
|
|
debugf: () => {},
|
|
infof: () => {},
|
|
warnf: () => {},
|
|
errorf: () => {},
|
|
},
|
|
// The explorer's token list, which refreshBalances() also fetches.
|
|
debugFetch: jest.fn(async () => ({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => mockExplorer.items,
|
|
})),
|
|
urlOrigin: () => "",
|
|
setRuntimeDebug: () => {},
|
|
isDebug: () => false,
|
|
}));
|
|
|
|
// The confirmation screen's Etherscan label lookup is the only fetch() these
|
|
// screens make; it 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,
|
|
children: [],
|
|
addEventListener(name, fn) {
|
|
handlers.set(name, fn);
|
|
},
|
|
appendChild(child) {
|
|
this.children.push(child);
|
|
return child;
|
|
},
|
|
querySelectorAll: () => [],
|
|
querySelector: () => null,
|
|
remove() {},
|
|
focus() {},
|
|
// The success screen hides whole sections through .parentElement.
|
|
get parentElement() {
|
|
return global.document.getElementById(id + "-parent");
|
|
},
|
|
};
|
|
}
|
|
|
|
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 {
|
|
Interface,
|
|
Transaction,
|
|
Wallet,
|
|
getAddress,
|
|
parseEther,
|
|
} = require("ethers");
|
|
const { refreshBalances } = require("../src/shared/balances");
|
|
const { encryptWithPassword } = require("../src/shared/vault");
|
|
const { state } = require("../src/shared/state");
|
|
const { clearPrices } = require("../src/shared/prices");
|
|
const send = require("../src/popup/views/send");
|
|
const confirmTx = require("../src/popup/views/confirmTx");
|
|
const txStatus = require("../src/popup/views/txStatus");
|
|
|
|
const PRIVATE_KEY = "0x" + "11".repeat(32);
|
|
const HOLDER = new Wallet(PRIVATE_KEY).address;
|
|
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
|
|
const PASSWORD = "correct horse battery staple";
|
|
|
|
const GWEI = 1000000000n;
|
|
// 1.234567890123456789 ETH.
|
|
const BALANCE_WEI = 1234567890123456789n;
|
|
|
|
// A token the bundled list does not know, with enough holders to be listed,
|
|
// at 6 decimal places.
|
|
const TOKEN = "0x" + "d".repeat(40);
|
|
|
|
const transferIface = new Interface([
|
|
"function transfer(address to, uint256 amount)",
|
|
]);
|
|
|
|
let encryptedSecret;
|
|
|
|
// The confirmation screen Review leads to, once shown.
|
|
let confirmed = null;
|
|
|
|
function el(id) {
|
|
return global.document.getElementById(id);
|
|
}
|
|
|
|
function text(id) {
|
|
return el(id).textContent;
|
|
}
|
|
|
|
// show() reads the balance and then 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));
|
|
}
|
|
|
|
// The address holds BALANCE_WEI and, when `tokenUnits` is given, that many
|
|
// base units of TOKEN, fetched and stored where the popup stores them. Send
|
|
// is then open for `token` ("ETH" or TOKEN) with the recipient entered.
|
|
async function openSend(token, tokenUnits = null) {
|
|
mockNode.balanceWei = BALANCE_WEI;
|
|
mockNode.tokenUnits = tokenUnits ?? 0n;
|
|
mockExplorer.items =
|
|
tokenUnits === null
|
|
? []
|
|
: [
|
|
{
|
|
value: String(tokenUnits),
|
|
token: {
|
|
type: "ERC-20",
|
|
address_hash: TOKEN,
|
|
symbol: "TOK",
|
|
name: "Token",
|
|
decimals: "6",
|
|
holders_count: "50000",
|
|
},
|
|
},
|
|
];
|
|
state.wallets = [
|
|
{
|
|
type: "key",
|
|
name: "Wallet 1",
|
|
encryptedSecret,
|
|
addresses: [{ address: HOLDER }],
|
|
},
|
|
];
|
|
state.selectedWallet = 0;
|
|
state.selectedAddress = 0;
|
|
await refreshBalances(
|
|
state.wallets,
|
|
"https://rpc.example.invalid",
|
|
"https://blockscout.example/api/v2",
|
|
state.trackedTokens,
|
|
"mainnet",
|
|
);
|
|
send.init({
|
|
showConfirmTx: (info) => (confirmed = info),
|
|
pageClosed: new AbortController().signal,
|
|
});
|
|
confirmTx.init({});
|
|
send.resetSendValidation();
|
|
state.currentView = "send";
|
|
state.selectedToken = token;
|
|
el("send-to").value = RECIPIENT;
|
|
el("send-amount").value = "";
|
|
}
|
|
|
|
// Press Review and show the confirmation screen with its fee estimate settled.
|
|
async function review() {
|
|
await el("btn-send-review").handlers.get("click")();
|
|
confirmTx.show(confirmed);
|
|
await settle();
|
|
}
|
|
|
|
async function signAndSend() {
|
|
el("confirm-tx-password").value = PASSWORD;
|
|
await el("btn-confirm-send").handlers.get("click")();
|
|
}
|
|
|
|
// The one transaction the stub node was handed.
|
|
function broadcastTx() {
|
|
expect(mockNode.broadcast).toHaveLength(1);
|
|
return Transaction.from(mockNode.broadcast[0]);
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
encryptedSecret = await encryptWithPassword(PRIVATE_KEY, PASSWORD);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
elements.clear();
|
|
clearPrices();
|
|
confirmed = null;
|
|
state.selectedToken = null;
|
|
state.trackedTokens = [];
|
|
state.fraudContracts = [];
|
|
state.currentView = null;
|
|
mockNode.broadcast = [];
|
|
mockNode.broadcastError = null;
|
|
mockNode.receipt = null;
|
|
txStatus.init({
|
|
doRefreshAndRender() {},
|
|
pageClosed: new AbortController().signal,
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
txStatus.endWait();
|
|
});
|
|
|
|
describe("the wallet's own send shows its amount to four decimal places", () => {
|
|
test("for a short amount", async () => {
|
|
await openSend("ETH");
|
|
el("send-amount").value = "0.5";
|
|
await review();
|
|
expect(text("confirm-amount")).toBe("0.5000 ETH");
|
|
|
|
await signAndSend();
|
|
expect(text("wait-tx-summary")).toBe("0.5000 ETH");
|
|
expect(broadcastTx().value).toBe(parseEther("0.5"));
|
|
});
|
|
|
|
// Send accepts both. The approval screen writes the same value `0.5000`.
|
|
test.each([".5", "00.5"])("for an amount typed as %s", async (typed) => {
|
|
await openSend("ETH");
|
|
el("send-amount").value = typed;
|
|
await review();
|
|
expect(text("confirm-amount")).toBe("0.5000 ETH");
|
|
|
|
await signAndSend();
|
|
expect(text("wait-tx-summary")).toBe("0.5000 ETH");
|
|
expect(broadcastTx().value).toBe(parseEther("0.5"));
|
|
});
|
|
|
|
test("for a token amount typed as .5", async () => {
|
|
// 100 TOK.
|
|
await openSend(TOKEN, 100000000n);
|
|
el("send-amount").value = ".5";
|
|
await review();
|
|
expect(text("confirm-amount")).toBe("0.5000 TOK");
|
|
|
|
await signAndSend();
|
|
expect(text("wait-tx-summary")).toBe("0.5000 TOK");
|
|
const [, amount] = transferIface.decodeFunctionData(
|
|
"transfer",
|
|
broadcastTx().data,
|
|
);
|
|
expect(amount).toBe(500000n);
|
|
});
|
|
|
|
test("for a Max amount with many decimals", async () => {
|
|
await openSend("ETH");
|
|
await el("btn-send-max").handlers.get("click")();
|
|
// The balance minus 21000 gas at 20 gwei.
|
|
expect(el("send-amount").value).toBe("1.234147890123456789");
|
|
await review();
|
|
expect(text("confirm-amount")).toBe("1.2341 ETH");
|
|
|
|
await signAndSend();
|
|
expect(text("wait-tx-summary")).toBe("1.2341 ETH");
|
|
expect(broadcastTx().value).toBe(BALANCE_WEI - 21000n * 20n * GWEI);
|
|
});
|
|
|
|
// Cut to four decimals it would read 0.0000, so it is shown to its first
|
|
// significant digit instead, and signed in full.
|
|
test("for a tiny nonzero amount", async () => {
|
|
await openSend("ETH");
|
|
el("send-amount").value = "0.000012345";
|
|
await review();
|
|
expect(text("confirm-amount")).toBe("0.00001 ETH");
|
|
|
|
await signAndSend();
|
|
expect(text("wait-tx-summary")).toBe("0.00001 ETH");
|
|
expect(broadcastTx().value).toBe(parseEther("0.000012345"));
|
|
});
|
|
|
|
test("for a token amount", async () => {
|
|
// 100 TOK.
|
|
await openSend(TOKEN, 100000000n);
|
|
el("send-amount").value = "12.345678";
|
|
await review();
|
|
expect(text("confirm-amount")).toBe("12.3456 TOK");
|
|
|
|
await signAndSend();
|
|
expect(text("wait-tx-summary")).toBe("12.3456 TOK");
|
|
const tx = broadcastTx();
|
|
expect(tx.to).toBe(getAddress(TOKEN));
|
|
const [to, amount] = transferIface.decodeFunctionData(
|
|
"transfer",
|
|
tx.data,
|
|
);
|
|
expect(to).toBe(RECIPIENT);
|
|
expect(amount).toBe(12345678n);
|
|
});
|
|
|
|
test("on the success screen", async () => {
|
|
await openSend("ETH");
|
|
el("send-amount").value = "0.5";
|
|
await review();
|
|
await signAndSend();
|
|
// A popup opened later resumes the wait, and the receipt is there.
|
|
mockNode.receipt = { blockNumber: 21000000 };
|
|
txStatus.restoreWait();
|
|
await settle();
|
|
expect(state.currentView).toBe("success-tx");
|
|
expect(text("success-tx-summary")).toBe("0.5000 ETH");
|
|
});
|
|
|
|
test("on the error screen", async () => {
|
|
await openSend("ETH");
|
|
el("send-amount").value = "0.5";
|
|
await review();
|
|
mockNode.broadcastError = new Error("the node refused it");
|
|
await signAndSend();
|
|
expect(state.currentView).toBe("error-tx");
|
|
expect(text("error-tx-summary")).toBe("0.5000 ETH");
|
|
});
|
|
});
|