fix: a confirmation screen drawn again keeps the network it was reviewed for (closes #586)
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
This commit is contained in:
@@ -7,25 +7,36 @@
|
||||
// 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 signed transactions the stub node was handed, with its network's chain id.
|
||||
const mockNode = { broadcast: [] };
|
||||
// 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,
|
||||
@@ -128,6 +139,16 @@ 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");
|
||||
|
||||
@@ -150,6 +171,7 @@ beforeAll(async () => {
|
||||
|
||||
beforeEach(() => {
|
||||
elements.clear();
|
||||
mockNode.feeAsked = [];
|
||||
mockNode.broadcast = [];
|
||||
txStatus.showWait.mockClear();
|
||||
txStatus.showError.mockClear();
|
||||
@@ -187,6 +209,12 @@ async function review(networkId) {
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -238,6 +266,60 @@ describe.each([
|
||||
},
|
||||
);
|
||||
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user