Out of the box the wallet sent the user's addresses to Blockscout for history and token lookups, and each recipient to Etherscan for its label. Two settings, "Use Blockscout" and "Check recipients on Etherscan", now gate those requests where they are made. Both are off in a new profile and offered unticked on Welcome and in Settings. While one is off, the screens that relied on it say so in a plain line naming the setting; after either changes in Settings, Back draws the screen it lands on again. ENS lookups ask only the RPC endpoint, so a name resolved off-chain does not resolve. README and the user guide say what is contacted by default and what each setting adds. Model: opus-5-5
228 lines
7.1 KiB
JavaScript
228 lines
7.1 KiB
JavaScript
// A late answer for a confirmation screen the user has left changes nothing on
|
|
// the screen shown since (https://git.eeqj.de/sneak/AutistMask/issues/575).
|
|
//
|
|
// The confirmation screen asks the node and the explorer about its transaction
|
|
// without waiting for the answers: the recipient checks, which can show three
|
|
// warning boxes, the balance read and the fee estimate. Back, a new recipient
|
|
// and Review again show the screen for another transaction while those
|
|
// questions may still be open. The recipient checks showed their boxes
|
|
// whenever they answered, so a box about the first recipient could appear on
|
|
// the second recipient's screen.
|
|
//
|
|
// Driven through the real confirmation screen, with only the node, the
|
|
// explorer and the DOM stubbed. Each test holds back the first screen's answer
|
|
// to one question, and gives it once the screen it tests is shown.
|
|
|
|
"use strict";
|
|
|
|
// The From address of every transaction here. The jest.mock() factory below
|
|
// may only name outside variables whose names start with "mock".
|
|
const mockFrom = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
|
|
|
jest.mock("ethers", () => {
|
|
const actual = jest.requireActual("ethers");
|
|
// Every answer is a mock, so a test can hold one back. Every address has
|
|
// sent one transaction and has no code, and only the From address holds
|
|
// anything: 5 ETH.
|
|
class StubProvider {}
|
|
Object.assign(StubProvider.prototype, {
|
|
getBalance: jest.fn(async (address) =>
|
|
address === mockFrom ? actual.parseEther("5") : 0n,
|
|
),
|
|
getCode: jest.fn(async () => "0x"),
|
|
getTransactionCount: jest.fn(async () => 1),
|
|
// 10 gwei expected, 20 gwei reserved per gas.
|
|
getFeeData: jest.fn(async () => ({
|
|
maxFeePerGas: 20000000000n,
|
|
maxPriorityFeePerGas: 1000000000n,
|
|
gasPrice: 10000000000n,
|
|
})),
|
|
estimateGas: jest.fn(async () => 21000n),
|
|
});
|
|
return { ...actual, JsonRpcProvider: StubProvider };
|
|
});
|
|
|
|
// An explorer address page whose title is `title`.
|
|
function explorerPage(title) {
|
|
return { ok: true, text: async () => "<title>" + title + "</title>" };
|
|
}
|
|
|
|
// The explorer labels no address.
|
|
global.fetch = jest.fn(async () => explorerPage("Etherscan"));
|
|
|
|
// The Etherscan label check is made, so its box can be shown.
|
|
const { makeStorageStub } = require("./support/storageStub");
|
|
global.chrome = {
|
|
storage: makeStorageStub({
|
|
autistmask: { checkRecipientsOnEtherscan: true },
|
|
}),
|
|
runtime: { sendMessage() {} },
|
|
};
|
|
|
|
// A stub DOM: every id resolves to a recording element.
|
|
const elements = new Map();
|
|
|
|
function makeEl(id) {
|
|
return {
|
|
id,
|
|
textContent: "",
|
|
innerHTML: "",
|
|
value: "",
|
|
disabled: false,
|
|
style: {},
|
|
dataset: {},
|
|
classList: {
|
|
add() {},
|
|
remove() {},
|
|
toggle() {},
|
|
contains: () => false,
|
|
},
|
|
addEventListener() {},
|
|
querySelectorAll: () => [],
|
|
remove() {},
|
|
};
|
|
}
|
|
|
|
global.document = {
|
|
getElementById(id) {
|
|
if (!elements.has(id)) elements.set(id, makeEl(id));
|
|
return elements.get(id);
|
|
},
|
|
};
|
|
|
|
const { JsonRpcProvider, parseEther } = require("ethers");
|
|
const { loadState } = require("../src/shared/state");
|
|
const confirmTx = require("../src/popup/views/confirmTx");
|
|
|
|
const node = JsonRpcProvider.prototype;
|
|
const FIRST = "0xC0FfEE0000000000000000000000000000c0fFEe";
|
|
const SECOND = "0xE2E0000000000000000000000000000000000E2e";
|
|
|
|
function el(id) {
|
|
return global.document.getElementById(id);
|
|
}
|
|
|
|
function shown(id) {
|
|
return el(id).style.visibility === "visible";
|
|
}
|
|
|
|
// Show the confirmation screen for 1 ETH to `to` on mainnet, as Review on the
|
|
// Send screen does.
|
|
function review(to) {
|
|
confirmTx.show({
|
|
from: mockFrom,
|
|
to,
|
|
ensName: null,
|
|
amount: "1",
|
|
token: "ETH",
|
|
tokenSymbol: null,
|
|
tokenDecimals: null,
|
|
max: false,
|
|
});
|
|
}
|
|
|
|
// show() does not wait for its questions; this lets every answer not held
|
|
// back arrive.
|
|
async function settle() {
|
|
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
|
|
}
|
|
|
|
// Hold back the next answer `question` gives. Returns the function that gives
|
|
// it.
|
|
function holdNextAnswer(question) {
|
|
let give;
|
|
question.mockImplementationOnce(() => new Promise((r) => (give = r)));
|
|
return (answer) => give(answer);
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
await loadState();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
elements.clear();
|
|
});
|
|
|
|
// The three boxes the recipient checks show, each with the question whose
|
|
// answer shows it for FIRST, and that answer. FIRST holds no ETH, so the
|
|
// answer that it has sent no transaction is what shows the box saying so.
|
|
describe.each([
|
|
{
|
|
box: "confirm-contract-warning",
|
|
question: node.getCode,
|
|
answer: "0x6080604052",
|
|
},
|
|
{
|
|
box: "confirm-recipient-warning",
|
|
question: node.getTransactionCount,
|
|
answer: 0,
|
|
},
|
|
{
|
|
box: "confirm-etherscan-warning",
|
|
question: global.fetch,
|
|
answer: explorerPage(
|
|
"Fake_Phishing1 | Address: " + FIRST + " | Etherscan",
|
|
),
|
|
},
|
|
])("the $box box", ({ box, question, answer }) => {
|
|
test("is shown when the answer about the recipient arrives while its screen is shown", async () => {
|
|
const give = holdNextAnswer(question);
|
|
review(FIRST);
|
|
await settle();
|
|
expect(shown(box)).toBe(false);
|
|
|
|
give(answer);
|
|
await settle();
|
|
expect(shown(box)).toBe(true);
|
|
});
|
|
|
|
test("is not shown on the next recipient's screen when the answer about the first arrives after that screen is shown", async () => {
|
|
const give = holdNextAnswer(question);
|
|
review(FIRST);
|
|
await settle();
|
|
review(SECOND);
|
|
await settle();
|
|
expect(shown(box)).toBe(false);
|
|
|
|
give(answer);
|
|
await settle();
|
|
expect(shown(box)).toBe(false);
|
|
});
|
|
});
|
|
|
|
test("a balance read answered after the next screen is shown leaves that screen's balance and balance check as they are", async () => {
|
|
const give = holdNextAnswer(node.getBalance);
|
|
review(FIRST);
|
|
await settle();
|
|
review(SECOND);
|
|
await settle();
|
|
expect(el("confirm-balance").textContent).toBe("5.0000 ETH");
|
|
|
|
give(parseEther("0.5"));
|
|
await settle();
|
|
expect(el("confirm-balance").textContent).toBe("5.0000 ETH");
|
|
expect(shown("confirm-balance-error")).toBe(false);
|
|
expect(el("btn-confirm-send").disabled).toBe(false);
|
|
});
|
|
|
|
test("a fee estimate answered after the next screen is shown leaves that screen's fee as it is", async () => {
|
|
const give = holdNextAnswer(node.getFeeData);
|
|
review(FIRST);
|
|
await settle();
|
|
review(SECOND);
|
|
await settle();
|
|
expect(el("confirm-fee-amount").textContent).toBe("~0.0002 ETH");
|
|
|
|
// 100 times the fee the second screen was quoted.
|
|
give({
|
|
maxFeePerGas: 2000000000000n,
|
|
maxPriorityFeePerGas: 100000000000n,
|
|
gasPrice: 1000000000000n,
|
|
});
|
|
await settle();
|
|
expect(el("confirm-fee-amount").textContent).toBe("~0.0002 ETH");
|
|
expect(el("confirm-fee-reserve").textContent).toBe(
|
|
"up to 0.0004 ETH reserved",
|
|
);
|
|
});
|