diff --git a/TODO.md b/TODO.md index 49525b6..7004d95 100644 --- a/TODO.md +++ b/TODO.md @@ -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: ` 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 diff --git a/src/background/index.js b/src/background/index.js index 42b5c27..5172cba 100644 --- a/src/background/index.js +++ b/src/background/index.js @@ -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 diff --git a/src/content/inpage.js b/src/content/inpage.js index 604e5e4..38f57a8 100644 --- a/src/content/inpage.js +++ b/src/content/inpage.js @@ -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) { diff --git a/tests/inpageErrors.test.js b/tests/inpageErrors.test.js index c45446b..f6af1d3 100644 --- a/tests/inpageErrors.test.js +++ b/tests/inpageErrors.test.js @@ -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 =