A request the node refuses now reaches the page with the node's own JSON-RPC code and data: a reverting eth_call, an eth_sendTransaction whose gas estimate reverts, a failed broadcast. Every other refusal from the background carries a code too: 4100 when there is no address, -32603 for a failure inside the wallet, -32602 for a transaction request the wallet refuses as malformed. On the transaction path the page keeps the wallet's own sentence. nodeError(), which finds the node's error inside an ethers error, lives in src/shared/approvalVerify.js beside failureTexts(), which did the same lookup. Not run in a browser. Model: opus-5-5
283 lines
9.0 KiB
JavaScript
283 lines
9.0 KiB
JavaScript
// What a page is told when a request fails
|
|
// (https://git.eeqj.de/sneak/AutistMask/issues/533).
|
|
//
|
|
// EIP-1193 gives every rejected request a numeric `code`, and lets it carry
|
|
// `data`. A node's error used to reach the page as its message alone: a
|
|
// reverting eth_call lost code 3 and the revert data a site needs to decode
|
|
// the revert, and a site could not tell that failure from any other.
|
|
//
|
|
// Each request here goes from a page through the real background to a fake
|
|
// node, and what is checked is the answer the background gives the page. The
|
|
// content script relays that answer as it is, and src/content/inpage.js turns
|
|
// its code and data into the error the page catches (tests/inpageErrors.test.js).
|
|
//
|
|
// The background reaches a node two ways, and the fake node answers both:
|
|
// fetch(), which the read-only methods are proxied through, and ethers' own
|
|
// HTTP transport, which a transaction is prepared through.
|
|
|
|
const { AbiCoder } = require("ethers");
|
|
const { networkById } = require("../src/shared/networks");
|
|
const { makeStorageStub } = require("./support/storageStub");
|
|
|
|
const ORIGIN = "https://dapp.example";
|
|
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
|
const TOKEN = "0x1111111111111111111111111111111111111111";
|
|
const SEPOLIA = networkById("sepolia");
|
|
|
|
// Error("bal"), which a token whose transfer() does require(..., "bal")
|
|
// reverts with when the balance is too low.
|
|
const REVERT_DATA =
|
|
"0x08c379a0" +
|
|
AbiCoder.defaultAbiCoder().encode(["string"], ["bal"]).slice(2);
|
|
|
|
// What a node answers for a call that reverts, and for an address that is not
|
|
// one.
|
|
const REVERTED = {
|
|
code: 3,
|
|
message: "execution reverted: bal",
|
|
data: REVERT_DATA,
|
|
};
|
|
const INVALID_ADDRESS = { code: -32602, message: "invalid string length" };
|
|
|
|
const WITH_ADDRESS = {
|
|
hasWallet: true,
|
|
wallets: [
|
|
{
|
|
name: "Wallet 1",
|
|
type: "hd",
|
|
xpub: "xpub-1",
|
|
addresses: [
|
|
{ address: ADDRESS, balance: "0.0", tokenBalances: [] },
|
|
],
|
|
},
|
|
],
|
|
activeAddress: ADDRESS,
|
|
networkId: "sepolia",
|
|
rpcUrl: SEPOLIA.defaultRpcUrl,
|
|
blockscoutUrl: SEPOLIA.defaultBlockscoutUrl,
|
|
allowedSites: { [ADDRESS]: [ORIGIN] },
|
|
deniedSites: {},
|
|
trackedTokens: [],
|
|
};
|
|
|
|
const NO_ADDRESS = {
|
|
...WITH_ADDRESS,
|
|
hasWallet: false,
|
|
wallets: [],
|
|
activeAddress: null,
|
|
allowedSites: {},
|
|
};
|
|
|
|
const realFetch = globalThis.fetch;
|
|
|
|
afterEach(() => {
|
|
delete global.chrome;
|
|
globalThis.fetch = realFetch;
|
|
});
|
|
|
|
// The background, over a stored profile, with a node that answers each
|
|
// JSON-RPC method from `node`: a method's entry is the result, or
|
|
// `{ error }` for the error the node answers with. A method not in `node`
|
|
// is answered "method not found", so a test that reaches one fails on it.
|
|
// `node` null is a node that cannot be reached at all.
|
|
function loadBackground(profile, node) {
|
|
jest.resetModules();
|
|
|
|
jest.doMock("../src/shared/phishingDomains", () => ({
|
|
isPhishingDomain: () => false,
|
|
}));
|
|
jest.doMock("../src/shared/alarms", () => ({
|
|
BALANCE_REFRESH_ALARM: "balance",
|
|
BALANCE_REFRESH_PERIOD_MINUTES: 1,
|
|
ensureRecurringAlarms: jest.fn(async () => {}),
|
|
registerAlarmHandlers: jest.fn(),
|
|
}));
|
|
|
|
function reply({ id, method }) {
|
|
const entry = node[method];
|
|
if (entry === undefined) {
|
|
return {
|
|
jsonrpc: "2.0",
|
|
id,
|
|
error: { code: -32601, message: "method not found: " + method },
|
|
};
|
|
}
|
|
if (entry && entry.error)
|
|
return { jsonrpc: "2.0", id, error: entry.error };
|
|
return { jsonrpc: "2.0", id, result: entry };
|
|
}
|
|
|
|
globalThis.fetch = jest.fn(async (url, opts) => {
|
|
if (!node) throw new TypeError("fetch failed");
|
|
return { status: 200, json: async () => reply(JSON.parse(opts.body)) };
|
|
});
|
|
|
|
// The ethers the background is about to load: jest.resetModules() above
|
|
// means it is a fresh copy, not the one this file required at the top.
|
|
require("ethers").FetchRequest.registerGetUrl(async (req) => {
|
|
if (!node) throw new Error("connect ECONNREFUSED");
|
|
const body = JSON.parse(Buffer.from(req.body).toString("utf8"));
|
|
const answer = Array.isArray(body) ? body.map(reply) : reply(body);
|
|
return {
|
|
statusCode: 200,
|
|
statusMessage: "OK",
|
|
headers: { "content-type": "application/json" },
|
|
body: Buffer.from(JSON.stringify(answer)),
|
|
};
|
|
});
|
|
|
|
let messageListener = null;
|
|
global.chrome = {
|
|
storage: makeStorageStub({ autistmask: profile }),
|
|
runtime: {
|
|
getURL: (path) => "chrome-extension://autistmask/" + path,
|
|
onMessage: {
|
|
addListener: (fn) => {
|
|
messageListener = fn;
|
|
},
|
|
},
|
|
onConnect: { addListener: () => {} },
|
|
lastError: null,
|
|
},
|
|
windows: { onRemoved: { addListener: () => {} } },
|
|
action: { setPopup: () => {} },
|
|
};
|
|
|
|
require("../src/background/index");
|
|
|
|
// The answer the page gets to one request.
|
|
return function request(method, params) {
|
|
return new Promise((resolve) => {
|
|
messageListener(
|
|
{ type: "AUTISTMASK_RPC", method, params },
|
|
{ origin: ORIGIN },
|
|
resolve,
|
|
);
|
|
});
|
|
};
|
|
}
|
|
|
|
const TRANSFER = {
|
|
from: ADDRESS,
|
|
to: TOKEN,
|
|
// transfer(ADDRESS, 1)
|
|
data:
|
|
"0xa9059cbb" +
|
|
AbiCoder.defaultAbiCoder()
|
|
.encode(["address", "uint256"], [ADDRESS, 1])
|
|
.slice(2),
|
|
};
|
|
|
|
describe("a node's error reaches the page with the node's code and data", () => {
|
|
test("a reverting eth_call", async () => {
|
|
const request = loadBackground(WITH_ADDRESS, {
|
|
eth_call: { error: REVERTED },
|
|
});
|
|
|
|
const answer = await request("eth_call", [TRANSFER, "latest"]);
|
|
|
|
expect(answer).toEqual({ error: REVERTED });
|
|
});
|
|
|
|
test("an eth_getBalance for an address that is not one", async () => {
|
|
const request = loadBackground(WITH_ADDRESS, {
|
|
eth_getBalance: { error: INVALID_ADDRESS },
|
|
});
|
|
|
|
const answer = await request("eth_getBalance", ["0x1234", "latest"]);
|
|
|
|
expect(answer).toEqual({ error: INVALID_ADDRESS });
|
|
});
|
|
|
|
// The wallet's own sentence, with the node's code and revert data, so the
|
|
// site can decode why its transaction would revert.
|
|
test("an eth_sendTransaction whose gas estimate reverts", async () => {
|
|
const request = loadBackground(WITH_ADDRESS, {
|
|
eth_getTransactionCount: "0x7",
|
|
eth_estimateGas: { error: REVERTED },
|
|
});
|
|
|
|
const answer = await request("eth_sendTransaction", [TRANSFER]);
|
|
|
|
expect(answer).toEqual({
|
|
error: {
|
|
code: REVERTED.code,
|
|
message: expect.stringMatching(
|
|
/^The transaction could not be prepared: execution reverted/,
|
|
),
|
|
data: REVERT_DATA,
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
// One request for each way a request is refused without the node's code: by
|
|
// the wallet itself, or because the node could not be reached.
|
|
describe("no rejected request reaches the page without a numeric code", () => {
|
|
test.each([
|
|
[
|
|
"eth_requestAccounts with no address",
|
|
NO_ADDRESS,
|
|
{},
|
|
"eth_requestAccounts",
|
|
[],
|
|
4100,
|
|
],
|
|
[
|
|
"personal_sign with no address",
|
|
NO_ADDRESS,
|
|
{},
|
|
"personal_sign",
|
|
["0x00", ADDRESS],
|
|
4100,
|
|
],
|
|
[
|
|
"eth_signTypedData_v4 with no address",
|
|
NO_ADDRESS,
|
|
{},
|
|
"eth_signTypedData_v4",
|
|
[ADDRESS, "{}"],
|
|
4100,
|
|
],
|
|
[
|
|
"eth_sendTransaction with no address",
|
|
NO_ADDRESS,
|
|
{},
|
|
"eth_sendTransaction",
|
|
[TRANSFER],
|
|
4100,
|
|
],
|
|
[
|
|
"eth_sendTransaction with two different gas limits",
|
|
WITH_ADDRESS,
|
|
{},
|
|
"eth_sendTransaction",
|
|
[{ ...TRANSFER, gas: "0x5208", gasLimit: "0x5209" }],
|
|
-32602,
|
|
],
|
|
[
|
|
"eth_call to a node that cannot be reached",
|
|
WITH_ADDRESS,
|
|
null,
|
|
"eth_call",
|
|
[TRANSFER, "latest"],
|
|
-32603,
|
|
],
|
|
[
|
|
"eth_sendTransaction to a node that cannot be reached",
|
|
WITH_ADDRESS,
|
|
null,
|
|
"eth_sendTransaction",
|
|
[TRANSFER],
|
|
-32603,
|
|
],
|
|
])("%s", async (_name, profile, node, method, params, code) => {
|
|
const request = loadBackground(profile, node);
|
|
|
|
const answer = await request(method, params);
|
|
|
|
expect(answer.error.code).toBe(code);
|
|
expect(answer.error.message).toEqual(expect.any(String));
|
|
});
|
|
});
|