ConfirmTx and the wait, success and error screens printed Send's amount field as it stood (`.5 ETH`, or every digit Max filled in); TxApproval shows `0.5000 ETH`. ConfirmTx now writes the amount as TxApproval does: parsed at the scale it is encoded with, written back as plain decimal text and cut by truncateAmountNeverZero(), and hands the status screens a copy carrying that text. A token whose scale is unknown, which cannot be sent, has the field's text cut the same way. The transaction is still signed for the exact amount, which also prices the USD value. A saved confirmation screen whose amount is not text is no longer redrawn. Model: opus-5-5
598 lines
20 KiB
JavaScript
598 lines
20 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,
|
|
tokenUnits: 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;
|
|
}
|
|
// balanceOf(), the one contract call these screens make before Sign
|
|
// & Send.
|
|
async call() {
|
|
return actual.AbiCoder.defaultAbiCoder().encode(
|
|
["uint256"],
|
|
[mockNode.tokenUnits],
|
|
);
|
|
}
|
|
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. The node reports the
|
|
// token holding the explorer does.
|
|
async function refreshWith(balanceWei, tokenItems = []) {
|
|
mockNode.balanceWei = balanceWei;
|
|
mockNode.tokenUnits =
|
|
tokenItems.length > 0 ? BigInt(tokenItems[0].value) : 0n;
|
|
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(confirmed.amount).toBe(maxAfter(20n * GWEI));
|
|
expect(text("confirm-amount")).toBe("1.2341 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(confirmed.amount).toBe(maxAfter(30n * GWEI));
|
|
expect(text("confirm-amount")).toBe("1.2339 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(confirmed.amount).toBe(maxAfter(10n * GWEI));
|
|
expect(text("confirm-amount")).toBe("1.2343 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(confirmed.amount).toBe("0.5");
|
|
expect(text("confirm-amount")).toBe("0.5000 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(confirmed.amount).toBe("1234.567890123456789012");
|
|
expect(text("confirm-amount")).toBe("1234.5678 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(confirmed.amount).toBe("1234.567890123456789012");
|
|
expect(text("confirm-amount")).toBe("1234.5678 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.");
|
|
});
|
|
});
|