The blocklist URL in shipped code named a competitor and pointed at a moving ref, and the extension re-fetched from it every 24 hours, which also meant a third party decided what this wallet warns about. All of that is gone. script/vendor-blocklist fetches upstream at a pinned commit, verifies the sha256 of the bytes that commit serves, and writes src/shared/phishingBlocklist.json. It is build-time tooling, never shipped, and the one place in the repo that names the upstream project; a source reference nobody can verify is not a source reference. The artifact stores truncated sha256 digests rather than domain names. That is what censors it: the previous file contained the competitor's name 6,475 times, as phishing domains impersonating them, and not one of those domains is dropped. It also makes lookups a binary search over a fixed-width string, so nothing is built at module load — which matters on MV3, where the worker re-evaluates the module on every wake — and takes the file from 8.7 MB to 1.7 MB. script/check-censored enforces the rest: it reads the name out of the vendoring script rather than repeating it, and fails on any occurrence in the working tree or under dist/ that is not one of the three literals shipped code cannot avoid — two provider-shim identifiers in src/content/inpage.js and one ERC-20's on-chain name in src/shared/tokenList.js. Each is permitted only at the path that carries it, and at the emitted paths that path is bundled into, so a literal appearing anywhere else fails like any other occurrence. It runs in make check, which inspects dist/ when there is one and says loudly when there is not, and again with --require-dist at the end of every make build. Removing the runtime fetch retires the delta, the extension-storage persistence and the 24-hour alarm from #158. A retired alarm is now cleared rather than left waking the worker forever on installs that already have it. The e2e suite drives the warning end to end from a real blocklisted origin served as a real http(s) site, with a control asserting the banner stays hidden for one that is not listed. Its service-worker interception canary needed a new anchor, since the startup fetch it used to watch for no longer happens: it now wakes the worker with a message and asks it for one throwaway fetch. LICENSE no longer cites a repository that returns 404. eslint.config.js gains one block: script/lib/ holds node programs the shell entrypoints call, and without it they lint with no globals at all.
1915 lines
69 KiB
JavaScript
1915 lines
69 KiB
JavaScript
// The background's approval message wiring, driven end to end: a dApp
|
|
// eth_sendTransaction raises a pending approval, and the popup answers it with
|
|
// AUTISTMASK_TX_RESPONSE / AUTISTMASK_SIGN_RESPONSE.
|
|
//
|
|
// What this exists for is the duplicate response. The handler verifies and
|
|
// broadcasts asynchronously, and the approval deliberately survives a
|
|
// retryable failure so the user can try again with the transaction they
|
|
// already saw — which means the entry being present is not by itself proof
|
|
// that no attempt is running. A second response carrying the same id (a
|
|
// reloaded approval window re-rendering a live Approve button, a popup that
|
|
// emits the message twice) must not start a second verify and broadcast: the
|
|
// same approved transaction signed twice verifies twice, and the transfer
|
|
// would go out twice.
|
|
//
|
|
// It also covers what the approval is verified against. The approval now
|
|
// carries the transaction the background populated and the screen displayed,
|
|
// and the address that was active when it was raised — so a fee, a nonce or an
|
|
// address that moved between approval and signing is refused rather than
|
|
// signed.
|
|
|
|
const { Network, Wallet } = require("ethers");
|
|
|
|
// The real formatter the approval screen renders failures through. Bound here,
|
|
// before any jest.doMock() of the module, so the copy assertions below check
|
|
// what the user is actually shown.
|
|
const { describeSigningFailure } = require("../src/shared/approvalVerify");
|
|
|
|
const SIGNER_KEY =
|
|
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
|
|
const OTHER_KEY =
|
|
"0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a";
|
|
const signer = new Wallet(SIGNER_KEY);
|
|
const other = new Wallet(OTHER_KEY);
|
|
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
|
|
|
const ORIGIN = "https://dapp.example";
|
|
const HOSTNAME = "dapp.example";
|
|
// A page the wallet has never been connected to, whose requests are refused.
|
|
const UNCONNECTED_ORIGIN = "https://stranger.example";
|
|
const EXT_URL = "chrome-extension://autistmask/";
|
|
|
|
// An origin the persisted state has never allowed, so asking to connect from
|
|
// it raises a prompt rather than being answered from allowedSites.
|
|
const FRESH_ORIGIN = "https://fresh.example";
|
|
|
|
// The approval id in the most recent popup URL of a list, or null when none
|
|
// of them carries one. Takes both shapes: the absolute URL windows.create()
|
|
// is given and the extension-relative one action.setPopup() is given.
|
|
function approvalIdIn(urls) {
|
|
for (let i = urls.length - 1; i >= 0; i--) {
|
|
if (!urls[i] || !urls[i].includes("?approval=")) continue;
|
|
return new URL(urls[i], EXT_URL).searchParams.get("approval");
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// What the dApp asks for: no nonce, no gas, no fees. This is the shape that
|
|
// makes a duplicate broadcast possible at all.
|
|
const TX_PARAMS = {
|
|
from: signer.address,
|
|
to: RECIPIENT,
|
|
value: "0x2386f26fc10000",
|
|
data: "0x",
|
|
};
|
|
|
|
// The nonce the stubbed node reports, and so the nonce the background
|
|
// populates the approval with.
|
|
const NONCE = 7;
|
|
|
|
// "Hello AutistMask" as the hex string a dApp passes to personal_sign.
|
|
const MESSAGE = "0x48656c6c6f204175746973744d61736b";
|
|
|
|
// The transaction the background populates and the approval screen displays.
|
|
// The nonce is a parameter because the duplicate case turns on two artifacts
|
|
// differing in a field the dApp fixed nothing for.
|
|
// The two chains the tests switch between, as both forms the code uses: the
|
|
// hex chain id the wallet's network record carries, and the number the node
|
|
// and the signed artifact carry.
|
|
const MAINNET = { hex: "0x1", num: 1 };
|
|
const SEPOLIA = { hex: "0xaa36a7", num: 11155111 };
|
|
|
|
function populated(nonce, chainId) {
|
|
return {
|
|
type: 2,
|
|
chainId: chainId || MAINNET.num,
|
|
nonce,
|
|
gasLimit: 100000n,
|
|
maxFeePerGas: 2000000000n,
|
|
maxPriorityFeePerGas: 1000000000n,
|
|
to: TX_PARAMS.to,
|
|
value: BigInt(TX_PARAMS.value),
|
|
data: TX_PARAMS.data,
|
|
};
|
|
}
|
|
|
|
function signedAtNonce(nonce, withWallet, chainId) {
|
|
return (withWallet || signer).signTransaction(populated(nonce, chainId));
|
|
}
|
|
|
|
// The node the background populates against. Its answers are the numbers the
|
|
// approval screen shows, so they are also the numbers every artifact below is
|
|
// signed at.
|
|
function fakeProvider(broadcastTransaction, overrides, chainId) {
|
|
return {
|
|
broadcastTransaction,
|
|
getNetwork: async () => Network.from(chainId || MAINNET.num),
|
|
getTransactionCount: async () => NONCE,
|
|
estimateGas: async () => 100000n,
|
|
getFeeData: async () => ({
|
|
gasPrice: 2000000000n,
|
|
maxFeePerGas: 2000000000n,
|
|
maxPriorityFeePerGas: 1000000000n,
|
|
}),
|
|
...(overrides || {}),
|
|
};
|
|
}
|
|
|
|
// A promise whose settlement the test controls, so a broadcast can be held in
|
|
// flight while the second response arrives.
|
|
function deferred() {
|
|
let resolve;
|
|
let reject;
|
|
const promise = new Promise((res, rej) => {
|
|
resolve = res;
|
|
reject = rej;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
// Load the background worker against stubbed browser and network APIs and
|
|
// return the handles the tests drive it through. Everything that would touch
|
|
// the network or the browser's own schedulers is mocked; the approval
|
|
// verification is the real module, because that is what the handler under
|
|
// test is wired to.
|
|
function loadBackground(options) {
|
|
const opts = options || {};
|
|
jest.resetModules();
|
|
|
|
const broadcastTransaction = jest.fn();
|
|
const loadState = jest.fn(opts.loadState || (async () => {}));
|
|
|
|
// The network the wallet is on, which the tests switch under a pending
|
|
// approval. The node the transaction is populated against is on the same
|
|
// one, as it would be: switching networks switches the RPC endpoint too.
|
|
let chain = MAINNET;
|
|
|
|
jest.doMock("../src/shared/state", () => ({
|
|
state: { rpcUrl: "https://rpc.invalid", wallets: [] },
|
|
loadState,
|
|
saveState: jest.fn(async () => {}),
|
|
currentNetwork: () => ({ chainId: chain.hex }),
|
|
}));
|
|
jest.doMock("../src/shared/balances", () => ({
|
|
getProvider: () =>
|
|
fakeProvider(broadcastTransaction, opts.provider, chain.num),
|
|
refreshBalances: jest.fn(async () => {}),
|
|
}));
|
|
jest.doMock("../src/shared/phishingDomains", () => ({
|
|
isPhishingDomain: () => false,
|
|
}));
|
|
jest.doMock("../src/shared/alarms", () => ({
|
|
BALANCE_REFRESH_ALARM: "balance",
|
|
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;
|
|
let connectListener = null;
|
|
const created = [];
|
|
const removed = [];
|
|
// Every URL the background put on the browser action. A site approval
|
|
// raised through action.openPopup() opens no window at all, so this is
|
|
// the only place its id appears.
|
|
const actionPopups = [];
|
|
|
|
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;
|
|
},
|
|
},
|
|
// Captured, not swallowed: the approval port is what carries a
|
|
// site connection's decision and the popup teardown that races
|
|
// it, so a no-op stub here hides the whole subject of #275.
|
|
onConnect: {
|
|
addListener: (fn) => {
|
|
connectListener = fn;
|
|
},
|
|
},
|
|
lastError: null,
|
|
},
|
|
windows: {
|
|
getLastFocused: (cb) => cb(null),
|
|
create: (options2, cb) => {
|
|
created.push(options2);
|
|
// A browser that answers with no window at all. The approval
|
|
// then has no window it can ever be answered in.
|
|
cb(opts.noWindow ? undefined : { id: created.length });
|
|
},
|
|
remove: (id, cb) => {
|
|
removed.push(id);
|
|
if (cb) cb();
|
|
},
|
|
// Captured, not swallowed: closing the approval window is the
|
|
// event that used to retire an approval out from under a live
|
|
// broadcast, and a no-op stub here hides exactly that.
|
|
onRemoved: {
|
|
addListener: (fn) => {
|
|
windowRemovedListener = fn;
|
|
},
|
|
},
|
|
},
|
|
tabs: {
|
|
query: (q, cb) => cb([]),
|
|
sendMessage: () => {},
|
|
},
|
|
action: {
|
|
setPopup: (o) => {
|
|
actionPopups.push(o.popup);
|
|
},
|
|
// The production route for a site connection. Present only when
|
|
// a test asks for it, because with it the prompt is the toolbar
|
|
// popup: no window is created, so windows.onRemoved can never
|
|
// fire for it and the port disconnect is the only close signal
|
|
// that exists.
|
|
...(opts.actionPopup ? { openPopup: () => Promise.resolve() } : {}),
|
|
},
|
|
};
|
|
|
|
require("../src/background/index");
|
|
|
|
// Send a message the way the browser would, and hand back whatever the
|
|
// handler passed to sendResponse.
|
|
function send(msg, sender) {
|
|
const sendResponse = jest.fn();
|
|
const kept = messageListener(msg, sender || {}, sendResponse);
|
|
return { sendResponse, kept };
|
|
}
|
|
|
|
// Raise a pending transaction approval the way a dApp does, and dig the
|
|
// approval id back out of the popup URL the background opened.
|
|
function requestTx(txParams, origin) {
|
|
let rpcResult = null;
|
|
// The window this request opens, if it opens one. A request refused
|
|
// before an approval is raised opens none, and the window belonging to
|
|
// some other request must not be handed back as this one's.
|
|
const windowIndex = created.length;
|
|
const sendResponse = jest.fn((r) => {
|
|
rpcResult = r;
|
|
});
|
|
messageListener(
|
|
{
|
|
type: "AUTISTMASK_RPC",
|
|
method: "eth_sendTransaction",
|
|
params: [txParams || TX_PARAMS],
|
|
},
|
|
{ origin: origin || ORIGIN },
|
|
sendResponse,
|
|
);
|
|
return {
|
|
id: () =>
|
|
created.length > windowIndex
|
|
? new URL(created[windowIndex].url).searchParams.get(
|
|
"approval",
|
|
)
|
|
: null,
|
|
result: () => rpcResult,
|
|
};
|
|
}
|
|
|
|
// The same for a message-signing approval, which pins the signing address
|
|
// at approval time in exactly the same way.
|
|
function requestSign(from) {
|
|
let rpcResult = null;
|
|
messageListener(
|
|
{
|
|
type: "AUTISTMASK_RPC",
|
|
method: "personal_sign",
|
|
params: [MESSAGE, from || signer.address],
|
|
},
|
|
{ origin: ORIGIN },
|
|
(r) => {
|
|
rpcResult = r;
|
|
},
|
|
);
|
|
return {
|
|
id: () =>
|
|
new URL(created[created.length - 1].url).searchParams.get(
|
|
"approval",
|
|
),
|
|
result: () => rpcResult,
|
|
};
|
|
}
|
|
|
|
// A dApp asking to connect. The origin defaults to one the persisted
|
|
// state has never allowed, so the request really does raise a prompt
|
|
// instead of being answered from allowedSites.
|
|
function requestSite(origin) {
|
|
let rpcResult = null;
|
|
messageListener(
|
|
{
|
|
type: "AUTISTMASK_RPC",
|
|
method: "eth_requestAccounts",
|
|
params: [],
|
|
},
|
|
{ origin: origin || FRESH_ORIGIN },
|
|
(r) => {
|
|
rpcResult = r;
|
|
},
|
|
);
|
|
return {
|
|
// Wherever the prompt went: the toolbar popup URL when
|
|
// action.openPopup() carried it, the created window otherwise.
|
|
id: () =>
|
|
approvalIdIn(actionPopups) ||
|
|
approvalIdIn(created.map((c) => c.url)),
|
|
result: () => rpcResult,
|
|
};
|
|
}
|
|
|
|
// The popup's approval port, as the browser delivers it. Messages posted
|
|
// on a port and that port's disconnect travel one channel in FIFO order,
|
|
// which is exactly the property the fix rests on, so this stub delivers
|
|
// them in the order the caller emits them and never reorders them.
|
|
function connectApproval(id, senderUrl) {
|
|
const onMessage = [];
|
|
const onDisconnect = [];
|
|
const port = {
|
|
name: "approval:" + id,
|
|
sender: {
|
|
url:
|
|
senderUrl === undefined
|
|
? EXT_URL + "src/popup/index.html?approval=" + id
|
|
: senderUrl,
|
|
},
|
|
onMessage: { addListener: (fn) => onMessage.push(fn) },
|
|
onDisconnect: { addListener: (fn) => onDisconnect.push(fn) },
|
|
};
|
|
connectListener(port);
|
|
return {
|
|
decide: (approved, remember) => {
|
|
for (const fn of onMessage) {
|
|
fn(
|
|
{
|
|
type: "AUTISTMASK_APPROVAL_DECISION",
|
|
approved,
|
|
remember: !!remember,
|
|
},
|
|
port,
|
|
);
|
|
}
|
|
},
|
|
disconnect: () => {
|
|
for (const fn of onDisconnect) fn(port);
|
|
},
|
|
};
|
|
}
|
|
|
|
// 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,
|
|
requestSite,
|
|
connectApproval,
|
|
closeWindow,
|
|
broadcastTransaction,
|
|
loadState,
|
|
created,
|
|
removed,
|
|
// The user switching account in the toolbar popup, as the background
|
|
// sees it: the persisted active address changes underneath a pending
|
|
// approval.
|
|
setActiveAddress: (address) => {
|
|
persisted.activeAddress = address;
|
|
},
|
|
// The user switching network in the toolbar popup.
|
|
setNetwork: (network) => {
|
|
chain = network;
|
|
},
|
|
fromPopup: { url: EXT_URL + "src/popup/index.html" },
|
|
};
|
|
}
|
|
|
|
// Let the handler's promise chain run to the next suspension point. Raising a
|
|
// transaction approval now populates it against the node first, which is
|
|
// several awaits deep before the window is opened.
|
|
async function settle() {
|
|
for (let i = 0; i < 50; i++) await Promise.resolve();
|
|
}
|
|
|
|
// settle() only drains microtasks. A handler whose last-resort .catch() has to
|
|
// run after a macrotask boundary needs those turns too, so the assertion that
|
|
// the page WAS answered is what reports a regression rather than a timeout.
|
|
async function settleIncludingRejections() {
|
|
await settle();
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
}
|
|
|
|
afterEach(() => {
|
|
delete global.chrome;
|
|
jest.resetModules();
|
|
});
|
|
|
|
describe("one approval, one broadcast", () => {
|
|
test("a second AUTISTMASK_TX_RESPONSE for the same id does not broadcast again", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
const id = pending.id();
|
|
expect(id).toBeTruthy();
|
|
|
|
const inFlight = deferred();
|
|
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
|
|
|
|
// The popup answers. Verification passes and the broadcast is held
|
|
// open, which is the whole window the second message arrives in.
|
|
const first = bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(7),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
|
|
|
// A reloaded approval window signs the same approval again, at another
|
|
// nonce. The claim is taken before anything is verified, so what this
|
|
// asserts is the interlock and not the nonce comparison: the refusal
|
|
// below is the claim's own message, which a verification failure does
|
|
// not produce.
|
|
const second = bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(8),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
|
|
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
|
expect(second.sendResponse).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
error: expect.stringMatching(/already being sent/),
|
|
retryable: false,
|
|
}),
|
|
);
|
|
|
|
inFlight.resolve({ hash: "0xfeed" });
|
|
await settle();
|
|
expect(first.sendResponse).toHaveBeenCalledWith({ txHash: "0xfeed" });
|
|
expect(pending.result()).toEqual({ result: "0xfeed" });
|
|
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("the same artifact sent twice broadcasts once", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
const id = pending.id();
|
|
|
|
const inFlight = deferred();
|
|
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
|
|
const raw = await signedAtNonce(7);
|
|
const msg = {
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
rawSignedTx: raw,
|
|
};
|
|
|
|
bg.send(msg, { url: bg.fromPopup.url });
|
|
bg.send(msg, { url: bg.fromPopup.url });
|
|
await settle();
|
|
inFlight.resolve({ hash: "0xfeed" });
|
|
await settle();
|
|
|
|
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("a response arriving after the broadcast finished finds nothing to send", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
const id = pending.id();
|
|
|
|
bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
|
|
bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(7),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
|
|
const late = bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(8),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
|
|
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
|
expect(late.sendResponse).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("a second AUTISTMASK_SIGN_RESPONSE for the same id is refused", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
const id = pending.id();
|
|
|
|
// Hold the transaction approval in flight, then answer it a second
|
|
// time as if it were a sign approval: the sign handler must apply the
|
|
// same interlock rather than running its own verification.
|
|
const inFlight = deferred();
|
|
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
|
|
bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(7),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
|
|
const second = bg.send(
|
|
{
|
|
type: "AUTISTMASK_SIGN_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
signature: "0x00",
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
|
|
expect(second.sendResponse).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
error: expect.stringMatching(/already being signed/),
|
|
retryable: false,
|
|
}),
|
|
);
|
|
inFlight.resolve({ hash: "0xfeed" });
|
|
await settle();
|
|
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
// Populating the transaction before the approval window opens is what makes
|
|
// the displayed object the verified object. It also fixes the nonce before the
|
|
// user has answered anything: two requests populated concurrently take the
|
|
// same nonce from a node that has seen neither of them broadcast, and the
|
|
// second can then never be sent, because the only way to give it a fresh nonce
|
|
// is to populate it again after the user has read the old one off the screen.
|
|
// So the second request is refused while the first is unanswered.
|
|
describe("one transaction approval at a time", () => {
|
|
test("a second eth_sendTransaction while one is pending is refused before it takes a nonce", async () => {
|
|
const getTransactionCount = jest.fn(async () => NONCE);
|
|
const bg = loadBackground({ provider: { getTransactionCount } });
|
|
|
|
const first = bg.requestTx();
|
|
await settle();
|
|
expect(first.id()).toBeTruthy();
|
|
expect(getTransactionCount).toHaveBeenCalledTimes(1);
|
|
|
|
const second = bg.requestTx();
|
|
await settle();
|
|
|
|
expect(second.result()).toEqual({
|
|
error: {
|
|
code: -32002,
|
|
message: expect.stringMatching(
|
|
/one transaction at a time.+already in progress/,
|
|
),
|
|
},
|
|
});
|
|
// Where the refusal happened matters as much as that it happened: no
|
|
// second window, and the node was never asked for a second nonce.
|
|
expect(bg.created).toHaveLength(1);
|
|
expect(getTransactionCount).toHaveBeenCalledTimes(1);
|
|
|
|
// The refusal leaves the pending approval untouched, and it still
|
|
// sends.
|
|
bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
|
|
bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id: first.id(),
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(NONCE),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
expect(first.result()).toEqual({ result: "0xfeed" });
|
|
});
|
|
|
|
// The slot is only defensible if the wallet was going to raise an approval
|
|
// anyway. Taken any earlier, a request the wallet refuses outright still
|
|
// holds it, and any page at all — connected or not — can deny the user's
|
|
// own transactions for as long as it keeps asking.
|
|
test("a request the wallet refuses does not take the slot from the connected site", async () => {
|
|
const bg = loadBackground();
|
|
|
|
// Both delivered before either reaches its first suspension point,
|
|
// which is the interleaving the slot exists for.
|
|
const stranger = bg.requestTx(TX_PARAMS, UNCONNECTED_ORIGIN);
|
|
const connected = bg.requestTx();
|
|
await settle();
|
|
|
|
expect(stranger.result()).toEqual({
|
|
error: { code: 4100, message: "Unauthorized" },
|
|
});
|
|
// The connected site's transaction was raised, not refused as one the
|
|
// user already has in progress.
|
|
expect(connected.result()).toBeNull();
|
|
expect(connected.id()).toBeTruthy();
|
|
expect(bg.created).toHaveLength(1);
|
|
});
|
|
|
|
// The user closes an approval window that looks hung while the attempt
|
|
// behind it is still running, and that attempt then fails in a way that
|
|
// would normally leave the approval standing for a retry. There is no
|
|
// window left to retry in, so leaving it standing answers the requesting
|
|
// page never — and holds the slot for the life of the worker with it.
|
|
test("an approval whose window closed under a failed attempt is answered, and frees the next request", async () => {
|
|
const stalled = deferred();
|
|
const bg = loadBackground({
|
|
loadState: async () => {
|
|
await stalled.promise;
|
|
throw new Error("The wallet data could not be read.");
|
|
},
|
|
});
|
|
|
|
const first = bg.requestTx();
|
|
await settle();
|
|
bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id: first.id(),
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(NONCE),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
|
|
// The attempt owns the approval, so closing the window does not settle
|
|
// it: the attempt may yet broadcast, and it is the one that reports.
|
|
bg.closeWindow(1);
|
|
await settle();
|
|
expect(first.result()).toBeNull();
|
|
|
|
stalled.resolve();
|
|
await settle();
|
|
expect(first.result()).toEqual({
|
|
error: { code: 4001, message: "User rejected the request." },
|
|
});
|
|
|
|
const second = bg.requestTx();
|
|
await settle();
|
|
expect(second.result()).toBeNull();
|
|
expect(second.id()).toBeTruthy();
|
|
expect(bg.created).toHaveLength(2);
|
|
});
|
|
|
|
// An approval with no window is one nothing can ever answer.
|
|
test("a request whose approval window cannot be opened is answered rather than left waiting", async () => {
|
|
const bg = loadBackground({ noWindow: true });
|
|
|
|
const first = bg.requestTx();
|
|
await settle();
|
|
expect(first.result()).toEqual({
|
|
error: {
|
|
code: -32603,
|
|
message: expect.stringMatching(
|
|
/could not open its approval window/,
|
|
),
|
|
},
|
|
});
|
|
|
|
// And it did not take the slot with it.
|
|
const second = bg.requestTx();
|
|
await settle();
|
|
expect(second.result()).toEqual({
|
|
error: {
|
|
code: -32603,
|
|
message: expect.stringMatching(
|
|
/could not open its approval window/,
|
|
),
|
|
},
|
|
});
|
|
});
|
|
|
|
test("an answered approval frees the next request", async () => {
|
|
const bg = loadBackground();
|
|
const first = bg.requestTx();
|
|
await settle();
|
|
|
|
// The user closes the approval window, which rejects it.
|
|
bg.closeWindow(1);
|
|
await settle();
|
|
expect(first.result()).toEqual({
|
|
error: { code: 4001, message: "User rejected the request." },
|
|
});
|
|
|
|
const second = bg.requestTx();
|
|
await settle();
|
|
expect(second.id()).toBeTruthy();
|
|
expect(bg.created).toHaveLength(2);
|
|
});
|
|
|
|
test("a signature request is not held up by a pending transaction", async () => {
|
|
const bg = loadBackground();
|
|
bg.requestTx();
|
|
await settle();
|
|
|
|
// A signature consumes no nonce, so it has nothing to collide with.
|
|
const signing = bg.requestSign();
|
|
await settle();
|
|
expect(signing.id()).toBeTruthy();
|
|
expect(signing.result()).toBeNull();
|
|
expect(bg.created).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
// A nonce collision found before the transaction reaches the network is the
|
|
// one send failure the wallet can speak about with certainty. The user is told
|
|
// it did not go out and to send it again, rather than being warned it might
|
|
// already be on the chain — which would send them looking for a transaction
|
|
// that does not exist, and stop them retrying the one that never went.
|
|
describe("a nonce collision is reported as a transaction that did not go out", () => {
|
|
test("a broadcast the node refused for the nonce is not reported as possibly sent", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
|
|
bg.broadcastTransaction.mockRejectedValue(
|
|
Object.assign(new Error("nonce too low"), {
|
|
code: "NONCE_EXPIRED",
|
|
}),
|
|
);
|
|
const answer = bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id: pending.id(),
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(NONCE),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
|
|
expect(answer.sendResponse).toHaveBeenCalledWith({
|
|
error: expect.stringMatching(/nonce had already been used/),
|
|
retryable: false,
|
|
stage: "nonce",
|
|
});
|
|
expect(pending.result()).toEqual({
|
|
error: {
|
|
message: expect.stringMatching(
|
|
/transaction was not sent, because its nonce/,
|
|
),
|
|
},
|
|
});
|
|
});
|
|
|
|
test("a nonce this wallet already broadcast is refused without asking the node again", async () => {
|
|
const bg = loadBackground();
|
|
const first = bg.requestTx();
|
|
await settle();
|
|
|
|
bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
|
|
bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id: first.id(),
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(NONCE),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
expect(first.result()).toEqual({ result: "0xfeed" });
|
|
|
|
// The stubbed node still reports NONCE as the next nonce — a pending
|
|
// count that lags a broadcast the node has already taken — so this
|
|
// second approval is populated at a nonce this worker has spent.
|
|
const second = bg.requestTx();
|
|
await settle();
|
|
const answer = bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id: second.id(),
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(NONCE),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
|
|
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
|
expect(answer.sendResponse).toHaveBeenCalledWith({
|
|
error: expect.stringMatching(/nonce had already been used/),
|
|
retryable: false,
|
|
stage: "nonce",
|
|
});
|
|
expect(second.result()).toEqual({
|
|
error: {
|
|
message: expect.stringMatching(/nonce had already been used/),
|
|
},
|
|
});
|
|
});
|
|
|
|
// Nonce spaces are per chain, and the wallet switches networks. A nonce
|
|
// this wallet spent on one chain says nothing about the same nonce on
|
|
// another — and low nonces overlap across chains as a matter of course, so
|
|
// a record that ignored the chain would refuse ordinary transactions,
|
|
// permanently and with a message that is not true of them.
|
|
test("a nonce spent on one chain is not refused on another", async () => {
|
|
const bg = loadBackground();
|
|
|
|
const first = bg.requestTx();
|
|
await settle();
|
|
bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
|
|
bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id: first.id(),
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(NONCE),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
expect(first.result()).toEqual({ result: "0xfeed" });
|
|
|
|
// The user switches network. On this chain the address has sent
|
|
// nothing, so the node populates the next transaction at the same
|
|
// nonce — correctly.
|
|
bg.setNetwork(SEPOLIA);
|
|
const second = bg.requestTx();
|
|
await settle();
|
|
bg.broadcastTransaction.mockResolvedValue({ hash: "0xbeef" });
|
|
bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id: second.id(),
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(NONCE, undefined, SEPOLIA.num),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
|
|
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(2);
|
|
expect(second.result()).toEqual({ result: "0xbeef" });
|
|
});
|
|
});
|
|
|
|
// The approval carries the transaction the user was shown and the address it
|
|
// was raised for, and the artifact is checked against both. Every case here is
|
|
// one the old comparison — against the dApp's request, for the address that is
|
|
// active now — would have broadcast.
|
|
describe("what the approval is verified against", () => {
|
|
// The approval screen showed the populated fee. An artifact at ten times
|
|
// that fee, still far below the ceilings, is what the ceilings alone could
|
|
// not catch.
|
|
test("a fee differing from the displayed one is refused, not sent", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
const id = pending.id();
|
|
|
|
const raw = await signer.signTransaction({
|
|
...populated(NONCE),
|
|
maxFeePerGas: 20000000000n,
|
|
});
|
|
const answer = bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
rawSignedTx: raw,
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
|
|
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
|
|
expect(answer.sendResponse).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
error: expect.stringMatching(/approved maximum fee per gas/),
|
|
retryable: false,
|
|
stage: "verify",
|
|
}),
|
|
);
|
|
expect(pending.result()).toEqual({
|
|
error: {
|
|
message: expect.stringMatching(/approved maximum fee per gas/),
|
|
},
|
|
});
|
|
});
|
|
|
|
test("a nonce differing from the displayed one is refused, not sent", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
const id = pending.id();
|
|
|
|
const answer = bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(NONCE + 1),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
|
|
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
|
|
expect(answer.sendResponse).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
error: expect.stringMatching(/approved nonce/),
|
|
retryable: false,
|
|
stage: "verify",
|
|
}),
|
|
);
|
|
});
|
|
|
|
// The address switch. The approval named one account; the wallet is on
|
|
// another by the time the artifact arrives. Both halves are covered: the
|
|
// popup signing as the account that is active now, and the popup correctly
|
|
// signing as the approved account while the wallet has moved on.
|
|
test("an artifact signed by the address that is active now is refused", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
const id = pending.id();
|
|
|
|
bg.setActiveAddress(other.address);
|
|
const answer = bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(NONCE, other),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
|
|
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
|
|
expect(answer.sendResponse).toHaveBeenCalledWith(
|
|
expect.objectContaining({ retryable: false, stage: "verify" }),
|
|
);
|
|
expect(pending.result()).toEqual({
|
|
error: { message: expect.stringMatching(/active address changed/) },
|
|
});
|
|
});
|
|
|
|
test("an address switch refuses even the correctly signed artifact", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
const id = pending.id();
|
|
|
|
bg.setActiveAddress(other.address);
|
|
const answer = bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(NONCE),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
|
|
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
|
|
expect(answer.sendResponse).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
error: expect.stringMatching(/active address changed/),
|
|
retryable: false,
|
|
stage: "verify",
|
|
}),
|
|
);
|
|
// A refusal, so the approval is spent: the same artifact offered again
|
|
// finds nothing to answer.
|
|
const retry = bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(NONCE),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
expect(retry.sendResponse).not.toHaveBeenCalled();
|
|
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("a switch back to the approved address still sends", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
const id = pending.id();
|
|
|
|
bg.setActiveAddress(other.address);
|
|
bg.setActiveAddress(signer.address);
|
|
bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
|
|
bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(NONCE),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
|
|
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
|
expect(pending.result()).toEqual({ result: "0xfeed" });
|
|
});
|
|
|
|
// The popup is handed the populated transaction and the address it is for,
|
|
// and nothing else it would have to fetch or decide.
|
|
test("the popup is given the transaction it is to sign", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
|
|
const details = bg.send(
|
|
{ type: "AUTISTMASK_GET_APPROVAL", id: pending.id() },
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
const shown = details.sendResponse.mock.calls[0][0];
|
|
expect(shown.type).toBe("tx");
|
|
expect(shown.approvedFrom).toBe(signer.address);
|
|
expect(shown.approvedTx).toEqual({
|
|
type: 2,
|
|
from: signer.address,
|
|
chainId: "0x1",
|
|
nonce: "0x7",
|
|
gasLimit: "0x186a0",
|
|
maxFeePerGas: "0x77359400",
|
|
maxPriorityFeePerGas: "0x3b9aca00",
|
|
to: RECIPIENT,
|
|
value: TX_PARAMS.value,
|
|
data: "0x",
|
|
accessList: [],
|
|
});
|
|
});
|
|
|
|
// A request naming an account the wallet is not on is refused outright
|
|
// rather than signed as whichever account is active.
|
|
test("a request from another address raises no approval at all", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestTx({ ...TX_PARAMS, from: other.address });
|
|
await settle();
|
|
|
|
expect(pending.result()).toEqual({
|
|
error: {
|
|
code: 4100,
|
|
message: expect.stringMatching(/not the active one/),
|
|
},
|
|
});
|
|
expect(bg.created).toEqual([]);
|
|
});
|
|
|
|
// Message signing pins the address the same way, and refuses the same way.
|
|
// A signature is not a transaction, but a permit signed by an account the
|
|
// approval did not name spends that account's tokens all the same.
|
|
test("a sign approval refuses a signature after an address switch", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestSign();
|
|
await settle();
|
|
|
|
bg.setActiveAddress(other.address);
|
|
const answer = bg.send(
|
|
{
|
|
type: "AUTISTMASK_SIGN_RESPONSE",
|
|
id: pending.id(),
|
|
approved: true,
|
|
signature: await signer.signMessage(
|
|
Buffer.from(MESSAGE.slice(2), "hex"),
|
|
),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
|
|
expect(answer.sendResponse).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
error: expect.stringMatching(/active address changed/),
|
|
retryable: false,
|
|
}),
|
|
);
|
|
expect(pending.result()).toEqual({
|
|
error: { message: expect.stringMatching(/active address changed/) },
|
|
});
|
|
});
|
|
|
|
test("a sign request from another address raises no approval at all", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestSign(other.address);
|
|
await settle();
|
|
|
|
expect(pending.result()).toEqual({
|
|
error: {
|
|
code: 4100,
|
|
message: expect.stringMatching(/not the active one/),
|
|
},
|
|
});
|
|
expect(bg.created).toEqual([]);
|
|
});
|
|
|
|
// Population is a network round trip with the user's hands free. An
|
|
// approval raised for the address that was active when it started could
|
|
// never be signed once the wallet has moved off it, so it is never raised.
|
|
test("an address switch during population raises no approval", async () => {
|
|
let bg;
|
|
bg = loadBackground({
|
|
provider: {
|
|
// The user switches account in the toolbar popup while the
|
|
// node is being asked for a gas estimate.
|
|
estimateGas: async () => {
|
|
bg.setActiveAddress(other.address);
|
|
return 100000n;
|
|
},
|
|
},
|
|
});
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
|
|
expect(pending.result()).toEqual({
|
|
error: {
|
|
message: expect.stringMatching(
|
|
/active address changed while this transaction was being prepared/,
|
|
),
|
|
},
|
|
});
|
|
expect(bg.created).toEqual([]);
|
|
});
|
|
|
|
// Population happens before the window exists, so its failure is a failure
|
|
// of the request: no approval, no window, and the error goes back to the
|
|
// page the click came from.
|
|
test("a transaction that cannot be prepared opens no window", async () => {
|
|
const bg = loadBackground({
|
|
provider: {
|
|
estimateGas: async () => {
|
|
throw new Error("execution reverted");
|
|
},
|
|
},
|
|
});
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
|
|
expect(pending.result()).toEqual({
|
|
error: {
|
|
message: expect.stringMatching(
|
|
/could not be prepared.*execution reverted/,
|
|
),
|
|
},
|
|
});
|
|
expect(bg.created).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// The interlock must not cost the retry the approval exists to allow.
|
|
describe("the interlock releases a failed attempt", () => {
|
|
test("a retryable failure before the broadcast leaves the approval usable", async () => {
|
|
let failNext = true;
|
|
const bg = loadBackground({
|
|
loadState: async () => {
|
|
if (failNext) {
|
|
failNext = false;
|
|
throw new Error("storage unavailable");
|
|
}
|
|
},
|
|
});
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
const id = pending.id();
|
|
|
|
const first = bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(7),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
|
|
expect(first.sendResponse).toHaveBeenCalledWith(
|
|
expect.objectContaining({ retryable: true }),
|
|
);
|
|
|
|
bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
|
|
const retry = bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(7),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
|
|
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
|
expect(retry.sendResponse).toHaveBeenCalledWith({ txHash: "0xfeed" });
|
|
expect(pending.result()).toEqual({ result: "0xfeed" });
|
|
});
|
|
|
|
test("a mismatched artifact spends the approval outright", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
const id = pending.id();
|
|
|
|
// Signed for a different recipient than the one that was approved.
|
|
const wrong = await signer.signTransaction({
|
|
...populated(7),
|
|
to: "0xdAC17F958D2ee523a2206206994597C13D831ec7",
|
|
});
|
|
const first = bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
rawSignedTx: wrong,
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
expect(first.sendResponse).toHaveBeenCalledWith(
|
|
expect.objectContaining({ retryable: false, stage: "verify" }),
|
|
);
|
|
|
|
const retry = bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(7),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
|
|
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
|
|
expect(retry.sendResponse).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// The claim is what makes one approval one broadcast, so it has to hold
|
|
// against everything else that retires an approval, not just against a second
|
|
// AUTISTMASK_TX_RESPONSE. Each of these paths used to resolve the waiting
|
|
// promise 4001 while the attempt behind it ran to completion: the transaction
|
|
// reached the chain and the page was told the user rejected it, which invites
|
|
// the user to send it a second time at a fresh nonce.
|
|
describe("a claimed approval outlives every other retirement path", () => {
|
|
// The approval popup stays open across the broadcast it is waiting on, so
|
|
// a user closing an apparently-hung window needs no adversary at all.
|
|
test("closing the approval window mid-broadcast still reports the result", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
const id = pending.id();
|
|
|
|
const inFlight = deferred();
|
|
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
|
|
const first = bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(7),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
|
|
|
// The user closes the window while the broadcast is still open.
|
|
bg.closeWindow(1);
|
|
await settle();
|
|
expect(pending.result()).toBeNull();
|
|
|
|
inFlight.resolve({ hash: "0xfeed" });
|
|
await settle();
|
|
|
|
expect(pending.result()).toEqual({ result: "0xfeed" });
|
|
expect(first.sendResponse).toHaveBeenCalledWith({ txHash: "0xfeed" });
|
|
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("switching the active address mid-broadcast still reports the result", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
const id = pending.id();
|
|
|
|
const inFlight = deferred();
|
|
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
|
|
bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(7),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
|
|
|
// The user switches account in the toolbar popup, which rejects and
|
|
// force-closes every pending approval.
|
|
bg.send(
|
|
{ type: "AUTISTMASK_ACTIVE_CHANGED" },
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
expect(pending.result()).toBeNull();
|
|
// The window an in-flight attempt reports into is left standing too.
|
|
expect(bg.removed).toEqual([]);
|
|
|
|
inFlight.resolve({ hash: "0xfeed" });
|
|
await settle();
|
|
|
|
expect(pending.result()).toEqual({ result: "0xfeed" });
|
|
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("a reject arriving mid-broadcast is refused, not honoured", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
const id = pending.id();
|
|
|
|
const inFlight = deferred();
|
|
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
|
|
bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(7),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
|
|
const reject = bg.send(
|
|
{ type: "AUTISTMASK_TX_RESPONSE", id, approved: false },
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
expect(pending.result()).toBeNull();
|
|
expect(reject.sendResponse).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
retryable: false,
|
|
stage: "broadcast",
|
|
}),
|
|
);
|
|
|
|
inFlight.resolve({ hash: "0xfeed" });
|
|
await settle();
|
|
|
|
expect(pending.result()).toEqual({ result: "0xfeed" });
|
|
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
// The refusals above must not cost the rejection its ordinary meaning.
|
|
test("with no attempt running, closing the window still rejects", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
|
|
bg.closeWindow(1);
|
|
await settle();
|
|
|
|
expect(pending.result()).toEqual({
|
|
error: { code: 4001, message: "User rejected the request." },
|
|
});
|
|
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("with no attempt running, an active-address switch still rejects and closes", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
|
|
bg.send(
|
|
{ type: "AUTISTMASK_ACTIVE_CHANGED" },
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
|
|
expect(pending.result()).toEqual({
|
|
error: { code: 4001, message: "User rejected the request." },
|
|
});
|
|
expect(bg.removed).toEqual([1]);
|
|
});
|
|
|
|
// A sign approval held by a running verification is the same shape, and
|
|
// the refusal must not tell the user to start again from the site while
|
|
// the first attempt may still hand back a signature.
|
|
test("a reject during a sign attempt is refused with the in-flight stage", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
const id = pending.id();
|
|
|
|
const inFlight = deferred();
|
|
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
|
|
bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(7),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
|
|
const reject = bg.send(
|
|
{ type: "AUTISTMASK_SIGN_RESPONSE", id, approved: false },
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settle();
|
|
expect(reject.sendResponse).toHaveBeenCalledWith(
|
|
expect.objectContaining({ retryable: false, stage: "inflight" }),
|
|
);
|
|
|
|
inFlight.resolve({ hash: "0xfeed" });
|
|
await settle();
|
|
expect(pending.result()).toEqual({ result: "0xfeed" });
|
|
});
|
|
});
|
|
|
|
// A handler that throws must still answer. `sendResponse` is the only thing
|
|
// that settles the page's window.ethereum.request() promise, so a throw that
|
|
// escapes a handler leaves that promise pending forever — no error, no
|
|
// timeout, indistinguishable from a slow wallet. Each case below drives a real
|
|
// throw out of a handler rather than asserting the catch block exists.
|
|
describe("a handler that throws still settles the page", () => {
|
|
const INTERNAL_ERROR = {
|
|
code: -32603,
|
|
message:
|
|
"AutistMask could not complete this request because of an internal error.",
|
|
};
|
|
|
|
let errorLog;
|
|
beforeEach(() => {
|
|
errorLog = jest.spyOn(console, "error").mockImplementation(() => {});
|
|
});
|
|
afterEach(() => {
|
|
errorLog.mockRestore();
|
|
});
|
|
|
|
// getState() awaits extension storage unguarded, and every read path in
|
|
// handleRpc goes through it. A storage read that rejects is the whole
|
|
// failure — no hook in the handler itself.
|
|
test("a rejected handleRpc rejects the page instead of hanging it", async () => {
|
|
const bg = loadBackground({
|
|
storageGet: async () => {
|
|
throw new Error("storage unavailable");
|
|
},
|
|
});
|
|
|
|
const answer = bg.send(
|
|
{ type: "AUTISTMASK_RPC", method: "eth_accounts", params: [] },
|
|
{ origin: ORIGIN },
|
|
);
|
|
await settleIncludingRejections();
|
|
|
|
// The channel is held open for the async answer, and the answer
|
|
// arrives.
|
|
expect(answer.kept).toBe(true);
|
|
expect(answer.sendResponse).toHaveBeenCalledWith({
|
|
error: INTERNAL_ERROR,
|
|
});
|
|
// Not swallowed: the throw is on the background console, which is how
|
|
// this class gets caught in future.
|
|
expect(errorLog).toHaveBeenCalledWith(
|
|
"[AutistMask]",
|
|
"RPC request failed:",
|
|
"eth_accounts",
|
|
expect.objectContaining({ message: "storage unavailable" }),
|
|
);
|
|
});
|
|
|
|
// The transaction response handler wraps every statement in a try, so what
|
|
// escapes it is a throw from inside one of its catch blocks. Here the
|
|
// failure classifier itself throws while classifying a real verification
|
|
// failure — the approval is left claimed, so nothing else can settle it.
|
|
// The escape happens before broadcastTransaction() is reached, so the
|
|
// reported stage must be the one that says the transaction is gone.
|
|
test("a throw while verifying a transaction settles both the page and the popup", async () => {
|
|
const bg = loadBackground({
|
|
approvalVerify: {
|
|
describeTxFailure: () => {
|
|
throw new Error("classifier broke");
|
|
},
|
|
},
|
|
});
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
const id = pending.id();
|
|
|
|
// A real verification failure: the artifact is signed at a nonce the
|
|
// approval never displayed.
|
|
const answer = bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(NONCE + 1),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settleIncludingRejections();
|
|
|
|
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
|
|
expect(pending.result()).toEqual({ error: INTERNAL_ERROR });
|
|
expect(answer.sendResponse).toHaveBeenCalledWith({
|
|
error: INTERNAL_ERROR.message,
|
|
retryable: false,
|
|
// Nothing was broadcast, so the popup must say the request is gone
|
|
// rather than that it may still have reached the network.
|
|
stage: "verify",
|
|
});
|
|
expect(errorLog).toHaveBeenCalledWith(
|
|
"[AutistMask]",
|
|
"transaction approval response failed:",
|
|
expect.objectContaining({ message: "classifier broke" }),
|
|
);
|
|
// The copy the user actually reads, from the popup's own formatter.
|
|
expect(
|
|
describeSigningFailure(answer.sendResponse.mock.calls[0][0], "")
|
|
.message,
|
|
).toBe(
|
|
INTERNAL_ERROR.message +
|
|
" This request can no longer be signed." +
|
|
" Please start it again from the site.",
|
|
);
|
|
});
|
|
|
|
// The other side of the same local: once broadcastTransaction() has been
|
|
// entered the wallet genuinely cannot tell whether the node took the
|
|
// transaction, and the copy that warns about a second send is correct.
|
|
test("a throw while handling a failed broadcast reports the broadcast stage", async () => {
|
|
const bg = loadBackground({
|
|
approvalVerify: {
|
|
describeTxFailure: () => {
|
|
throw new Error("classifier broke");
|
|
},
|
|
},
|
|
});
|
|
bg.broadcastTransaction.mockRejectedValue(new Error("node refused"));
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
const id = pending.id();
|
|
|
|
// The approved artifact, so verification passes and the failure
|
|
// happens at the broadcast.
|
|
const answer = bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(NONCE),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settleIncludingRejections();
|
|
|
|
expect(bg.broadcastTransaction).toHaveBeenCalled();
|
|
expect(pending.result()).toEqual({ error: INTERNAL_ERROR });
|
|
expect(answer.sendResponse).toHaveBeenCalledWith({
|
|
error: INTERNAL_ERROR.message,
|
|
retryable: false,
|
|
stage: "broadcast",
|
|
});
|
|
expect(
|
|
describeSigningFailure(answer.sendResponse.mock.calls[0][0], "")
|
|
.message,
|
|
).toBe(
|
|
INTERNAL_ERROR.message +
|
|
" The transaction may still have reached the network." +
|
|
" Check the account before sending it again.",
|
|
);
|
|
});
|
|
|
|
test("a throw while handling a failed signature settles both the page and the popup", async () => {
|
|
const bg = loadBackground({
|
|
approvalVerify: {
|
|
failureIsRetryable: () => {
|
|
throw new Error("classifier broke");
|
|
},
|
|
},
|
|
});
|
|
const pending = bg.requestSign();
|
|
await settle();
|
|
|
|
// A real verification failure: the active address moved after the
|
|
// approval was raised.
|
|
bg.setActiveAddress(other.address);
|
|
const answer = bg.send(
|
|
{
|
|
type: "AUTISTMASK_SIGN_RESPONSE",
|
|
id: pending.id(),
|
|
approved: true,
|
|
signature: await signer.signMessage(
|
|
Buffer.from(MESSAGE.slice(2), "hex"),
|
|
),
|
|
},
|
|
{ url: bg.fromPopup.url },
|
|
);
|
|
await settleIncludingRejections();
|
|
|
|
expect(pending.result()).toEqual({ error: INTERNAL_ERROR });
|
|
expect(answer.sendResponse).toHaveBeenCalledWith({
|
|
error: INTERNAL_ERROR.message,
|
|
retryable: false,
|
|
});
|
|
expect(errorLog).toHaveBeenCalledWith(
|
|
"[AutistMask]",
|
|
"sign approval response failed:",
|
|
expect.objectContaining({ message: "classifier broke" }),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("popup-only messages", () => {
|
|
test("a page sender cannot answer an approval", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
const id = pending.id();
|
|
|
|
const spoof = bg.send(
|
|
{
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id,
|
|
approved: true,
|
|
rawSignedTx: await signedAtNonce(7),
|
|
},
|
|
{ url: ORIGIN + "/index.html" },
|
|
);
|
|
await settle();
|
|
|
|
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
|
|
expect(spoof.sendResponse).toHaveBeenCalledWith({
|
|
error: "Unauthorized sender",
|
|
});
|
|
});
|
|
});
|
|
|
|
// A site connection decided in a popup that closes on the next line.
|
|
//
|
|
// The decision and the teardown are two events the popup emits back to back,
|
|
// and the background must not be able to reach different outcomes depending on
|
|
// which of them it processes first. It cannot, because they are now one
|
|
// channel: the decision is posted on the approval port that the close then
|
|
// disconnects, so it is delivered first. Every test here therefore emits the
|
|
// close IMMEDIATELY after the decision, with nothing awaited in between —
|
|
// which is what the popup does, and what used to report a user who approved as
|
|
// having refused (#275).
|
|
describe("a site connection decided as the popup closes", () => {
|
|
// The production route: chrome.action.openPopup() put the prompt in the
|
|
// toolbar popup, which is not a window, so nothing but the port
|
|
// disconnect can tell the background this prompt is gone.
|
|
test("approving in the toolbar popup connects the site", async () => {
|
|
const bg = loadBackground({ actionPopup: true });
|
|
const pending = bg.requestSite();
|
|
await settle();
|
|
const id = pending.id();
|
|
expect(id).toBeTruthy();
|
|
expect(bg.created).toHaveLength(0);
|
|
|
|
const port = bg.connectApproval(id);
|
|
port.decide(true, false);
|
|
port.disconnect();
|
|
await settle();
|
|
|
|
expect(pending.result()).toEqual({ result: [signer.address] });
|
|
});
|
|
|
|
test("closing the toolbar popup without deciding is a rejection", async () => {
|
|
const bg = loadBackground({ actionPopup: true });
|
|
const pending = bg.requestSite();
|
|
await settle();
|
|
|
|
const port = bg.connectApproval(pending.id());
|
|
port.disconnect();
|
|
await settle();
|
|
|
|
expect(pending.result()).toEqual({
|
|
error: { code: 4001, message: "User rejected the request." },
|
|
});
|
|
});
|
|
|
|
test("rejecting is a rejection, and the close that follows adds nothing", async () => {
|
|
const bg = loadBackground({ actionPopup: true });
|
|
const pending = bg.requestSite();
|
|
await settle();
|
|
|
|
const port = bg.connectApproval(pending.id());
|
|
port.decide(false, false);
|
|
port.disconnect();
|
|
await settle();
|
|
|
|
expect(pending.result()).toEqual({
|
|
error: { code: 4001, message: "User rejected the request." },
|
|
});
|
|
});
|
|
|
|
// The port carries a decision now, so it carries the sender check the
|
|
// one-off message used to carry. A content script that guessed an
|
|
// approval id must not be able to connect the site it is running on.
|
|
test("a decision from a page sender is ignored, and the close rejects", async () => {
|
|
const bg = loadBackground({ actionPopup: true });
|
|
const pending = bg.requestSite();
|
|
await settle();
|
|
|
|
const port = bg.connectApproval(pending.id(), FRESH_ORIGIN + "/x.html");
|
|
port.decide(true, true);
|
|
await settle();
|
|
expect(pending.result()).toBeNull();
|
|
|
|
port.disconnect();
|
|
await settle();
|
|
expect(pending.result()).toEqual({
|
|
error: { code: 4001, message: "User rejected the request." },
|
|
});
|
|
});
|
|
|
|
// The fallback shape, where openPopup() is unavailable and the prompt is
|
|
// a window the extension opened. Closing it fires windows.onRemoved as
|
|
// well, on a channel of its own that is ordered against nothing — so the
|
|
// window event must not be allowed to decide a site approval either.
|
|
//
|
|
// The event goes FIRST here, which is the interleaving the guard in the
|
|
// onRemoved listener exists for: the approval is still pending when the
|
|
// event arrives, so the listener really reaches it and really has to
|
|
// decline it. With the decision first there is nothing left in
|
|
// pendingApprovals and the listener finds no approval to spare.
|
|
test("approving in the fallback window survives a window event that lands first", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestSite();
|
|
await settle();
|
|
expect(bg.created).toHaveLength(1);
|
|
|
|
const port = bg.connectApproval(pending.id());
|
|
bg.closeWindow(1);
|
|
port.decide(true, false);
|
|
port.disconnect();
|
|
await settle();
|
|
|
|
expect(pending.result()).toEqual({ result: [signer.address] });
|
|
});
|
|
|
|
test("approving in the fallback window survives a window event that follows", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestSite();
|
|
await settle();
|
|
|
|
const port = bg.connectApproval(pending.id());
|
|
port.decide(true, false);
|
|
bg.closeWindow(1);
|
|
port.disconnect();
|
|
await settle();
|
|
|
|
expect(pending.result()).toEqual({ result: [signer.address] });
|
|
});
|
|
|
|
// The connected port is what silences the window event, so connecting one
|
|
// must take the same sender check the decision takes. Otherwise a content
|
|
// script that guessed the id switches off the only settlement path a
|
|
// prompt whose real popup never connected has, and the dApp hangs.
|
|
test("a port from a page sender does not silence the window event", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestSite();
|
|
await settle();
|
|
|
|
// Connected and held open — no disconnect, so nothing but the window
|
|
// event can settle this approval.
|
|
bg.connectApproval(pending.id(), FRESH_ORIGIN + "/x.html");
|
|
bg.closeWindow(1);
|
|
await settle();
|
|
|
|
expect(pending.result()).toEqual({
|
|
error: { code: 4001, message: "User rejected the request." },
|
|
});
|
|
});
|
|
|
|
// Same shape, and the same window event arriving before the popup has
|
|
// said anything at all — which is a user closing the window rather than
|
|
// deciding, and still has to reach the dApp as a rejection.
|
|
test("closing the fallback window without deciding is a rejection", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestSite();
|
|
await settle();
|
|
|
|
const port = bg.connectApproval(pending.id());
|
|
bg.closeWindow(1);
|
|
port.disconnect();
|
|
await settle();
|
|
|
|
expect(pending.result()).toEqual({
|
|
error: { code: 4001, message: "User rejected the request." },
|
|
});
|
|
});
|
|
|
|
// The net under the paragraph above: a prompt whose page never got as far
|
|
// as connecting the port has no disconnect to reject it, so the window
|
|
// event has to. Otherwise the dApp waits forever on a window that is gone.
|
|
test("a window that closes before its popup ever connected still rejects", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestSite();
|
|
await settle();
|
|
|
|
bg.closeWindow(1);
|
|
await settle();
|
|
|
|
expect(pending.result()).toEqual({
|
|
error: { code: 4001, message: "User rejected the request." },
|
|
});
|
|
});
|
|
|
|
// The `isSite &&` half of that skip, which is what keeps it from reaching
|
|
// a tx or sign approval. The popup connects its port in show() before it
|
|
// knows the approval's type, and the background sets portConnected without
|
|
// looking at the type either, so a tx approval in the fallback window
|
|
// carries the flag too. Without the conjunct the window event would skip
|
|
// it, windowClosed would never be set, releaseApproval() would never settle
|
|
// it, and the page would hang — the #271 regression this guard is written
|
|
// around.
|
|
test("a tx window closed with the port connected still rejects", async () => {
|
|
const bg = loadBackground();
|
|
const pending = bg.requestTx();
|
|
await settle();
|
|
bg.connectApproval(pending.id());
|
|
await settle();
|
|
bg.closeWindow(1);
|
|
await settle();
|
|
expect(pending.result()).toEqual({
|
|
error: { code: 4001, message: "User rejected the request." },
|
|
});
|
|
});
|
|
});
|