Compare commits

..
1 Commits
Author SHA1 Message Date
sneak 447eb2e70c fix: Sign & Send refuses a transfer reviewed on another network (closes #524)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Settings is reachable from the confirmation screen, and Back from it only
unhides the screen as it was drawn. Sign & Send took the network at the
click, so a transfer reviewed on Sepolia went out on mainnet after a switch
in between. show() now records the chain id of the network it draws for.
Sign & Send refuses with a message on the screen when the active network
differs, and the send carries that chain id, so ethers will not populate it
for a provider on any other network.

Model: opus-5-5
2026-10-14 06:49:16 +00:00
8 changed files with 293 additions and 50 deletions
+9 -8
View File
@@ -239,12 +239,8 @@ provide:
`make check` stays non-mutating. Linting runs only in the container, because
an ESLint result that depends on whichever ESLint the host happens to have is
not a result, so docker is required to lint.
- `script/fmt` — format all files (writes), on the host, with the `prettier`
that `script/bootstrap` installs in `node_modules/`; when that one is missing
it fails and says to run `script/bootstrap`, instead of using a `prettier`
from the `PATH`
- `script/fmt-check` — check formatting (read-only), on the host, with the same
`prettier` and the same refusal
- `script/fmt` — format all files (writes), on the host
- `script/fmt-check` — check formatting (read-only), on the host
- `script/check` — run `script/test`, `script/lint` and `script/fmt-check`
- `script/check-censored` — assert the competitor name RULES.md bars appears
nowhere in the working tree or under `dist/` outside its documented
@@ -1645,6 +1641,12 @@ view would leave a wallet one click from deletion.
the contract's own answer is read at signing time only to be compared with
it, and a disagreement is a refusal rather than a preference for either
value (`src/shared/transferAmount.js`)
- "Sign & Send" after the active network was switched in Settings while this
screen was open → nothing is signed; "The active network changed after
this transaction was reviewed, so it was not sent. Please go back and
review it again." in the errors box, no screen change. The transaction
carries the chain id of the network the screen was shown for, so it cannot
be signed for another
- "Sign & Send" (wrong password) → "Wrong password." on the password error
line, no screen change
- "Back" → **Send**
@@ -2663,8 +2665,7 @@ plaintext list of anyone's domain names.
When a dApp on a blocklisted domain requests a wallet connection, transaction
approval, or signature, the approval popup displays a prominent red warning
banner alerting the user. The domain checker matches exact hostnames and all
parent domains (subdomain matching), and checks a hostname written with a
trailing dot (`name.`) as `name`.
parent domains (subdomain matching).
#### Transaction Decoding
-7
View File
@@ -25,13 +25,6 @@ run_yarn() {
main() {
cd "$ROOT"
# Without the repo's own prettier, yarn runs whatever prettier is on
# the PATH, which may be a version that formats files differently.
if [ ! -x node_modules/.bin/prettier ]; then
echo "fmt: this repo's prettier is not installed in node_modules/," \
"so run script/bootstrap first." >&2
exit 1
fi
echo "Formatting..."
run_yarn run fmt
}
-7
View File
@@ -26,13 +26,6 @@ run_yarn() {
main() {
cd "$ROOT"
# Without the repo's own prettier, yarn runs whatever prettier is on
# the PATH, which may be a version that formats files differently.
if [ ! -x node_modules/.bin/prettier ]; then
echo "fmt-check: this repo's prettier is not installed in" \
"node_modules/, so run script/bootstrap first." >&2
exit 1
fi
echo "Checking formatting..."
run_yarn run fmt-check
}
+3 -6
View File
@@ -633,9 +633,8 @@ function isExtensionSender(sender) {
// had refused.
//
// TX and sign approvals do not decide here. They stay pending across a
// disconnect. When their window closes, the windows.onRemoved listener below
// rejects them, unless an approved attempt is still running, in which case
// that attempt answers the page.
// disconnect — the user can reopen the toolbar popup — and are rejected by the
// windows.onRemoved listener below.
runtime.onConnect.addListener((port) => {
if (port.name.startsWith("approval:")) {
const id = port.name.split(":")[1];
@@ -663,9 +662,7 @@ runtime.onConnect.addListener((port) => {
const approval = pendingApprovals[id];
if (approval) {
if (approval.type === "tx" || approval.type === "sign") {
// When its window closes, windows.onRemoved rejects it,
// unless an approved attempt is still running, in which
// case that attempt answers the page.
// Keep pending — user can reopen the toolbar popup
return;
}
settleApproval(id, { approved: false, remember: false });
+21 -5
View File
@@ -101,6 +101,10 @@ function show(txInfo) {
feeStatus = FEE_PENDING;
feeWei = null;
maxSendFees = null;
// The network this screen is drawn for, and the only one it sends on:
// Settings can switch the active network while the screen is open. The
// wait, success and error screens label the amount by it too.
txInfo.chainId = currentNetwork().chainId;
const isErc20 = txInfo.token !== "ETH";
// The raw symbol is the price-table key; the capped one is what the
@@ -490,7 +494,12 @@ async function populateVerifyAndSend(connectedSigner, tx, fees = null) {
);
request = await contract.transfer.populateTransaction(tx.to, amount);
}
const populated = await connectedSigner.populateTransaction(request);
// The chain id of the network the screen was drawn for: ethers refuses to
// populate it for a provider on any other network.
const populated = await connectedSigner.populateTransaction({
...request,
chainId: tx.chainId,
});
assertWithinCeilings(populated);
return connectedSigner.sendTransaction(populated);
}
@@ -547,6 +556,17 @@ function init(_ctx) {
return;
}
// Back from Settings only unhides this screen, still drawn for the
// network that was active when it was shown.
if (currentNetwork().chainId !== pendingTx.chainId) {
showSendError(
"The active network changed after this transaction was" +
" reviewed, so it was not sent. Please go back and review" +
" it again.",
);
return;
}
const password = $("confirm-tx-password").value;
if (!password) {
showError(
@@ -575,10 +595,6 @@ function init(_ctx) {
$("btn-confirm-send").disabled = true;
$("btn-confirm-send").classList.add("text-muted");
// The network it is sent on. The wait, success and error screens
// label its amount by this, not by the network active when they draw.
pendingTx.chainId = currentNetwork().chainId;
let tx;
try {
const signer = getSignerForAddress(
+1 -4
View File
@@ -110,14 +110,11 @@ function hashListed(hash) {
* Generate hostname variants for subdomain matching.
* "sub.evil.com" yields ["sub.evil.com", "evil.com"].
*
* A trailing dot is dropped: "evil.com." is the fully qualified spelling of
* "evil.com", and the browser loads the same site from either.
*
* @param {string} hostname
* @returns {string[]}
*/
function hostnameVariants(hostname) {
const h = hostname.toLowerCase().replace(/\.$/, "");
const h = hostname.toLowerCase();
const variants = [h];
const parts = h.split(".");
// Parent domains: a.b.c.d -> b.c.d, c.d
+259
View File
@@ -0,0 +1,259 @@
// 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.
//
// 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: [] };
jest.mock("ethers", () => {
const actual = jest.requireActual("ethers");
class StubProvider {
constructor(url, network) {
this.network = network;
}
async getNetwork() {
return this.network;
}
async getFeeData() {
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 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.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,
});
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.",
);
});
},
);
// 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([]);
});
-13
View File
@@ -89,12 +89,6 @@ describe("vendored blocklist", () => {
expect(isPhishingDomain(LISTED[0].toUpperCase())).toBe(true);
});
test("a hostname ending in a dot is the same host", () => {
expect(isPhishingDomain(LISTED[1] + ".")).toBe(true);
expect(isPhishingDomain("wallet." + LISTED[1] + ".")).toBe(true);
expect(isPhishingDomain(CLEAN[0] + ".")).toBe(false);
});
test("returns false for an empty or missing hostname", () => {
expect(isPhishingDomain("")).toBe(false);
expect(isPhishingDomain(null)).toBe(false);
@@ -139,13 +133,6 @@ describe("hostnameVariants", () => {
test("lowercases hostnames", () => {
expect(hostnameVariants("Evil.COM")).toEqual(["evil.com"]);
});
test("drops the trailing dot of a fully qualified hostname", () => {
expect(hostnameVariants("sub.evil.com.")).toEqual([
"sub.evil.com",
"evil.com",
]);
});
});
describe("domain hashing", () => {