All checks were successful
check / check (push) Successful in 27s
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.
1280 lines
44 KiB
JavaScript
1280 lines
44 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";
|
|
const EXT_URL = "chrome-extension://autistmask/";
|
|
|
|
// What the dApp asks for: no nonce, no gas, no fees. This is the shape that
|
|
// makes a duplicate broadcast possible at all.
|
|
const TX_PARAMS = {
|
|
from: signer.address,
|
|
to: RECIPIENT,
|
|
value: "0x2386f26fc10000",
|
|
data: "0x",
|
|
};
|
|
|
|
// The nonce the stubbed node reports, and so the nonce the background
|
|
// populates the approval with.
|
|
const NONCE = 7;
|
|
|
|
// "Hello AutistMask" as the hex string a dApp passes to personal_sign.
|
|
const MESSAGE = "0x48656c6c6f204175746973744d61736b";
|
|
|
|
// The transaction the background populates and the approval screen displays.
|
|
// The nonce is a parameter because the duplicate case turns on two artifacts
|
|
// differing in a field the dApp fixed nothing for.
|
|
function populated(nonce) {
|
|
return {
|
|
type: 2,
|
|
chainId: 1,
|
|
nonce,
|
|
gasLimit: 100000n,
|
|
maxFeePerGas: 2000000000n,
|
|
maxPriorityFeePerGas: 1000000000n,
|
|
to: TX_PARAMS.to,
|
|
value: BigInt(TX_PARAMS.value),
|
|
data: TX_PARAMS.data,
|
|
};
|
|
}
|
|
|
|
function signedAtNonce(nonce, withWallet) {
|
|
return (withWallet || signer).signTransaction(populated(nonce));
|
|
}
|
|
|
|
// The node the background populates against. Its answers are the numbers the
|
|
// approval screen shows, so they are also the numbers every artifact below is
|
|
// signed at.
|
|
function fakeProvider(broadcastTransaction, overrides) {
|
|
return {
|
|
broadcastTransaction,
|
|
getNetwork: async () => Network.from(1),
|
|
getTransactionCount: async () => NONCE,
|
|
estimateGas: async () => 100000n,
|
|
getFeeData: async () => ({
|
|
gasPrice: 2000000000n,
|
|
maxFeePerGas: 2000000000n,
|
|
maxPriorityFeePerGas: 1000000000n,
|
|
}),
|
|
...(overrides || {}),
|
|
};
|
|
}
|
|
|
|
// A promise whose settlement the test controls, so a broadcast can be held in
|
|
// flight while the second response arrives.
|
|
function deferred() {
|
|
let resolve;
|
|
let reject;
|
|
const promise = new Promise((res, rej) => {
|
|
resolve = res;
|
|
reject = rej;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
// Load the background worker against stubbed browser and network APIs and
|
|
// return the handles the tests drive it through. Everything that would touch
|
|
// the network or the browser's own schedulers is mocked; the approval
|
|
// verification is the real module, because that is what the handler under
|
|
// test is wired to.
|
|
function loadBackground(options) {
|
|
const opts = options || {};
|
|
jest.resetModules();
|
|
|
|
const broadcastTransaction = jest.fn();
|
|
const loadState = jest.fn(opts.loadState || (async () => {}));
|
|
|
|
jest.doMock("../src/shared/state", () => ({
|
|
state: { rpcUrl: "https://rpc.invalid", wallets: [] },
|
|
loadState,
|
|
saveState: jest.fn(async () => {}),
|
|
currentNetwork: () => ({ chainId: "0x1" }),
|
|
}));
|
|
jest.doMock("../src/shared/balances", () => ({
|
|
getProvider: () => fakeProvider(broadcastTransaction, opts.provider),
|
|
refreshBalances: jest.fn(async () => {}),
|
|
}));
|
|
jest.doMock("../src/shared/phishingDomains", () => ({
|
|
isPhishingDomain: () => false,
|
|
refreshPhishingListOnSchedule: jest.fn(async () => {}),
|
|
initPhishingList: jest.fn(async () => {}),
|
|
}));
|
|
jest.doMock("../src/shared/alarms", () => ({
|
|
BALANCE_REFRESH_ALARM: "balance",
|
|
PHISHING_REFRESH_ALARM: "phishing",
|
|
BALANCE_REFRESH_PERIOD_MINUTES: 1,
|
|
ensureRecurringAlarms: jest.fn(async () => {}),
|
|
registerAlarmHandlers: jest.fn(),
|
|
}));
|
|
// The real verification module, except where a test replaces one export
|
|
// with a throw to drive the handler's own error handling into failing.
|
|
if (opts.approvalVerify) {
|
|
jest.doMock("../src/shared/approvalVerify", () => ({
|
|
...jest.requireActual("../src/shared/approvalVerify"),
|
|
...opts.approvalVerify,
|
|
}));
|
|
}
|
|
|
|
const persisted = {
|
|
wallets: [
|
|
{ name: "Wallet 1", type: "hd", addresses: [signer.address] },
|
|
],
|
|
rpcUrl: "https://rpc.invalid",
|
|
activeAddress: signer.address,
|
|
allowedSites: { [signer.address]: [HOSTNAME] },
|
|
deniedSites: {},
|
|
};
|
|
|
|
let messageListener = null;
|
|
let windowRemovedListener = null;
|
|
const created = [];
|
|
const removed = [];
|
|
|
|
global.chrome = {
|
|
storage: {
|
|
local: {
|
|
get: jest.fn(
|
|
opts.storageGet ||
|
|
(async () => ({ autistmask: persisted })),
|
|
),
|
|
set: jest.fn(async () => {}),
|
|
},
|
|
},
|
|
runtime: {
|
|
getURL: (path) => EXT_URL + path,
|
|
onMessage: {
|
|
addListener: (fn) => {
|
|
messageListener = fn;
|
|
},
|
|
},
|
|
onConnect: { addListener: () => {} },
|
|
lastError: null,
|
|
},
|
|
windows: {
|
|
getLastFocused: (cb) => cb(null),
|
|
create: (options2, cb) => {
|
|
created.push(options2);
|
|
cb({ id: created.length });
|
|
},
|
|
remove: (id, cb) => {
|
|
removed.push(id);
|
|
if (cb) cb();
|
|
},
|
|
// Captured, not swallowed: closing the approval window is the
|
|
// event that used to retire an approval out from under a live
|
|
// broadcast, and a no-op stub here hides exactly that.
|
|
onRemoved: {
|
|
addListener: (fn) => {
|
|
windowRemovedListener = fn;
|
|
},
|
|
},
|
|
},
|
|
tabs: {
|
|
query: (q, cb) => cb([]),
|
|
sendMessage: () => {},
|
|
},
|
|
action: { setPopup: () => {} },
|
|
};
|
|
|
|
require("../src/background/index");
|
|
|
|
// Send a message the way the browser would, and hand back whatever the
|
|
// handler passed to sendResponse.
|
|
function send(msg, sender) {
|
|
const sendResponse = jest.fn();
|
|
const kept = messageListener(msg, sender || {}, sendResponse);
|
|
return { sendResponse, kept };
|
|
}
|
|
|
|
// Raise a pending transaction approval the way a dApp does, and dig the
|
|
// approval id back out of the popup URL the background opened.
|
|
function requestTx(txParams) {
|
|
let rpcResult = null;
|
|
const sendResponse = jest.fn((r) => {
|
|
rpcResult = r;
|
|
});
|
|
messageListener(
|
|
{
|
|
type: "AUTISTMASK_RPC",
|
|
method: "eth_sendTransaction",
|
|
params: [txParams || TX_PARAMS],
|
|
},
|
|
{ origin: ORIGIN },
|
|
sendResponse,
|
|
);
|
|
return {
|
|
id: () => new URL(created[0].url).searchParams.get("approval"),
|
|
result: () => rpcResult,
|
|
};
|
|
}
|
|
|
|
// The same for a message-signing approval, which pins the signing address
|
|
// at approval time in exactly the same way.
|
|
function requestSign(from) {
|
|
let rpcResult = null;
|
|
messageListener(
|
|
{
|
|
type: "AUTISTMASK_RPC",
|
|
method: "personal_sign",
|
|
params: [MESSAGE, from || signer.address],
|
|
},
|
|
{ origin: ORIGIN },
|
|
(r) => {
|
|
rpcResult = r;
|
|
},
|
|
);
|
|
return {
|
|
id: () =>
|
|
new URL(created[created.length - 1].url).searchParams.get(
|
|
"approval",
|
|
),
|
|
result: () => rpcResult,
|
|
};
|
|
}
|
|
|
|
// The user closes the approval popup. `created` is index-aligned with the
|
|
// ids the window stub hands back, so window 1 is the first popup opened.
|
|
function closeWindow(windowId) {
|
|
windowRemovedListener(windowId);
|
|
}
|
|
|
|
return {
|
|
send,
|
|
requestTx,
|
|
requestSign,
|
|
closeWindow,
|
|
broadcastTransaction,
|
|
loadState,
|
|
created,
|
|
removed,
|
|
// The user switching account in the toolbar popup, as the background
|
|
// sees it: the persisted active address changes underneath a pending
|
|
// approval.
|
|
setActiveAddress: (address) => {
|
|
persisted.activeAddress = address;
|
|
},
|
|
fromPopup: { url: EXT_URL + "src/popup/index.html" },
|
|
};
|
|
}
|
|
|
|
// Let the handler's promise chain run to the next suspension point. Raising a
|
|
// transaction approval now populates it against the node first, which is
|
|
// several awaits deep before the window is opened.
|
|
async function settle() {
|
|
for (let i = 0; i < 50; i++) await Promise.resolve();
|
|
}
|
|
|
|
// 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);
|
|
});
|
|
});
|
|
|
|
// 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",
|
|
});
|
|
});
|
|
});
|