diff --git a/TODO.md b/TODO.md index e318de9..d3898e5 100644 --- a/TODO.md +++ b/TODO.md @@ -45,6 +45,20 @@ undefined identifiers, which is how # Completed Steps +- 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` — diff --git a/src/content/inpage.js b/src/content/inpage.js index 8147f49..ee19d0d 100644 --- a/src/content/inpage.js +++ b/src/content/inpage.js @@ -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); } diff --git a/tests/e2e/network.js b/tests/e2e/network.js index 260b9d7..64f43a3 100644 --- a/tests/e2e/network.js +++ b/tests/e2e/network.js @@ -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,", " };", diff --git a/tests/e2e/run.js b/tests/e2e/run.js index c02604f..409d198 100644 --- a/tests/e2e/run.js +++ b/tests/e2e/run.js @@ -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; } diff --git a/tests/inpageErrors.test.js b/tests/inpageErrors.test.js new file mode 100644 index 0000000..c45446b --- /dev/null +++ b/tests/inpageErrors.test.js @@ -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", + ); + }); +});