fix: failed requests reach the page with an error code and the node's data (closes #533)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run

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
This commit was merged in pull request #565.
This commit is contained in:
2026-10-18 22:12:10 +02:00
parent e48308c5b6
commit f00954e2c5
6 changed files with 390 additions and 30 deletions
+4 -2
View File
@@ -2200,8 +2200,10 @@ view would leave a wallet one click from deletion.
one shown and signed in place of the node's estimate, but the node still
estimates the transaction with that limit, as the check that it neither
reverts nor runs out of gas; a request that gives both with different values
is refused. A request that cannot be populated — unreachable node, reverting
gas estimate — opens no window and is failed back to the site. Only one
is refused with EIP-1474 code `-32602`. A request that cannot be populated —
unreachable node, reverting gas estimate — opens no window and is failed back
to the site, with the node's own error code and data when the node refused it,
so the site can decode a revert, and with `-32603` otherwise. Only one
transaction approval exists at a time: populating fixes the nonce, so a second
`eth_sendTransaction` arriving while one is unanswered is refused with
EIP-1193 code `-32002` rather than being populated at the same nonce. It opens
+45 -20
View File
@@ -26,6 +26,7 @@ const {
typedDataMessageRefusal,
typedDataChainRefusal,
failureIsRetryable,
nodeError,
describeTxFailure,
sameAddress,
ApprovalMismatchError,
@@ -272,6 +273,21 @@ function failureError(err) {
return { code: INTERNAL_ERROR_CODE, message: INTERNAL_ERROR_MESSAGE };
}
// The EIP-1193 error for a request that failed, with `message` as the
// sentence the page is shown. `rpcError` holds the JSON-RPC code and data the
// failure carries, if any: the node's own when the node refused the request,
// passed on unchanged so a site can tell a revert from any other failure and
// decode its revert data. A failure with no numeric code is the wallet's own,
// and is answered with the internal error code above.
function requestError(message, rpcError) {
if (rpcError && typeof rpcError.code === "number") {
const error = { code: rpcError.code, message };
if (rpcError.data !== undefined) error.data = rpcError.data;
return error;
}
return { code: INTERNAL_ERROR_CODE, message };
}
// The active address of a profile snapshot. Pure, and taking the snapshot as
// an argument rather than reading storage itself: a handler that has already
// read state must not answer "which account is this" from a SECOND, later read
@@ -317,7 +333,8 @@ function extractHostname(origin) {
}
}
// Proxy an RPC call to the Ethereum node
// Proxy an RPC call to the Ethereum node. An error the node answers with is
// thrown with the node's own JSON-RPC `code` and `data` on it, for the page.
async function proxyRpc(method, params) {
const rpcUrl = await getRpcUrl();
const resp = await debugFetch(rpcUrl, {
@@ -332,7 +349,10 @@ async function proxyRpc(method, params) {
});
const json = await resp.json();
if (json.error) {
throw new Error(json.error.message || "RPC error");
const err = new Error(json.error.message || "RPC error");
err.code = json.error.code;
err.data = json.error.data;
throw err;
}
return json.result;
}
@@ -728,7 +748,7 @@ async function handleConnectionRequest(origin) {
const s = await getState();
const activeAddress = activeAddressOf(s);
if (!activeAddress) {
return { error: { message: "No accounts available" } };
return { error: { code: 4100, message: "No accounts available" } };
}
const allowed = s.allowedSites[activeAddress] || [];
@@ -992,7 +1012,7 @@ async function handleRpc(method, params, origin) {
const s = await getState();
const activeAddress = activeAddressOf(s);
if (!activeAddress)
return { error: { message: "No accounts available" } };
return { error: { code: 4100, message: "No accounts available" } };
const allowed = s.allowedSites[activeAddress] || [];
if (
@@ -1046,7 +1066,7 @@ async function handleRpc(method, params, origin) {
const s = await getState();
const activeAddress = activeAddressOf(s);
if (!activeAddress)
return { error: { message: "No accounts available" } };
return { error: { code: 4100, message: "No accounts available" } };
const allowed = s.allowedSites[activeAddress] || [];
if (
@@ -1093,12 +1113,13 @@ async function handleRpc(method, params, origin) {
const result = await proxyRpc(method, params);
return { result };
} catch (e) {
// A node that answered with an error is reported as itself. The
// wallet being unable to read its own profile is not that, and it
// must not be flattened into a message with no code: it goes back
// to the dispatcher, which has the one answer for it.
// A node that answered with an error is reported as itself, with
// its own code and data. The wallet being unable to read its own
// profile is not that, and it must not be answered as an ordinary
// failure: it goes back to the dispatcher, which has the one
// answer for it.
if (e instanceof StateUnusableError) throw e;
return { error: { message: e.message } };
return { error: requestError(e.message, e) };
}
}
@@ -1114,7 +1135,9 @@ async function handleRpc(method, params, origin) {
async function handleSendTransaction(params, origin) {
const s = await getState();
const activeAddress = activeAddressOf(s);
if (!activeAddress) return { error: { message: "No accounts available" } };
if (!activeAddress) {
return { error: { code: 4100, message: "No accounts available" } };
}
const allowed = s.allowedSites[activeAddress] || [];
if (
@@ -1173,7 +1196,7 @@ async function handleSendTransaction(params, origin) {
txParams,
);
} catch (e) {
return { error: { message: e.message } };
return { error: requestError(e.message, e) };
}
// Population is a network round trip, and the user can switch address
@@ -1183,10 +1206,9 @@ async function handleSendTransaction(params, origin) {
// while the page is still waiting and nothing has been displayed.
if (!sameAddress(await getActiveAddress(), activeAddress)) {
return {
error: {
message:
"The active address changed while this transaction was being prepared, so it was not sent.",
},
error: requestError(
"The active address changed while this transaction was being prepared, so it was not sent.",
),
};
}
@@ -1679,7 +1701,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (outcome.spendApproval) {
settleApproval(
msg.id,
{ error: { message: outcome.error } },
{ error: requestError(outcome.error) },
{ holdsClaim: true },
);
} else {
@@ -1706,7 +1728,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
const outcome = describeTxFailure(TX_STAGE_NONCE, null);
settleApproval(
msg.id,
{ error: { message: outcome.error } },
{ error: requestError(outcome.error) },
{ holdsClaim: true },
);
sendResponse({
@@ -1739,10 +1761,13 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
// that says plainly it did not take the transaction:
// describeTxFailure() reclassifies that, and the stage it
// returns is the one reported.
//
// A node that refused the transaction gives the page its own
// code and data, whatever sentence the page is shown.
const outcome = describeTxFailure(TX_STAGE_BROADCAST, e);
settleApproval(
msg.id,
{ error: { message: outcome.error } },
{ error: requestError(outcome.error, nodeError(e)) },
{ holdsClaim: true },
);
sendResponse({
@@ -1864,7 +1889,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (!retryable) {
settleApproval(
msg.id,
{ error: { message: errMsg } },
{ error: requestError(errMsg) },
{ holdsClaim: true },
);
} else {
+23 -3
View File
@@ -41,6 +41,7 @@ const {
ALLOWED_TX_TYPES,
SERIALIZED_FIELDS,
assertWithinCeilings,
nodeError,
} = require("./approvalVerify");
// How long the population may take before the request is failed back to the
@@ -72,15 +73,27 @@ const REQUEST_FIELDS = [
"type",
];
// EIP-1474's code for invalid parameters: what the page is given when the
// site asked for a transaction this wallet refuses as it was asked for.
const INVALID_PARAMS_CODE = -32602;
// Why no approval can be raised. The message is the sentence the page is
// shown. `code` and `data` are the JSON-RPC ones it is given with it: the
// node's own, unchanged, when the node refused the transaction, so a site can
// decode a revert; INVALID_PARAMS_CODE when the site's request is refused;
// none when the failure is the wallet's own, which the background answers as
// an internal error.
class ApprovalPrepareError extends Error {
constructor(message) {
constructor(message, code, data) {
super(message);
this.name = "ApprovalPrepareError";
if (code !== undefined) this.code = code;
if (data !== undefined) this.data = data;
}
}
function fail(message) {
return new ApprovalPrepareError(message);
function fail(message, code, data) {
return new ApprovalPrepareError(message, code, data);
}
function present(v) {
@@ -134,6 +147,7 @@ function requestFrom(txParams, from) {
) {
throw fail(
"The site asked for two different gas limits for this transaction.",
INVALID_PARAMS_CODE,
);
}
request.gasLimit = txParams.gas;
@@ -144,6 +158,7 @@ function requestFrom(txParams, from) {
) {
throw fail(
"The site asked for a transaction of a type this wallet does not sign.",
INVALID_PARAMS_CODE,
);
}
return request;
@@ -228,6 +243,9 @@ async function prepareApprovalTx(provider, from, txParams) {
);
} catch (e) {
if (e instanceof ApprovalPrepareError) throw e;
// A refusal from the node, such as a reverting gas estimate, keeps
// the node's code and revert data.
const fromNode = nodeError(e);
throw fail(
sentence(
"The transaction could not be prepared: " +
@@ -235,6 +253,8 @@ async function prepareApprovalTx(provider, from, txParams) {
e.message ||
"the network did not answer"),
),
fromNode?.code,
fromNode?.data,
);
}
+11 -1
View File
@@ -746,6 +746,15 @@ function errorText(err) {
return "The transaction could not be sent.";
}
// The JSON-RPC error object the node answered with, `{ code, message, data }`,
// out of the error ethers threw for that answer. ethers keeps it under
// `info.error`, or under `error` when it could not classify it. Null when
// there is none, as for a node that could not be reached.
function nodeError(err) {
if (!err || typeof err !== "object") return null;
return err.error || (err.info && err.info.error) || null;
}
// Every string a failure might carry its reason in. ethers reports the node's
// own words in `shortMessage`, but a JSON-RPC error it could not classify is
// nested under `error` or `info.error` with the node's message intact, and the
@@ -757,7 +766,7 @@ function failureTexts(err) {
for (const text of [err.shortMessage, err.message, err.reason]) {
if (text) texts.push(String(text));
}
const nested = err.error || (err.info && err.info.error);
const nested = nodeError(err);
if (nested && nested.message) texts.push(String(nested.message));
return texts;
}
@@ -891,6 +900,7 @@ module.exports = {
assertWithinCeilings,
sameAddress,
failureIsRetryable,
nodeError,
isNonceCollision,
describeTxFailure,
describeSigningFailure,
+25 -4
View File
@@ -1161,9 +1161,12 @@ describe("a nonce collision is reported as a transaction that did not go out", (
const pending = bg.requestTx();
await settle();
// As ethers throws it: its own classification, and the node's
// JSON-RPC error under info.error.
bg.broadcastTransaction.mockRejectedValue(
Object.assign(new Error("nonce too low"), {
code: "NONCE_EXPIRED",
info: { error: { code: -32000, message: "nonce too low" } },
}),
);
const answer = bg.send(
@@ -1182,8 +1185,10 @@ describe("a nonce collision is reported as a transaction that did not go out", (
retryable: false,
stage: "nonce",
});
// The wallet's sentence, with the node's own code.
expect(pending.result()).toEqual({
error: {
code: -32000,
message: expect.stringMatching(
/transaction was not sent, because its nonce/,
),
@@ -1233,6 +1238,7 @@ describe("a nonce collision is reported as a transaction that did not go out", (
});
expect(second.result()).toEqual({
error: {
code: -32603,
message: expect.stringMatching(/nonce had already been used/),
},
});
@@ -1323,6 +1329,7 @@ describe("what the approval is verified against", () => {
);
expect(pending.result()).toEqual({
error: {
code: -32603,
message: expect.stringMatching(/approved maximum fee per gas/),
},
});
@@ -1382,7 +1389,10 @@ describe("what the approval is verified against", () => {
expect.objectContaining({ retryable: false, stage: "verify" }),
);
expect(pending.result()).toEqual({
error: { message: expect.stringMatching(/active address changed/) },
error: {
code: -32603,
message: expect.stringMatching(/active address changed/),
},
});
});
@@ -1526,7 +1536,10 @@ describe("what the approval is verified against", () => {
}),
);
expect(pending.result()).toEqual({
error: { message: expect.stringMatching(/active address changed/) },
error: {
code: -32603,
message: expect.stringMatching(/active address changed/),
},
});
});
@@ -1576,7 +1589,9 @@ describe("what the approval is verified against", () => {
error: refusal,
retryable: false,
});
expect(pending.result()).toEqual({ error: { message: refusal } });
expect(pending.result()).toEqual({
error: { code: -32603, message: refusal },
});
});
// ethers signs a `bool` field sent as the string "false" as true, and one
@@ -1611,7 +1626,9 @@ describe("what the approval is verified against", () => {
error: refusal,
retryable: false,
});
expect(pending.result()).toEqual({ error: { message: refusal } });
expect(pending.result()).toEqual({
error: { code: -32603, message: refusal },
});
},
);
@@ -1649,6 +1666,7 @@ describe("what the approval is verified against", () => {
expect(pending.result()).toEqual({
error: {
code: -32603,
message: expect.stringMatching(
/active address changed while this transaction was being prepared/,
),
@@ -1671,8 +1689,11 @@ describe("what the approval is verified against", () => {
const pending = bg.requestTx();
await settle();
// A failure with no node error in it is answered as the wallet's own;
// tests/rpcErrorCodes.test.js has the node's own revert.
expect(pending.result()).toEqual({
error: {
code: -32603,
message: expect.stringMatching(
/could not be prepared.*execution reverted/,
),
+282
View File
@@ -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));
});
});