ConfirmTx set the transaction's chain id to the active network's every time it drew the screen, so a popup reopened onto a saved confirmation after a network switch in Settings described the reviewed transfer as the new network's and sent it there. show() now sets the chain id only on a transaction Send hands over, and the screen describes the transaction entirely on that network: native currency, USD values, explorer links, and the node and explorer its checks ask. explorerUrl(), renderAddressHtml(), getPrice(), formatFee() and checkEtherscanLabel() take an optional chain id for this. A saved transaction whose chain id names no network is not restored. Model: opus-5-5
516 lines
18 KiB
JavaScript
516 lines
18 KiB
JavaScript
// A transfer is sent only on the network its confirmation screen was drawn for
|
|
// (https://git.eeqj.de/sneak/AutistMask/issues/524).
|
|
//
|
|
// Settings is reachable from the confirmation screen, and Back from it only
|
|
// unhides the screen, still labelled for the network it was drawn for. Sign &
|
|
// Send used to take the network at the moment of the click, so 1 SepoliaETH
|
|
// reviewed on Sepolia went out as 1 ETH on mainnet once mainnet was chosen in
|
|
// Settings in between.
|
|
//
|
|
// The same holds when the popup was closed after the switch and reopened, and
|
|
// Back drew the screen again from the transaction it saved
|
|
// (https://git.eeqj.de/sneak/AutistMask/issues/586): the screen was drawn for
|
|
// the new network, and Sign & Send sent the reviewed transfer there. Drawn
|
|
// again, the screen describes the transaction entirely on the network it was
|
|
// reviewed for, because choosing that network again in Settings and pressing
|
|
// Back only unhides it, and Sign & Send then sends from it.
|
|
//
|
|
// Driven through the real confirmation screen, signer and chain switch, with
|
|
// only the node, fetch and the DOM stubbed. The stub node is on the network
|
|
// its provider was built for, so a broadcast records the network it went to.
|
|
|
|
"use strict";
|
|
|
|
// The questions the stub node was asked for the fee and the recipient checks,
|
|
// by method, network name and RPC URL, in order. The signed transactions it
|
|
// was handed, with its network's chain id and RPC URL. And whether it quotes
|
|
// fees as a network with no type-2 pricing does.
|
|
const mockNode = { asked: [], broadcast: [], legacyFees: false };
|
|
|
|
jest.mock("ethers", () => {
|
|
const actual = jest.requireActual("ethers");
|
|
class StubProvider {
|
|
constructor(url, network) {
|
|
this.url = url;
|
|
this.network = network;
|
|
}
|
|
record(method) {
|
|
mockNode.asked.push({
|
|
method,
|
|
network: this.network.name,
|
|
url: this.url,
|
|
});
|
|
}
|
|
async getNetwork() {
|
|
return this.network;
|
|
}
|
|
async getFeeData() {
|
|
this.record("getFeeData");
|
|
if (mockNode.legacyFees) {
|
|
return {
|
|
maxFeePerGas: null,
|
|
maxPriorityFeePerGas: null,
|
|
gasPrice: 10000000000n,
|
|
};
|
|
}
|
|
return {
|
|
maxFeePerGas: 20000000000n,
|
|
maxPriorityFeePerGas: 1000000000n,
|
|
gasPrice: 10000000000n,
|
|
};
|
|
}
|
|
async estimateGas() {
|
|
return 21000n;
|
|
}
|
|
async getCode() {
|
|
this.record("getCode");
|
|
return "0x";
|
|
}
|
|
async getTransactionCount() {
|
|
this.record("getTransactionCount");
|
|
return 1;
|
|
}
|
|
async lookupAddress() {
|
|
return null;
|
|
}
|
|
async broadcastTransaction(signed) {
|
|
mockNode.broadcast.push({
|
|
chainId: this.network.chainId,
|
|
url: this.url,
|
|
signed,
|
|
});
|
|
return { hash: "0x" + "ab".repeat(32) };
|
|
}
|
|
}
|
|
return { ...actual, JsonRpcProvider: StubProvider };
|
|
});
|
|
|
|
jest.mock("../src/shared/log", () => ({
|
|
log: {
|
|
debugf: () => {},
|
|
infof: () => {},
|
|
warnf: () => {},
|
|
errorf: () => {},
|
|
},
|
|
debugFetch: jest.fn(async () => ({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({ 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 Etherscan label check is its only caller here: the explorer pages it
|
|
// asks for are recorded, and nothing is fetched.
|
|
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,
|
|
addEventListener(name, fn) {
|
|
handlers.set(name, fn);
|
|
},
|
|
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 { BaseWallet, Transaction, Wallet } = require("ethers");
|
|
const { encryptWithPassword } = require("../src/shared/vault");
|
|
const { state, loadState } = require("../src/shared/state");
|
|
const { onChainSwitch } = require("../src/shared/chainSwitch");
|
|
const { NETWORKS } = require("../src/shared/networks");
|
|
const { prices } = require("../src/shared/prices");
|
|
const {
|
|
goBack,
|
|
pushCurrentView,
|
|
setBackRenderer,
|
|
showView,
|
|
} = require("../src/popup/views/helpers");
|
|
const {
|
|
makeBackRenderer,
|
|
resetRenderedViews,
|
|
} = require("../src/popup/viewRouter");
|
|
const txStatus = require("../src/popup/views/txStatus");
|
|
const confirmTx = require("../src/popup/views/confirmTx");
|
|
|
|
const PRIVATE_KEY = "0x" + "11".repeat(32);
|
|
const HOLDER = new Wallet(PRIVATE_KEY).address;
|
|
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
|
|
const TOKEN = "0x7070707070707070707070707070707070707070";
|
|
const PASSWORD = "correct horse battery staple";
|
|
|
|
function el(id) {
|
|
return global.document.getElementById(id);
|
|
}
|
|
|
|
let encryptedSecret;
|
|
let signTransaction;
|
|
|
|
beforeAll(async () => {
|
|
await loadState();
|
|
encryptedSecret = await encryptWithPassword(PRIVATE_KEY, PASSWORD);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
elements.clear();
|
|
mockNode.asked = [];
|
|
mockNode.broadcast = [];
|
|
mockNode.legacyFees = false;
|
|
global.fetch.mockClear();
|
|
txStatus.showWait.mockClear();
|
|
txStatus.showError.mockClear();
|
|
signTransaction = jest.spyOn(BaseWallet.prototype, "signTransaction");
|
|
});
|
|
|
|
afterEach(() => {
|
|
signTransaction.mockRestore();
|
|
});
|
|
|
|
// Show the confirmation screen for 1 of the native token on `networkId`, or
|
|
// for the transfer `changes` makes of it, as Review on the Send screen does,
|
|
// with its fee estimate settled.
|
|
async function review(networkId, changes = {}) {
|
|
await onChainSwitch(networkId);
|
|
state.wallets = [
|
|
{
|
|
type: "key",
|
|
name: "Wallet 1",
|
|
encryptedSecret,
|
|
addresses: [{ address: HOLDER, balance: "5.0" }],
|
|
},
|
|
];
|
|
state.selectedWallet = 0;
|
|
state.selectedAddress = 0;
|
|
confirmTx.init({});
|
|
confirmTx.show({
|
|
from: HOLDER,
|
|
to: RECIPIENT,
|
|
ensName: null,
|
|
amount: "1",
|
|
token: "ETH",
|
|
balance: "5.0",
|
|
tokenSymbol: null,
|
|
tokenBalance: null,
|
|
tokenDecimals: null,
|
|
max: false,
|
|
...changes,
|
|
});
|
|
await settle();
|
|
}
|
|
|
|
// The user's own node for a network, set in Settings.
|
|
function ownNode(networkId) {
|
|
return "https://" + networkId + ".node.example.invalid";
|
|
}
|
|
|
|
// After review() on `reviewedOn`: the user's own node set for it, the Settings
|
|
// gear, `switchedTo` chosen there, the popup closed and reopened onto
|
|
// Settings, and Back, which draws the confirmation screen again from the
|
|
// transaction it saved. The reopened page holds an ETH price of $2,000 and a
|
|
// USDC price of $1 whichever network is active, so each USD value shows which
|
|
// network it was priced for.
|
|
async function switchReopenAndGoBack(reviewedOn, switchedTo) {
|
|
state.rpcUrl = ownNode(reviewedOn);
|
|
pushCurrentView();
|
|
showView("settings");
|
|
await onChainSwitch(switchedTo);
|
|
|
|
await loadState();
|
|
elements.clear();
|
|
resetRenderedViews();
|
|
setBackRenderer(makeBackRenderer(state, { confirmTx }));
|
|
confirmTx.init({});
|
|
prices.ETH = 2000;
|
|
prices.USDC = 1;
|
|
mockNode.asked = [];
|
|
global.fetch.mockClear();
|
|
goBack();
|
|
await settle();
|
|
}
|
|
|
|
// The USD value `text` as the screen appends it for `networkId`: nothing on a
|
|
// testnet, which has no USD values.
|
|
function usdOn(networkId, text) {
|
|
return NETWORKS[networkId].isTestnet ? "" : " (" + text + ")";
|
|
}
|
|
|
|
// The screen for the transfer review() shows by default, described on
|
|
// `networkId`: in its native currency, priced by it, linking to its explorer.
|
|
function expectNativeTransferOn(networkId) {
|
|
const { nativeCurrency: symbol, explorerUrl } = NETWORKS[networkId];
|
|
expect(el("confirm-type").textContent).toBe(
|
|
"Native " + symbol + " transfer",
|
|
);
|
|
expect(el("confirm-amount").textContent).toBe(
|
|
"1 " + symbol + usdOn(networkId, "$2,000.00"),
|
|
);
|
|
expect(el("confirm-balance").textContent).toBe(
|
|
"5.0000 " + symbol + usdOn(networkId, "$10,000.00"),
|
|
);
|
|
expect(el("confirm-fee-amount").textContent).toBe(
|
|
"~0.0002 " + symbol + usdOn(networkId, "$0.42"),
|
|
);
|
|
expect(el("confirm-fee-reserve").textContent).toBe(
|
|
"up to 0.0004 " + symbol + " reserved",
|
|
);
|
|
expect(el("confirm-gas-error").textContent).toBe(
|
|
"You do not have enough " +
|
|
symbol +
|
|
" to pay the network fee for this transfer. Please add " +
|
|
symbol +
|
|
" to this address and try again.",
|
|
);
|
|
expect(el("confirm-recipient-warning").textContent).toContain(
|
|
" holds no " + symbol + ". ",
|
|
);
|
|
expect(el("confirm-contract-warning").textContent).toContain(
|
|
" Sending " + symbol + " or tokens ",
|
|
);
|
|
expect(el("confirm-from").innerHTML).toContain(
|
|
`href="${explorerUrl}/address/${HOLDER}"`,
|
|
);
|
|
expect(el("confirm-to").innerHTML).toContain(
|
|
`href="${explorerUrl}/address/${RECIPIENT}"`,
|
|
);
|
|
}
|
|
|
|
// show() estimates the fee and checks the recipient without awaiting either;
|
|
// this lets both finish.
|
|
async function settle() {
|
|
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
|
|
}
|
|
|
|
async function signAndSend() {
|
|
el("confirm-tx-password").value = PASSWORD;
|
|
await el("btn-confirm-send").handlers.get("click")();
|
|
}
|
|
|
|
test("a transfer reviewed on Sepolia and sent goes out on Sepolia", async () => {
|
|
await review("sepolia");
|
|
expect(el("confirm-type").textContent).toBe("Native SepoliaETH transfer");
|
|
expect(el("btn-confirm-send").disabled).toBe(false);
|
|
await signAndSend();
|
|
expect(mockNode.broadcast).toHaveLength(1);
|
|
expect(mockNode.broadcast[0].chainId).toBe(11155111n);
|
|
expect(Transaction.from(mockNode.broadcast[0].signed).chainId).toBe(
|
|
11155111n,
|
|
);
|
|
expect(txStatus.showWait).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
describe.each([
|
|
["sepolia", "mainnet", "SepoliaETH"],
|
|
["mainnet", "sepolia", "ETH"],
|
|
])(
|
|
"a transfer reviewed on %s, with %s chosen in Settings before Sign & Send",
|
|
(reviewedOn, switchedTo, symbol) => {
|
|
test("is neither signed nor sent, and the screen says why", async () => {
|
|
await review(reviewedOn);
|
|
expect(el("confirm-type").textContent).toBe(
|
|
"Native " + symbol + " transfer",
|
|
);
|
|
// Settings' network select, then Back, which only unhides the
|
|
// screen as it was drawn.
|
|
await onChainSwitch(switchedTo);
|
|
await signAndSend();
|
|
|
|
expect(signTransaction).not.toHaveBeenCalled();
|
|
expect(mockNode.broadcast).toEqual([]);
|
|
expect(txStatus.showWait).not.toHaveBeenCalled();
|
|
expect(txStatus.showError).not.toHaveBeenCalled();
|
|
expect(el("confirm-errors").style.visibility).toBe("visible");
|
|
expect(el("confirm-errors").innerHTML).toContain(
|
|
"The active network changed after this transaction was" +
|
|
" reviewed, so it was not sent. Please go back and review" +
|
|
" it again.",
|
|
);
|
|
});
|
|
},
|
|
);
|
|
|
|
describe.each([
|
|
["sepolia", "mainnet"],
|
|
["mainnet", "sepolia"],
|
|
])(
|
|
"a transfer reviewed on %s, with %s chosen in Settings, the popup reopened and Back pressed",
|
|
(reviewedOn, switchedTo) => {
|
|
const { nativeCurrency: symbol, explorerUrl } = NETWORKS[reviewedOn];
|
|
|
|
test("is described on the network it was reviewed for, and neither signed nor sent", async () => {
|
|
await review(reviewedOn);
|
|
await switchReopenAndGoBack(reviewedOn, switchedTo);
|
|
|
|
expect(state.currentView).toBe("confirm-tx");
|
|
expectNativeTransferOn(reviewedOn);
|
|
expect(mockNode.asked).toEqual([
|
|
{
|
|
method: "getFeeData",
|
|
network: reviewedOn,
|
|
url: ownNode(reviewedOn),
|
|
},
|
|
{
|
|
method: "getCode",
|
|
network: reviewedOn,
|
|
url: ownNode(reviewedOn),
|
|
},
|
|
{
|
|
method: "getTransactionCount",
|
|
network: reviewedOn,
|
|
url: ownNode(reviewedOn),
|
|
},
|
|
]);
|
|
expect(global.fetch.mock.calls.map(([url]) => url)).toEqual([
|
|
explorerUrl + "/address/" + RECIPIENT,
|
|
]);
|
|
expect(el("btn-confirm-send").disabled).toBe(false);
|
|
|
|
await signAndSend();
|
|
|
|
expect(signTransaction).not.toHaveBeenCalled();
|
|
expect(mockNode.broadcast).toEqual([]);
|
|
expect(txStatus.showWait).not.toHaveBeenCalled();
|
|
expect(txStatus.showError).not.toHaveBeenCalled();
|
|
expect(el("confirm-errors").innerHTML).toContain(
|
|
"The active network changed after this transaction was" +
|
|
" reviewed, so it was not sent.",
|
|
);
|
|
});
|
|
|
|
test("is sent on that network, as described, once it is chosen in Settings again and Back pressed", async () => {
|
|
await review(reviewedOn);
|
|
await switchReopenAndGoBack(reviewedOn, switchedTo);
|
|
// The Settings gear, the reviewed network chosen, and Back, which
|
|
// only unhides the screen as it was drawn.
|
|
pushCurrentView();
|
|
showView("settings");
|
|
await onChainSwitch(reviewedOn);
|
|
goBack();
|
|
|
|
expect(state.currentView).toBe("confirm-tx");
|
|
expectNativeTransferOn(reviewedOn);
|
|
|
|
await signAndSend();
|
|
|
|
const chainId = BigInt(NETWORKS[reviewedOn].chainId);
|
|
expect(mockNode.broadcast).toHaveLength(1);
|
|
expect(mockNode.broadcast[0].chainId).toBe(chainId);
|
|
expect(mockNode.broadcast[0].url).toBe(ownNode(reviewedOn));
|
|
expect(Transaction.from(mockNode.broadcast[0].signed).chainId).toBe(
|
|
chainId,
|
|
);
|
|
expect(txStatus.showWait).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("for more than the balance, says so in that network's native currency", async () => {
|
|
await review(reviewedOn, { amount: "10" });
|
|
await switchReopenAndGoBack(reviewedOn, switchedTo);
|
|
|
|
expect(el("confirm-errors").innerHTML).toContain(
|
|
"Insufficient balance. You have 5.0000 " +
|
|
symbol +
|
|
" but are trying to send 10 " +
|
|
symbol +
|
|
".",
|
|
);
|
|
});
|
|
|
|
test("with a fee quoted by a node with no type-2 pricing, shows the fee in that network's native currency and price", async () => {
|
|
await review(reviewedOn);
|
|
mockNode.legacyFees = true;
|
|
await switchReopenAndGoBack(reviewedOn, switchedTo);
|
|
|
|
expect(el("confirm-fee-amount").textContent).toBe(
|
|
"0.0002 " + symbol + usdOn(reviewedOn, "$0.42"),
|
|
);
|
|
});
|
|
|
|
test("of an ERC-20 token, links the token contract to that network's explorer and prices the token by that network", async () => {
|
|
await review(reviewedOn, {
|
|
token: TOKEN,
|
|
tokenSymbol: "USDC",
|
|
tokenBalance: "50",
|
|
tokenDecimals: 6,
|
|
amount: "10",
|
|
});
|
|
await switchReopenAndGoBack(reviewedOn, switchedTo);
|
|
|
|
expect(el("confirm-type").textContent).toBe(
|
|
"ERC-20 token transfer (USDC)",
|
|
);
|
|
expect(el("confirm-token-contract").innerHTML).toContain(
|
|
`href="${explorerUrl}/address/${TOKEN}"`,
|
|
);
|
|
expect(el("confirm-amount").textContent).toBe(
|
|
"10 USDC" + usdOn(reviewedOn, "$10.00"),
|
|
);
|
|
expect(el("confirm-balance").textContent).toBe(
|
|
"50.0000 USDC" + usdOn(reviewedOn, "$50.00"),
|
|
);
|
|
});
|
|
},
|
|
);
|
|
|
|
// The check in the click handler tells the user; the send itself carries the
|
|
// chain id the screen was drawn for, so a signer on another network's node
|
|
// cannot sign it even if the network changes after that check.
|
|
test("the send is refused by a signer on another network's node", async () => {
|
|
const { getProvider } = require("../src/shared/balances");
|
|
const signer = new Wallet(PRIVATE_KEY).connect(
|
|
getProvider("https://rpc.example.invalid", "mainnet"),
|
|
);
|
|
await expect(
|
|
confirmTx.populateVerifyAndSend(signer, {
|
|
token: "ETH",
|
|
to: RECIPIENT,
|
|
amount: "1",
|
|
chainId: NETWORKS.sepolia.chainId,
|
|
}),
|
|
).rejects.toThrow("transaction chainId mismatch");
|
|
expect(signTransaction).not.toHaveBeenCalled();
|
|
expect(mockNode.broadcast).toEqual([]);
|
|
});
|