Compare commits
3 Commits
c66cec2f8b
...
issue-280-
| Author | SHA1 | Date | |
|---|---|---|---|
| 9665ac448e | |||
| 9dcd875dd4 | |||
| c755a5e944 |
33
TODO.md
33
TODO.md
@@ -45,6 +45,39 @@ undefined identifiers, which is how
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-08-14: A background message handler that throws now rejects the page
|
||||
instead of hanging it. `handleRpc(...).then(sendResponse)` had no `.catch()`,
|
||||
and `sendResponse` is the only thing that settles the dApp's
|
||||
`window.ethereum.request()` promise — so any throw inside `handleRpc` left
|
||||
that promise pending forever, with no error and no timeout, indistinguishable
|
||||
from a slow wallet. It now answers `{ code: -32603, message }` (the JSON-RPC
|
||||
internal error EIP-1474 defines and EIP-1193 defers to; no EIP-1193 4xxx code
|
||||
describes "the wallet broke" and none was invented) and logs the method and
|
||||
the throw to the background console rather than swallowing them. The two async
|
||||
IIFEs behind `AUTISTMASK_TX_RESPONSE` and `AUTISTMASK_SIGN_RESPONSE` were the
|
||||
same shape one level down — every statement inside a `try`, but a throw out of
|
||||
a `catch` block escaping unhandled — and each got a last-resort `.catch()`
|
||||
settling the approval through `settleApproval()` and answering the popup; the
|
||||
transaction one reports the broadcast stage, because it cannot tell whether
|
||||
the transaction reached the network. Every other handler on the path is
|
||||
synchronous. All three are driven by real failures — a rejecting storage read,
|
||||
and a failure classifier that throws while classifying a genuine verification
|
||||
failure — and were demonstrated failing first, the RPC one with `sendResponse`
|
||||
at zero calls ([#280](https://git.eeqj.de/sneak/AutistMask/issues/280)).
|
||||
- 2026-08-12: EIP-1193 error codes now reach the page. `src/content/inpage.js`
|
||||
rebuilt every failure as `new Error(error.message)`, so the code the
|
||||
background produced and the content script relayed intact was dropped in the
|
||||
last hop and a dApp checking `err.code === 4001` saw `undefined` — a wallet
|
||||
the user deliberately declined was indistinguishable from one that broke. The
|
||||
provider now rejects with a `ProviderRpcError` carrying `code` and, where the
|
||||
boundary sent one, `data`, passed through verbatim rather than matched against
|
||||
a list, so 4001, 4100 and 4902 all arrive and a future code needs no edit
|
||||
here. An error the background sent with no code stays a plain `Error` with no
|
||||
`code` property, and `message` is unchanged in every case. All four request
|
||||
entry points (`request`, `enable`, `send`, `sendAsync`) are covered by
|
||||
`tests/inpageErrors.test.js`, and the e2e probe that printed the missing code
|
||||
now requires it on the page's Error as well as on the wire, for all four
|
||||
rejected flows ([#274](https://git.eeqj.de/sneak/AutistMask/issues/274)).
|
||||
- 2026-08-12: `KNOWN_SYMBOLS` now maps a symbol to the set of contract addresses
|
||||
that bear it, not to one of them. A ticker is not unique: seven of the 512
|
||||
bundled tokens — `FRAX`, `REUSD`, `TON`, `EURE`, `MSUSD`, `MUSD` and `JPYC` —
|
||||
|
||||
@@ -57,6 +57,16 @@ const connectedSites = {};
|
||||
// Pending approval requests: { id: { origin, hostname, resolve } }
|
||||
const pendingApprovals = {};
|
||||
|
||||
// What the page is told when a request failed in a way the wallet has no
|
||||
// specific answer for. -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 one is not invented here. The cause is
|
||||
// logged rather than put in the message: the page gets a stable sentence, the
|
||||
// background console gets the throw.
|
||||
const INTERNAL_ERROR_CODE = -32603;
|
||||
const INTERNAL_ERROR_MESSAGE =
|
||||
"AutistMask could not complete this request because of an internal error.";
|
||||
|
||||
async function getState() {
|
||||
const result = await storageApi.get("autistmask");
|
||||
return (
|
||||
@@ -865,9 +875,26 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
// keep fallback
|
||||
}
|
||||
}
|
||||
handleRpc(msg.method, msg.params, trustedOrigin).then((response) => {
|
||||
sendResponse(response);
|
||||
});
|
||||
handleRpc(msg.method, msg.params, trustedOrigin)
|
||||
.then((response) => {
|
||||
sendResponse(response);
|
||||
})
|
||||
.catch((err) => {
|
||||
// Without this the page's window.ethereum.request() promise
|
||||
// stays pending forever: no response is sent, the content
|
||||
// script posts nothing back, and the dApp cannot tell the
|
||||
// failure from a slow wallet. handleRpc does real work —
|
||||
// state loads, provider calls, transaction population — so
|
||||
// "it does not throw today" is not a property anyone is
|
||||
// maintaining.
|
||||
log.errorf("RPC request failed:", msg.method, err);
|
||||
sendResponse({
|
||||
error: {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: INTERNAL_ERROR_MESSAGE,
|
||||
},
|
||||
});
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1051,7 +1078,31 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
stage: TX_STAGE_BROADCAST,
|
||||
});
|
||||
}
|
||||
})();
|
||||
})().catch((e) => {
|
||||
// Every statement above 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 ever answered. Settle both, through
|
||||
// the same chokepoint as every other retirement. The stage is
|
||||
// broadcast because this cannot tell whether the transaction
|
||||
// reached the network, and that is the wording that does not
|
||||
// invite a second send.
|
||||
log.errorf("transaction approval response failed:", e);
|
||||
settleApproval(
|
||||
msg.id,
|
||||
{
|
||||
error: {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: INTERNAL_ERROR_MESSAGE,
|
||||
},
|
||||
},
|
||||
{ holdsClaim: true },
|
||||
);
|
||||
sendResponse({
|
||||
error: INTERNAL_ERROR_MESSAGE,
|
||||
retryable: false,
|
||||
stage: TX_STAGE_BROADCAST,
|
||||
});
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1135,7 +1186,25 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
}
|
||||
sendResponse({ error: errMsg, retryable });
|
||||
}
|
||||
})();
|
||||
})().catch((e) => {
|
||||
// Same shape as the transaction path: a throw out of the catch
|
||||
// block above would leave the popup and the page both waiting.
|
||||
log.errorf("sign approval response failed:", e);
|
||||
settleApproval(
|
||||
msg.id,
|
||||
{
|
||||
error: {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: INTERNAL_ERROR_MESSAGE,
|
||||
},
|
||||
},
|
||||
{ holdsClaim: true },
|
||||
);
|
||||
sendResponse({
|
||||
error: INTERNAL_ERROR_MESSAGE,
|
||||
retryable: false,
|
||||
});
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,39 @@
|
||||
let nextId = 1;
|
||||
const pending = {};
|
||||
|
||||
// EIP-1193 ProviderRpcError: `code`, `message`, optional `data`. A class
|
||||
// rather than properties bolted onto an Error because this object crosses
|
||||
// no boundary after construction — it is built in the page's own realm and
|
||||
// handed straight to the caller's catch — so the prototype survives and
|
||||
// `error.name` is a stable thing for a dApp to see.
|
||||
class ProviderRpcError extends Error {
|
||||
constructor(code, message, data) {
|
||||
super(message);
|
||||
this.name = "ProviderRpcError";
|
||||
this.code = code;
|
||||
if (data !== undefined) this.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild a boundary error as the error the page catches, carrying the
|
||||
// code (and data) the extension reported. Without this a dApp cannot tell
|
||||
// a user's refusal (4001) from a wallet that broke, and retries or shows
|
||||
// an error instead of accepting the refusal.
|
||||
//
|
||||
// Whatever code arrived is passed through verbatim rather than being
|
||||
// matched against a list: the extension emits 4001, 4100 and 4902 today,
|
||||
// and a code this file has never heard of is still the truth about what
|
||||
// happened. An error reported with no code at all stays a plain Error —
|
||||
// a ProviderRpcError whose `code` is undefined would advertise a
|
||||
// conformance it does not have. `message` is untouched in every case.
|
||||
function toPageError(error) {
|
||||
const message = (error && error.message) || "Request failed";
|
||||
if (error && error.code !== undefined && error.code !== null) {
|
||||
return new ProviderRpcError(error.code, message, error.data);
|
||||
}
|
||||
return new Error(message);
|
||||
}
|
||||
|
||||
// Listen for responses from the content script
|
||||
window.addEventListener("message", function onUuid(event) {
|
||||
if (event.source !== window) return;
|
||||
@@ -20,7 +53,7 @@
|
||||
if (!p) return;
|
||||
delete pending[id];
|
||||
if (error) {
|
||||
p.reject(new Error(error.message || "Request failed"));
|
||||
p.reject(toPageError(error));
|
||||
} else {
|
||||
p.resolve(result);
|
||||
}
|
||||
|
||||
@@ -133,6 +133,14 @@ function loadBackground(options) {
|
||||
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: [
|
||||
@@ -152,7 +160,10 @@ function loadBackground(options) {
|
||||
global.chrome = {
|
||||
storage: {
|
||||
local: {
|
||||
get: jest.fn(async () => ({ autistmask: persisted })),
|
||||
get: jest.fn(
|
||||
opts.storageGet ||
|
||||
(async () => ({ autistmask: persisted })),
|
||||
),
|
||||
set: jest.fn(async () => {}),
|
||||
},
|
||||
},
|
||||
@@ -280,6 +291,25 @@ async function settle() {
|
||||
for (let i = 0; i < 50; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
// Node aborts the worker process on an unhandled rejection; an extension
|
||||
// service worker does not — the promise is simply never settled, nothing is
|
||||
// sent back, and the page's window.ethereum.request() waits forever. Recording
|
||||
// them instead of dying on them keeps that difference visible: the assertion
|
||||
// that the page WAS answered is what reports the failure, and the recording is
|
||||
// asserted empty alongside it.
|
||||
const unhandledRejections = [];
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
unhandledRejections.push(reason);
|
||||
});
|
||||
|
||||
// Node reports an unhandled rejection on the macrotask turn after the promise
|
||||
// was left unhandled, which is past everything settle() waits for.
|
||||
async function settleIncludingRejections() {
|
||||
await settle();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete global.chrome;
|
||||
jest.resetModules();
|
||||
@@ -1034,6 +1064,147 @@ describe("a claimed approval outlives every other retirement path", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// 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(() => {});
|
||||
unhandledRejections.length = 0;
|
||||
});
|
||||
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,
|
||||
});
|
||||
expect(unhandledRejections).toEqual([]);
|
||||
// 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.
|
||||
test("a throw while handling a failed 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,
|
||||
// The handler cannot tell whether the transaction reached the
|
||||
// network, so the popup must not say "start again from the site".
|
||||
stage: "broadcast",
|
||||
});
|
||||
expect(unhandledRejections).toEqual([]);
|
||||
expect(errorLog).toHaveBeenCalledWith(
|
||||
"[AutistMask]",
|
||||
"transaction approval response failed:",
|
||||
expect.objectContaining({ message: "classifier broke" }),
|
||||
);
|
||||
});
|
||||
|
||||
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(unhandledRejections).toEqual([]);
|
||||
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();
|
||||
|
||||
@@ -86,9 +86,11 @@ const DAPP_URL = DAPP_ORIGIN + "/";
|
||||
// never drive the popup that has to settle it; start() files the promise
|
||||
// under a key and settle() collects it once the prompt has been dealt with.
|
||||
//
|
||||
// The rejection branch records `code` as it arrives. EIP-1193 says a user
|
||||
// rejection is a ProviderRpcError carrying code 4001; what the page can
|
||||
// actually see is recorded here rather than assumed, and asserted in run.js.
|
||||
// The rejection branch records the whole observable shape of the error as it
|
||||
// arrives — name, message, and whether a `code` is present at all as distinct
|
||||
// from its value. EIP-1193 says a user rejection is a ProviderRpcError
|
||||
// carrying code 4001; what the page can actually see is recorded here rather
|
||||
// than assumed, and asserted in run.js.
|
||||
//
|
||||
// The message log is the page's half of the boundary observation: every
|
||||
// AUTISTMASK_* message that crosses between this page and the content
|
||||
@@ -120,6 +122,7 @@ const DAPP_HTML = [
|
||||
" return {",
|
||||
" settled: 'rejected',",
|
||||
" message: String((error && error.message) || error),",
|
||||
" name: error ? error.name : undefined,",
|
||||
" hasCode: !!error && 'code' in Object(error),",
|
||||
" code: error ? error.code : undefined,",
|
||||
" };",
|
||||
|
||||
@@ -1591,15 +1591,14 @@ async function lastResponseError(page) {
|
||||
}
|
||||
|
||||
// A rejected prompt, asserted at both ends: the page's promise rejected
|
||||
// rather than hanging or resolving, and the response that crossed the
|
||||
// boundary carried EIP-1193 code 4001.
|
||||
// rather than hanging or resolving, and EIP-1193 code 4001 is present both
|
||||
// on the wire and on the Error the calling page catches.
|
||||
//
|
||||
// The code is asserted on the wire because that is the only place it
|
||||
// survives. src/content/inpage.js rebuilds the rejection as `new
|
||||
// Error(error.message)`, so the Error the calling page catches carries the
|
||||
// message and no code. That is reported rather than asserted either way —
|
||||
// locking in the current behaviour would make the gap permanent, and
|
||||
// asserting the code on the Error would fail today.
|
||||
// Both ends matter because they used to disagree. The code crossed the
|
||||
// boundary correctly and src/content/inpage.js then threw it away, rebuilding
|
||||
// every rejection as `new Error(error.message)` — so a dApp branching on
|
||||
// `err.code === 4001` saw undefined and could not tell a refusal from a
|
||||
// failure (#274). Asserting only the wire would leave that gap invisible.
|
||||
async function assertUserRejection(page, key, label) {
|
||||
const outcome = await settleRequest(page, key);
|
||||
assert(
|
||||
@@ -1621,15 +1620,32 @@ async function assertUserRejection(page, key, label) {
|
||||
" did not carry EIP-1193 code 4001 across the boundary: " +
|
||||
JSON.stringify(error),
|
||||
);
|
||||
assert(
|
||||
outcome.hasCode,
|
||||
label +
|
||||
" reached the page as an error with no code property at all, so a " +
|
||||
"dApp cannot tell the user's refusal from a failure: " +
|
||||
JSON.stringify(outcome),
|
||||
);
|
||||
assert(
|
||||
outcome.code === 4001,
|
||||
label +
|
||||
" reached the page with code " +
|
||||
JSON.stringify(outcome.code) +
|
||||
" rather than EIP-1193 4001",
|
||||
);
|
||||
assert(
|
||||
outcome.name === "ProviderRpcError",
|
||||
label +
|
||||
" reached the page as " +
|
||||
JSON.stringify(outcome.name) +
|
||||
" rather than an EIP-1193 ProviderRpcError",
|
||||
);
|
||||
console.log(
|
||||
"# " +
|
||||
label +
|
||||
": boundary code=" +
|
||||
error.code +
|
||||
" page Error.code=" +
|
||||
JSON.stringify(outcome.code) +
|
||||
" page Error carries a code=" +
|
||||
outcome.hasCode,
|
||||
": code 4001 on the wire and on the page's " +
|
||||
outcome.name,
|
||||
);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
310
tests/inpageErrors.test.js
Normal file
310
tests/inpageErrors.test.js
Normal file
@@ -0,0 +1,310 @@
|
||||
// The EIP-1193 error the page actually catches (src/content/inpage.js).
|
||||
//
|
||||
// The bug this pins down (issue #274): the provider rebuilt every failure as
|
||||
// `new Error(error.message)`, so the `code` the background produced and the
|
||||
// content script relayed intact was thrown away in the last hop. A dApp
|
||||
// checking `err.code === 4001` — the standard way to tell "the user said no"
|
||||
// from "the wallet broke" — saw undefined, and well-behaved sites showed an
|
||||
// error or retried instead of accepting the refusal.
|
||||
//
|
||||
// inpage.js is a bare IIFE injected into the page's JS context, not a module:
|
||||
// it takes no import and exports nothing, and reaches for `window` at load.
|
||||
// So it is evaluated here the way the browser evaluates it, against a stub
|
||||
// window, and the provider is collected from `window.ethereum`. The globals it
|
||||
// touches are passed in as function parameters rather than assigned to
|
||||
// globalThis: nothing leaks between tests, and the source is compiled in this
|
||||
// realm, so the errors it constructs are comparable against this file's own
|
||||
// `Error` — which a second realm's intrinsics would silently defeat.
|
||||
//
|
||||
// There is no jsdom in this repo; see tests/txStatus.test.js.
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { webcrypto } = require("crypto");
|
||||
|
||||
const SOURCE = fs.readFileSync(
|
||||
path.join(__dirname, "..", "src", "content", "inpage.js"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const loadInto = new Function(
|
||||
"window",
|
||||
"self",
|
||||
"crypto",
|
||||
"Event",
|
||||
"CustomEvent",
|
||||
SOURCE,
|
||||
);
|
||||
|
||||
class StubEvent {
|
||||
constructor(type) {
|
||||
this.type = type;
|
||||
}
|
||||
}
|
||||
|
||||
class StubCustomEvent extends StubEvent {
|
||||
constructor(type, init) {
|
||||
super(type);
|
||||
this.detail = init && init.detail;
|
||||
}
|
||||
}
|
||||
|
||||
// Every code the background emits on the RPC path today, read out of
|
||||
// src/background/index.js. The provider must not know this list — it passes
|
||||
// through whatever arrived — but the cases below are the real ones.
|
||||
const REJECTED = 4001; // user rejected the request
|
||||
const UNAUTHORIZED = 4100; // site not connected / wrong address
|
||||
const UNRECOGNIZED_CHAIN = 4902; // switch/add to an unsupported chain
|
||||
|
||||
// A stub window with the four things inpage.js touches: message listeners,
|
||||
// postMessage out to the content script, window.ethereum, and dispatchEvent
|
||||
// for the EIP-6963 announcement.
|
||||
function loadProvider() {
|
||||
const messageListeners = [];
|
||||
const posted = [];
|
||||
|
||||
const win = {
|
||||
addEventListener(type, fn) {
|
||||
if (type === "message") messageListeners.push(fn);
|
||||
},
|
||||
removeEventListener(type, fn) {
|
||||
const i = messageListeners.indexOf(fn);
|
||||
if (type === "message" && i !== -1) messageListeners.splice(i, 1);
|
||||
},
|
||||
postMessage(data) {
|
||||
posted.push(data);
|
||||
},
|
||||
dispatchEvent() {
|
||||
return true;
|
||||
},
|
||||
};
|
||||
win.window = win;
|
||||
|
||||
loadInto(win, win, webcrypto, StubEvent, StubCustomEvent);
|
||||
|
||||
// Deliver the content script's answer to an outstanding request. The id is
|
||||
// read back off the wire rather than assumed: inpage.js issues its own
|
||||
// eth_chainId at load, so the first id a test sees is not 1.
|
||||
function respond(response) {
|
||||
const request = posted
|
||||
.filter((m) => m.type === "AUTISTMASK_REQUEST")
|
||||
.pop();
|
||||
expect(request).toBeDefined();
|
||||
const event = {
|
||||
source: win,
|
||||
data: { type: "AUTISTMASK_RESPONSE", id: request.id, ...response },
|
||||
};
|
||||
for (const fn of messageListeners.slice()) fn(event);
|
||||
}
|
||||
|
||||
return { provider: win.ethereum, posted, respond };
|
||||
}
|
||||
|
||||
// Start a request, answer it with `response`, and hand back the rejection.
|
||||
// Fails the test if the call resolves instead.
|
||||
async function rejectionFrom(start, response) {
|
||||
const { provider, respond } = loadProvider();
|
||||
const settled = start(provider).then(
|
||||
(result) => ({ resolved: result }),
|
||||
(error) => ({ error }),
|
||||
);
|
||||
// The provider posts synchronously, so the request is already on the wire.
|
||||
respond(response);
|
||||
const outcome = await settled;
|
||||
expect(outcome).not.toHaveProperty("resolved");
|
||||
return outcome.error;
|
||||
}
|
||||
|
||||
describe("an EIP-1193 code reaches the page", () => {
|
||||
test("a user rejection arrives as code 4001", async () => {
|
||||
const err = await rejectionFrom(
|
||||
(p) => p.request({ method: "eth_requestAccounts" }),
|
||||
{
|
||||
error: {
|
||||
code: REJECTED,
|
||||
message: "User rejected the request.",
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(err.code).toBe(REJECTED);
|
||||
expect(err.message).toBe("User rejected the request.");
|
||||
});
|
||||
|
||||
test("it is a ProviderRpcError, and an Error", async () => {
|
||||
const err = await rejectionFrom(
|
||||
(p) => p.request({ method: "eth_requestAccounts" }),
|
||||
{
|
||||
error: {
|
||||
code: REJECTED,
|
||||
message: "User rejected the request.",
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.name).toBe("ProviderRpcError");
|
||||
});
|
||||
|
||||
test("4100 unauthorized arrives intact", async () => {
|
||||
const err = await rejectionFrom(
|
||||
(p) => p.request({ method: "personal_sign", params: ["0x00"] }),
|
||||
{ error: { code: UNAUTHORIZED, message: "Unauthorized" } },
|
||||
);
|
||||
expect(err.code).toBe(UNAUTHORIZED);
|
||||
expect(err.message).toBe("Unauthorized");
|
||||
});
|
||||
|
||||
test("4902 unrecognized chain arrives intact", async () => {
|
||||
const message =
|
||||
"AutistMask supports Ethereum Mainnet and Sepolia Testnet only.";
|
||||
const err = await rejectionFrom(
|
||||
(p) => p.request({ method: "wallet_switchEthereumChain" }),
|
||||
{ error: { code: UNRECOGNIZED_CHAIN, message } },
|
||||
);
|
||||
expect(err.code).toBe(UNRECOGNIZED_CHAIN);
|
||||
expect(err.message).toBe(message);
|
||||
});
|
||||
|
||||
// The provider is not allowed to know the list above: a code added to the
|
||||
// background later must reach the page without this file being edited.
|
||||
test("a code the provider has never heard of is passed through", async () => {
|
||||
const err = await rejectionFrom(
|
||||
(p) => p.request({ method: "eth_accounts" }),
|
||||
{ error: { code: 4900, message: "Disconnected" } },
|
||||
);
|
||||
expect(err.code).toBe(4900);
|
||||
});
|
||||
|
||||
test("data is carried when the boundary sent it", async () => {
|
||||
const err = await rejectionFrom(
|
||||
(p) => p.request({ method: "eth_call" }),
|
||||
{
|
||||
error: {
|
||||
code: -32000,
|
||||
message: "execution reverted",
|
||||
data: "0x08c379a0",
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(err.code).toBe(-32000);
|
||||
expect(err.data).toBe("0x08c379a0");
|
||||
});
|
||||
|
||||
test("no data property is invented when the boundary sent none", async () => {
|
||||
const err = await rejectionFrom(
|
||||
(p) => p.request({ method: "eth_requestAccounts" }),
|
||||
{
|
||||
error: {
|
||||
code: REJECTED,
|
||||
message: "User rejected the request.",
|
||||
},
|
||||
},
|
||||
);
|
||||
expect("data" in err).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the message is untouched", () => {
|
||||
test("a coded error keeps the message byte for byte", async () => {
|
||||
const message =
|
||||
"This site asked to sign as an address that is not " +
|
||||
"the active one.";
|
||||
const err = await rejectionFrom(
|
||||
(p) => p.request({ method: "personal_sign" }),
|
||||
{ error: { code: UNAUTHORIZED, message } },
|
||||
);
|
||||
expect(err.message).toBe(message);
|
||||
});
|
||||
|
||||
test("an error the background sent with no code keeps its message", async () => {
|
||||
const err = await rejectionFrom(
|
||||
(p) => p.request({ method: "eth_sendTransaction" }),
|
||||
{ error: { message: "No accounts available" } },
|
||||
);
|
||||
expect(err.message).toBe("No accounts available");
|
||||
});
|
||||
|
||||
// A ProviderRpcError whose code is undefined would claim a conformance it
|
||||
// does not have, and `'code' in err` is exactly what a careful dApp asks.
|
||||
test("an error with no code gets no code property at all", async () => {
|
||||
const err = await rejectionFrom(
|
||||
(p) => p.request({ method: "eth_sendTransaction" }),
|
||||
{ error: { message: "No accounts available" } },
|
||||
);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect("code" in err).toBe(false);
|
||||
});
|
||||
|
||||
test("an error with no message keeps the generic fallback", async () => {
|
||||
const err = await rejectionFrom(
|
||||
(p) => p.request({ method: "eth_sendTransaction" }),
|
||||
{ error: { code: REJECTED } },
|
||||
);
|
||||
expect(err.message).toBe("Request failed");
|
||||
expect(err.code).toBe(REJECTED);
|
||||
});
|
||||
});
|
||||
|
||||
// Every entry point the provider exposes, not just eth_requestAccounts. They
|
||||
// all funnel through the same response listener, and this is what says so.
|
||||
describe("every request path carries the code", () => {
|
||||
const rejection = {
|
||||
error: { code: REJECTED, message: "User rejected the request." },
|
||||
};
|
||||
|
||||
test("request()", async () => {
|
||||
const err = await rejectionFrom(
|
||||
(p) => p.request({ method: "eth_requestAccounts" }),
|
||||
rejection,
|
||||
);
|
||||
expect(err.code).toBe(REJECTED);
|
||||
});
|
||||
|
||||
test("enable()", async () => {
|
||||
const err = await rejectionFrom((p) => p.enable(), rejection);
|
||||
expect(err.code).toBe(REJECTED);
|
||||
});
|
||||
|
||||
test("send(method, params)", async () => {
|
||||
const err = await rejectionFrom(
|
||||
(p) => p.send("eth_requestAccounts", []),
|
||||
rejection,
|
||||
);
|
||||
expect(err.code).toBe(REJECTED);
|
||||
});
|
||||
|
||||
test("send({ method, params })", async () => {
|
||||
const err = await rejectionFrom(
|
||||
(p) => p.send({ method: "personal_sign", params: ["0x00"] }),
|
||||
rejection,
|
||||
);
|
||||
expect(err.code).toBe(REJECTED);
|
||||
});
|
||||
|
||||
test("sendAsync() hands the code to its callback", async () => {
|
||||
const { provider, respond } = loadProvider();
|
||||
const called = new Promise((resolve) => {
|
||||
provider.sendAsync({ id: 1, method: "eth_requestAccounts" }, (e) =>
|
||||
resolve(e),
|
||||
);
|
||||
});
|
||||
respond(rejection);
|
||||
const err = await called;
|
||||
expect(err.name).toBe("ProviderRpcError");
|
||||
expect(err.code).toBe(REJECTED);
|
||||
expect(err.message).toBe("User rejected the request.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the success path is unchanged", () => {
|
||||
test("a result still resolves", async () => {
|
||||
const { provider, respond } = loadProvider();
|
||||
const settled = provider.request({ method: "eth_requestAccounts" });
|
||||
respond({ result: ["0xb61264DEFB0c4B8afb3D73724be15310036743a5"] });
|
||||
await expect(settled).resolves.toEqual([
|
||||
"0xb61264DEFB0c4B8afb3D73724be15310036743a5",
|
||||
]);
|
||||
expect(provider.selectedAddress).toBe(
|
||||
"0xb61264DEFB0c4B8afb3D73724be15310036743a5",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user