The transaction lists and ENS name lookups on the address and token screens, the address scan after a wallet is created, the endpoint checks in Settings, the wait screen's receipt check, the Send screen's Max fee estimate and the token lookup on both add-token screens now check the signal the popup aborts on pagehide before reporting a failed request. scanForAddresses(), resolveEnsNames() and lookupTokenInfo() take the signal. End-to-end tests reload the popup on the address screen and during the address scan with their requests held. Jest tests show each of these reports a real failure and stays silent once the popup has closed. The transaction detail and confirmation screens are left out: they discard the popup context that carries the signal. Model: opus-5-5
580 lines
19 KiB
JavaScript
580 lines
19 KiB
JavaScript
// The Send screen's "Max" control
|
|
// (https://git.eeqj.de/sneak/AutistMask/issues/198).
|
|
//
|
|
// For ETH it fills in the exact balance minus the fee reserve the
|
|
// confirmation screen's balance check gates on, never the four-decimal balance
|
|
// the Send screen shows; the confirmation screen re-derives that amount from
|
|
// its own estimate, and signs with that estimate's fee fields. For a token it
|
|
// fills in the whole balance, and the check that ETH covers the fee still
|
|
// applies.
|
|
//
|
|
// Driven through the real refreshBalances(), Send screen and confirmation
|
|
// screen, Sign & Send included, with only the node, the explorer and the DOM
|
|
// stubbed.
|
|
|
|
"use strict";
|
|
|
|
// What the stub node answers, and the signed transactions it was handed.
|
|
const mockNode = {
|
|
balanceWei: 0n,
|
|
feeData: null,
|
|
broadcast: [],
|
|
};
|
|
|
|
// The token rows the stub explorer reports for the address.
|
|
const mockExplorer = { items: [] };
|
|
|
|
jest.mock("ethers", () => {
|
|
const actual = jest.requireActual("ethers");
|
|
class StubProvider {
|
|
async getBalance() {
|
|
return mockNode.balanceWei;
|
|
}
|
|
async lookupAddress() {
|
|
return null;
|
|
}
|
|
async getFeeData() {
|
|
return mockNode.feeData;
|
|
}
|
|
async estimateGas() {
|
|
return 21000n;
|
|
}
|
|
async getCode() {
|
|
return "0x";
|
|
}
|
|
async getTransactionCount() {
|
|
return 1;
|
|
}
|
|
async getNetwork() {
|
|
return { chainId: 1n };
|
|
}
|
|
async broadcastTransaction(signed) {
|
|
mockNode.broadcast.push(signed);
|
|
return { hash: "0x" + "ab".repeat(32) };
|
|
}
|
|
}
|
|
return {
|
|
...actual,
|
|
JsonRpcProvider: StubProvider,
|
|
Network: { from: () => ({}) },
|
|
};
|
|
});
|
|
|
|
jest.mock("../src/shared/log", () => ({
|
|
log: {
|
|
debugf: () => {},
|
|
infof: () => {},
|
|
warnf: () => {},
|
|
errorf: jest.fn(),
|
|
},
|
|
// 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 wait screen polls for a receipt; these tests stop at the broadcast.
|
|
jest.mock("../src/popup/views/txStatus", () => ({
|
|
showWait: jest.fn(),
|
|
showError: jest.fn(),
|
|
}));
|
|
|
|
// 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() {},
|
|
};
|
|
}
|
|
|
|
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 { Transaction, Wallet, formatEther } = require("ethers");
|
|
const { refreshBalances } = require("../src/shared/balances");
|
|
const { encryptWithPassword } = require("../src/shared/vault");
|
|
const { state } = require("../src/shared/state");
|
|
const { log } = require("../src/shared/log");
|
|
const send = require("../src/popup/views/send");
|
|
const confirmTx = require("../src/popup/views/confirmTx");
|
|
|
|
const PRIVATE_KEY = "0x" + "11".repeat(32);
|
|
const HOLDER = new Wallet(PRIVATE_KEY).address;
|
|
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
|
|
// A second address of the wallet, and a second recipient.
|
|
const OTHER = "0x" + "e".repeat(40);
|
|
const PASSWORD = "correct horse battery staple";
|
|
|
|
const GWEI = 1000000000n;
|
|
const GAS = 21000n;
|
|
|
|
// The Send screen shows this balance as 1.2345 ETH.
|
|
const BALANCE_WEI = 1234567890123456789n;
|
|
|
|
// A token the bundled list does not know, with enough holders to be listed.
|
|
const TOKEN = "0x" + "d".repeat(40);
|
|
|
|
// Fee data whose reserve is 21000 gas at `maxFeePerGas`. The expected cost,
|
|
// at gasPrice, is lower, as it is on mainnet.
|
|
function fees(maxFeePerGas) {
|
|
return {
|
|
maxFeePerGas,
|
|
maxPriorityFeePerGas: GWEI,
|
|
gasPrice: maxFeePerGas / 2n,
|
|
};
|
|
}
|
|
|
|
// The balance minus a reserve of 21000 gas at `maxFeePerGas`.
|
|
function maxAfter(maxFeePerGas) {
|
|
return formatEther(BALANCE_WEI - GAS * maxFeePerGas);
|
|
}
|
|
|
|
function el(id) {
|
|
return global.document.getElementById(id);
|
|
}
|
|
|
|
function text(id) {
|
|
return el(id).textContent;
|
|
}
|
|
|
|
// The ETH balance the node reports and the token rows the explorer reports,
|
|
// fetched and stored exactly where the popup stores them.
|
|
async function refreshWith(balanceWei, tokenItems = []) {
|
|
mockNode.balanceWei = balanceWei;
|
|
mockExplorer.items = tokenItems;
|
|
state.wallets = [
|
|
{
|
|
type: "key",
|
|
name: "Wallet 1",
|
|
encryptedSecret: await encryptWithPassword(PRIVATE_KEY, PASSWORD),
|
|
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",
|
|
);
|
|
}
|
|
|
|
function tokenRow(value, decimals = "18") {
|
|
return {
|
|
value: String(value),
|
|
token: {
|
|
type: "ERC-20",
|
|
address_hash: TOKEN,
|
|
symbol: "TOK",
|
|
name: "Token",
|
|
decimals,
|
|
holders_count: "50000",
|
|
},
|
|
};
|
|
}
|
|
|
|
// The confirmation screen Review leads to, once shown.
|
|
let confirmed = null;
|
|
|
|
// Stands in for the popup's: aborting it is the popup closing.
|
|
let pageClosed;
|
|
|
|
// Open the Send screen for `token` ("ETH" or a token address), with the
|
|
// recipient entered.
|
|
function openSend(token = "ETH") {
|
|
send.init({
|
|
showConfirmTx: (info) => (confirmed = info),
|
|
pageClosed: pageClosed.signal,
|
|
});
|
|
confirmTx.init({});
|
|
send.resetSendValidation();
|
|
state.currentView = "send";
|
|
state.selectedToken = token;
|
|
el("send-to").value = RECIPIENT;
|
|
el("send-amount").value = "";
|
|
}
|
|
|
|
async function pressMax() {
|
|
await el("btn-send-max").handlers.get("click")();
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
|
|
// show() starts the fee estimate without awaiting it; this lets it settle.
|
|
async function settle() {
|
|
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
|
|
}
|
|
|
|
function canSend() {
|
|
return !el("btn-confirm-send").disabled;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
elements.clear();
|
|
log.errorf.mockClear();
|
|
pageClosed = new AbortController();
|
|
confirmed = null;
|
|
state.selectedToken = null;
|
|
state.trackedTokens = [];
|
|
state.fraudContracts = [];
|
|
state.currentView = null;
|
|
mockNode.feeData = fees(20n * GWEI);
|
|
mockNode.broadcast = [];
|
|
});
|
|
|
|
describe("Max on an ETH send", () => {
|
|
test("fills in the exact balance minus the fee reserve", async () => {
|
|
await refreshWith(BALANCE_WEI);
|
|
openSend();
|
|
send.updateSendBalance();
|
|
expect(text("send-balance")).toBe("Current balance: 1.2345 ETH");
|
|
await pressMax();
|
|
// 1.234567890123456789 - 21000 * 20 gwei.
|
|
expect(el("send-amount").value).toBe("1.234147890123456789");
|
|
});
|
|
|
|
test("leaves exactly the fee reserve behind, and the confirmation screen enables Send", async () => {
|
|
await refreshWith(BALANCE_WEI);
|
|
openSend();
|
|
await pressMax();
|
|
await review();
|
|
expect(text("confirm-amount")).toBe(maxAfter(20n * GWEI) + " ETH");
|
|
expect(el("confirm-errors").innerHTML).toBe("");
|
|
expect(el("confirm-amount-fee-error").style.visibility).toBe("hidden");
|
|
expect(canSend()).toBe(true);
|
|
});
|
|
|
|
test("re-derives the amount when the fee estimate changes", async () => {
|
|
await refreshWith(BALANCE_WEI);
|
|
openSend();
|
|
await pressMax();
|
|
expect(el("send-amount").value).toBe(maxAfter(20n * GWEI));
|
|
|
|
// The fee rises between Max and the confirmation screen's estimate.
|
|
// Kept, the Send screen's amount would be refused for want of funds.
|
|
mockNode.feeData = fees(30n * GWEI);
|
|
await review();
|
|
expect(text("confirm-amount")).toBe(maxAfter(30n * GWEI) + " ETH");
|
|
expect(canSend()).toBe(true);
|
|
|
|
// And falls when the screen is shown again, as on reopening the popup.
|
|
mockNode.feeData = fees(10n * GWEI);
|
|
confirmTx.restore();
|
|
await settle();
|
|
expect(text("confirm-amount")).toBe(maxAfter(10n * GWEI) + " ETH");
|
|
expect(canSend()).toBe(true);
|
|
});
|
|
|
|
test("is signed with the fee its amount leaves behind", async () => {
|
|
await refreshWith(BALANCE_WEI);
|
|
openSend();
|
|
await pressMax();
|
|
await review();
|
|
// The fee the node quotes rises after the estimate. Fetched afresh
|
|
// at signing, it would make amount plus fee more than the balance.
|
|
mockNode.feeData = fees(25n * GWEI);
|
|
el("confirm-tx-password").value = PASSWORD;
|
|
await el("btn-confirm-send").handlers.get("click")();
|
|
|
|
expect(mockNode.broadcast).toHaveLength(1);
|
|
const tx = Transaction.from(mockNode.broadcast[0]);
|
|
expect(tx.to).toBe(RECIPIENT);
|
|
expect(tx.maxFeePerGas).toBe(20n * GWEI);
|
|
expect(tx.value + tx.gasLimit * tx.maxFeePerGas).toBe(BALANCE_WEI);
|
|
});
|
|
|
|
test("says so instead of filling in an amount when the balance does not cover the fee", async () => {
|
|
// 0.0001 ETH against a reserve of 0.00042 ETH.
|
|
await refreshWith(100000000000000n);
|
|
openSend();
|
|
await pressMax();
|
|
expect(el("send-amount").value).toBe("");
|
|
expect(text("flash-msg")).toBe(
|
|
"Your balance does not cover the network fee.",
|
|
);
|
|
});
|
|
|
|
test("asks for the recipient first, since the fee depends on it", async () => {
|
|
await refreshWith(BALANCE_WEI);
|
|
openSend();
|
|
el("send-to").value = "";
|
|
await pressMax();
|
|
expect(el("send-amount").value).toBe("");
|
|
expect(text("flash-msg")).toBe(
|
|
"Please enter a recipient address first.",
|
|
);
|
|
});
|
|
|
|
// Holds the node's fee answer, so Max's estimate is still running, until
|
|
// the returned function is called.
|
|
function holdFeeEstimate() {
|
|
let release;
|
|
mockNode.feeData = new Promise((resolve) => {
|
|
release = () => resolve(fees(20n * GWEI));
|
|
});
|
|
return release;
|
|
}
|
|
|
|
test.each([
|
|
["the same address", 0],
|
|
["another address", 1],
|
|
])(
|
|
"fills nothing in once Send was left and opened again for %s while the fee was estimated",
|
|
async (_, addressIndex) => {
|
|
await refreshWith(BALANCE_WEI);
|
|
state.wallets[0].addresses.push({
|
|
address: OTHER,
|
|
balance: "2.0",
|
|
tokenBalances: [],
|
|
});
|
|
openSend();
|
|
const release = holdFeeEstimate();
|
|
const pressed = pressMax();
|
|
|
|
// Back, then Send again as home.js opens it, with the same
|
|
// recipient typed in again.
|
|
state.selectedAddress = addressIndex;
|
|
el("send-to").value = "";
|
|
el("send-amount").value = "";
|
|
send.resetSendValidation();
|
|
el("send-to").value = RECIPIENT;
|
|
|
|
release();
|
|
await pressed;
|
|
expect(el("send-amount").value).toBe("");
|
|
expect(text("flash-msg")).toBe("");
|
|
},
|
|
);
|
|
|
|
test("fills nothing in and says nothing once Send was left while the fee was estimated", async () => {
|
|
// 0.0001 ETH, which does not cover the fee: a result that landed
|
|
// would say so on whichever screen is shown.
|
|
await refreshWith(100000000000000n);
|
|
openSend();
|
|
const release = holdFeeEstimate();
|
|
const pressed = pressMax();
|
|
state.currentView = "home";
|
|
release();
|
|
await pressed;
|
|
expect(el("send-amount").value).toBe("");
|
|
expect(text("flash-msg")).toBe("");
|
|
});
|
|
|
|
test("fills nothing in when the recipient changed while the fee was estimated", async () => {
|
|
await refreshWith(BALANCE_WEI);
|
|
openSend();
|
|
const release = holdFeeEstimate();
|
|
const pressed = pressMax();
|
|
el("send-to").value = OTHER;
|
|
release();
|
|
await pressed;
|
|
expect(el("send-amount").value).toBe("");
|
|
expect(text("flash-msg")).toBe("");
|
|
});
|
|
|
|
test("fills nothing in when the holding was changed while the fee was estimated", async () => {
|
|
await refreshWith(BALANCE_WEI, [tokenRow(10n ** 18n)]);
|
|
// Opened from the home screen, where the dropdown picks the holding.
|
|
openSend(null);
|
|
el("send-token").value = "ETH";
|
|
const release = holdFeeEstimate();
|
|
const pressed = pressMax();
|
|
el("send-token").value = TOKEN;
|
|
el("send-token").handlers.get("change")();
|
|
release();
|
|
await pressed;
|
|
expect(el("send-amount").value).toBe("");
|
|
expect(text("flash-msg")).toBe("");
|
|
});
|
|
|
|
test("keeps an amount typed while the fee was estimated", async () => {
|
|
await refreshWith(BALANCE_WEI);
|
|
openSend();
|
|
const release = holdFeeEstimate();
|
|
const pressed = pressMax();
|
|
el("send-amount").value = "0.5";
|
|
el("send-amount").handlers.get("input")();
|
|
release();
|
|
await pressed;
|
|
expect(el("send-amount").value).toBe("0.5");
|
|
expect(text("flash-msg")).toBe("");
|
|
});
|
|
|
|
// Holds the node's fee answer until the returned function fails it, as a
|
|
// node that cannot be reached, or a request the popup's closing
|
|
// cancelled, does.
|
|
function holdFailingFeeEstimate() {
|
|
let fail;
|
|
mockNode.feeData = new Promise((_, reject) => {
|
|
fail = () => reject(new TypeError("Failed to fetch"));
|
|
});
|
|
return fail;
|
|
}
|
|
|
|
test("reports a fee estimate that fails while the popup is open", async () => {
|
|
await refreshWith(BALANCE_WEI);
|
|
openSend();
|
|
const fail = holdFailingFeeEstimate();
|
|
const pressed = pressMax();
|
|
fail();
|
|
await pressed;
|
|
expect(log.errorf).toHaveBeenCalledWith(
|
|
"max amount fee estimate failed:",
|
|
"Failed to fetch",
|
|
);
|
|
expect(text("flash-msg")).toBe(
|
|
"The network fee could not be estimated.",
|
|
);
|
|
});
|
|
|
|
// https://git.eeqj.de/sneak/AutistMask/issues/475
|
|
test("does not report a fee estimate that fails once the popup has closed", async () => {
|
|
await refreshWith(BALANCE_WEI);
|
|
openSend();
|
|
const fail = holdFailingFeeEstimate();
|
|
const pressed = pressMax();
|
|
pageClosed.abort();
|
|
fail();
|
|
await pressed;
|
|
expect(log.errorf).not.toHaveBeenCalled();
|
|
expect(el("send-amount").value).toBe("");
|
|
});
|
|
|
|
test("fills in once the held fee estimate arrives with nothing changed", async () => {
|
|
await refreshWith(BALANCE_WEI);
|
|
openSend();
|
|
const release = holdFeeEstimate();
|
|
const pressed = pressMax();
|
|
release();
|
|
await pressed;
|
|
expect(el("send-amount").value).toBe(maxAfter(20n * GWEI));
|
|
});
|
|
|
|
test("typed over, is an ordinary amount the confirmation screen keeps", async () => {
|
|
await refreshWith(BALANCE_WEI);
|
|
openSend();
|
|
await pressMax();
|
|
el("send-amount").value = "0.5";
|
|
el("send-amount").handlers.get("input")();
|
|
mockNode.feeData = fees(30n * GWEI);
|
|
await review();
|
|
expect(text("confirm-amount")).toBe("0.5 ETH");
|
|
});
|
|
});
|
|
|
|
describe("Max on a token send", () => {
|
|
// 1234.567890123456789012 TOK; the Send screen shows 1234.5678.
|
|
const TOKEN_UNITS = 1234567890123456789012n;
|
|
|
|
test("fills in the whole token balance", async () => {
|
|
await refreshWith(BALANCE_WEI, [tokenRow(TOKEN_UNITS)]);
|
|
openSend(TOKEN);
|
|
await pressMax();
|
|
expect(el("send-amount").value).toBe("1234.567890123456789012");
|
|
await review();
|
|
expect(text("confirm-amount")).toBe("1234.567890123456789012 TOK");
|
|
expect(canSend()).toBe(true);
|
|
});
|
|
|
|
test("of a token with more than 18 decimal places, fills in the balance cut down to the 18 the confirmation screen accepts", async () => {
|
|
// 1234.567890123456789012999999 TOK at 24 decimal places.
|
|
await refreshWith(BALANCE_WEI, [
|
|
tokenRow(1234567890123456789012999999n, "24"),
|
|
]);
|
|
openSend(TOKEN);
|
|
await pressMax();
|
|
expect(el("send-amount").value).toBe("1234.567890123456789012");
|
|
await review();
|
|
expect(text("confirm-amount")).toBe("1234.567890123456789012 TOK");
|
|
expect(canSend()).toBe(true);
|
|
});
|
|
|
|
test("is still refused when ETH cannot cover the fee", async () => {
|
|
await refreshWith(0n, [tokenRow(TOKEN_UNITS)]);
|
|
openSend(TOKEN);
|
|
await pressMax();
|
|
expect(el("send-amount").value).toBe("1234.567890123456789012");
|
|
await review();
|
|
expect(el("confirm-gas-error").style.visibility).toBe("visible");
|
|
expect(canSend()).toBe(false);
|
|
});
|
|
|
|
test("says so when the token balance is unknown", async () => {
|
|
// No scale from the explorer, the bundled list or a tracked token.
|
|
const row = tokenRow(TOKEN_UNITS);
|
|
delete row.token.decimals;
|
|
await refreshWith(BALANCE_WEI, [row]);
|
|
openSend(TOKEN);
|
|
await pressMax();
|
|
expect(el("send-amount").value).toBe("");
|
|
expect(text("flash-msg")).toBe("This token's balance is unknown.");
|
|
});
|
|
|
|
test("says so when the token balance is zero", async () => {
|
|
state.trackedTokens = [
|
|
{ address: TOKEN, symbol: "TOK", name: "Token", decimals: 18 },
|
|
];
|
|
await refreshWith(BALANCE_WEI, [tokenRow(0n)]);
|
|
openSend(TOKEN);
|
|
await pressMax();
|
|
expect(el("send-amount").value).toBe("");
|
|
expect(text("flash-msg")).toBe("This token's balance is zero.");
|
|
});
|
|
});
|