Compare commits

...

2 Commits

Author SHA1 Message Date
73db8eee43 fix: one transaction approval at a time, and honest copy for a nonce collision (closes #271)
All checks were successful
check / check (push) Successful in 40s
Populating the transaction in the background before the approval window
opens is what makes the displayed object the verified object. It also
fixes the nonce before the user has answered anything, so two
eth_sendTransaction calls populated concurrently took the same nonce
from a node that had seen neither of them broadcast, and the second
could never be sent: its approved nonce is spent, and the only way to
give it a fresh one is to populate it again after the user has read the
old one off the screen.

A second transaction approval is now refused while one is unanswered,
with EIP-1193 code -32002. The refusal happens before anything is
populated — no second nonce is allocated, no window opens — and the slot
is released when the requesting page has its answer. Signature approvals
are not gated; a signature consumes no nonce.

A collision that does happen is now reported for what it is. A broadcast
the node refused for the nonce, and an approval carrying a nonce this
worker has already broadcast (caught before the node is asked at all),
both report that the transaction did not reach the network and to send
it again, instead of the standing broadcast wording that warns it may
have sent. "already known" keeps that ambiguous wording deliberately: a
node that says it has the transaction has it.

Nothing about verification is weakened. The approval still carries the
transaction the screen displayed, and the artifact is still compared
against that object field for field.
2026-08-14 04:12:12 +00:00
9dcd875dd4 fix: carry EIP-1193 error codes through to the page (closes #274)
All checks were successful
check / check (push) Successful in 28s
The provider rebuilt every rejection as a bare Error carrying only a message,
so a dApp checking err.code === 4001 saw undefined and could not tell a user's
deliberate refusal from a failure. Well-behaved sites therefore showed an error
or retried instead of accepting the refusal. The code was produced correctly
and did cross the extension boundary; it was lost in the last hop.

Rejections now reach the page as a ProviderRpcError carrying code, and data
where present. The code is passed through verbatim rather than matched against
a whitelist, so a code added upstream later needs no change here. An error that
genuinely has no code stays a plain Error with no code property at all, rather
than advertising code: undefined -- 'code' in err is what a careful dApp asks.

Messages are unchanged for every path, verified byte-for-byte against the
previous provider across every background error shape.

The end-to-end assertion that printed the observed code now requires it.
2026-08-12 13:47:33 +02:00
10 changed files with 939 additions and 82 deletions

View File

@@ -1163,7 +1163,11 @@ on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
opening the window, so the screen shows a complete transaction and the signed opening the window, so the screen shows a complete transaction and the signed
artifact can be compared with it field for field. A request that cannot be artifact can be compared with it field for field. A request that cannot be
populated — unreachable node, reverting gas estimate — opens no window and is populated — unreachable node, reverting gas estimate — opens no window and is
failed back to the site. failed back to the site. 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 no window and takes no nonce, and the
site can send it again once the pending one is answered.
- **Elements**: - **Elements**:
- "Transaction Request" heading - "Transaction Request" heading
- Phishing warning banner (shown when the hostname is on the phishing - Phishing warning banner (shown when the hostname is on the phishing

31
TODO.md
View File

@@ -45,6 +45,37 @@ undefined identifiers, which is how
# Completed Steps # Completed Steps
- 2026-08-14: One transaction approval at a time. Populating in the background
before the window opens is what makes the displayed object the verified
object, and it also fixes the nonce: two `eth_sendTransaction` calls populated
concurrently took the same nonce from a node that had seen neither broadcast,
and the second could then never be sent, because the only way to give it a
fresh nonce is to populate it again after the user has read the old one off
the screen. A second request is now refused with EIP-1193 `-32002` while one
is unanswered — before anything is populated, so no second nonce is allocated
and no second window opens — and the slot is freed when the page has its
answer. Signature approvals are not gated, consuming no nonce. A collision
that does happen is also reported accurately now: a broadcast the node refused
for the nonce, and an approval carrying a nonce this worker has already
broadcast (caught before the node is asked at all), both say the transaction
did not reach the network and to send it again, instead of warning that it may
have sent. `already known` deliberately keeps the ambiguous wording, because a
node that says it has the transaction has it
([#271](https://git.eeqj.de/sneak/AutistMask/issues/271)).
- 2026-08-12: EIP-1193 error codes now reach the page. `src/content/inpage.js`
rebuilt every failure as `new Error(error.message)`, so the code the
background produced and the content script relayed intact was dropped in the
last hop and a dApp checking `err.code === 4001` saw `undefined` — a wallet
the user deliberately declined was indistinguishable from one that broke. The
provider now rejects with a `ProviderRpcError` carrying `code` and, where the
boundary sent one, `data`, passed through verbatim rather than matched against
a list, so 4001, 4100 and 4902 all arrive and a future code needs no edit
here. An error the background sent with no code stays a plain `Error` with no
`code` property, and `message` is unchanged in every case. All four request
entry points (`request`, `enable`, `send`, `sendAsync`) are covered by
`tests/inpageErrors.test.js`, and the e2e probe that printed the missing code
now requires it on the page's Error as well as on the wire, for all four
rejected flows ([#274](https://git.eeqj.de/sneak/AutistMask/issues/274)).
- 2026-08-12: `KNOWN_SYMBOLS` now maps a symbol to the set of contract addresses - 2026-08-12: `KNOWN_SYMBOLS` now maps a symbol to the set of contract addresses
that bear it, not to one of them. A ticker is not unique: seven of the 512 that bear it, not to one of them. A ticker is not unique: seven of the 512
bundled tokens — `FRAX`, `REUSD`, `TON`, `EURE`, `MSUSD`, `MUSD` and `JPYC` bundled tokens — `FRAX`, `REUSD`, `TON`, `EURE`, `MSUSD`, `MUSD` and `JPYC`

View File

@@ -24,6 +24,7 @@ const {
TX_STAGE_VERIFY, TX_STAGE_VERIFY,
TX_STAGE_BROADCAST, TX_STAGE_BROADCAST,
TX_STAGE_INFLIGHT, TX_STAGE_INFLIGHT,
TX_STAGE_NONCE,
} = require("../shared/approvalVerify"); } = require("../shared/approvalVerify");
const { prepareApprovalTx } = require("../shared/approvalTx"); const { prepareApprovalTx } = require("../shared/approvalTx");
const { const {
@@ -57,6 +58,81 @@ const connectedSites = {};
// Pending approval requests: { id: { origin, hostname, resolve } } // Pending approval requests: { id: { origin, hostname, resolve } }
const pendingApprovals = {}; const pendingApprovals = {};
// One transaction approval at a time, wallet-wide.
//
// The transaction a site asks for is populated before its approval window
// opens, so that the object the user is shown is the object the signed
// artifact is verified against. Populating fixes the nonce. Two requests
// populated concurrently therefore take the SAME nonce — the node reports the
// same pending count to both, neither having been broadcast — and whichever is
// broadcast second is refused by the network for a nonce it can never be
// re-signed at, because re-signing it would mean signing something other than
// what was displayed.
//
// So the second request is refused while the first is unanswered. It is
// refused before anything is populated, so no second nonce is allocated at
// all, and while the page is still waiting with nothing on screen. The
// alternatives were considered and rejected in
// https://git.eeqj.de/sneak/AutistMask/issues/271: populating again at Confirm
// puts a nonce on screen that is not the nonce that gets signed, and
// allocating around in-flight approvals makes the wallet's own bookkeeping the
// authority on a nonce the network has not accepted, which an abandoned
// approval then leaves a hole in.
//
// Sign approvals are not gated: a signature consumes no nonce.
let txApprovalSlotHeld = false;
// EIP-1474 "resource unavailable": the standard code for a request that is
// refused because another one is already pending.
const TX_APPROVAL_PENDING_CODE = -32002;
const TX_APPROVAL_PENDING_MESSAGE =
"Another transaction is already waiting to be approved in AutistMask," +
" so this one was not sent. Please answer that request, then send this" +
" one again.";
// Take the slot, or refuse. Called before the first await of the
// eth_sendTransaction handler, so two requests arriving in the same tick
// cannot both pass it.
function reserveTxApprovalSlot() {
if (txApprovalSlotHeld) return false;
txApprovalSlotHeld = true;
return true;
}
function releaseTxApprovalSlot() {
txApprovalSlotHeld = false;
}
// Nonces this worker has already handed to the node, per address. This is the
// wallet's own knowledge that a nonce is spent, and it is checked before a
// broadcast rather than after: a node's pending count can lag a transaction it
// has itself just accepted, and a request populated inside that window would
// otherwise be signed and sent at a nonce this wallet has already used.
//
// The record dies with the worker, which is correct rather than merely
// convenient: after a restart the node's count is the only answer available,
// and a transaction of this wallet's that the node has forgotten is one the
// user does want to be able to send again.
const broadcastNonces = {};
function broadcastNoncesFor(address) {
const key = String(address || "").toLowerCase();
if (!broadcastNonces[key]) broadcastNonces[key] = new Set();
return broadcastNonces[key];
}
// An approved transaction's nonce as a decimal string, or null if it cannot be
// read as a number. Verification refuses an unreadable nonce before this is
// ever reached; null here only keeps the record from holding junk.
function approvedNonce(approvedTx) {
try {
return BigInt(approvedTx.nonce).toString();
} catch {
return null;
}
}
async function getState() { async function getState() {
const result = await storageApi.get("autistmask"); const result = await storageApi.get("autistmask");
return ( return (
@@ -585,68 +661,24 @@ async function handleRpc(method, params, origin) {
} }
if (method === "eth_sendTransaction") { if (method === "eth_sendTransaction") {
const s = await getState(); // Synchronous, before any await: two requests delivered in the same
const activeAddress = await getActiveAddress(); // tick must not both get past this.
if (!activeAddress) if (!reserveTxApprovalSlot()) {
return { error: { message: "No accounts available" } };
const hostname = extractHostname(origin);
const allowed = s.allowedSites[activeAddress] || [];
if (
!allowed.includes(hostname) &&
!connectedSites[origin + ":" + activeAddress]
) {
return { error: { code: 4100, message: "Unauthorized" } };
}
const txParams = params?.[0] || {};
if (namesAnotherAddress(txParams.from, activeAddress)) {
return { return {
error: { error: {
code: 4100, code: TX_APPROVAL_PENDING_CODE,
message: message: TX_APPROVAL_PENDING_MESSAGE,
"This site asked to send from an address that is not the active one.",
}, },
}; };
} }
// Populate here, before any window opens, so that the transaction the
// user is shown is a complete one and is the same object the signed
// artifact is checked against. A failure raises no approval at all and
// is reported to the requesting page; see approvalTx.js.
let approvedTx;
try { try {
approvedTx = await prepareApprovalTx( return await handleSendTransaction(params, origin);
getProvider(await getRpcUrl()), } finally {
activeAddress, // Held until the page has its answer — the approval was broadcast,
txParams, // rejected, or retired by a closed window — because until then its
); // nonce is allocated and unspent.
} catch (e) { releaseTxApprovalSlot();
return { error: { message: e.message } };
} }
// Population is a network round trip, and the user can switch address
// during it. Raising the approval anyway would put an account on the
// screen that the wallet is no longer on, and it could never be signed
// — the signing handler refuses exactly that. Refuse it here instead,
// 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.",
},
};
}
const decision = await requestTxApproval(
origin,
hostname,
approvedTx,
activeAddress,
);
if (decision.error) return { error: decision.error };
return { result: decision.txHash };
} }
// Proxy safe read-only methods to the RPC node // Proxy safe read-only methods to the RPC node
@@ -662,6 +694,73 @@ async function handleRpc(method, params, origin) {
return { error: { message: "Unsupported method: " + method } }; return { error: { message: "Unsupported method: " + method } };
} }
// The body of eth_sendTransaction, from the connection check through to the
// user's decision. Its caller holds the single transaction-approval slot for
// as long as this runs.
async function handleSendTransaction(params, origin) {
const s = await getState();
const activeAddress = await getActiveAddress();
if (!activeAddress) return { error: { message: "No accounts available" } };
const hostname = extractHostname(origin);
const allowed = s.allowedSites[activeAddress] || [];
if (
!allowed.includes(hostname) &&
!connectedSites[origin + ":" + activeAddress]
) {
return { error: { code: 4100, message: "Unauthorized" } };
}
const txParams = params?.[0] || {};
if (namesAnotherAddress(txParams.from, activeAddress)) {
return {
error: {
code: 4100,
message:
"This site asked to send from an address that is not the active one.",
},
};
}
// Populate here, before any window opens, so that the transaction the
// user is shown is a complete one and is the same object the signed
// artifact is checked against. A failure raises no approval at all and
// is reported to the requesting page; see approvalTx.js.
let approvedTx;
try {
approvedTx = await prepareApprovalTx(
getProvider(await getRpcUrl()),
activeAddress,
txParams,
);
} catch (e) {
return { error: { message: e.message } };
}
// Population is a network round trip, and the user can switch address
// during it. Raising the approval anyway would put an account on the
// screen that the wallet is no longer on, and it could never be signed
// — the signing handler refuses exactly that. Refuse it here instead,
// 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.",
},
};
}
const decision = await requestTxApproval(
origin,
hostname,
approvedTx,
activeAddress,
);
if (decision.error) return { error: decision.error };
return { result: decision.txHash };
}
// Broadcast chainChanged to all tabs when the network is switched. // Broadcast chainChanged to all tabs when the network is switched.
function broadcastChainChanged(chainId) { function broadcastChainChanged(chainId) {
tabsApi.query({}, (tabs) => { tabsApi.query({}, (tabs) => {
@@ -959,7 +1058,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
sendResponse({ sendResponse({
error: outcome.error, error: outcome.error,
retryable: outcome.retryable, retryable: outcome.retryable,
stage: TX_STAGE_SIGN, stage: outcome.stage,
}); });
return false; return false;
} }
@@ -1019,7 +1118,28 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
sendResponse({ sendResponse({
error: outcome.error, error: outcome.error,
retryable: outcome.retryable, retryable: outcome.retryable,
stage: TX_STAGE_VERIFY, stage: outcome.stage,
});
return;
}
// A nonce this worker has already broadcast for this address. The
// node is not asked: it has answered once already, and the wallet
// holding the receipt of that answer is what makes this failure
// one the user can be told did not reach the network.
const nonce = approvedNonce(approval.approvedTx);
const spent = broadcastNoncesFor(approval.approvedFrom);
if (nonce !== null && spent.has(nonce)) {
const outcome = describeTxFailure(TX_STAGE_NONCE, null);
settleApproval(
msg.id,
{ error: { message: outcome.error } },
{ holdsClaim: true },
);
sendResponse({
error: outcome.error,
retryable: outcome.retryable,
stage: outcome.stage,
}); });
return; return;
} }
@@ -1027,6 +1147,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
try { try {
const provider = getProvider(state.rpcUrl); const provider = getProvider(state.rpcUrl);
const tx = await provider.broadcastTransaction(msg.rawSignedTx); const tx = await provider.broadcastTransaction(msg.rawSignedTx);
if (nonce !== null) spent.add(nonce);
settleApproval( settleApproval(
msg.id, msg.id,
{ txHash: tx.hash }, { txHash: tx.hash },
@@ -1039,6 +1160,11 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
// tell a transaction that never left from one already in the // tell a transaction that never left from one already in the
// mempool. The page has been given its outcome for this // mempool. The page has been given its outcome for this
// request; a second attempt would report a second one. // request; a second attempt would report a second one.
//
// Unless the node blamed the nonce, which is the one answer
// that says plainly it did not take the transaction:
// describeTxFailure() reclassifies that, and the stage it
// returns is the one reported.
const outcome = describeTxFailure(TX_STAGE_BROADCAST, e); const outcome = describeTxFailure(TX_STAGE_BROADCAST, e);
settleApproval( settleApproval(
msg.id, msg.id,
@@ -1048,7 +1174,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
sendResponse({ sendResponse({
error: outcome.error, error: outcome.error,
retryable: outcome.retryable, retryable: outcome.retryable,
stage: TX_STAGE_BROADCAST, stage: outcome.stage,
}); });
} }
})(); })();

View File

@@ -11,6 +11,39 @@
let nextId = 1; let nextId = 1;
const pending = {}; const pending = {};
// EIP-1193 ProviderRpcError: `code`, `message`, optional `data`. A class
// rather than properties bolted onto an Error because this object crosses
// no boundary after construction — it is built in the page's own realm and
// handed straight to the caller's catch — so the prototype survives and
// `error.name` is a stable thing for a dApp to see.
class ProviderRpcError extends Error {
constructor(code, message, data) {
super(message);
this.name = "ProviderRpcError";
this.code = code;
if (data !== undefined) this.data = data;
}
}
// Rebuild a boundary error as the error the page catches, carrying the
// code (and data) the extension reported. Without this a dApp cannot tell
// a user's refusal (4001) from a wallet that broke, and retries or shows
// an error instead of accepting the refusal.
//
// Whatever code arrived is passed through verbatim rather than being
// matched against a list: the extension emits 4001, 4100 and 4902 today,
// and a code this file has never heard of is still the truth about what
// happened. An error reported with no code at all stays a plain Error —
// a ProviderRpcError whose `code` is undefined would advertise a
// conformance it does not have. `message` is untouched in every case.
function toPageError(error) {
const message = (error && error.message) || "Request failed";
if (error && error.code !== undefined && error.code !== null) {
return new ProviderRpcError(error.code, message, error.data);
}
return new Error(message);
}
// Listen for responses from the content script // Listen for responses from the content script
window.addEventListener("message", function onUuid(event) { window.addEventListener("message", function onUuid(event) {
if (event.source !== window) return; if (event.source !== window) return;
@@ -20,7 +53,7 @@
if (!p) return; if (!p) return;
delete pending[id]; delete pending[id];
if (error) { if (error) {
p.reject(new Error(error.message || "Request failed")); p.reject(toPageError(error));
} else { } else {
p.resolve(result); p.resolve(result);
} }

View File

@@ -602,6 +602,12 @@ const TX_STAGE_BROADCAST = "broadcast";
// may yet succeed, so the one thing the popup must not say is "start again // may yet succeed, so the one thing the popup must not say is "start again
// from the site". // from the site".
const TX_STAGE_INFLIGHT = "inflight"; const TX_STAGE_INFLIGHT = "inflight";
// A transaction refused for a nonce that is already spoken for, either by the
// node's own answer or by this wallet's record of what it has broadcast. It is
// the one broadcast-stage failure that is not ambiguous: the transaction was
// not taken, so the user is told it did not reach the network and to send it
// again, rather than being warned that it might already be out there.
const TX_STAGE_NONCE = "nonce";
function errorText(err) { function errorText(err) {
if (typeof err === "string" && err !== "") return err; if (typeof err === "string" && err !== "") return err;
@@ -611,6 +617,59 @@ function errorText(err) {
return "The transaction could not be sent."; return "The transaction could not be sent.";
} }
// 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
// classification below has to see that too.
function failureTexts(err) {
if (typeof err === "string") return [err];
if (!err || typeof err !== "object") return [];
const texts = [];
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);
if (nested && nested.message) texts.push(String(nested.message));
return texts;
}
// What the Ethereum clients say when a transaction's nonce is already spoken
// for: either it is below the account's next nonce, or another transaction is
// sitting in the pool at that nonce and this one did not outbid it. Either way
// the node answered, and its answer was that it did not take this transaction.
//
// "already known" is deliberately absent. A node that says it knows the
// transaction has it, so that transaction did reach the network and the
// ambiguous broadcast wording is the correct one for it.
const NONCE_COLLISION_PATTERNS = [
/nonce too low/i,
/nonce has already been used/i,
/invalid nonce/i,
/oldnonce/i,
/replacement transaction underpriced/i,
/replacement fee too low/i,
];
// ethers' own classification of the same two conditions.
const NONCE_COLLISION_CODES = ["NONCE_EXPIRED", "REPLACEMENT_UNDERPRICED"];
// Whether a failed send is a nonce collision.
function isNonceCollision(err) {
if (!err) return false;
if (err.code && NONCE_COLLISION_CODES.includes(err.code)) return true;
return failureTexts(err).some((text) =>
NONCE_COLLISION_PATTERNS.some((pattern) => pattern.test(text)),
);
}
// What both the requesting page and the popup are told about a nonce
// collision. The node's own words ("nonce too low") are a fragment and are
// replaced rather than passed through: they are not a sentence, and they say
// less than the wallet knows.
const NONCE_COLLISION_MESSAGE =
"The transaction was not sent, because its nonce had already been used" +
" by another transaction.";
// What the background does with a pending transaction approval after a failed // What the background does with a pending transaction approval after a failed
// attempt: what it tells the popup, and whether the approval is spent // attempt: what it tells the popup, and whether the approval is spent
// (resolved to the requesting page as an error and deleted) or left standing // (resolved to the requesting page as an error and deleted) or left standing
@@ -627,12 +686,31 @@ function errorText(err) {
// that never left from one that is already in the mempool. The approval is // that never left from one that is already in the mempool. The approval is
// spent and the requesting page has been given its outcome; a second // spent and the requesting page has been given its outcome; a second
// attempt against it would report a second outcome for one request. // attempt against it would report a second outcome for one request.
// - nonce: terminal too, and the one case where the wallet does know the
// transaction never left. The approval carries a nonce that is spent, so
// the artifact signed against it can never be accepted and the user is told
// to send it again from the site.
//
// The stage comes back out because a broadcast failure the node blamed on the
// nonce is reclassified here; the caller reports the stage this returns rather
// than the one it passed in.
function describeTxFailure(stage, err) { function describeTxFailure(stage, err) {
if (
stage === TX_STAGE_NONCE ||
(stage === TX_STAGE_BROADCAST && isNonceCollision(err))
) {
return {
error: NONCE_COLLISION_MESSAGE,
retryable: false,
spendApproval: true,
stage: TX_STAGE_NONCE,
};
}
const error = errorText(err); const error = errorText(err);
const retryable = const retryable =
stage === TX_STAGE_SIGN || stage === TX_STAGE_SIGN ||
(stage === TX_STAGE_VERIFY && failureIsRetryable(err)); (stage === TX_STAGE_VERIFY && failureIsRetryable(err));
return { error, retryable, spendApproval: !retryable }; return { error, retryable, spendApproval: !retryable, stage };
} }
// What the popup shows and does after the background reports a failed signing // What the popup shows and does after the background reports a failed signing
@@ -642,14 +720,20 @@ function describeTxFailure(stage, err) {
// //
// A failed broadcast gets its own wording: the transaction may already be on // A failed broadcast gets its own wording: the transaction may already be on
// the network, so telling the user to start again from the site is exactly the // the network, so telling the user to start again from the site is exactly the
// wrong instruction. // wrong instruction. A nonce collision is the exception to that exception —
// the transaction demonstrably did not go out, and saying it might have would
// send the user hunting for a transaction that does not exist.
function describeSigningFailure(response, fallbackMessage) { function describeSigningFailure(response, fallbackMessage) {
let message = (response && response.error) || fallbackMessage; let message = (response && response.error) || fallbackMessage;
if (!/[.!?]$/.test(message)) message += "."; if (!/[.!?]$/.test(message)) message += ".";
const retryable = !!(response && response.retryable); const retryable = !!(response && response.retryable);
const stage = response && response.stage; const stage = response && response.stage;
if (!retryable) { if (!retryable) {
if (stage === TX_STAGE_BROADCAST) { if (stage === TX_STAGE_NONCE) {
message +=
" The transaction did not reach the network." +
" Please send it again from the site.";
} else if (stage === TX_STAGE_BROADCAST) {
message += message +=
" The transaction may still have reached the network." + " The transaction may still have reached the network." +
" Check the account before sending it again."; " Check the account before sending it again.";
@@ -675,9 +759,11 @@ module.exports = {
assertWithinCeilings, assertWithinCeilings,
sameAddress, sameAddress,
failureIsRetryable, failureIsRetryable,
isNonceCollision,
describeTxFailure, describeTxFailure,
describeSigningFailure, describeSigningFailure,
ApprovalMismatchError, ApprovalMismatchError,
NONCE_COLLISION_MESSAGE,
ALLOWED_TX_TYPES, ALLOWED_TX_TYPES,
SERIALIZED_FIELDS, SERIALIZED_FIELDS,
FORBIDDEN_FIELDS, FORBIDDEN_FIELDS,
@@ -686,6 +772,7 @@ module.exports = {
TX_STAGE_VERIFY, TX_STAGE_VERIFY,
TX_STAGE_BROADCAST, TX_STAGE_BROADCAST,
TX_STAGE_INFLIGHT, TX_STAGE_INFLIGHT,
TX_STAGE_NONCE,
MAX_GAS_LIMIT, MAX_GAS_LIMIT,
MAX_FEE_PER_GAS, MAX_FEE_PER_GAS,
}; };

View File

@@ -14,8 +14,10 @@ const {
assertWithinCeilings, assertWithinCeilings,
sameAddress, sameAddress,
failureIsRetryable, failureIsRetryable,
isNonceCollision,
describeTxFailure, describeTxFailure,
describeSigningFailure, describeSigningFailure,
NONCE_COLLISION_MESSAGE,
ALLOWED_TX_TYPES, ALLOWED_TX_TYPES,
SERIALIZED_FIELDS, SERIALIZED_FIELDS,
FORBIDDEN_FIELDS, FORBIDDEN_FIELDS,
@@ -23,6 +25,7 @@ const {
TX_STAGE_SIGN, TX_STAGE_SIGN,
TX_STAGE_VERIFY, TX_STAGE_VERIFY,
TX_STAGE_BROADCAST, TX_STAGE_BROADCAST,
TX_STAGE_NONCE,
MAX_GAS_LIMIT, MAX_GAS_LIMIT,
MAX_FEE_PER_GAS, MAX_FEE_PER_GAS,
} = require("../src/shared/approvalVerify"); } = require("../src/shared/approvalVerify");
@@ -1191,7 +1194,6 @@ describe("signing failure and retry", () => {
"already known", "already known",
"timeout of 30000ms exceeded", "timeout of 30000ms exceeded",
"could not coalesce error", "could not coalesce error",
"replacement transaction underpriced",
]) { ]) {
const outcome = describeTxFailure( const outcome = describeTxFailure(
TX_STAGE_BROADCAST, TX_STAGE_BROADCAST,
@@ -1199,10 +1201,76 @@ describe("signing failure and retry", () => {
); );
expect(outcome.retryable).toBe(false); expect(outcome.retryable).toBe(false);
expect(outcome.spendApproval).toBe(true); expect(outcome.spendApproval).toBe(true);
expect(outcome.stage).toBe(TX_STAGE_BROADCAST);
expect(outcome.error).toBe(message); expect(outcome.error).toBe(message);
} }
}); });
// The one broadcast failure that is not ambiguous. The node answered, and
// its answer was that the nonce was already spoken for, so this
// transaction is not in a mempool anywhere.
test("a nonce the node refused is classified however it was worded", () => {
for (const err of [
new Error("nonce too low"),
new Error("replacement transaction underpriced"),
Object.assign(new Error("could not coalesce error"), {
code: "NONCE_EXPIRED",
}),
Object.assign(new Error("could not coalesce error"), {
code: "REPLACEMENT_UNDERPRICED",
}),
// The shape ethers hands up when it could not classify the node's
// error itself: the node's own words are nested underneath.
Object.assign(new Error("could not coalesce error"), {
info: { error: { code: -32000, message: "OldNonce" } },
}),
]) {
const outcome = describeTxFailure(TX_STAGE_BROADCAST, err);
expect(
describeSigningFailure(
outcome,
"The transaction could not be sent.",
).message,
).toMatch(/did not reach the network/);
expect(outcome.retryable).toBe(false);
expect(outcome.spendApproval).toBe(true);
expect(outcome.error).toBe(NONCE_COLLISION_MESSAGE);
expect(outcome.stage).toBe(TX_STAGE_NONCE);
expect(isNonceCollision(err)).toBe(true);
}
});
// A node that says it knows the transaction has it, so it did reach the
// network and the ambiguous wording is the correct one.
test("already known is not a nonce collision", () => {
const err = new Error("already known");
const outcome = describeTxFailure(TX_STAGE_BROADCAST, err);
expect(
describeSigningFailure(
outcome,
"The transaction could not be sent.",
).message,
).toMatch(/may still have reached the network/);
expect(outcome.stage).toBe(TX_STAGE_BROADCAST);
expect(isNonceCollision(err)).toBe(false);
});
test("a nonce collision says the transaction did not reach the network", () => {
const outcome = describeTxFailure(
TX_STAGE_BROADCAST,
new Error("nonce too low"),
);
const copy = describeSigningFailure(
outcome,
"The transaction could not be sent.",
);
expect(copy.retryable).toBe(false);
expect(copy.message).toMatch(/did not reach the network/);
expect(copy.message).not.toMatch(/may still have reached the network/);
expect(copy.message).toMatch(/Please send it again from the site\.$/);
expect(copy.message).toMatch(/^[A-Z].*\.$/);
});
test("a failed broadcast does not tell the user to send it again", () => { test("a failed broadcast does not tell the user to send it again", () => {
const outcome = describeSigningFailure( const outcome = describeSigningFailure(
{ {

View File

@@ -206,6 +206,10 @@ function loadBackground(options) {
// approval id back out of the popup URL the background opened. // approval id back out of the popup URL the background opened.
function requestTx(txParams) { function requestTx(txParams) {
let rpcResult = null; let rpcResult = null;
// The window this request opens, if it opens one. A request refused
// before an approval is raised opens none, and the window belonging to
// some other request must not be handed back as this one's.
const windowIndex = created.length;
const sendResponse = jest.fn((r) => { const sendResponse = jest.fn((r) => {
rpcResult = r; rpcResult = r;
}); });
@@ -219,7 +223,12 @@ function loadBackground(options) {
sendResponse, sendResponse,
); );
return { return {
id: () => new URL(created[0].url).searchParams.get("approval"), id: () =>
created.length > windowIndex
? new URL(created[windowIndex].url).searchParams.get(
"approval",
)
: null,
result: () => rpcResult, result: () => rpcResult,
}; };
} }
@@ -444,6 +453,176 @@ describe("one approval, one broadcast", () => {
}); });
}); });
// Populating the transaction before the approval window opens is what makes
// the displayed object the verified object. It also fixes the nonce before the
// user has answered anything: two requests populated concurrently take the
// same nonce from a node that has seen neither of them broadcast, and the
// second can then never be sent, because the only way to give it a fresh nonce
// is to populate it again after the user has read the old one off the screen.
// So the second request is refused while the first is unanswered.
describe("one transaction approval at a time", () => {
test("a second eth_sendTransaction while one is pending is refused before it takes a nonce", async () => {
const getTransactionCount = jest.fn(async () => NONCE);
const bg = loadBackground({ provider: { getTransactionCount } });
const first = bg.requestTx();
await settle();
expect(first.id()).toBeTruthy();
expect(getTransactionCount).toHaveBeenCalledTimes(1);
const second = bg.requestTx();
await settle();
expect(second.result()).toEqual({
error: {
code: -32002,
message: expect.stringMatching(
/already waiting to be approved/,
),
},
});
// Where the refusal happened matters as much as that it happened: no
// second window, and the node was never asked for a second nonce.
expect(bg.created).toHaveLength(1);
expect(getTransactionCount).toHaveBeenCalledTimes(1);
// The refusal leaves the pending approval untouched, and it still
// sends.
bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id: first.id(),
approved: true,
rawSignedTx: await signedAtNonce(NONCE),
},
{ url: bg.fromPopup.url },
);
await settle();
expect(first.result()).toEqual({ result: "0xfeed" });
});
test("an answered approval frees the next request", async () => {
const bg = loadBackground();
const first = bg.requestTx();
await settle();
// The user closes the approval window, which rejects it.
bg.closeWindow(1);
await settle();
expect(first.result()).toEqual({
error: { code: 4001, message: "User rejected the request." },
});
const second = bg.requestTx();
await settle();
expect(second.id()).toBeTruthy();
expect(bg.created).toHaveLength(2);
});
test("a signature request is not held up by a pending transaction", async () => {
const bg = loadBackground();
bg.requestTx();
await settle();
// A signature consumes no nonce, so it has nothing to collide with.
const signing = bg.requestSign();
await settle();
expect(signing.id()).toBeTruthy();
expect(signing.result()).toBeNull();
expect(bg.created).toHaveLength(2);
});
});
// A nonce collision found before the transaction reaches the network is the
// one send failure the wallet can speak about with certainty. The user is told
// it did not go out and to send it again, rather than being warned it might
// already be on the chain — which would send them looking for a transaction
// that does not exist, and stop them retrying the one that never went.
describe("a nonce collision is reported as a transaction that did not go out", () => {
test("a broadcast the node refused for the nonce is not reported as possibly sent", async () => {
const bg = loadBackground();
const pending = bg.requestTx();
await settle();
bg.broadcastTransaction.mockRejectedValue(
Object.assign(new Error("nonce too low"), {
code: "NONCE_EXPIRED",
}),
);
const answer = bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id: pending.id(),
approved: true,
rawSignedTx: await signedAtNonce(NONCE),
},
{ url: bg.fromPopup.url },
);
await settle();
expect(answer.sendResponse).toHaveBeenCalledWith({
error: expect.stringMatching(/nonce had already been used/),
retryable: false,
stage: "nonce",
});
expect(pending.result()).toEqual({
error: {
message: expect.stringMatching(
/transaction was not sent, because its nonce/,
),
},
});
});
test("a nonce this wallet already broadcast is refused without asking the node again", async () => {
const bg = loadBackground();
const first = bg.requestTx();
await settle();
bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id: first.id(),
approved: true,
rawSignedTx: await signedAtNonce(NONCE),
},
{ url: bg.fromPopup.url },
);
await settle();
expect(first.result()).toEqual({ result: "0xfeed" });
// The stubbed node still reports NONCE as the next nonce — a pending
// count that lags a broadcast the node has already taken — so this
// second approval is populated at a nonce this worker has spent.
const second = bg.requestTx();
await settle();
const answer = bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id: second.id(),
approved: true,
rawSignedTx: await signedAtNonce(NONCE),
},
{ url: bg.fromPopup.url },
);
await settle();
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
expect(answer.sendResponse).toHaveBeenCalledWith({
error: expect.stringMatching(/nonce had already been used/),
retryable: false,
stage: "nonce",
});
expect(second.result()).toEqual({
error: {
message: expect.stringMatching(/nonce had already been used/),
},
});
});
});
// The approval carries the transaction the user was shown and the address it // The approval carries the transaction the user was shown and the address it
// was raised for, and the artifact is checked against both. Every case here is // was raised for, and the artifact is checked against both. Every case here is
// one the old comparison — against the dApp's request, for the address that is // one the old comparison — against the dApp's request, for the address that is

View File

@@ -86,9 +86,11 @@ const DAPP_URL = DAPP_ORIGIN + "/";
// never drive the popup that has to settle it; start() files the promise // never drive the popup that has to settle it; start() files the promise
// under a key and settle() collects it once the prompt has been dealt with. // under a key and settle() collects it once the prompt has been dealt with.
// //
// The rejection branch records `code` as it arrives. EIP-1193 says a user // The rejection branch records the whole observable shape of the error as it
// rejection is a ProviderRpcError carrying code 4001; what the page can // arrives — name, message, and whether a `code` is present at all as distinct
// actually see is recorded here rather than assumed, and asserted in run.js. // from its value. EIP-1193 says a user rejection is a ProviderRpcError
// carrying code 4001; what the page can actually see is recorded here rather
// than assumed, and asserted in run.js.
// //
// The message log is the page's half of the boundary observation: every // The message log is the page's half of the boundary observation: every
// AUTISTMASK_* message that crosses between this page and the content // AUTISTMASK_* message that crosses between this page and the content
@@ -120,6 +122,7 @@ const DAPP_HTML = [
" return {", " return {",
" settled: 'rejected',", " settled: 'rejected',",
" message: String((error && error.message) || error),", " message: String((error && error.message) || error),",
" name: error ? error.name : undefined,",
" hasCode: !!error && 'code' in Object(error),", " hasCode: !!error && 'code' in Object(error),",
" code: error ? error.code : undefined,", " code: error ? error.code : undefined,",
" };", " };",

View File

@@ -1591,15 +1591,14 @@ async function lastResponseError(page) {
} }
// A rejected prompt, asserted at both ends: the page's promise rejected // A rejected prompt, asserted at both ends: the page's promise rejected
// rather than hanging or resolving, and the response that crossed the // rather than hanging or resolving, and EIP-1193 code 4001 is present both
// boundary carried EIP-1193 code 4001. // on the wire and on the Error the calling page catches.
// //
// The code is asserted on the wire because that is the only place it // Both ends matter because they used to disagree. The code crossed the
// survives. src/content/inpage.js rebuilds the rejection as `new // boundary correctly and src/content/inpage.js then threw it away, rebuilding
// Error(error.message)`, so the Error the calling page catches carries the // every rejection as `new Error(error.message)` so a dApp branching on
// message and no code. That is reported rather than asserted either way — // `err.code === 4001` saw undefined and could not tell a refusal from a
// locking in the current behaviour would make the gap permanent, and // failure (#274). Asserting only the wire would leave that gap invisible.
// asserting the code on the Error would fail today.
async function assertUserRejection(page, key, label) { async function assertUserRejection(page, key, label) {
const outcome = await settleRequest(page, key); const outcome = await settleRequest(page, key);
assert( assert(
@@ -1621,15 +1620,32 @@ async function assertUserRejection(page, key, label) {
" did not carry EIP-1193 code 4001 across the boundary: " + " did not carry EIP-1193 code 4001 across the boundary: " +
JSON.stringify(error), JSON.stringify(error),
); );
assert(
outcome.hasCode,
label +
" reached the page as an error with no code property at all, so a " +
"dApp cannot tell the user's refusal from a failure: " +
JSON.stringify(outcome),
);
assert(
outcome.code === 4001,
label +
" reached the page with code " +
JSON.stringify(outcome.code) +
" rather than EIP-1193 4001",
);
assert(
outcome.name === "ProviderRpcError",
label +
" reached the page as " +
JSON.stringify(outcome.name) +
" rather than an EIP-1193 ProviderRpcError",
);
console.log( console.log(
"# " + "# " +
label + label +
": boundary code=" + ": code 4001 on the wire and on the page's " +
error.code + outcome.name,
" page Error.code=" +
JSON.stringify(outcome.code) +
" page Error carries a code=" +
outcome.hasCode,
); );
return outcome; return outcome;
} }

310
tests/inpageErrors.test.js Normal file
View File

@@ -0,0 +1,310 @@
// The EIP-1193 error the page actually catches (src/content/inpage.js).
//
// The bug this pins down (issue #274): the provider rebuilt every failure as
// `new Error(error.message)`, so the `code` the background produced and the
// content script relayed intact was thrown away in the last hop. A dApp
// checking `err.code === 4001` — the standard way to tell "the user said no"
// from "the wallet broke" — saw undefined, and well-behaved sites showed an
// error or retried instead of accepting the refusal.
//
// inpage.js is a bare IIFE injected into the page's JS context, not a module:
// it takes no import and exports nothing, and reaches for `window` at load.
// So it is evaluated here the way the browser evaluates it, against a stub
// window, and the provider is collected from `window.ethereum`. The globals it
// touches are passed in as function parameters rather than assigned to
// globalThis: nothing leaks between tests, and the source is compiled in this
// realm, so the errors it constructs are comparable against this file's own
// `Error` — which a second realm's intrinsics would silently defeat.
//
// There is no jsdom in this repo; see tests/txStatus.test.js.
const fs = require("fs");
const path = require("path");
const { webcrypto } = require("crypto");
const SOURCE = fs.readFileSync(
path.join(__dirname, "..", "src", "content", "inpage.js"),
"utf8",
);
const loadInto = new Function(
"window",
"self",
"crypto",
"Event",
"CustomEvent",
SOURCE,
);
class StubEvent {
constructor(type) {
this.type = type;
}
}
class StubCustomEvent extends StubEvent {
constructor(type, init) {
super(type);
this.detail = init && init.detail;
}
}
// Every code the background emits on the RPC path today, read out of
// src/background/index.js. The provider must not know this list — it passes
// through whatever arrived — but the cases below are the real ones.
const REJECTED = 4001; // user rejected the request
const UNAUTHORIZED = 4100; // site not connected / wrong address
const UNRECOGNIZED_CHAIN = 4902; // switch/add to an unsupported chain
// A stub window with the four things inpage.js touches: message listeners,
// postMessage out to the content script, window.ethereum, and dispatchEvent
// for the EIP-6963 announcement.
function loadProvider() {
const messageListeners = [];
const posted = [];
const win = {
addEventListener(type, fn) {
if (type === "message") messageListeners.push(fn);
},
removeEventListener(type, fn) {
const i = messageListeners.indexOf(fn);
if (type === "message" && i !== -1) messageListeners.splice(i, 1);
},
postMessage(data) {
posted.push(data);
},
dispatchEvent() {
return true;
},
};
win.window = win;
loadInto(win, win, webcrypto, StubEvent, StubCustomEvent);
// Deliver the content script's answer to an outstanding request. The id is
// read back off the wire rather than assumed: inpage.js issues its own
// eth_chainId at load, so the first id a test sees is not 1.
function respond(response) {
const request = posted
.filter((m) => m.type === "AUTISTMASK_REQUEST")
.pop();
expect(request).toBeDefined();
const event = {
source: win,
data: { type: "AUTISTMASK_RESPONSE", id: request.id, ...response },
};
for (const fn of messageListeners.slice()) fn(event);
}
return { provider: win.ethereum, posted, respond };
}
// Start a request, answer it with `response`, and hand back the rejection.
// Fails the test if the call resolves instead.
async function rejectionFrom(start, response) {
const { provider, respond } = loadProvider();
const settled = start(provider).then(
(result) => ({ resolved: result }),
(error) => ({ error }),
);
// The provider posts synchronously, so the request is already on the wire.
respond(response);
const outcome = await settled;
expect(outcome).not.toHaveProperty("resolved");
return outcome.error;
}
describe("an EIP-1193 code reaches the page", () => {
test("a user rejection arrives as code 4001", async () => {
const err = await rejectionFrom(
(p) => p.request({ method: "eth_requestAccounts" }),
{
error: {
code: REJECTED,
message: "User rejected the request.",
},
},
);
expect(err.code).toBe(REJECTED);
expect(err.message).toBe("User rejected the request.");
});
test("it is a ProviderRpcError, and an Error", async () => {
const err = await rejectionFrom(
(p) => p.request({ method: "eth_requestAccounts" }),
{
error: {
code: REJECTED,
message: "User rejected the request.",
},
},
);
expect(err).toBeInstanceOf(Error);
expect(err.name).toBe("ProviderRpcError");
});
test("4100 unauthorized arrives intact", async () => {
const err = await rejectionFrom(
(p) => p.request({ method: "personal_sign", params: ["0x00"] }),
{ error: { code: UNAUTHORIZED, message: "Unauthorized" } },
);
expect(err.code).toBe(UNAUTHORIZED);
expect(err.message).toBe("Unauthorized");
});
test("4902 unrecognized chain arrives intact", async () => {
const message =
"AutistMask supports Ethereum Mainnet and Sepolia Testnet only.";
const err = await rejectionFrom(
(p) => p.request({ method: "wallet_switchEthereumChain" }),
{ error: { code: UNRECOGNIZED_CHAIN, message } },
);
expect(err.code).toBe(UNRECOGNIZED_CHAIN);
expect(err.message).toBe(message);
});
// The provider is not allowed to know the list above: a code added to the
// background later must reach the page without this file being edited.
test("a code the provider has never heard of is passed through", async () => {
const err = await rejectionFrom(
(p) => p.request({ method: "eth_accounts" }),
{ error: { code: 4900, message: "Disconnected" } },
);
expect(err.code).toBe(4900);
});
test("data is carried when the boundary sent it", async () => {
const err = await rejectionFrom(
(p) => p.request({ method: "eth_call" }),
{
error: {
code: -32000,
message: "execution reverted",
data: "0x08c379a0",
},
},
);
expect(err.code).toBe(-32000);
expect(err.data).toBe("0x08c379a0");
});
test("no data property is invented when the boundary sent none", async () => {
const err = await rejectionFrom(
(p) => p.request({ method: "eth_requestAccounts" }),
{
error: {
code: REJECTED,
message: "User rejected the request.",
},
},
);
expect("data" in err).toBe(false);
});
});
describe("the message is untouched", () => {
test("a coded error keeps the message byte for byte", async () => {
const message =
"This site asked to sign as an address that is not " +
"the active one.";
const err = await rejectionFrom(
(p) => p.request({ method: "personal_sign" }),
{ error: { code: UNAUTHORIZED, message } },
);
expect(err.message).toBe(message);
});
test("an error the background sent with no code keeps its message", async () => {
const err = await rejectionFrom(
(p) => p.request({ method: "eth_sendTransaction" }),
{ error: { message: "No accounts available" } },
);
expect(err.message).toBe("No accounts available");
});
// A ProviderRpcError whose code is undefined would claim a conformance it
// does not have, and `'code' in err` is exactly what a careful dApp asks.
test("an error with no code gets no code property at all", async () => {
const err = await rejectionFrom(
(p) => p.request({ method: "eth_sendTransaction" }),
{ error: { message: "No accounts available" } },
);
expect(err).toBeInstanceOf(Error);
expect("code" in err).toBe(false);
});
test("an error with no message keeps the generic fallback", async () => {
const err = await rejectionFrom(
(p) => p.request({ method: "eth_sendTransaction" }),
{ error: { code: REJECTED } },
);
expect(err.message).toBe("Request failed");
expect(err.code).toBe(REJECTED);
});
});
// Every entry point the provider exposes, not just eth_requestAccounts. They
// all funnel through the same response listener, and this is what says so.
describe("every request path carries the code", () => {
const rejection = {
error: { code: REJECTED, message: "User rejected the request." },
};
test("request()", async () => {
const err = await rejectionFrom(
(p) => p.request({ method: "eth_requestAccounts" }),
rejection,
);
expect(err.code).toBe(REJECTED);
});
test("enable()", async () => {
const err = await rejectionFrom((p) => p.enable(), rejection);
expect(err.code).toBe(REJECTED);
});
test("send(method, params)", async () => {
const err = await rejectionFrom(
(p) => p.send("eth_requestAccounts", []),
rejection,
);
expect(err.code).toBe(REJECTED);
});
test("send({ method, params })", async () => {
const err = await rejectionFrom(
(p) => p.send({ method: "personal_sign", params: ["0x00"] }),
rejection,
);
expect(err.code).toBe(REJECTED);
});
test("sendAsync() hands the code to its callback", async () => {
const { provider, respond } = loadProvider();
const called = new Promise((resolve) => {
provider.sendAsync({ id: 1, method: "eth_requestAccounts" }, (e) =>
resolve(e),
);
});
respond(rejection);
const err = await called;
expect(err.name).toBe("ProviderRpcError");
expect(err.code).toBe(REJECTED);
expect(err.message).toBe("User rejected the request.");
});
});
describe("the success path is unchanged", () => {
test("a result still resolves", async () => {
const { provider, respond } = loadProvider();
const settled = provider.request({ method: "eth_requestAccounts" });
respond({ result: ["0xb61264DEFB0c4B8afb3D73724be15310036743a5"] });
await expect(settled).resolves.toEqual([
"0xb61264DEFB0c4B8afb3D73724be15310036743a5",
]);
expect(provider.selectedAddress).toBe(
"0xb61264DEFB0c4B8afb3D73724be15310036743a5",
);
});
});