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. One drawn again from the saved screen keeps its own, so Sign & Send refuses it while another network is active. The screen labels its amounts in that network's native currency and asks that network's RPC endpoint for the fee and the recipient checks. Model: opus-5-5
342 lines
11 KiB
JavaScript
342 lines
11 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.
|
|
//
|
|
// Driven through the real confirmation screen, signer and chain switch, with
|
|
// only the node 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 nodes asked for the fee, by network name and RPC URL, in order. And the
|
|
// signed transactions the stub node was handed, with its network's chain id.
|
|
const mockNode = { feeAsked: [], broadcast: [] };
|
|
|
|
jest.mock("ethers", () => {
|
|
const actual = jest.requireActual("ethers");
|
|
class StubProvider {
|
|
constructor(url, network) {
|
|
this.url = url;
|
|
this.network = network;
|
|
}
|
|
async getNetwork() {
|
|
return this.network;
|
|
}
|
|
async getFeeData() {
|
|
mockNode.feeAsked.push({
|
|
network: this.network.name,
|
|
url: this.url,
|
|
});
|
|
return {
|
|
maxFeePerGas: 20000000000n,
|
|
maxPriorityFeePerGas: 1000000000n,
|
|
gasPrice: 10000000000n,
|
|
};
|
|
}
|
|
async estimateGas() {
|
|
return 21000n;
|
|
}
|
|
async getCode() {
|
|
return "0x";
|
|
}
|
|
async getTransactionCount() {
|
|
return 1;
|
|
}
|
|
async lookupAddress() {
|
|
return null;
|
|
}
|
|
async broadcastTransaction(signed) {
|
|
mockNode.broadcast.push({ chainId: this.network.chainId, 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(),
|
|
}));
|
|
|
|
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 {
|
|
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 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.feeAsked = [];
|
|
mockNode.broadcast = [];
|
|
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`, as
|
|
// Review on the Send screen does, with its fee estimate settled.
|
|
async function review(networkId) {
|
|
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,
|
|
});
|
|
await settle();
|
|
}
|
|
|
|
// 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", "SepoliaETH"],
|
|
["mainnet", "sepolia", "ETH"],
|
|
])(
|
|
"a transfer reviewed on %s, with %s chosen in Settings, the popup reopened and Back pressed",
|
|
(reviewedOn, switchedTo, symbol) => {
|
|
test("is described on the network it was reviewed for, and neither signed nor sent", async () => {
|
|
await review(reviewedOn);
|
|
// The user's own node for that network, set in Settings.
|
|
const ownNode = "https://" + reviewedOn + ".node.example.invalid";
|
|
state.rpcUrl = ownNode;
|
|
// The Settings gear, then its network select.
|
|
pushCurrentView();
|
|
showView("settings");
|
|
await onChainSwitch(switchedTo);
|
|
|
|
// The reopened popup loads what was saved onto a blank page, and
|
|
// Back draws the confirmation screen again from it.
|
|
await loadState();
|
|
elements.clear();
|
|
resetRenderedViews();
|
|
setBackRenderer(makeBackRenderer(state, { confirmTx }));
|
|
confirmTx.init({});
|
|
mockNode.feeAsked = [];
|
|
goBack();
|
|
await settle();
|
|
|
|
expect(state.currentView).toBe("confirm-tx");
|
|
expect(el("confirm-type").textContent).toBe(
|
|
"Native " + symbol + " transfer",
|
|
);
|
|
expect(el("confirm-balance").textContent).toBe("5.0000 " + symbol);
|
|
expect(el("confirm-fee-amount").textContent).toBe(
|
|
"~0.0002 " + symbol,
|
|
);
|
|
expect(mockNode.feeAsked).toEqual([
|
|
{ network: reviewedOn, url: ownNode },
|
|
]);
|
|
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.",
|
|
);
|
|
});
|
|
},
|
|
);
|
|
|
|
// 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([]);
|
|
});
|