fix: one transaction approval at a time, and honest copy for a nonce collision (closes #271) #284
Reference in New Issue
Block a user
Delete Branch "issue-271-concurrent-nonce"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #271.
The decision, and why
Of the three options the issue named, this takes the third: refuse a second transaction approval while one is pending.
What changed
src/background/index.jseth_sendTransactionbranch before its firstawait— two requests delivered in the same tick cannot both pass it — and released when the requesting page has its answer (broadcast, rejected, or window closed). The second request is refused with EIP-1193-32002(resource unavailable, the standard code for "already pending") and never reachesprepareApprovalTx(): no second nonce, no second window. Signature approvals are not gated, consuming no nonce.handleSendTransaction()so the reservation and itsfinallyread as one thing; the body itself is unchanged.sendResponsenow reportsoutcome.stagerather than the stage it passed in, because a broadcast failure the node blamed on the nonce is reclassified.src/shared/approvalVerify.jsisNonceCollision()and a newTX_STAGE_NONCE. Classified from ethers'NONCE_EXPIRED/REPLACEMENT_UNDERPRICEDcodes and from the node's own words (nonce too low,nonce has already been used,invalid nonce,OldNonce,replacement transaction underpriced,replacement fee too low), including the nestedinfo.error.messageshape ethers hands up when it could not classify the error itself.already knownis deliberately not a collision: a node that says it knows the transaction has it, so it did reach the network and the existing ambiguous wording is the correct one for it.nonce too low) is replaced rather than passed through: it is not a sentence, and it says less than the wallet knows.Verification is untouched. The approval still carries the transaction the screen displayed, and the artifact is still compared against that object field for field.
The new tests, failing first
Written against the unfixed tree and run there before any source change.
make testonnext+ tests only: 7 failed, 682 passed.nullis the whole defect: the second request had not answered the page, because it had raised its own approval window at the same nonce the first one is holding.Two broadcasts, the second at a nonce this wallet had already used — the terminal failure the issue describes.
Also red beforehand:
a nonce the node refused is classified however it was worded,already known is not a nonce collision, and the existinga failed broadcast is terminal, whatever the node said(extended to pinoutcome.stage).One existing assertion changed rather than being added to: that terminal-broadcast test looped over four node messages asserting the wallet passes each through verbatim, and
replacement transaction underpricedis now one of the reclassified ones. It is still asserted terminal, in the new nonce test, with the new message.Verification
make check— green: 28 suites, 689 tests,script/verify-build18 cases,prettier --checkclean.make fmtrun; the branch is rebased onnextat9dcd875.Not covered, and knowingly so: the popup's own Send screen populates at send time and is outside the approval record, so it is not gated by this slot. A nonce it takes under an open dApp approval still collides — and that collision now reports accurately through both paths above, which is the part of it this issue asked for. An approval the user never answers holds the slot until the window is closed or the worker restarts; that is the same interval in which its nonce is allocated and unspent, so it is the intended behaviour rather than a leak.
FAIL —
needs-rework. Three defects, all insrc/background/index.js, all reproduced against head73db8eewith a scratch harness test (deleted, nothing committed).1.
src/background/index.js:117-124and:1130-1151— the broadcast-nonce record is keyed by address only, so a nonce spent on one chain blocks that nonce on every other chain.broadcastNoncesFor(address)has no chain component, while the wallet switches networks (wallet_switchEthereumChainat:502-510,currentNetwork().chainIdat:1101) and nonce spaces are per chain. Reproduced: broadcast at nonce 7 on chain 1, switch network, approve a transaction the node populated at nonce 7 on chain 11155111 →broadcastTransactionwas never called for it (call count stayed at 1) and both the page and the popup were told "The transaction was not sent, because its nonce had already been used by another transaction." That is false — no transaction of this user's has used that nonce on the new chain — and it is unrecoverable: the copy says "Please send it again from the site", the resend repopulates the same nonce, and it is refused identically for the life of the worker. Low nonces overlap across chains routinely, so this blocks ordinary multi-chain use with an inaccurate message, in the exact direction the issue was filed to stop. Acceptable: key the record by chain id plus address, record under the chain the broadcast actually went out on, and check only the chain that is current.2.
:674-681— the slot is released only when the approval promise resolves, and there are paths on which it never resolves; the wallet then refuses everyeth_sendTransactionfor the life of the worker.finally { releaseTxApprovalSlot(); }waits onrequestTxApproval(). An approval whose attempt is claimed, whose window is then closed (:934-950, wheresettleApproval()correctly declines whileattemptInFlight), and whose attempt then fails retryably (:1116releaseApproval(approval)— reached by any non-mismatch throw:loadState(),getActiveAddress(), an unparseable artifact) is left inpendingApprovalswith no window and no resolver. Reproduced: after that sequence the first request's result is stillnulland the nexteth_sendTransactionreturns-32002with zero windows opened, permanently.windowsApi.createhanding back no window at:278-282strands it the same way. Before this PR that sequence stranded one request; with the slot it denies the wallet's main function until the worker restarts. Acceptable: bind the slot to the approval id and free it whenever that approval leavespendingApprovalsor loses its window — including the window-closed path that currently declines to settle — rather than only on promise resolution.3.
:663-673— the slot is taken before the authorization check, so any web page can make the user's single legitimate transaction fail with "already pending".reserveTxApprovalSlot()is the first statement of the branch, ahead ofawait getState()/getActiveAddress()and the4100 Unauthorizedreturn insidehandleSendTransaction(). Reproduced: an unconnected origin'seth_sendTransaction(itself rejected4100) held the slot while the connected dApp's own single request was refused with-32002and no window opened. Any page can loop this and deny sending wallet-wide.TX_APPROVAL_PENDING_MESSAGEis also untrue in that window: nothing is "already waiting to be approved" and there is nothing for the user to answer. Acceptable: the take is atomic because nothing awaits between test and set, not because of where it sits in the function — move it after the authorization andfromchecks, immediately beforeprepareApprovalTx().Verified and passing:
make checkgreen here (28 suites, 689 tests,script/verify-build18 cases, prettier clean); the seven new/changed tests are load-bearing — revertingsrc/to9dcd875with the tests kept gives 7 failed / 682 passed, matching the PR body;replacement transaction underpricedis correctly a collision (the node refused that artifact) andalready knowncorrectly is not; verification is untouched and the nonce check sits afterverifySignedTx(), so #216 is not weakened; merges cleanly onto currentnext(0be20d7), no conflict with #282 today; commit title,TODO.mdin the same commit, terminology and formatting all fine; no attribution trailers.Disclosures: the head commit's CI check is still "Waiting to run", so CI green is unconfirmed — the local
make checkabove is the evidence. Neither e2e suite was run (docker browser suites, not part ofcheck). The disclosed popup-Send gap is waived against the issue's definition of done: the Send screen is not aneth_sendTransactionrequest, and finding 1 is the part of it that matters.View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.