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 28s
All checks were successful
check / check (push) Successful in 28s
This commit was merged in pull request #284.
This commit is contained in:
@@ -24,6 +24,7 @@ const {
|
||||
TX_STAGE_VERIFY,
|
||||
TX_STAGE_BROADCAST,
|
||||
TX_STAGE_INFLIGHT,
|
||||
TX_STAGE_NONCE,
|
||||
} = require("../shared/approvalVerify");
|
||||
const { prepareApprovalTx } = require("../shared/approvalTx");
|
||||
const {
|
||||
@@ -57,6 +58,114 @@ const connectedSites = {};
|
||||
// Pending approval requests: { id: { origin, hostname, resolve } }
|
||||
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.
|
||||
//
|
||||
// The slot is null when free, and otherwise the handle of the request holding
|
||||
// it. Once that request has raised its approval the handle carries the
|
||||
// approval's id, so that retiring the approval frees the slot: every exit from
|
||||
// pendingApprovals goes through settleApproval(), which makes that one hook
|
||||
// complete. The holder's own finally is the backstop for the interval before
|
||||
// the approval exists.
|
||||
let txApprovalSlot = null;
|
||||
|
||||
// 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;
|
||||
|
||||
// True at every moment this can be sent: the slot is taken immediately before
|
||||
// the transaction is populated, so the other request is either being prepared
|
||||
// or on screen. It does not claim the other one is displayed yet, because for
|
||||
// the length of one network round trip it is not.
|
||||
const TX_APPROVAL_PENDING_MESSAGE =
|
||||
"AutistMask handles one transaction at a time, and another one is" +
|
||||
" already in progress, so this one was not sent. Please finish that" +
|
||||
" transaction, then send this one again.";
|
||||
|
||||
// Take the slot, or refuse. Nothing awaits between the test and the set, so
|
||||
// two requests that reach this in the same tick cannot both pass it — the
|
||||
// position of the call in the handler is irrelevant to that, which is why it
|
||||
// sits after the authorization checks. A page the wallet is going to refuse
|
||||
// anyway must not be able to take the slot away from the connected site.
|
||||
function reserveTxApprovalSlot() {
|
||||
if (txApprovalSlot) return null;
|
||||
txApprovalSlot = { approvalId: null };
|
||||
return txApprovalSlot;
|
||||
}
|
||||
|
||||
// Free the slot, if this handle is still the one holding it.
|
||||
function releaseTxApprovalSlot(handle) {
|
||||
if (handle && txApprovalSlot !== handle) return;
|
||||
txApprovalSlot = null;
|
||||
}
|
||||
|
||||
// Free the slot held on behalf of a retired approval. Called from
|
||||
// settleApproval() for every approval, and a no-op for the ones the slot was
|
||||
// not taken for.
|
||||
function releaseTxApprovalSlotFor(approvalId) {
|
||||
if (txApprovalSlot && txApprovalSlot.approvalId === approvalId) {
|
||||
txApprovalSlot = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Nonces this worker has already handed to the node, per chain and 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 chain is part of the key because nonce spaces are per chain and the
|
||||
// wallet switches networks. Without it a nonce spent on one chain would refuse
|
||||
// that nonce on every other chain — and low nonces overlap across chains as a
|
||||
// matter of course, so the refusal would be both routine and false.
|
||||
//
|
||||
// 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(chainId, address) {
|
||||
const key =
|
||||
String(chainId).toLowerCase() +
|
||||
":" +
|
||||
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() {
|
||||
const result = await storageApi.get("autistmask");
|
||||
return (
|
||||
@@ -148,11 +257,41 @@ function settleApproval(id, result, options) {
|
||||
const holdsClaim = !!(options && options.holdsClaim);
|
||||
if (approval.attemptInFlight && !holdsClaim) return false;
|
||||
delete pendingApprovals[id];
|
||||
// The transaction-approval slot is held for exactly as long as the
|
||||
// approval it was taken for is alive, and this is the one place an
|
||||
// approval stops being alive.
|
||||
releaseTxApprovalSlotFor(id);
|
||||
approval.resolve(result);
|
||||
resetPopupUrl();
|
||||
return true;
|
||||
}
|
||||
|
||||
// What a pending approval resolves to when it is given up on rather than
|
||||
// answered: the window was closed, or could not be opened at all. A tx or sign
|
||||
// approval answers the requesting page in EIP-1193 shape; a site-connection
|
||||
// approval answers the connection handler in its own.
|
||||
function abandonedResult(approval, code, message) {
|
||||
if (approval.type === "tx" || approval.type === "sign") {
|
||||
return { error: { code, message } };
|
||||
}
|
||||
return { approved: false, remember: false };
|
||||
}
|
||||
|
||||
// A window the user closed without answering is a refusal by the user, which
|
||||
// is 4001 and the wording every other rejection path already uses.
|
||||
const APPROVAL_REJECTED_CODE = 4001;
|
||||
const APPROVAL_REJECTED_MESSAGE = "User rejected the request.";
|
||||
|
||||
// The window could not be opened, so the user was never asked. This is the
|
||||
// wallet failing, not the user refusing, so it does not claim to be a
|
||||
// rejection: -32603 is the JSON-RPC code for the wallet's own internal
|
||||
// failure, and the page is told plainly that nothing was shown.
|
||||
const APPROVAL_WINDOW_FAILED_CODE = -32603;
|
||||
|
||||
const APPROVAL_WINDOW_FAILED_MESSAGE =
|
||||
"AutistMask could not open its approval window, so this request was not" +
|
||||
" shown to you and nothing was sent.";
|
||||
|
||||
// Take exclusive hold of a pending approval for one attempt, or refuse.
|
||||
//
|
||||
// An approval that failed retryably has to stay in pendingApprovals, so its
|
||||
@@ -172,8 +311,26 @@ function claimApproval(approval) {
|
||||
|
||||
// Release an approval whose attempt failed in a way the user can retry.
|
||||
// Nothing was broadcast, so the next attempt may claim it.
|
||||
//
|
||||
// Unless the window it would be retried in is already gone. The user closed it
|
||||
// while the attempt was running and settleApproval() declined then, correctly,
|
||||
// because the attempt still owned the approval; the attempt has now failed, so
|
||||
// nothing owns it and nothing can reach it. Left standing it would hold the
|
||||
// requesting page's promise open forever and, with it, the transaction
|
||||
// approval slot. It is settled here as the rejection the closed window
|
||||
// already meant.
|
||||
function releaseApproval(approval) {
|
||||
approval.attemptInFlight = false;
|
||||
if (approval.windowClosed) {
|
||||
settleApproval(
|
||||
approval.id,
|
||||
abandonedResult(
|
||||
approval,
|
||||
APPROVAL_REJECTED_CODE,
|
||||
APPROVAL_REJECTED_MESSAGE,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Open approval in a separate popup window.
|
||||
@@ -200,9 +357,35 @@ function openApprovalWindow(id) {
|
||||
);
|
||||
}
|
||||
windowsApi.create(opts, (win) => {
|
||||
if (win) {
|
||||
pendingApprovals[id].windowId = win.id;
|
||||
const approval = pendingApprovals[id];
|
||||
if (!approval) {
|
||||
// Settled while the window was opening — an address switch,
|
||||
// say. Nothing is waiting on it, and a window showing an
|
||||
// approval that no longer exists is not left on screen.
|
||||
if (win) {
|
||||
windowsApi.remove(win.id, () => {
|
||||
if (runtime.lastError) {
|
||||
// window already closed
|
||||
}
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!win) {
|
||||
// No window means no way to ever answer this approval, and an
|
||||
// approval nothing can answer holds the requesting page's
|
||||
// promise open forever. Settle it now instead.
|
||||
settleApproval(
|
||||
id,
|
||||
abandonedResult(
|
||||
approval,
|
||||
APPROVAL_WINDOW_FAILED_CODE,
|
||||
APPROVAL_WINDOW_FAILED_MESSAGE,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
approval.windowId = win.id;
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -212,7 +395,7 @@ function openApprovalWindow(id) {
|
||||
function requestApproval(origin, hostname) {
|
||||
return new Promise((resolve) => {
|
||||
const id = crypto.randomUUID();
|
||||
pendingApprovals[id] = { origin, hostname, resolve };
|
||||
pendingApprovals[id] = { id, origin, hostname, resolve };
|
||||
|
||||
if (actionApi && typeof actionApi.openPopup === "function") {
|
||||
actionApi.setPopup({
|
||||
@@ -243,10 +426,13 @@ function requestApproval(origin, hostname) {
|
||||
// it is pinned here rather than read again at signing time — an address switch
|
||||
// between approval and signing must refuse, not sign from an account this
|
||||
// screen never named.
|
||||
function requestTxApproval(origin, hostname, approvedTx, approvedFrom) {
|
||||
// `slot` is the transaction-approval slot its caller holds. Handing the
|
||||
// approval's id to it is what makes retiring the approval free the slot.
|
||||
function requestTxApproval(origin, hostname, approvedTx, approvedFrom, slot) {
|
||||
return new Promise((resolve) => {
|
||||
const id = crypto.randomUUID();
|
||||
pendingApprovals[id] = {
|
||||
id,
|
||||
origin,
|
||||
hostname,
|
||||
approvedTx,
|
||||
@@ -254,6 +440,7 @@ function requestTxApproval(origin, hostname, approvedTx, approvedFrom) {
|
||||
resolve,
|
||||
type: "tx",
|
||||
};
|
||||
if (slot) slot.approvalId = id;
|
||||
|
||||
openApprovalWindow(id);
|
||||
});
|
||||
@@ -267,6 +454,7 @@ function requestSignApproval(origin, hostname, signParams, approvedFrom) {
|
||||
return new Promise((resolve) => {
|
||||
const id = crypto.randomUUID();
|
||||
pendingApprovals[id] = {
|
||||
id,
|
||||
origin,
|
||||
hostname,
|
||||
signParams,
|
||||
@@ -585,31 +773,68 @@ async function handleRpc(method, params, origin) {
|
||||
}
|
||||
|
||||
if (method === "eth_sendTransaction") {
|
||||
const s = await getState();
|
||||
const activeAddress = await getActiveAddress();
|
||||
if (!activeAddress)
|
||||
return { error: { message: "No accounts available" } };
|
||||
return await handleSendTransaction(params, origin);
|
||||
}
|
||||
|
||||
const hostname = extractHostname(origin);
|
||||
const allowed = s.allowedSites[activeAddress] || [];
|
||||
if (
|
||||
!allowed.includes(hostname) &&
|
||||
!connectedSites[origin + ":" + activeAddress]
|
||||
) {
|
||||
return { error: { code: 4100, message: "Unauthorized" } };
|
||||
// Proxy safe read-only methods to the RPC node
|
||||
if (PROXY_METHODS.includes(method)) {
|
||||
try {
|
||||
const result = await proxyRpc(method, params);
|
||||
return { result };
|
||||
} catch (e) {
|
||||
return { error: { message: e.message } };
|
||||
}
|
||||
}
|
||||
|
||||
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.",
|
||||
},
|
||||
};
|
||||
}
|
||||
return { error: { message: "Unsupported method: " + method } };
|
||||
}
|
||||
|
||||
// The body of eth_sendTransaction, from the connection check through to the
|
||||
// user's decision. It takes the single transaction-approval slot once it knows
|
||||
// it is going to populate a transaction, and holds it until the requesting
|
||||
// page has its answer.
|
||||
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.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Everything above refuses without populating anything, so the slot is
|
||||
// taken here rather than at the top of the handler: a page the wallet was
|
||||
// never going to serve must not be able to hold the slot and make the
|
||||
// connected site's own transaction fail as "already in progress". The
|
||||
// reservation is atomic because nothing awaits between its test and its
|
||||
// set, not because of where it sits.
|
||||
const slot = reserveTxApprovalSlot();
|
||||
if (!slot) {
|
||||
return {
|
||||
error: {
|
||||
code: TX_APPROVAL_PENDING_CODE,
|
||||
message: TX_APPROVAL_PENDING_MESSAGE,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// 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
|
||||
@@ -644,22 +869,17 @@ async function handleRpc(method, params, origin) {
|
||||
hostname,
|
||||
approvedTx,
|
||||
activeAddress,
|
||||
slot,
|
||||
);
|
||||
if (decision.error) return { error: decision.error };
|
||||
return { result: decision.txHash };
|
||||
} finally {
|
||||
// Retiring the approval has normally freed the slot already, through
|
||||
// settleApproval(); this covers the paths that return before an
|
||||
// approval exists at all, and frees nothing if another request has
|
||||
// since taken the slot.
|
||||
releaseTxApprovalSlot(slot);
|
||||
}
|
||||
|
||||
// Proxy safe read-only methods to the RPC node
|
||||
if (PROXY_METHODS.includes(method)) {
|
||||
try {
|
||||
const result = await proxyRpc(method, params);
|
||||
return { result };
|
||||
} catch (e) {
|
||||
return { error: { message: e.message } };
|
||||
}
|
||||
}
|
||||
|
||||
return { error: { message: "Unsupported method: " + method } };
|
||||
}
|
||||
|
||||
// Broadcast chainChanged to all tabs when the network is switched.
|
||||
@@ -694,15 +914,11 @@ async function broadcastAccountsChanged() {
|
||||
// being signed and broadcast right now, and neither rejecting it to the
|
||||
// page nor closing the window it is reporting into is survivable.
|
||||
for (const [id, approval] of Object.entries(pendingApprovals)) {
|
||||
const rejection =
|
||||
approval.type === "tx" || approval.type === "sign"
|
||||
? {
|
||||
error: {
|
||||
code: 4001,
|
||||
message: "User rejected the request.",
|
||||
},
|
||||
}
|
||||
: { approved: false, remember: false };
|
||||
const rejection = abandonedResult(
|
||||
approval,
|
||||
APPROVAL_REJECTED_CODE,
|
||||
APPROVAL_REJECTED_MESSAGE,
|
||||
);
|
||||
if (!settleApproval(id, rejection)) continue;
|
||||
if (approval.windowId) {
|
||||
windowsApi.remove(approval.windowId, () => {
|
||||
@@ -831,21 +1047,19 @@ startBackgroundJobs();
|
||||
// verify and broadcast it is waiting on, so a user closing an apparently-hung
|
||||
// window is an ordinary event with an attempt already in flight behind it.
|
||||
// settleApproval() refuses those, which leaves the attempt to report its real
|
||||
// outcome to the page.
|
||||
// outcome to the page — and the window is recorded as gone, so that an attempt
|
||||
// which then fails retryably settles instead of waiting in a window that no
|
||||
// longer exists.
|
||||
if (windowsApi && windowsApi.onRemoved) {
|
||||
windowsApi.onRemoved.addListener((windowId) => {
|
||||
for (const [id, approval] of Object.entries(pendingApprovals)) {
|
||||
if (approval.windowId !== windowId) continue;
|
||||
const rejection =
|
||||
approval.type === "tx" || approval.type === "sign"
|
||||
? {
|
||||
error: {
|
||||
code: 4001,
|
||||
message: "User rejected the request.",
|
||||
},
|
||||
}
|
||||
: { approved: false, remember: false };
|
||||
settleApproval(id, rejection);
|
||||
const rejection = abandonedResult(
|
||||
approval,
|
||||
APPROVAL_REJECTED_CODE,
|
||||
APPROVAL_REJECTED_MESSAGE,
|
||||
);
|
||||
if (!settleApproval(id, rejection)) approval.windowClosed = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -959,7 +1173,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
sendResponse({
|
||||
error: outcome.error,
|
||||
retryable: outcome.retryable,
|
||||
stage: TX_STAGE_SIGN,
|
||||
stage: outcome.stage,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
@@ -975,8 +1189,15 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
}
|
||||
|
||||
(async () => {
|
||||
// The chain this attempt is on, read once. Verification below
|
||||
// refuses an artifact signed for any other chain, and the nonce
|
||||
// record is both consulted and written under this one, so a
|
||||
// network switch part-way through cannot make the check and the
|
||||
// record disagree about which chain the nonce was spent on.
|
||||
let chainId;
|
||||
try {
|
||||
await loadState();
|
||||
chainId = currentNetwork().chainId;
|
||||
const activeAddress = await getActiveAddress();
|
||||
// An address switch between approval and signing refuses. The
|
||||
// approval named one account; signing from whichever account
|
||||
@@ -999,7 +1220,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
msg.rawSignedTx,
|
||||
approval.approvedTx,
|
||||
approval.approvedFrom,
|
||||
currentNetwork().chainId,
|
||||
chainId,
|
||||
);
|
||||
} catch (e) {
|
||||
// A signed transaction that is not the approved one is not
|
||||
@@ -1019,7 +1240,31 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
sendResponse({
|
||||
error: outcome.error,
|
||||
retryable: outcome.retryable,
|
||||
stage: TX_STAGE_VERIFY,
|
||||
stage: outcome.stage,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// A nonce this worker has already broadcast for this address on
|
||||
// this chain. 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.
|
||||
// A nonce spent on another chain is not spent here — the chains
|
||||
// count separately, and refusing across them would block ordinary
|
||||
// use with a message that is not true.
|
||||
const nonce = approvedNonce(approval.approvedTx);
|
||||
const spent = broadcastNoncesFor(chainId, 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;
|
||||
}
|
||||
@@ -1027,6 +1272,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
try {
|
||||
const provider = getProvider(state.rpcUrl);
|
||||
const tx = await provider.broadcastTransaction(msg.rawSignedTx);
|
||||
if (nonce !== null) spent.add(nonce);
|
||||
settleApproval(
|
||||
msg.id,
|
||||
{ txHash: tx.hash },
|
||||
@@ -1039,6 +1285,11 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
// tell a transaction that never left from one already in the
|
||||
// mempool. The page has been given its outcome for this
|
||||
// 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);
|
||||
settleApproval(
|
||||
msg.id,
|
||||
@@ -1048,7 +1299,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
sendResponse({
|
||||
error: outcome.error,
|
||||
retryable: outcome.retryable,
|
||||
stage: TX_STAGE_BROADCAST,
|
||||
stage: outcome.stage,
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user