A page's request that the node refuses now reaches it with the node's own JSON-RPC code and data: a proxied read such as a reverting eth_call, a transaction whose gas estimate reverts, and a failed broadcast. Every other refusal carries a code too: 4100 when there is no address, -32602 when the site asks for two different gas limits or a transaction type this wallet does not sign, and -32603 for a failure inside the wallet. nodeError() in approvalVerify.js reads the node's error out of what ethers throws, as failureTexts() already did inline. 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));
|
|
});
|
|
});
|