All checks were successful
check / check (push) Successful in 28s
The provider rebuilt every rejection as a bare Error carrying only a message, so a dApp checking err.code === 4001 saw undefined and could not tell a user's deliberate refusal from a failure. Well-behaved sites therefore showed an error or retried instead of accepting the refusal. The code was produced correctly and did cross the extension boundary; it was lost in the last hop. Rejections now reach the page as a ProviderRpcError carrying code, and data where present. The code is passed through verbatim rather than matched against a whitelist, so a code added upstream later needs no change here. An error that genuinely has no code stays a plain Error with no code property at all, rather than advertising code: undefined -- 'code' in err is what a careful dApp asks. Messages are unchanged for every path, verified byte-for-byte against the previous provider across every background error shape. The end-to-end assertion that printed the observed code now requires it.
311 lines
11 KiB
JavaScript
311 lines
11 KiB
JavaScript
// 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",
|
|
);
|
|
});
|
|
});
|