fix: refuse an unsupported method with EIP-1193 code 4200 #421

Merged
clawbot merged 1 commits from issue-279-unsupported-method-4200 into next 2026-10-04 09:24:42 +02:00
4 changed files with 78 additions and 11 deletions
+7
View File
@@ -45,6 +45,13 @@ but the review is broader than any of them.
# Completed Steps
- 2026-10-04: A method the wallet does not implement is refused with EIP-1193
code `4200` ([#279](https://git.eeqj.de/sneak/AutistMask/issues/279)). The
background's `Unsupported method: <method>` error carried no code, so a site
probing for an optional method could not tell "not implemented" from "the call
failed". The message is unchanged; the background's other errors with no code
are untouched.
- 2026-10-04: Settings lists the sites connected without "Remember", and
removing a site there disconnects it
([#406](https://git.eeqj.de/sneak/AutistMask/issues/406)). Such a connection
+3 -1
View File
@@ -932,7 +932,9 @@ async function handleRpc(method, params, origin) {
}
}
return { error: { message: "Unsupported method: " + method } };
// EIP-1193 4200 lets a site tell "this wallet does not implement that"
// from "that call failed", and fall back.
return { error: { code: 4200, message: "Unsupported method: " + method } };
}
// The body of eth_sendTransaction, from the connection check through to the
+6 -5
View File
@@ -31,11 +31,12 @@
// 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.
// matched against a list: the extension emits codes such as 4001, 4100,
// 4200 and 4902, 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) {
+62 -5
View File
@@ -49,11 +49,12 @@ class StubCustomEvent extends StubEvent {
}
}
// 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.
// Examples of codes the background emits on the RPC path, read out of
// src/background/index.js. The provider must not know any list of codes — it
// passes through whatever arrived — but the cases below are real ones.
const REJECTED = 4001; // user rejected the request
const UNAUTHORIZED = 4100; // site not connected / wrong address
const UNSUPPORTED_METHOD = 4200; // a method the wallet does not implement
const UNRECOGNIZED_CHAIN = 4902; // switch/add to an unsupported chain
// A stub window with the four things inpage.js touches: message listeners,
@@ -115,6 +116,41 @@ async function rejectionFrom(start, response) {
return outcome.error;
}
// The reply the real background worker (src/background/index.js) sends for
// `method`, loaded against just enough of the extension API to receive one
// RPC message. Same shape as tests/coldWorkerChainId.test.js.
function backgroundReply(method) {
jest.resetModules();
jest.doMock("../src/shared/alarms", () => ({
BALANCE_REFRESH_ALARM: "balance",
BALANCE_REFRESH_PERIOD_MINUTES: 1,
ensureRecurringAlarms: async () => {},
registerAlarmHandlers: () => {},
}));
let messageListener = null;
global.chrome = {
runtime: {
onMessage: {
addListener: (fn) => {
messageListener = fn;
},
},
onConnect: { addListener: () => {} },
},
};
require("../src/background/index");
return new Promise((resolve) => {
messageListener(
{ type: "AUTISTMASK_RPC", method, params: [] },
{ origin: "https://dapp.example" },
resolve,
);
});
}
describe("an EIP-1193 code reaches the page", () => {
test("a user rejection arrives as code 4001", async () => {
const err = await rejectionFrom(
@@ -164,8 +200,9 @@ describe("an EIP-1193 code reaches the page", () => {
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.
// The provider is not allowed to know the codes above: any other code,
// including one added to the background later, must reach the page
// without inpage.js 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" }),
@@ -203,6 +240,26 @@ describe("an EIP-1193 code reaches the page", () => {
});
});
// The reply here is the background's own, not one written in this file: it
// used to carry no code for a method the wallet does not implement
// (https://git.eeqj.de/sneak/AutistMask/issues/279), so a site probing for an
// optional method could not tell "not implemented" from "the call failed".
describe("a method the wallet does not implement", () => {
afterEach(() => {
delete global.chrome;
});
test("reaches the page as code 4200", async () => {
const method = "wallet_noSuchMethod";
const err = await rejectionFrom(
(p) => p.request({ method }),
await backgroundReply(method),
);
expect(err.code).toBe(UNSUPPORTED_METHOD);
expect(err.message).toBe("Unsupported method: " + method);
});
});
describe("the message is untouched", () => {
test("a coded error keeps the message byte for byte", async () => {
const message =