Files
AutistMask/tests/backgroundApproval.test.js
clawbot 993afbc5da
All checks were successful
check / check (push) Successful in 32s
harden: verify the signed transaction against what the popup displayed (closes #216)
The signed artifact was compared with the dApp's request object, so every
field the dApp left out — normally the nonce, the gas limit and every fee
field, because populateTransaction() filled them in the popup — was checked
by nothing but the absolute ceilings. A bare transfer at the fee ceiling
hands the validator 2.1 ETH. The ceilings were never the defect: the thing
being verified was not the thing the user approved.

The transaction is now populated in the background, before the approval
window opens, and that populated object is what is displayed, what the popup
signs, and what the artifact is verified against. Every consequential field
is compared exactly.

- src/shared/approvalTx.js populates the request through a VoidSigner over
  the configured RPC and serializes the result to the fields its type
  serializes, as hex quantities that survive the JSON messaging boundary.
  Fields the wallet does not act on are dropped before ethers sees the
  page's object.
- Population failure raises no approval and opens no window: the error goes
  back to the requesting page, bounded by a 20-second timeout. A
  half-initialised approval record would be exactly the state the settle
  interlock exists to keep out of that record, and the same estimate
  previously failed after the user had typed their password.
- verifySignedTx compares the artifact field by field over
  SERIALIZED_FIELDS[type], plus the type itself. A quantity the approval
  does not fix is a refusal rather than a skipped comparison. The ceilings
  stay as a documented backstop and now also apply at population, where they
  bound what an RPC node can talk the wallet into displaying.
- The approval pins the address it was raised for. Verification uses that
  address, not getActiveAddress(), and an address switch between approval and
  signing refuses rather than signing from an account the screen never named
  — including a switch during population, and on the message-signing path. A
  request naming an address that is not the active one is refused outright.
- The approval screen shows the network, gas limit, fee per gas, maximum fee
  and nonce it now vouches for, and the popup signs the object it was given
  with no provider and no population of its own.

The settle chokepoint is untouched: one delete of pendingApprovals and one
approval.resolve(), both inside settleApproval(), the claim taken
synchronously before the first await, and a refused settle still leaving the
approval window standing.
2026-08-12 10:09:11 +00:00

1061 lines
36 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(),
}));
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(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();
}
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" });
});
});
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",
});
});
});