All checks were successful
check / check (push) Successful in 46s
handleRpc(...).then(sendResponse) had no .catch(), and sendResponse is the only
thing that settles the dApp's window.ethereum.request() promise. Any throw
inside handleRpc therefore sent nothing back: the content script posted nothing,
and the page's promise stayed pending forever with no error and no timeout,
indistinguishable from a slow wallet. handleRpc does real work -- state loads,
provider calls, transaction population, approval plumbing -- so "it does not
throw today" was not a property anyone was maintaining.
A rejected handleRpc now answers { code: -32603, message }. -32603 is the
JSON-RPC internal error EIP-1474 defines and EIP-1193 defers to for RPC-layer
failures; no EIP-1193 4xxx code describes "the wallet broke" and none was
invented for it. The cause is not put in the message: the page gets a stable
sentence, the background console gets the method and the throw, so the failure
is visible rather than swallowed.
The two async IIFEs behind AUTISTMASK_TX_RESPONSE and AUTISTMASK_SIGN_RESPONSE
are the same shape one level down. Every statement is inside a try, but a throw
from one of the catch blocks escapes as an unhandled rejection and neither the
popup nor the page is answered. Each gets a last-resort .catch() that settles
the approval through settleApproval() -- the existing chokepoint, with no new
delete or resolve -- and answers the popup. The transaction one reports the
broadcast stage, because it cannot tell whether the transaction reached the
network and that is the wording that does not invite a second send. Every other
message handler on the path is synchronous and cannot leave a promise pending.
Each of the three is driven by a real failure rather than a hook in the handler:
a rejecting extension-storage read, which getState() awaits unguarded, and a
failure classifier that throws while classifying a genuine verification failure.
All three were demonstrated failing against the unfixed code, the RPC one with
sendResponse at zero calls, which is precisely the page-side hang.
1232 lines
42 KiB
JavaScript
1232 lines
42 KiB
JavaScript
// The background's approval message wiring, driven end to end: a dApp
|
|
// eth_sendTransaction raises a pending approval, and the popup answers it with
|
|
// AUTISTMASK_TX_RESPONSE / AUTISTMASK_SIGN_RESPONSE.
|
|
//
|
|
// What this exists for is the duplicate response. The handler verifies and
|
|
// broadcasts asynchronously, and the approval deliberately survives a
|
|
// retryable failure so the user can try again with the transaction they
|
|
// already saw — which means the entry being present is not by itself proof
|
|
// that no attempt is running. A second response carrying the same id (a
|
|
// reloaded approval window re-rendering a live Approve button, a popup that
|
|
// emits the message twice) must not start a second verify and broadcast: the
|
|
// same approved transaction signed twice verifies twice, and the transfer
|
|
// would go out twice.
|
|
//
|
|
// It also covers what the approval is verified against. The approval now
|
|
// carries the transaction the background populated and the screen displayed,
|
|
// and the address that was active when it was raised — so a fee, a nonce or an
|
|
// address that moved between approval and signing is refused rather than
|
|
// signed.
|
|
|
|
const { Network, Wallet } = require("ethers");
|
|
|
|
const SIGNER_KEY =
|
|
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
|
|
const OTHER_KEY =
|
|
"0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a";
|
|
const signer = new Wallet(SIGNER_KEY);
|
|
const other = new Wallet(OTHER_KEY);
|
|
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
|
|
|
const ORIGIN = "https://dapp.example";
|
|
const HOSTNAME = "dapp.example";
|
|
const EXT_URL = "chrome-extension://autistmask/";
|
|
|
|
// What the dApp asks for: no nonce, no gas, no fees. This is the shape that
|
|
// makes a duplicate broadcast possible at all.
|
|
const TX_PARAMS = {
|
|
from: signer.address,
|
|
to: RECIPIENT,
|
|
value: "0x2386f26fc10000",
|
|
data: "0x",
|
|
};
|
|
|
|
// The nonce the stubbed node reports, and so the nonce the background
|
|
// populates the approval with.
|
|
const NONCE = 7;
|
|
|
|
// "Hello AutistMask" as the hex string a dApp passes to personal_sign.
|
|
const MESSAGE = "0x48656c6c6f204175746973744d61736b";
|
|
|
|
// The transaction the background populates and the approval screen displays.
|
|
// The nonce is a parameter because the duplicate case turns on two artifacts
|
|
// differing in a field the dApp fixed nothing for.
|
|
function populated(nonce) {
|
|
return {
|
|
type: 2,
|
|
chainId: 1,
|
|
nonce,
|
|
gasLimit: 100000n,
|
|
maxFeePerGas: 2000000000n,
|
|
maxPriorityFeePerGas: 1000000000n,
|
|
to: TX_PARAMS.to,
|
|
value: BigInt(TX_PARAMS.value),
|
|
data: TX_PARAMS.data,
|
|
};
|
|
}
|
|
|
|
function signedAtNonce(nonce, withWallet) {
|
|
return (withWallet || signer).signTransaction(populated(nonce));
|
|
}
|
|
|
|
// The node the background populates against. Its answers are the numbers the
|
|
// approval screen shows, so they are also the numbers every artifact below is
|
|
// signed at.
|
|
function fakeProvider(broadcastTransaction, overrides) {
|
|
return {
|
|
broadcastTransaction,
|
|
getNetwork: async () => Network.from(1),
|
|
getTransactionCount: async () => NONCE,
|
|
estimateGas: async () => 100000n,
|
|
getFeeData: async () => ({
|
|
gasPrice: 2000000000n,
|
|
maxFeePerGas: 2000000000n,
|
|
maxPriorityFeePerGas: 1000000000n,
|
|
}),
|
|
...(overrides || {}),
|
|
};
|
|
}
|
|
|
|
// A promise whose settlement the test controls, so a broadcast can be held in
|
|
// flight while the second response arrives.
|
|
function deferred() {
|
|
let resolve;
|
|
let reject;
|
|
const promise = new Promise((res, rej) => {
|
|
resolve = res;
|
|
reject = rej;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
// Load the background worker against stubbed browser and network APIs and
|
|
// return the handles the tests drive it through. Everything that would touch
|
|
// the network or the browser's own schedulers is mocked; the approval
|
|
// verification is the real module, because that is what the handler under
|
|
// test is wired to.
|
|
function loadBackground(options) {
|
|
const opts = options || {};
|
|
jest.resetModules();
|
|
|
|
const broadcastTransaction = jest.fn();
|
|
const loadState = jest.fn(opts.loadState || (async () => {}));
|
|
|
|
jest.doMock("../src/shared/state", () => ({
|
|
state: { rpcUrl: "https://rpc.invalid", wallets: [] },
|
|
loadState,
|
|
saveState: jest.fn(async () => {}),
|
|
currentNetwork: () => ({ chainId: "0x1" }),
|
|
}));
|
|
jest.doMock("../src/shared/balances", () => ({
|
|
getProvider: () => fakeProvider(broadcastTransaction, opts.provider),
|
|
refreshBalances: jest.fn(async () => {}),
|
|
}));
|
|
jest.doMock("../src/shared/phishingDomains", () => ({
|
|
isPhishingDomain: () => false,
|
|
refreshPhishingListOnSchedule: jest.fn(async () => {}),
|
|
initPhishingList: jest.fn(async () => {}),
|
|
}));
|
|
jest.doMock("../src/shared/alarms", () => ({
|
|
BALANCE_REFRESH_ALARM: "balance",
|
|
PHISHING_REFRESH_ALARM: "phishing",
|
|
BALANCE_REFRESH_PERIOD_MINUTES: 1,
|
|
ensureRecurringAlarms: jest.fn(async () => {}),
|
|
registerAlarmHandlers: jest.fn(),
|
|
}));
|
|
// The real verification module, except where a test replaces one export
|
|
// with a throw to drive the handler's own error handling into failing.
|
|
if (opts.approvalVerify) {
|
|
jest.doMock("../src/shared/approvalVerify", () => ({
|
|
...jest.requireActual("../src/shared/approvalVerify"),
|
|
...opts.approvalVerify,
|
|
}));
|
|
}
|
|
|
|
const persisted = {
|
|
wallets: [
|
|
{ name: "Wallet 1", type: "hd", addresses: [signer.address] },
|
|
],
|
|
rpcUrl: "https://rpc.invalid",
|
|
activeAddress: signer.address,
|
|
allowedSites: { [signer.address]: [HOSTNAME] },
|
|
deniedSites: {},
|
|
};
|
|
|
|
let messageListener = null;
|
|
let windowRemovedListener = null;
|
|
const created = [];
|
|
const removed = [];
|
|
|
|
global.chrome = {
|
|
storage: {
|
|
local: {
|
|
get: jest.fn(
|
|
opts.storageGet ||
|
|
(async () => ({ autistmask: persisted })),
|
|
),
|
|
set: jest.fn(async () => {}),
|
|
},
|
|
},
|
|
runtime: {
|
|
getURL: (path) => EXT_URL + path,
|
|
onMessage: {
|
|
addListener: (fn) => {
|
|
messageListener = fn;
|
|
},
|
|
},
|
|
onConnect: { addListener: () => {} },
|
|
lastError: null,
|
|
},
|
|
windows: {
|
|
getLastFocused: (cb) => cb(null),
|
|
create: (options2, cb) => {
|
|
created.push(options2);
|
|
cb({ id: created.length });
|
|
},
|
|
remove: (id, cb) => {
|
|
removed.push(id);
|
|
if (cb) cb();
|
|
},
|
|
// Captured, not swallowed: closing the approval window is the
|
|
// event that used to retire an approval out from under a live
|
|
// broadcast, and a no-op stub here hides exactly that.
|
|
onRemoved: {
|
|
addListener: (fn) => {
|
|
windowRemovedListener = fn;
|
|
},
|
|
},
|
|
},
|
|
tabs: {
|
|
query: (q, cb) => cb([]),
|
|
sendMessage: () => {},
|
|
},
|
|
action: { setPopup: () => {} },
|
|
};
|
|
|
|
require("../src/background/index");
|
|
|
|
// Send a message the way the browser would, and hand back whatever the
|
|
// handler passed to sendResponse.
|
|
function send(msg, sender) {
|
|
const sendResponse = jest.fn();
|
|
const kept = messageListener(msg, sender || {}, sendResponse);
|
|
return { sendResponse, kept };
|
|
}
|
|
|
|
// Raise a pending transaction approval the way a dApp does, and dig the
|
|
// approval id back out of the popup URL the background opened.
|
|
function requestTx(txParams) {
|
|
let rpcResult = null;
|
|
const sendResponse = jest.fn((r) => {
|
|
rpcResult = r;
|
|
});
|
|
messageListener(
|
|
{
|
|
type: "AUTISTMASK_RPC",
|
|
method: "eth_sendTransaction",
|
|
params: [txParams || TX_PARAMS],
|
|
},
|
|
{ origin: ORIGIN },
|
|
sendResponse,
|
|
);
|
|
return {
|
|
id: () => new URL(created[0].url).searchParams.get("approval"),
|
|
result: () => rpcResult,
|
|
};
|
|
}
|
|
|
|
// The same for a message-signing approval, which pins the signing address
|
|
// at approval time in exactly the same way.
|
|
function requestSign(from) {
|
|
let rpcResult = null;
|
|
messageListener(
|
|
{
|
|
type: "AUTISTMASK_RPC",
|
|
method: "personal_sign",
|
|
params: [MESSAGE, from || signer.address],
|
|
},
|
|
{ origin: ORIGIN },
|
|
(r) => {
|
|
rpcResult = r;
|
|
},
|
|
);
|
|
return {
|
|
id: () =>
|
|
new URL(created[created.length - 1].url).searchParams.get(
|
|
"approval",
|
|
),
|
|
result: () => rpcResult,
|
|
};
|
|
}
|
|
|
|
// The user closes the approval popup. `created` is index-aligned with the
|
|
// ids the window stub hands back, so window 1 is the first popup opened.
|
|
function closeWindow(windowId) {
|
|
windowRemovedListener(windowId);
|
|
}
|
|
|
|
return {
|
|
send,
|
|
requestTx,
|
|
requestSign,
|
|
closeWindow,
|
|
broadcastTransaction,
|
|
loadState,
|
|
created,
|
|
removed,
|
|
// The user switching account in the toolbar popup, as the background
|
|
// sees it: the persisted active address changes underneath a pending
|
|
// approval.
|
|
setActiveAddress: (address) => {
|
|
persisted.activeAddress = address;
|
|
},
|
|
fromPopup: { url: EXT_URL + "src/popup/index.html" },
|
|
};
|
|
}
|
|
|
|
// Let the handler's promise chain run to the next suspension point. Raising a
|
|
// transaction approval now populates it against the node first, which is
|
|
// several awaits deep before the window is opened.
|
|
async function settle() {
|
|
for (let i = 0; i < 50; i++) await Promise.resolve();
|
|
}
|
|
|
|
// Node aborts the worker process on an unhandled rejection; an extension
|
|
// service worker does not — the promise is simply never settled, nothing is
|
|
// sent back, and the page's window.ethereum.request() waits forever. Recording
|
|
// them instead of dying on them keeps that difference visible: the assertion
|
|
// that the page WAS answered is what reports the failure, and the recording is
|
|
// asserted empty alongside it.
|
|
const unhandledRejections = [];
|
|
process.on("unhandledRejection", (reason) => {
|
|
unhandledRejections.push(reason);
|
|
});
|
|
|
|
// Node reports an unhandled rejection on the macrotask turn after the promise
|
|
// was left unhandled, which is past everything settle() waits for.
|
|
async function settleIncludingRejections() {
|
|
await settle();
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
}
|
|
|
|
afterEach(() => {
|
|
delete global.chrome;
|
|
jest.resetModules();
|
|
});
|
|
|
|
describe("one approval, one broadcast", () => {
|
|
test("a second AUTISTMASK_TX_RESPONSE for the same id does not broadcast again", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
const id = pending.id();
|
|
expect(id).toBeTruthy();
|
|
|
|
const inFlight = deferred();
|
|
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
|
|
|
|
// The popup answers. Verification passes and the broadcast is held
|
|
// open, which is the whole window the second message arrives in.
|
|
const first = bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(7),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
|
|
|
// A reloaded approval window signs the same approval again, at another
|
|
// nonce. The claim is taken before anything is verified, so what this
|
|
// asserts is the interlock and not the nonce comparison: the refusal
|
|
// below is the claim's own message, which a verification failure does
|
|
// not produce.
|
|
const second = bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(8),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
|
|
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
|
expect(second.sendResponse).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
error: expect.stringMatching(/already being sent/),
|
|
retryable: false,
|
|
}),
|
|
);
|
|
|
|
inFlight.resolve({ hash: "0xfeed" });
|
|
await settle();
|
|
expect(first.sendResponse).toHaveBeenCalledWith({ txHash: "0xfeed" });
|
|
expect(pending.result()).toEqual({ result: "0xfeed" });
|
|
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("the same artifact sent twice broadcasts once", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
const id = pending.id();
|
|
|
|
const inFlight = deferred();
|
|
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
|
|
const raw = await signedAtNonce(7);
|
|
const msg = {
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
rawSignedTx: raw,
|
|
};
|
|
|
|
bg.send(msg, { url: bg.fromPopup.url });
|
|
bg.send(msg, { url: bg.fromPopup.url });
|
|
await settle();
|
|
inFlight.resolve({ hash: "0xfeed" });
|
|
await settle();
|
|
|
|
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("a response arriving after the broadcast finished finds nothing to send", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
const id = pending.id();
|
|
|
|
bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
|
|
bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(7),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
|
|
const late = bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(8),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
|
|
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
|
expect(late.sendResponse).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("a second AUTISTMASK_SIGN_RESPONSE for the same id is refused", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
const id = pending.id();
|
|
|
|
// Hold the transaction approval in flight, then answer it a second
|
|
// time as if it were a sign approval: the sign handler must apply the
|
|
// same interlock rather than running its own verification.
|
|
const inFlight = deferred();
|
|
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
|
|
bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(7),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
|
|
const second = bg.send(
|
|
{
|
|
type: "AUTISTMASK_SIGN_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
signature: "0x00",
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
|
|
expect(second.sendResponse).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
error: expect.stringMatching(/already being signed/),
|
|
retryable: false,
|
|
}),
|
|
);
|
|
inFlight.resolve({ hash: "0xfeed" });
|
|
await settle();
|
|
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
// The approval carries the transaction the user was shown and the address it
|
|
// was raised for, and the artifact is checked against both. Every case here is
|
|
// one the old comparison — against the dApp's request, for the address that is
|
|
// active now — would have broadcast.
|
|
describe("what the approval is verified against", () => {
|
|
// The approval screen showed the populated fee. An artifact at ten times
|
|
// that fee, still far below the ceilings, is what the ceilings alone could
|
|
// not catch.
|
|
test("a fee differing from the displayed one is refused, not sent", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
const id = pending.id();
|
|
|
|
const raw = await signer.signTransaction({
|
|
...populated(NONCE),
|
|
maxFeePerGas: 20000000000n,
|
|
});
|
|
const answer = bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
rawSignedTx: raw,
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
|
|
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
|
|
expect(answer.sendResponse).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
error: expect.stringMatching(/approved maximum fee per gas/),
|
|
retryable: false,
|
|
stage: "verify",
|
|
}),
|
|
);
|
|
expect(pending.result()).toEqual({
|
|
error: {
|
|
message: expect.stringMatching(/approved maximum fee per gas/),
|
|
},
|
|
});
|
|
});
|
|
|
|
test("a nonce differing from the displayed one is refused, not sent", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
const id = pending.id();
|
|
|
|
const answer = bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(NONCE + 1),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
|
|
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
|
|
expect(answer.sendResponse).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
error: expect.stringMatching(/approved nonce/),
|
|
retryable: false,
|
|
stage: "verify",
|
|
}),
|
|
);
|
|
});
|
|
|
|
// The address switch. The approval named one account; the wallet is on
|
|
// another by the time the artifact arrives. Both halves are covered: the
|
|
// popup signing as the account that is active now, and the popup correctly
|
|
// signing as the approved account while the wallet has moved on.
|
|
test("an artifact signed by the address that is active now is refused", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
const id = pending.id();
|
|
|
|
bg.setActiveAddress(other.address);
|
|
const answer = bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(NONCE, other),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
|
|
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
|
|
expect(answer.sendResponse).toHaveBeenCalledWith(
|
|
expect.objectContaining({ retryable: false, stage: "verify" }),
|
|
);
|
|
expect(pending.result()).toEqual({
|
|
error: { message: expect.stringMatching(/active address changed/) },
|
|
});
|
|
});
|
|
|
|
test("an address switch refuses even the correctly signed artifact", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
const id = pending.id();
|
|
|
|
bg.setActiveAddress(other.address);
|
|
const answer = bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(NONCE),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
|
|
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
|
|
expect(answer.sendResponse).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
error: expect.stringMatching(/active address changed/),
|
|
retryable: false,
|
|
stage: "verify",
|
|
}),
|
|
);
|
|
// A refusal, so the approval is spent: the same artifact offered again
|
|
// finds nothing to answer.
|
|
const retry = bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(NONCE),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
expect(retry.sendResponse).not.toHaveBeenCalled();
|
|
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("a switch back to the approved address still sends", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
const id = pending.id();
|
|
|
|
bg.setActiveAddress(other.address);
|
|
bg.setActiveAddress(signer.address);
|
|
bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
|
|
bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(NONCE),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
|
|
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
|
expect(pending.result()).toEqual({ result: "0xfeed" });
|
|
});
|
|
|
|
// The popup is handed the populated transaction and the address it is for,
|
|
// and nothing else it would have to fetch or decide.
|
|
test("the popup is given the transaction it is to sign", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
|
|
const details = bg.send(
|
|
{ type: "AUTISTMASK_GET_APPROVAL", id: pending.id() },
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
const shown = details.sendResponse.mock.calls[0][0];
|
|
expect(shown.type).toBe("tx");
|
|
expect(shown.approvedFrom).toBe(signer.address);
|
|
expect(shown.approvedTx).toEqual({
|
|
type: 2,
|
|
from: signer.address,
|
|
chainId: "0x1",
|
|
nonce: "0x7",
|
|
gasLimit: "0x186a0",
|
|
maxFeePerGas: "0x77359400",
|
|
maxPriorityFeePerGas: "0x3b9aca00",
|
|
to: RECIPIENT,
|
|
value: TX_PARAMS.value,
|
|
data: "0x",
|
|
accessList: [],
|
|
});
|
|
});
|
|
|
|
// A request naming an account the wallet is not on is refused outright
|
|
// rather than signed as whichever account is active.
|
|
test("a request from another address raises no approval at all", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestTx({ ...TX_PARAMS, from: other.address });
|
|
await settle();
|
|
|
|
expect(pending.result()).toEqual({
|
|
error: {
|
|
code: 4100,
|
|
message: expect.stringMatching(/not the active one/),
|
|
},
|
|
});
|
|
expect(bg.created).toEqual([]);
|
|
});
|
|
|
|
// Message signing pins the address the same way, and refuses the same way.
|
|
// A signature is not a transaction, but a permit signed by an account the
|
|
// approval did not name spends that account's tokens all the same.
|
|
test("a sign approval refuses a signature after an address switch", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestSign();
|
|
await settle();
|
|
|
|
bg.setActiveAddress(other.address);
|
|
const answer = bg.send(
|
|
{
|
|
type: "AUTISTMASK_SIGN_RESPONSE",
|
|
id: pending.id(),
|
|
approved: true,
|
|
signature: await signer.signMessage(
|
|
Buffer.from(MESSAGE.slice(2), "hex"),
|
|
),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
|
|
expect(answer.sendResponse).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
error: expect.stringMatching(/active address changed/),
|
|
retryable: false,
|
|
}),
|
|
);
|
|
expect(pending.result()).toEqual({
|
|
error: { message: expect.stringMatching(/active address changed/) },
|
|
});
|
|
});
|
|
|
|
test("a sign request from another address raises no approval at all", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestSign(other.address);
|
|
await settle();
|
|
|
|
expect(pending.result()).toEqual({
|
|
error: {
|
|
code: 4100,
|
|
message: expect.stringMatching(/not the active one/),
|
|
},
|
|
});
|
|
expect(bg.created).toEqual([]);
|
|
});
|
|
|
|
// Population is a network round trip with the user's hands free. An
|
|
// approval raised for the address that was active when it started could
|
|
// never be signed once the wallet has moved off it, so it is never raised.
|
|
test("an address switch during population raises no approval", async () => {
|
|
let bg;
|
|
bg = loadBackground({
|
|
provider: {
|
|
// The user switches account in the toolbar popup while the
|
|
// node is being asked for a gas estimate.
|
|
estimateGas: async () => {
|
|
bg.setActiveAddress(other.address);
|
|
return 100000n;
|
|
},
|
|
},
|
|
});
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
|
|
expect(pending.result()).toEqual({
|
|
error: {
|
|
message: expect.stringMatching(
|
|
/active address changed while this transaction was being prepared/,
|
|
),
|
|
},
|
|
});
|
|
expect(bg.created).toEqual([]);
|
|
});
|
|
|
|
// Population happens before the window exists, so its failure is a failure
|
|
// of the request: no approval, no window, and the error goes back to the
|
|
// page the click came from.
|
|
test("a transaction that cannot be prepared opens no window", async () => {
|
|
const bg = loadBackground({
|
|
provider: {
|
|
estimateGas: async () => {
|
|
throw new Error("execution reverted");
|
|
},
|
|
},
|
|
});
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
|
|
expect(pending.result()).toEqual({
|
|
error: {
|
|
message: expect.stringMatching(
|
|
/could not be prepared.*execution reverted/,
|
|
),
|
|
},
|
|
});
|
|
expect(bg.created).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// The interlock must not cost the retry the approval exists to allow.
|
|
describe("the interlock releases a failed attempt", () => {
|
|
test("a retryable failure before the broadcast leaves the approval usable", async () => {
|
|
let failNext = true;
|
|
const bg = loadBackground({
|
|
loadState: async () => {
|
|
if (failNext) {
|
|
failNext = false;
|
|
throw new Error("storage unavailable");
|
|
}
|
|
},
|
|
});
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
const id = pending.id();
|
|
|
|
const first = bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(7),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
|
|
expect(first.sendResponse).toHaveBeenCalledWith(
|
|
expect.objectContaining({ retryable: true }),
|
|
);
|
|
|
|
bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
|
|
const retry = bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(7),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
|
|
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
|
expect(retry.sendResponse).toHaveBeenCalledWith({ txHash: "0xfeed" });
|
|
expect(pending.result()).toEqual({ result: "0xfeed" });
|
|
});
|
|
|
|
test("a mismatched artifact spends the approval outright", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
const id = pending.id();
|
|
|
|
// Signed for a different recipient than the one that was approved.
|
|
const wrong = await signer.signTransaction({
|
|
...populated(7),
|
|
to: "0xdAC17F958D2ee523a2206206994597C13D831ec7",
|
|
});
|
|
const first = bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
rawSignedTx: wrong,
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
expect(first.sendResponse).toHaveBeenCalledWith(
|
|
expect.objectContaining({ retryable: false, stage: "verify" }),
|
|
);
|
|
|
|
const retry = bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(7),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
|
|
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
|
|
expect(retry.sendResponse).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// The claim is what makes one approval one broadcast, so it has to hold
|
|
// against everything else that retires an approval, not just against a second
|
|
// AUTISTMASK_TX_RESPONSE. Each of these paths used to resolve the waiting
|
|
// promise 4001 while the attempt behind it ran to completion: the transaction
|
|
// reached the chain and the page was told the user rejected it, which invites
|
|
// the user to send it a second time at a fresh nonce.
|
|
describe("a claimed approval outlives every other retirement path", () => {
|
|
// The approval popup stays open across the broadcast it is waiting on, so
|
|
// a user closing an apparently-hung window needs no adversary at all.
|
|
test("closing the approval window mid-broadcast still reports the result", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
const id = pending.id();
|
|
|
|
const inFlight = deferred();
|
|
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
|
|
const first = bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(7),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
|
|
|
// The user closes the window while the broadcast is still open.
|
|
bg.closeWindow(1);
|
|
await settle();
|
|
expect(pending.result()).toBeNull();
|
|
|
|
inFlight.resolve({ hash: "0xfeed" });
|
|
await settle();
|
|
|
|
expect(pending.result()).toEqual({ result: "0xfeed" });
|
|
expect(first.sendResponse).toHaveBeenCalledWith({ txHash: "0xfeed" });
|
|
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("switching the active address mid-broadcast still reports the result", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
const id = pending.id();
|
|
|
|
const inFlight = deferred();
|
|
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
|
|
bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(7),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
|
|
|
// The user switches account in the toolbar popup, which rejects and
|
|
// force-closes every pending approval.
|
|
bg.send(
|
|
{ type: "AUTISTMASK_ACTIVE_CHANGED" },
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
expect(pending.result()).toBeNull();
|
|
// The window an in-flight attempt reports into is left standing too.
|
|
expect(bg.removed).toEqual([]);
|
|
|
|
inFlight.resolve({ hash: "0xfeed" });
|
|
await settle();
|
|
|
|
expect(pending.result()).toEqual({ result: "0xfeed" });
|
|
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("a reject arriving mid-broadcast is refused, not honoured", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
const id = pending.id();
|
|
|
|
const inFlight = deferred();
|
|
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
|
|
bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(7),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
|
|
const reject = bg.send(
|
|
{ type: "AUTISTMASK_TX_RESPONSE", id, approved: false },
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
expect(pending.result()).toBeNull();
|
|
expect(reject.sendResponse).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
retryable: false,
|
|
stage: "broadcast",
|
|
}),
|
|
);
|
|
|
|
inFlight.resolve({ hash: "0xfeed" });
|
|
await settle();
|
|
|
|
expect(pending.result()).toEqual({ result: "0xfeed" });
|
|
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
// The refusals above must not cost the rejection its ordinary meaning.
|
|
test("with no attempt running, closing the window still rejects", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
|
|
bg.closeWindow(1);
|
|
await settle();
|
|
|
|
expect(pending.result()).toEqual({
|
|
error: { code: 4001, message: "User rejected the request." },
|
|
});
|
|
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("with no attempt running, an active-address switch still rejects and closes", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
|
|
bg.send(
|
|
{ type: "AUTISTMASK_ACTIVE_CHANGED" },
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
|
|
expect(pending.result()).toEqual({
|
|
error: { code: 4001, message: "User rejected the request." },
|
|
});
|
|
expect(bg.removed).toEqual([1]);
|
|
});
|
|
|
|
// A sign approval held by a running verification is the same shape, and
|
|
// the refusal must not tell the user to start again from the site while
|
|
// the first attempt may still hand back a signature.
|
|
test("a reject during a sign attempt is refused with the in-flight stage", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
const id = pending.id();
|
|
|
|
const inFlight = deferred();
|
|
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
|
|
bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(7),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
|
|
const reject = bg.send(
|
|
{ type: "AUTISTMASK_SIGN_RESPONSE", id, approved: false },
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
expect(reject.sendResponse).toHaveBeenCalledWith(
|
|
expect.objectContaining({ retryable: false, stage: "inflight" }),
|
|
);
|
|
|
|
inFlight.resolve({ hash: "0xfeed" });
|
|
await settle();
|
|
expect(pending.result()).toEqual({ result: "0xfeed" });
|
|
});
|
|
});
|
|
|
|
// A handler that throws must still answer. `sendResponse` is the only thing
|
|
// that settles the page's window.ethereum.request() promise, so a throw that
|
|
// escapes a handler leaves that promise pending forever — no error, no
|
|
// timeout, indistinguishable from a slow wallet. Each case below drives a real
|
|
// throw out of a handler rather than asserting the catch block exists.
|
|
describe("a handler that throws still settles the page", () => {
|
|
const INTERNAL_ERROR = {
|
|
code: -32603,
|
|
message:
|
|
"AutistMask could not complete this request because of an internal error.",
|
|
};
|
|
|
|
let errorLog;
|
|
beforeEach(() => {
|
|
errorLog = jest.spyOn(console, "error").mockImplementation(() => {});
|
|
unhandledRejections.length = 0;
|
|
});
|
|
afterEach(() => {
|
|
errorLog.mockRestore();
|
|
});
|
|
|
|
// getState() awaits extension storage unguarded, and every read path in
|
|
// handleRpc goes through it. A storage read that rejects is the whole
|
|
// failure — no hook in the handler itself.
|
|
test("a rejected handleRpc rejects the page instead of hanging it", async () => {
|
|
const bg = loadBackground({
|
|
storageGet: async () => {
|
|
throw new Error("storage unavailable");
|
|
},
|
|
});
|
|
|
|
const answer = bg.send(
|
|
{ type: "AUTISTMASK_RPC", method: "eth_accounts", params: [] },
|
|
{ origin: ORIGIN },
|
|
);
|
|
await settleIncludingRejections();
|
|
|
|
// The channel is held open for the async answer, and the answer
|
|
// arrives.
|
|
expect(answer.kept).toBe(true);
|
|
expect(answer.sendResponse).toHaveBeenCalledWith({
|
|
error: INTERNAL_ERROR,
|
|
});
|
|
expect(unhandledRejections).toEqual([]);
|
|
// Not swallowed: the throw is on the background console, which is how
|
|
// this class gets caught in future.
|
|
expect(errorLog).toHaveBeenCalledWith(
|
|
"[AutistMask]",
|
|
"RPC request failed:",
|
|
"eth_accounts",
|
|
expect.objectContaining({ message: "storage unavailable" }),
|
|
);
|
|
});
|
|
|
|
// The transaction response handler wraps every statement in a try, so what
|
|
// escapes it is a throw from inside one of its catch blocks. Here the
|
|
// failure classifier itself throws while classifying a real verification
|
|
// failure — the approval is left claimed, so nothing else can settle it.
|
|
test("a throw while handling a failed transaction settles both the page and the popup", async () => {
|
|
const bg = loadBackground({
|
|
approvalVerify: {
|
|
describeTxFailure: () => {
|
|
throw new Error("classifier broke");
|
|
},
|
|
},
|
|
});
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
const id = pending.id();
|
|
|
|
// A real verification failure: the artifact is signed at a nonce the
|
|
// approval never displayed.
|
|
const answer = bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(NONCE + 1),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settleIncludingRejections();
|
|
|
|
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
|
|
expect(pending.result()).toEqual({ error: INTERNAL_ERROR });
|
|
expect(answer.sendResponse).toHaveBeenCalledWith({
|
|
error: INTERNAL_ERROR.message,
|
|
retryable: false,
|
|
// The handler cannot tell whether the transaction reached the
|
|
// network, so the popup must not say "start again from the site".
|
|
stage: "broadcast",
|
|
});
|
|
expect(unhandledRejections).toEqual([]);
|
|
expect(errorLog).toHaveBeenCalledWith(
|
|
"[AutistMask]",
|
|
"transaction approval response failed:",
|
|
expect.objectContaining({ message: "classifier broke" }),
|
|
);
|
|
});
|
|
|
|
test("a throw while handling a failed signature settles both the page and the popup", async () => {
|
|
const bg = loadBackground({
|
|
approvalVerify: {
|
|
failureIsRetryable: () => {
|
|
throw new Error("classifier broke");
|
|
},
|
|
},
|
|
});
|
|
const pending = bg.requestSign();
|
|
await settle();
|
|
|
|
// A real verification failure: the active address moved after the
|
|
// approval was raised.
|
|
bg.setActiveAddress(other.address);
|
|
const answer = bg.send(
|
|
{
|
|
type: "AUTISTMASK_SIGN_RESPONSE",
|
|
id: pending.id(),
|
|
approved: true,
|
|
signature: await signer.signMessage(
|
|
Buffer.from(MESSAGE.slice(2), "hex"),
|
|
),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settleIncludingRejections();
|
|
|
|
expect(pending.result()).toEqual({ error: INTERNAL_ERROR });
|
|
expect(answer.sendResponse).toHaveBeenCalledWith({
|
|
error: INTERNAL_ERROR.message,
|
|
retryable: false,
|
|
});
|
|
expect(unhandledRejections).toEqual([]);
|
|
expect(errorLog).toHaveBeenCalledWith(
|
|
"[AutistMask]",
|
|
"sign approval response failed:",
|
|
expect.objectContaining({ message: "classifier broke" }),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("popup-only messages", () => {
|
|
test("a page sender cannot answer an approval", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
const id = pending.id();
|
|
|
|
const spoof = bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(7),
|
|
},
|
|
{ url: ORIGIN + "/index.html" },
|
|
);
|
|
await settle();
|
|
|
|
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
|
|
expect(spoof.sendResponse).toHaveBeenCalledWith({
|
|
error: "Unauthorized sender",
|
|
});
|
|
});
|
|
});
|