// 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", }); }); });