With debug mode on, debugFetch logged every request's full URL and body, so an RPC endpoint with an API key in its path or query string printed that key to the console on every request. It now logs the HTTP method, the URL's origin and, for a JSON-RPC body, the method name. The balance refresh and token lookup log the RPC endpoint by its origin too. Failed RPC calls print ethers' short message, since its full message for an HTTP error carries the request URL. A failed endpoint check in settings prints the endpoint's origin, since fetch's error for a URL with a user name and password carries the whole URL. The README's DEBUG Mode Policy says what debug mode logs. Model: opus-5-5
454 lines
16 KiB
JavaScript
454 lines
16 KiB
JavaScript
// The balance and fee lines of the Send and confirmation screens, and the fee
|
|
// line they must share with the approval screen.
|
|
//
|
|
// An ETH balance, a token balance or a fee below 0.000001 rendered as zero on
|
|
// these screens (https://git.eeqj.de/sneak/AutistMask/issues/343): the stored
|
|
// balances and the fee were each cut to six decimal places, a rule of their
|
|
// own, and a token holding cut to zero was dropped, while the approval screen
|
|
// showed the same fee through src/shared/amountDisplay.js with the nonzero
|
|
// floor. The balances are now stored exactly, every nonzero token holding
|
|
// kept, and the screens show them and the fee through that helper.
|
|
//
|
|
// Driven through the real refreshBalances(), Send screen, confirmation screen
|
|
// and approval screen, with only the node, the explorer and the DOM stubbed: a
|
|
// balance written onto state by hand would skip the place the cut happened.
|
|
|
|
"use strict";
|
|
|
|
// What the stub node answers. Each test sets what it needs.
|
|
const mockNode = {
|
|
balanceWei: 0n,
|
|
feeData: { maxFeePerGas: 1n, gasPrice: 1n },
|
|
};
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
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");
|
|
});
|
|
|
|
// The approval the background hands the approval screen. Set per test.
|
|
let approvalDetails = null;
|
|
|
|
const { makeStorageStub } = require("./support/storageStub");
|
|
global.chrome = {
|
|
storage: makeStorageStub(),
|
|
runtime: {
|
|
connect: () => ({
|
|
postMessage() {},
|
|
disconnect() {},
|
|
onDisconnect: { addListener() {} },
|
|
}),
|
|
sendMessage(message, callback) {
|
|
callback(
|
|
message.type === "AUTISTMASK_GET_APPROVAL"
|
|
? approvalDetails
|
|
: undefined,
|
|
);
|
|
},
|
|
},
|
|
};
|
|
|
|
// 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 { refreshBalances } = require("../src/shared/balances");
|
|
const { state } = require("../src/shared/state");
|
|
const {
|
|
prices,
|
|
clearPrices,
|
|
formatAddressTotal,
|
|
getAddressValue,
|
|
} = require("../src/shared/prices");
|
|
const send = require("../src/popup/views/send");
|
|
const confirmTx = require("../src/popup/views/confirmTx");
|
|
const approval = require("../src/popup/views/approval");
|
|
const {
|
|
addressHoldsFunds,
|
|
balanceLinesForAddress,
|
|
} = require("../src/popup/views/helpers");
|
|
|
|
const HOLDER = "0x" + "a".repeat(40);
|
|
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
|
|
|
|
// 0.0000005 ETH, or 0.0000005 of an 18-decimal token.
|
|
const HALF_MICRO_ETH = 500000000000n;
|
|
|
|
// A token the bundled list does not know.
|
|
const TOKEN = "0x" + "d".repeat(40);
|
|
|
|
// The explorer's row for TOKEN, holding `value` base units. With only five
|
|
// holders, it is listed only when the user tracks the token.
|
|
function tokenRow(value, token = {}) {
|
|
return {
|
|
value: String(value),
|
|
token: {
|
|
type: "ERC-20",
|
|
address_hash: TOKEN,
|
|
symbol: "TOK",
|
|
name: "Token",
|
|
decimals: "18",
|
|
holders_count: "5",
|
|
...token,
|
|
},
|
|
};
|
|
}
|
|
|
|
function text(id) {
|
|
return global.document.getElementById(id).textContent;
|
|
}
|
|
|
|
function errors() {
|
|
return global.document.getElementById("confirm-errors").innerHTML;
|
|
}
|
|
|
|
// 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 = [{ name: "Wallet 1", 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",
|
|
);
|
|
}
|
|
|
|
// Press Review on the Send screen for a send of `token` ("ETH" or a token
|
|
// address), and show the confirmation screen it leads to with its fee estimate
|
|
// settled.
|
|
async function confirmSend(amount, token = "ETH") {
|
|
let txInfo = null;
|
|
send.init({ showConfirmTx: (info) => (txInfo = info) });
|
|
state.selectedToken = token;
|
|
global.document.getElementById("send-to").value = RECIPIENT;
|
|
global.document.getElementById("send-amount").value = amount;
|
|
await global.document
|
|
.getElementById("btn-send-review")
|
|
.handlers.get("click")();
|
|
confirmTx.show(txInfo);
|
|
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
|
|
}
|
|
|
|
// The approval screen for a dApp transaction of 21000 gas, the gas the stub
|
|
// node estimates for the send above.
|
|
async function approveTxWithFeePerGas(maxFeePerGas) {
|
|
approvalDetails = {
|
|
type: "tx",
|
|
origin: "https://dapp.example",
|
|
approvedFrom: HOLDER,
|
|
approvedTx: {
|
|
to: RECIPIENT,
|
|
value: "0",
|
|
data: "0x",
|
|
chainId: "0x1",
|
|
gasLimit: "21000",
|
|
maxFeePerGas: String(maxFeePerGas),
|
|
nonce: 0,
|
|
},
|
|
};
|
|
await approval.show("1");
|
|
}
|
|
|
|
beforeEach(() => {
|
|
elements.clear();
|
|
state.selectedToken = null;
|
|
state.trackedTokens = [];
|
|
state.fraudContracts = [];
|
|
state.currentView = null;
|
|
mockNode.feeData = { maxFeePerGas: 1n, gasPrice: 1n };
|
|
});
|
|
|
|
describe("an ETH balance below 0.000001 never renders as zero", () => {
|
|
test("on the Send screen", async () => {
|
|
await refreshWith(HALF_MICRO_ETH);
|
|
state.selectedToken = "ETH";
|
|
send.updateSendBalance();
|
|
expect(text("send-balance")).toBe("Current balance: 0.0000005 ETH");
|
|
});
|
|
|
|
test("on the confirmation screen", async () => {
|
|
await refreshWith(HALF_MICRO_ETH);
|
|
await confirmSend("0.0000001");
|
|
expect(text("confirm-balance")).toBe("0.0000005 ETH");
|
|
});
|
|
|
|
test("while a balance above the floor keeps four decimals", async () => {
|
|
await refreshWith(1234567890000000000n);
|
|
await confirmSend("0.1");
|
|
expect(text("confirm-balance")).toBe("1.2345 ETH");
|
|
});
|
|
});
|
|
|
|
// A token the user tracks stays on the balance list when the explorer's row is
|
|
// dropped, so a holding of it below 0.000001 reached these screens as zero, and
|
|
// the send was checked against zero.
|
|
describe("a tracked token holding below 0.000001 never renders as zero", () => {
|
|
beforeEach(() => {
|
|
state.trackedTokens = [
|
|
{ address: TOKEN, symbol: "TOK", name: "Token", decimals: 18 },
|
|
];
|
|
});
|
|
|
|
test("on the Send screen", async () => {
|
|
await refreshWith(10n ** 18n, [tokenRow(HALF_MICRO_ETH)]);
|
|
state.selectedToken = TOKEN;
|
|
send.updateSendBalance();
|
|
expect(text("send-balance")).toBe("Current balance: 0.0000005 TOK");
|
|
});
|
|
|
|
test("on the confirmation screen, which checks the send against it", async () => {
|
|
await refreshWith(10n ** 18n, [tokenRow(HALF_MICRO_ETH)]);
|
|
await confirmSend("0.0000005", TOKEN);
|
|
expect(text("confirm-balance")).toBe("0.0000005 TOK");
|
|
expect(errors()).toBe("");
|
|
await confirmSend("0.0000006", TOKEN);
|
|
expect(errors()).toContain(
|
|
"You have 0.0000005 TOK but are trying to send 0.0000006 TOK.",
|
|
);
|
|
});
|
|
|
|
// The stored balance keeps all 24 places, and the balance check reads the
|
|
// first 18 of them rather than refusing it as no balance at all.
|
|
test("with more than 18 decimals, the send is checked against 18 of them", async () => {
|
|
state.trackedTokens[0].decimals = 24;
|
|
// 1.5 plus one base unit.
|
|
const value = 15n * 10n ** 23n + 1n;
|
|
await refreshWith(10n ** 18n, [tokenRow(value, { decimals: "24" })]);
|
|
await confirmSend("1.5", TOKEN);
|
|
expect(text("confirm-balance")).toBe("1.5000 TOK");
|
|
expect(errors()).toBe("");
|
|
});
|
|
|
|
test("with more than 18 decimals and a holding below 10^-18", async () => {
|
|
state.trackedTokens[0].decimals = 24;
|
|
// One base unit, 0.000000000000000000000001 TOK.
|
|
await refreshWith(10n ** 18n, [tokenRow(1n, { decimals: "24" })]);
|
|
state.selectedToken = TOKEN;
|
|
send.updateSendBalance();
|
|
expect(text("send-balance")).toBe(
|
|
"Current balance: 0.000000000000000000000001 TOK",
|
|
);
|
|
await confirmSend("0.000000000000000001", TOKEN);
|
|
expect(text("confirm-balance")).toBe("0.000000000000000000000001 TOK");
|
|
expect(errors()).toContain(
|
|
"You have 0.000000000000000000000001 TOK but are trying to send" +
|
|
" 0.000000000000000001 TOK.",
|
|
);
|
|
});
|
|
});
|
|
|
|
// A token the user does not track, with enough holders to be admitted. The
|
|
// balance fetch dropped a holding of it below 0.000001, but the token stays
|
|
// selected while its own screen is open: after sending 2 of a 2.0000003
|
|
// holding, the user is back on that screen, and Send read the missing row as
|
|
// zero.
|
|
describe("an untracked token holding below 0.000001 never renders as zero", () => {
|
|
const row = () => tokenRow(HALF_MICRO_ETH, { holders_count: "50000" });
|
|
|
|
test("on the Send screen", async () => {
|
|
await refreshWith(10n ** 18n, [row()]);
|
|
state.selectedToken = TOKEN;
|
|
send.updateSendBalance();
|
|
expect(text("send-balance")).toBe("Current balance: 0.0000005 TOK");
|
|
});
|
|
|
|
test("on the confirmation screen, which checks the send against it", async () => {
|
|
await refreshWith(10n ** 18n, [row()]);
|
|
await confirmSend("0.0000005", TOKEN);
|
|
expect(text("confirm-balance")).toBe("0.0000005 TOK");
|
|
expect(errors()).toBe("");
|
|
await confirmSend("0.0000006", TOKEN);
|
|
expect(errors()).toContain(
|
|
"You have 0.0000005 TOK but are trying to send 0.0000006 TOK.",
|
|
);
|
|
});
|
|
});
|
|
|
|
// The fetch keeps every holding, so the screens that showed only what it kept
|
|
// leave out a holding below 0.000001 themselves, and look as they did.
|
|
describe("a token holding below 0.000001 is still not listed", () => {
|
|
afterEach(() => {
|
|
clearPrices();
|
|
});
|
|
|
|
test("for a token the user does not track", async () => {
|
|
prices.ETH = 3000;
|
|
await refreshWith(0n, [
|
|
tokenRow(HALF_MICRO_ETH, { holders_count: "50000" }),
|
|
]);
|
|
const addr = state.wallets[0].addresses[0];
|
|
expect(balanceLinesForAddress(addr, [], true)).not.toContain(TOKEN);
|
|
expect(balanceLinesForAddress(addr, [], false)).not.toContain(TOKEN);
|
|
send.renderSendTokenSelect(addr);
|
|
const options = global.document.getElementById("send-token").children;
|
|
expect(options.map((o) => o.value)).toEqual([]);
|
|
// Not an unpriced token in the total, and not funds on the
|
|
// remove-address warning.
|
|
expect(formatAddressTotal(getAddressValue(addr))).toBe("Total: $0.00");
|
|
expect(addressHoldsFunds(addr)).toBe(false);
|
|
});
|
|
|
|
// As a tracked token holding nothing: listed only while zero balances are
|
|
// shown.
|
|
test("for a tracked token, unless zero balances are shown", async () => {
|
|
state.trackedTokens = [
|
|
{ address: TOKEN, symbol: "TOK", name: "Token", decimals: 18 },
|
|
];
|
|
await refreshWith(0n, [tokenRow(HALF_MICRO_ETH)]);
|
|
const addr = state.wallets[0].addresses[0];
|
|
expect(
|
|
balanceLinesForAddress(addr, state.trackedTokens, false),
|
|
).not.toContain(TOKEN);
|
|
expect(
|
|
balanceLinesForAddress(addr, state.trackedTokens, true),
|
|
).toContain(`data-token="${TOKEN}"`);
|
|
});
|
|
});
|
|
|
|
describe("a fee below 0.000001 ETH never renders as zero", () => {
|
|
test("when the estimate and the reserve are the same", async () => {
|
|
await refreshWith(10n ** 18n);
|
|
await confirmSend("0.1");
|
|
// 21000 gas at 1 wei is 0.000000000000021 ETH, shown to its first
|
|
// significant digit.
|
|
expect(text("confirm-fee-amount")).toBe("0.00000000000002 ETH");
|
|
});
|
|
|
|
test("when they differ, on both lines", async () => {
|
|
mockNode.feeData = { maxFeePerGas: 2n, gasPrice: 1n };
|
|
await refreshWith(10n ** 18n);
|
|
await confirmSend("0.1");
|
|
expect(text("confirm-fee-amount")).toBe("~0.00000000000002 ETH");
|
|
expect(text("confirm-fee-reserve")).toBe(
|
|
"up to 0.00000000000004 ETH reserved",
|
|
);
|
|
});
|
|
});
|
|
|
|
// The confirmation screen shows the reserve alone when the node quotes no
|
|
// cheaper estimate, and that reserve is the same gas limit times maximum fee
|
|
// per gas that the approval screen calls the max fee. An ETH price is set, as
|
|
// it is on mainnet, so the USD value has to match too.
|
|
describe("the same fee reads the same on the confirmation and approval screens", () => {
|
|
beforeEach(() => {
|
|
prices.ETH = 3000;
|
|
});
|
|
afterEach(() => {
|
|
clearPrices();
|
|
});
|
|
|
|
test.each([
|
|
// 21000 gas at 1 wei.
|
|
["below the floor", 1n, "0.00000000000002 ETH (< $0.01)"],
|
|
// 0.001235294117631 ETH, which is $3.71. Pricing the truncated
|
|
// 0.0012 instead would read $3.60.
|
|
["with more than four decimals", 58823529411n, "0.0012 ETH ($3.71)"],
|
|
])("%s", async (_label, feePerGas, expected) => {
|
|
mockNode.feeData = { maxFeePerGas: feePerGas, gasPrice: feePerGas };
|
|
await refreshWith(10n ** 18n);
|
|
await confirmSend("0.1");
|
|
expect(text("confirm-fee-amount")).toBe(expected);
|
|
await approveTxWithFeePerGas(feePerGas);
|
|
expect(text("approve-tx-fee")).toBe(expected);
|
|
});
|
|
});
|