fix: failed requests reach the page with an error code and the node's data (closes #533)
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
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
// 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));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user