// 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", ); }); });