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
+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 {