Max fills in a token's whole balance, or for ETH the exact balance minus the fee reserve the confirmation screen's balance check gates on. The confirmation screen works a max ETH amount out again from its own fee estimate and signs it with that estimate's fee fields, so a fee that rose before signing cannot push amount plus fee above the balance. validateTransfer() still gates every send, the check that ETH covers a token send's fee included. Where there is nothing to fill in, a flash message says why. Model: opus-5-5
410 lines
13 KiB
JavaScript
410 lines
13 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: () => {},
|
|
},
|
|
// 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 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";
|
|
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) {
|
|
return {
|
|
value: String(value),
|
|
token: {
|
|
type: "ERC-20",
|
|
address_hash: TOKEN,
|
|
symbol: "TOK",
|
|
name: "Token",
|
|
decimals: "18",
|
|
holders_count: "50000",
|
|
},
|
|
};
|
|
}
|
|
|
|
// The confirmation screen Review leads to, once shown.
|
|
let confirmed = null;
|
|
|
|
// 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) });
|
|
confirmTx.init({});
|
|
send.resetSendValidation();
|
|
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();
|
|
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.",
|
|
);
|
|
});
|
|
|
|
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("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.");
|
|
});
|
|
});
|