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. Nothing is stripped or overwritten by this: every deliberately
coded rejection the wallet emits (4001, 4100, 4902) is a returned value, not a
throw, so it travels the resolved path and never reaches this catch. 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 tracks the phase
it is in and reports that: an escape from the verify catch runs before
broadcastTransaction() is ever called, so it says the request is gone rather
than that it may still have reached the network, and only an escape from the
broadcast catch keeps the warning about a second send. Every other message
handler on the path is synchronous and cannot leave a promise pending.
Each of the four tests 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 or
broadcast failure. All four were demonstrated failing against the unfixed code,
the RPC one with sendResponse at zero calls, which is precisely the page-side
hang. The two transaction cases also assert the sentence describeSigningFailure
builds for each stage, which is the copy the user reads.
1621 lines
57 KiB
JavaScript
1621 lines
57 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");
|
|
|
|
// The real formatter the approval screen renders failures through. Bound here,
|
|
// before any jest.doMock() of the module, so the copy assertions below check
|
|
// what the user is actually shown.
|
|
const { describeSigningFailure } = require("../src/shared/approvalVerify");
|
|
|
|
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";
|
|
// A page the wallet has never been connected to, whose requests are refused.
|
|
const UNCONNECTED_ORIGIN = "https://stranger.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.
|
|
// The two chains the tests switch between, as both forms the code uses: the
|
|
// hex chain id the wallet's network record carries, and the number the node
|
|
// and the signed artifact carry.
|
|
const MAINNET = { hex: "0x1", num: 1 };
|
|
const SEPOLIA = { hex: "0xaa36a7", num: 11155111 };
|
|
|
|
function populated(nonce, chainId) {
|
|
return {
|
|
type: 2,
|
|
chainId: chainId || MAINNET.num,
|
|
nonce,
|
|
gasLimit: 100000n,
|
|
maxFeePerGas: 2000000000n,
|
|
maxPriorityFeePerGas: 1000000000n,
|
|
to: TX_PARAMS.to,
|
|
value: BigInt(TX_PARAMS.value),
|
|
data: TX_PARAMS.data,
|
|
};
|
|
}
|
|
|
|
function signedAtNonce(nonce, withWallet, chainId) {
|
|
return (withWallet || signer).signTransaction(populated(nonce, chainId));
|
|
}
|
|
|
|
// 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, chainId) {
|
|
return {
|
|
broadcastTransaction,
|
|
getNetwork: async () => Network.from(chainId || MAINNET.num),
|
|
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 () => {}));
|
|
|
|
// The network the wallet is on, which the tests switch under a pending
|
|
// approval. The node the transaction is populated against is on the same
|
|
// one, as it would be: switching networks switches the RPC endpoint too.
|
|
let chain = MAINNET;
|
|
|
|
jest.doMock("../src/shared/state", () => ({
|
|
state: { rpcUrl: "https://rpc.invalid", wallets: [] },
|
|
loadState,
|
|
saveState: jest.fn(async () => {}),
|
|
currentNetwork: () => ({ chainId: chain.hex }),
|
|
}));
|
|
jest.doMock("../src/shared/balances", () => ({
|
|
getProvider: () =>
|
|
fakeProvider(broadcastTransaction, opts.provider, chain.num),
|
|
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);
|
|
// A browser that answers with no window at all. The approval
|
|
// then has no window it can ever be answered in.
|
|
cb(opts.noWindow ? undefined : { 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, origin) {
|
|
let rpcResult = null;
|
|
// The window this request opens, if it opens one. A request refused
|
|
// before an approval is raised opens none, and the window belonging to
|
|
// some other request must not be handed back as this one's.
|
|
const windowIndex = created.length;
|
|
const sendResponse = jest.fn((r) => {
|
|
rpcResult = r;
|
|
});
|
|
messageListener(
|
|
{
|
|
type: "AUTISTMASK_RPC",
|
|
method: "eth_sendTransaction",
|
|
params: [txParams || TX_PARAMS],
|
|
},
|
|
{ origin: origin || ORIGIN },
|
|
sendResponse,
|
|
);
|
|
return {
|
|
id: () =>
|
|
created.length > windowIndex
|
|
? new URL(created[windowIndex].url).searchParams.get(
|
|
"approval",
|
|
)
|
|
: null,
|
|
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;
|
|
},
|
|
// The user switching network in the toolbar popup.
|
|
setNetwork: (network) => {
|
|
chain = network;
|
|
},
|
|
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();
|
|
}
|
|
|
|
// settle() only drains microtasks. A handler whose last-resort .catch() has to
|
|
// run after a macrotask boundary needs those turns too, so the assertion that
|
|
// the page WAS answered is what reports a regression rather than a timeout.
|
|
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);
|
|
});
|
|
});
|
|
|
|
// Populating the transaction before the approval window opens is what makes
|
|
// the displayed object the verified object. It also fixes the nonce before the
|
|
// user has answered anything: two requests populated concurrently take the
|
|
// same nonce from a node that has seen neither of them broadcast, and the
|
|
// second can then never be sent, because the only way to give it a fresh nonce
|
|
// is to populate it again after the user has read the old one off the screen.
|
|
// So the second request is refused while the first is unanswered.
|
|
describe("one transaction approval at a time", () => {
|
|
test("a second eth_sendTransaction while one is pending is refused before it takes a nonce", async () => {
|
|
const getTransactionCount = jest.fn(async () => NONCE);
|
|
const bg = loadBackground({ provider: { getTransactionCount } });
|
|
|
|
const first = bg.requestTx();
|
|
await settle();
|
|
expect(first.id()).toBeTruthy();
|
|
expect(getTransactionCount).toHaveBeenCalledTimes(1);
|
|
|
|
const second = bg.requestTx();
|
|
await settle();
|
|
|
|
expect(second.result()).toEqual({
|
|
error: {
|
|
code: -32002,
|
|
message: expect.stringMatching(
|
|
/one transaction at a time.+already in progress/,
|
|
),
|
|
},
|
|
});
|
|
// Where the refusal happened matters as much as that it happened: no
|
|
// second window, and the node was never asked for a second nonce.
|
|
expect(bg.created).toHaveLength(1);
|
|
expect(getTransactionCount).toHaveBeenCalledTimes(1);
|
|
|
|
// The refusal leaves the pending approval untouched, and it still
|
|
// sends.
|
|
bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
|
|
bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id: first.id(),
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(NONCE),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
expect(first.result()).toEqual({ result: "0xfeed" });
|
|
});
|
|
|
|
// The slot is only defensible if the wallet was going to raise an approval
|
|
// anyway. Taken any earlier, a request the wallet refuses outright still
|
|
// holds it, and any page at all — connected or not — can deny the user's
|
|
// own transactions for as long as it keeps asking.
|
|
test("a request the wallet refuses does not take the slot from the connected site", async () => {
|
|
const bg = loadBackground();
|
|
|
|
// Both delivered before either reaches its first suspension point,
|
|
// which is the interleaving the slot exists for.
|
|
const stranger = bg.requestTx(TX_PARAMS, UNCONNECTED_ORIGIN);
|
|
const connected = bg.requestTx();
|
|
await settle();
|
|
|
|
expect(stranger.result()).toEqual({
|
|
error: { code: 4100, message: "Unauthorized" },
|
|
});
|
|
// The connected site's transaction was raised, not refused as one the
|
|
// user already has in progress.
|
|
expect(connected.result()).toBeNull();
|
|
expect(connected.id()).toBeTruthy();
|
|
expect(bg.created).toHaveLength(1);
|
|
});
|
|
|
|
// The user closes an approval window that looks hung while the attempt
|
|
// behind it is still running, and that attempt then fails in a way that
|
|
// would normally leave the approval standing for a retry. There is no
|
|
// window left to retry in, so leaving it standing answers the requesting
|
|
// page never — and holds the slot for the life of the worker with it.
|
|
test("an approval whose window closed under a failed attempt is answered, and frees the next request", async () => {
|
|
const stalled = deferred();
|
|
const bg = loadBackground({
|
|
loadState: async () => {
|
|
await stalled.promise;
|
|
throw new Error("The wallet data could not be read.");
|
|
},
|
|
});
|
|
|
|
const first = bg.requestTx();
|
|
await settle();
|
|
bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id: first.id(),
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(NONCE),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
|
|
// The attempt owns the approval, so closing the window does not settle
|
|
// it: the attempt may yet broadcast, and it is the one that reports.
|
|
bg.closeWindow(1);
|
|
await settle();
|
|
expect(first.result()).toBeNull();
|
|
|
|
stalled.resolve();
|
|
await settle();
|
|
expect(first.result()).toEqual({
|
|
error: { code: 4001, message: "User rejected the request." },
|
|
});
|
|
|
|
const second = bg.requestTx();
|
|
await settle();
|
|
expect(second.result()).toBeNull();
|
|
expect(second.id()).toBeTruthy();
|
|
expect(bg.created).toHaveLength(2);
|
|
});
|
|
|
|
// An approval with no window is one nothing can ever answer.
|
|
test("a request whose approval window cannot be opened is answered rather than left waiting", async () => {
|
|
const bg = loadBackground({ noWindow: true });
|
|
|
|
const first = bg.requestTx();
|
|
await settle();
|
|
expect(first.result()).toEqual({
|
|
error: {
|
|
code: -32603,
|
|
message: expect.stringMatching(
|
|
/could not open its approval window/,
|
|
),
|
|
},
|
|
});
|
|
|
|
// And it did not take the slot with it.
|
|
const second = bg.requestTx();
|
|
await settle();
|
|
expect(second.result()).toEqual({
|
|
error: {
|
|
code: -32603,
|
|
message: expect.stringMatching(
|
|
/could not open its approval window/,
|
|
),
|
|
},
|
|
});
|
|
});
|
|
|
|
test("an answered approval frees the next request", async () => {
|
|
const bg = loadBackground();
|
|
const first = bg.requestTx();
|
|
await settle();
|
|
|
|
// The user closes the approval window, which rejects it.
|
|
bg.closeWindow(1);
|
|
await settle();
|
|
expect(first.result()).toEqual({
|
|
error: { code: 4001, message: "User rejected the request." },
|
|
});
|
|
|
|
const second = bg.requestTx();
|
|
await settle();
|
|
expect(second.id()).toBeTruthy();
|
|
expect(bg.created).toHaveLength(2);
|
|
});
|
|
|
|
test("a signature request is not held up by a pending transaction", async () => {
|
|
const bg = loadBackground();
|
|
bg.requestTx();
|
|
await settle();
|
|
|
|
// A signature consumes no nonce, so it has nothing to collide with.
|
|
const signing = bg.requestSign();
|
|
await settle();
|
|
expect(signing.id()).toBeTruthy();
|
|
expect(signing.result()).toBeNull();
|
|
expect(bg.created).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
// A nonce collision found before the transaction reaches the network is the
|
|
// one send failure the wallet can speak about with certainty. The user is told
|
|
// it did not go out and to send it again, rather than being warned it might
|
|
// already be on the chain — which would send them looking for a transaction
|
|
// that does not exist, and stop them retrying the one that never went.
|
|
describe("a nonce collision is reported as a transaction that did not go out", () => {
|
|
test("a broadcast the node refused for the nonce is not reported as possibly sent", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
|
|
bg.broadcastTransaction.mockRejectedValue(
|
|
Object.assign(new Error("nonce too low"), {
|
|
code: "NONCE_EXPIRED",
|
|
}),
|
|
);
|
|
const answer = bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id: pending.id(),
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(NONCE),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
|
|
expect(answer.sendResponse).toHaveBeenCalledWith({
|
|
error: expect.stringMatching(/nonce had already been used/),
|
|
retryable: false,
|
|
stage: "nonce",
|
|
});
|
|
expect(pending.result()).toEqual({
|
|
error: {
|
|
message: expect.stringMatching(
|
|
/transaction was not sent, because its nonce/,
|
|
),
|
|
},
|
|
});
|
|
});
|
|
|
|
test("a nonce this wallet already broadcast is refused without asking the node again", async () => {
|
|
const bg = loadBackground();
|
|
const first = bg.requestTx();
|
|
await settle();
|
|
|
|
bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
|
|
bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id: first.id(),
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(NONCE),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
expect(first.result()).toEqual({ result: "0xfeed" });
|
|
|
|
// The stubbed node still reports NONCE as the next nonce — a pending
|
|
// count that lags a broadcast the node has already taken — so this
|
|
// second approval is populated at a nonce this worker has spent.
|
|
const second = bg.requestTx();
|
|
await settle();
|
|
const answer = bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id: second.id(),
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(NONCE),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
|
|
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
|
expect(answer.sendResponse).toHaveBeenCalledWith({
|
|
error: expect.stringMatching(/nonce had already been used/),
|
|
retryable: false,
|
|
stage: "nonce",
|
|
});
|
|
expect(second.result()).toEqual({
|
|
error: {
|
|
message: expect.stringMatching(/nonce had already been used/),
|
|
},
|
|
});
|
|
});
|
|
|
|
// Nonce spaces are per chain, and the wallet switches networks. A nonce
|
|
// this wallet spent on one chain says nothing about the same nonce on
|
|
// another — and low nonces overlap across chains as a matter of course, so
|
|
// a record that ignored the chain would refuse ordinary transactions,
|
|
// permanently and with a message that is not true of them.
|
|
test("a nonce spent on one chain is not refused on another", async () => {
|
|
const bg = loadBackground();
|
|
|
|
const first = bg.requestTx();
|
|
await settle();
|
|
bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
|
|
bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id: first.id(),
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(NONCE),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
expect(first.result()).toEqual({ result: "0xfeed" });
|
|
|
|
// The user switches network. On this chain the address has sent
|
|
// nothing, so the node populates the next transaction at the same
|
|
// nonce — correctly.
|
|
bg.setNetwork(SEPOLIA);
|
|
const second = bg.requestTx();
|
|
await settle();
|
|
bg.broadcastTransaction.mockResolvedValue({ hash: "0xbeef" });
|
|
bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id: second.id(),
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(NONCE, undefined, SEPOLIA.num),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
|
|
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(2);
|
|
expect(second.result()).toEqual({ result: "0xbeef" });
|
|
});
|
|
});
|
|
|
|
// 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(() => {});
|
|
});
|
|
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,
|
|
});
|
|
// 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.
|
|
// The escape happens before broadcastTransaction() is reached, so the
|
|
// reported stage must be the one that says the transaction is gone.
|
|
test("a throw while verifying a 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,
|
|
// Nothing was broadcast, so the popup must say the request is gone
|
|
// rather than that it may still have reached the network.
|
|
stage: "verify",
|
|
});
|
|
expect(errorLog).toHaveBeenCalledWith(
|
|
"[AutistMask]",
|
|
"transaction approval response failed:",
|
|
expect.objectContaining({ message: "classifier broke" }),
|
|
);
|
|
// The copy the user actually reads, from the popup's own formatter.
|
|
expect(
|
|
describeSigningFailure(answer.sendResponse.mock.calls[0][0], "")
|
|
.message,
|
|
).toBe(
|
|
INTERNAL_ERROR.message +
|
|
" This request can no longer be signed." +
|
|
" Please start it again from the site.",
|
|
);
|
|
});
|
|
|
|
// The other side of the same local: once broadcastTransaction() has been
|
|
// entered the wallet genuinely cannot tell whether the node took the
|
|
// transaction, and the copy that warns about a second send is correct.
|
|
test("a throw while handling a failed broadcast reports the broadcast stage", async () => {
|
|
const bg = loadBackground({
|
|
approvalVerify: {
|
|
describeTxFailure: () => {
|
|
throw new Error("classifier broke");
|
|
},
|
|
},
|
|
});
|
|
bg.broadcastTransaction.mockRejectedValue(new Error("node refused"));
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
const id = pending.id();
|
|
|
|
// The approved artifact, so verification passes and the failure
|
|
// happens at the broadcast.
|
|
const answer = bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(NONCE),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settleIncludingRejections();
|
|
|
|
expect(bg.broadcastTransaction).toHaveBeenCalled();
|
|
expect(pending.result()).toEqual({ error: INTERNAL_ERROR });
|
|
expect(answer.sendResponse).toHaveBeenCalledWith({
|
|
error: INTERNAL_ERROR.message,
|
|
retryable: false,
|
|
stage: "broadcast",
|
|
});
|
|
expect(
|
|
describeSigningFailure(answer.sendResponse.mock.calls[0][0], "")
|
|
.message,
|
|
).toBe(
|
|
INTERNAL_ERROR.message +
|
|
" The transaction may still have reached the network." +
|
|
" Check the account before sending it again.",
|
|
);
|
|
});
|
|
|
|
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(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",
|
|
});
|
|
});
|
|
});
|