Max fills in a token's whole balance, cut down to the 18 decimal places the confirmation screen accepts, or for ETH the exact balance minus the fee reserve the confirmation screen's balance check gates on. An ETH fee estimate that finishes after the Send screen was left, or its address, holding or recipient changed, fills nothing in. 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
503 lines
16 KiB
JavaScript
503 lines
16 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";
|
|
// 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;
|
|
|
|
// 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.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();
|
|
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 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.");
|
|
});
|
|
});
|