allowedSites and deniedSites held the bare hostname, so a grant to https://dapp.example also authorised http://dapp.example and every port on that host, and the connection, transaction and signature prompts named only the hostname. Both lists now store and match the full origin (scheme://host[:port]), the key the connections approved without Remember already used. The prompts, the Settings site lists and AUTISTMASK_REMOVE_SITE use the origin too. Entries saved by hostname are not migrated (pre-1.0): they match no site. Model: opus-5-5
453 lines
16 KiB
JavaScript
453 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,
|
|
})),
|
|
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);
|
|
});
|
|
});
|