fix: WaitTx timeout no longer overwrites a rendered success screen (closes #155) #201
Reference in New Issue
Block a user
Delete Branch "fix/issue-155-waittx-timeout"
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 #155.
The lifecycle fix
src/popup/views/txStatus.js: the receipt poll calledshowSuccess()and thenfell through to the elapsed check, so the tick that crossed the 60-second
deadline rendered both outcomes and "Transaction Confirmed" was replaced by
"not confirmed within 60 seconds". A confirmed transaction was reported as
failed.
The wait now has one explicit lifecycle:
endWait()stops both timers and bumps a wait id. It runs on receipt, ontimeout, when a new wait starts, and when the user navigates away.
awaiton the receiptlookup, checks that id. So exactly one outcome can be rendered per wait, and
neither a stale timer nor a lookup still in flight can touch a view the wait
no longer owns.
A failed lookup is not a timeout — but it is bounded
A receipt lookup that throws means "no answer this tick", not "no receipt". The
poll tracks whether the lookup actually answered and returns before the
deadline check when it did not, leaving both timers running so the next tick
can answer.
Without this, one transient RPC error ended the wait: on the resume path the
first poll is immediate and, for any wait reopened more than 60 seconds after
broadcast — the ordinary "closed the popup, came back later" case — the
deadline condition is already true, so a single rejected lookup rendered the
timeout and
showError()calledendWait(). No retry. That is the samefailure mode #155 exists to
remove, so the resume feature could not ship with it.
Retrying is bounded, so it cannot become an unbounded wait:
MAX_CONSECUTIVE_LOOKUP_FAILURES = 6— 60 seconds at the poll cadence, thesame patience the confirmation deadline gets. Six failures in a row end the
wait on ErrorTx, whose message names the unreachable network and points at the
RPC URL in Settings; it deliberately does not say the transaction failed to
confirm, because the chain was never asked. Any lookup that answers, with a
receipt or with
null, resets the count, so a flaky RPC that keeps answeringnever accumulates its way to a false "network unreachable". ErrorTx has a Done
button, so the wait is self-clearing and
pendingWaitstops being persisted.The deadline itself is unchanged: a lookup that answers
nullpast 60 secondsstill times out, pinned by its own test.
Popup close
Chosen option: keep the poll in the popup and persist the wait.
showWait()writes
state.viewData.pendingWait(txInfo, hash, broadcast time),wait-txjoins
RESTORABLE_VIEWS(src/popup/restorableViews.js), andrestoreView()resumes through
txStatus.restoreWait(). The elapsed counter and the deadlineare still measured from the original broadcast, so a wait that already outlived
the deadline resolves on the immediate first poll instead of restarting the
clock.
restoreWait()validates every fieldstartWait()goes on to dereference, notjust the containers:
hash; a non-null, non-array objecttxInfo; a stringtxInfo.toand a stringtxInfo.amount; and a finite numericbroadcastTime. It returnsfalseotherwise, matching every sibling case inrestoreView().txInfo.tois the one that mattered — it reachesaddressTitle(), which callsaddress.toLowerCase(), sotxInfo: {},txInfo: []andtxInfo: { to: 42 }each threw a TypeError out ofrestoreView(), whichindex.jsinit()does not guard: the rest of popupinit is skipped and
wait-txis left on screen with no back control. A missingbroadcastTimegaveNaNand an unexitable "Waiting for confirmation...NaNs". The check is
typeofonly, not non-emptiness:""is what acontract-deployment approval persists (
approval.jswritesto: toAddr || "")and it renders harmlessly, so refusing it would abandon a wait the live path
itself created.
txInfo.tokenandtxInfo.tokenSymbolare deliberatelyunchecked — they are compared and coalesced rather than dereferenced, and
tokenSymbolisnullfor ETH.Rationale for polling in the popup: moving it to the background would depend on
setIntervalsurviving in an MV3 service worker, which is the separatelytracked worker lifetime problem. Persisting in the popup needs nothing from the
worker.
Side effect worth knowing: a wait started in the dApp approval window is
persisted too, so opening the toolbar popup afterwards resumes it there.
README.mdWaitTx section updated for the persistence, the single-outcomerule, the retry-on-failed-lookup behaviour and the bound on that retry.
Test evidence
tests/txStatus.test.js: thirteen tests on jest fake timers, no network(
getProvidermocked at the module boundary, DOM served by a stub since thisrepo has no jsdom).
The rejected-lookup fix, demonstrated failing. With the two new tests in
place and the source unchanged (the resume poll still falling through to the
deadline check on a thrown lookup,
restoreWait()still validating onlyhash),make test:The first test resumes a wait ten minutes after broadcast with the first lookup
rejecting: it asserts the view stays on WaitTx with its timers running, and
that the following tick's receipt then renders SuccessTx. The second walks the
malformed
pendingWaitshapes, now nine of them includingtxInfo: {},txInfo: [],txInfo: { to: 42 }and an object carryingtobut noamount.A tenth case asserts the opposite direction:
to: ""resumes.The bound on failed lookups. Three tests in
WaitTx against an RPC that never answers: every lookup rejecting reaches ErrorTx at 60s with thenetwork-unreachable copy, no timer surviving and no
pendingWaitleft (and nofurther
getTransactionReceiptcall over the next simulated hour); analternating reject/answer RPC is still on WaitTx at 90s, where a cumulative
counter would already have fired, and ends only at the sixth consecutive
failure at 100s; and the resume path against a dead RPC terminates the same
way, which is the case that made this unbounded before the cap.
The
RESTORABLE_VIEWSmembership.nextmoved the set intosrc/popup/restorableViews.jswhile this branch was open, and dropping"wait-tx"on the rebase would have killed the resume with no test failing —the tests above call
restoreWait()directly.wait-tx is restorablepins themembership, mirroring the exclusion assertions in
tests/showPhrase.test.js.The original poll-tick fix, still demonstrated failing. With only that fix
reverted (the
returnaftershowSuccess()and the post-await staleness checkremoved), the rest of the branch in place:
The two previously documented failures, plus the rejected-lookup test, which
also catches this revert: its second tick returns a receipt, and without the
returnaftershowSuccess()the deadline check then overwrites SuccessTxwith the timeout.
The remaining tests cover the genuine timeout reaching ErrorTx with the hash
and etherscan link, no timer surviving the view being left
(
jest.getTimerCount()is 0), the resume path's deadline arithmetic, and anulllookup past the deadline still timing out.The suite totals in the two revert transcripts above are from earlier rebases,
when
nextcarried fewer suites; the counts move withnext, thetxStatusresults do not.
make check
Green on head
d74dd40, rebased ontonextat158278d:Also run in the container on the same tree, with the
make checklayerexecuted rather than reused — it reports
DONE, notCACHED:make test-e2ewas not run, so theindex.jscase "wait-tx"call site isexercised only through the unit suite's stub DOM.
2049b7c815to5191737afeFAIL —
needs-rebase, plus one correctness defect to fix in the same pass.1. Conflicts with current
next.nexthas moved to19cb1ca; Gitea now reportsmergeable: false.git merge-tree --write-tree origin/next HEADconflicts inTODO.md— this branch and #163 both insert a bullet at the top of# Completed Steps. Rebase and re-push.2. CI has not run. The only status on head
5191737ischeck / check (push)= pending, "Waiting to run". Not green, not red — unverified. (make checkrun locally on the head: 9 suites / 155 tests passed, prettier clean.)3. Defect — a single transient RPC error on the resume path reports a confirmed transaction as failed.
src/popup/views/txStatus.js:103-127. On resume,restoreWait()callsstartWait(..., pollNow=true), sopoll()runs immediately. Ifprovider.getTransactionReceipt()rejects, thecatchat :108 logs and leavesreceipt = null, and control falls straight to the deadline check at :120. For any wait resumed more than 60s after broadcast — i.e. the ordinary case of closing the popup and coming back later —Date.now() - broadcastTime >= TIMEOUT_MSis already true, so that one errored lookup renders "Transaction was not confirmed within 60 seconds" andshowError()callsendWait(). The wait is over; there is no retry. A transaction that confirmed ten minutes ago is reported as failed, which is the exact failure mode #155 exists to remove. Before this change the fresh-wait path always got five successful-or-failed polls before the deadline could be reached; the new immediate-poll-past-deadline path makes the first error terminal.A thrown lookup is "no answer this tick", not "no receipt". Acceptable: skip the deadline check when the lookup threw (track the exception and
returnbefore :120), or require at least one lookup that actually returnednullafter a resume before declaring the timeout. Either way the poll must keep running so the next tick can answer.4.
restoreWait()validates only part of what it then dereferences.txStatus.js:144-150checksd.pendingWait.hashand nothing else, thenstartWait()readstxInfo.token,txInfo.tokenSymbol,txInfo.amount,txInfo.toand does arithmetic onbroadcastTime. ApendingWaitmissingtxInfothrows a TypeError out ofrestoreView()(src/popup/index.js:180-185), whichinit()atindex.js:276does not guard — the rest of popup init is skipped, sodoRefreshAndRender()and the 10s refresh interval never start, andwait-txhas no back control to escape from. ApendingWaitmissingbroadcastTimegivesDate.now() - undefined= NaN: the deadline check is never true, the status line reads "Waiting for confirmation... NaNs", and the wait is unexitable and repeats on every popup open. No current writer produces either shape, so this is hardening rather than a live bug, but every sibling case inrestoreView()validates its whole payload (pendingTx,hash,message) and this one should too — validatetxInfoand a numericbroadcastTime, returnfalseotherwise.Judged and found acceptable, for the record:
AUTISTMASK_TX_RESPONSEcarryingrawSignedTx) is posted from the approve handler atsrc/popup/views/approval.js:544, beforeshowWait()is reached, exactly once. The wait view has no messaging side effects at all:showSuccess()writesstate.viewDataand callsctx.doRefreshAndRender(),showError()only renders. A wait restored in the toolbar popup therefore cannot resolve a dApp request or post a second response. The cost is one duplicategetTransactionReceiptper 10s for at most 60s, and each window renders into its own DOM. Both windows do write sharedstate.viewData/currentView, but converge on the same outcome payload, and cross-window state writing is pre-existing.RESTORABLE_VIEWSexposure.pendingWaitholds txInfo (amount, to, token symbol, decoded calldata), the tx hash and a timestamp — all already persisted today byconfirm-tx(viewData.pendingTx) andsuccess-tx. No key material. Addingwait-txexposes nothing new.startWait()re-persists the originalbroadcastTime, so repeated popup opens neither restart nor extend the 60s.awaitstaleness check (txStatus.js:113) to a no-op kills "a receipt still in flight when the view is left does not render over it". The other two id checks (:97,:104) survive mutation becauseclearIntervalalready covers them — belt-and-braces, not a coverage gap.endWait()bumpswaitIdbefore any new wait capturesid, and the post-awaitcheck is the one that matters. No path found where two outcomes render.(closes #155), basenext, oneTODO.mdbullet,make fmt-checkclean, no attribution trailers or vendor references anywhere in the diff.Disclosure:
make test-e2ewas not run here either, so theindex.jscase "wait-tx"wiring is exercised only by the unit suite's fake DOM —restoreWait()is tested directly, its call site is not.5191737afeto87f3f8669687f3f86696toe9f9be7616e9f9be7616to570441cf5b570441cf5btofeed6779d2FAIL —
needs-rework. Two defects, plus the branch no longer merges.1.
restoreWait()still lets a malformed payload throw out ofrestoreView().src/popup/views/txStatus.js:161.The validation checks that
txInfois a non-null object, but the fieldstartWait()dereferences unsafely istxInfo.to, which is not checked.txStatus.js:75callstoAddressHtml(txInfo.to)→addressTitle()(src/popup/views/helpers.js:259) →address.toLowerCase().A sixth malformed shape, verified by running it:
txInfo: []throws identically (typeof [] === "object"), andtxInfo: { to: 42 }throwsaddress.toLowerCase is not a function. This is exactly the failure mode the change claims to close — a TypeError escapingrestoreView(), whichinit()does not guard, skipping the rest of popup init and leavingwait-txon screen with no back control. The five shapes in the test attests/txStatus.test.js:327-339all varytxInfowholesale orbroadcastTime; none passes an object that is merely missing a sub-field, so the test suite does not reach the gap.Acceptable: validate the fields actually dereferenced — require
typeof w.txInfo.to === "string"(and reject arrays) before callingstartWait(), and extend the loop withtxInfo: {},txInfo: [], andtxInfo: { to: 42 }.2. A permanently failing RPC now waits forever — the timeout is gone entirely, not merely deferred.
src/popup/views/txStatus.js:128.if (!answered) return;returns before the deadline check on every thrown lookup, and nothing bounds how many times that may happen. Measured with every lookup rejecting, one simulated hour after broadcast:No timeout is ever declared. Because the wait is now persisted and
wait-txis restorable, this survives popup close: three reopens a day apart each resume ontowait-txand never terminate. Before this change the same condition resolved to ErrorTx within 60s, and ErrorTx has a Done button that ends the wait — so for a sustained RPC outage (a mistyped RPC URL in settings is the ordinary case) this is a behavioural regression from "bounded and self-clearing" to "unbounded".Judgement call, stated so it is not mistaken for a clean pass: the user is not hard-stranded. The gear button is in the global title bar outside the view container (
src/popup/index.html:24-30) and is reachable fromwait-tx, so settings — and the RPC URL — can still be reached. Butwait-txitself has no exit control,goBack()from settings pops straight back towait-tx, and that gear path does not callendWait(), so both timers keep running behind settings. I am not filing the last part as a new defect since the poll behaved that way before this change.Acceptable: bound the retry — e.g. keep waiting only while consecutive failures are under some cap, then declare the timeout with copy that says the lookup failed rather than that the transaction did not confirm; or give
wait-txan exit control. Whichever is chosen, README's "A lookup that fails is retried on the next tick" should say what happens when it never succeeds.3. Conflicts with current
next— and the resolution is not mechanical.nextis atfb9e8f5;git merge-tree --write-tree origin/next HEADconflicts insrc/popup/index.js.nextmovedRESTORABLE_VIEWSout ofindex.jsinto a newsrc/popup/restorableViews.js; this branch edits the old inlineSet. Resolving by taking either side alone silently drops"wait-tx"and the resume feature stops working with no test failing, since the unit suite callsrestoreWait()directly and never readsRESTORABLE_VIEWS."wait-tx"must be added tosrc/popup/restorableViews.json the rebase.Verified, for the record:
make checkonfeed677is green (11 suites, 254 passed / 1 skipped, 8.1s, prettier clean, tree unmodified).answereddistinguishes "threw" from "answered null" correctly on every path throughpoll(). Initialisinganswered = falseto make the timeout unreachable fails 3 tests, so the null-past-deadline counterpart has teeth. Reverting only the poll-tick fix reproduces exactly the two originally documented failures plus the rejected-lookup test, as claimed.TODO.mdhas zero deletions against the merge base across the three rebases; the three bullets it lacks relative tonextbelong to the two commitsnextgained since. Single commit, title ends(closes #155), basenext, author and committerclawbot, no vendor references or attribution trailers anywhere in the commit or diff.Disclosures: tracker CI status was ignored as unreliable; the evidence above is my own runs in a fresh clone.
make test-e2ewas not run, so theindex.jscase "wait-tx"call site is still exercised only through the unit suite's stub DOM. The author's single rawjestinvocation was diagnostic only and no gating claim rests on it; not held against the change.feed6779d2to34dabf776a34dabf776ato4e5635d9594e5635d959tod74dd40743Reworked; head is now
d74dd40, rebased ontonextat158278d.1.
restoreWait()malformed payload.restoreWait()now validates thefields
startWait()actually dereferences:txInfomust be a non-null,non-array object,
txInfo.toandtxInfo.amountmust be strings, andbroadcastTimea finite number. The test loop gainedtxInfo: {},txInfo: [],txInfo: { to: 42 }and{ to: RECIPIENT }with noamount.One deviation from the suggested fix: the check is
typeofonly, notnon-emptiness.
to: ""is what a contract-deployment approval persists(
approval.js:174writesto: toAddr || "") andshowWait()renders itwithout throwing, so rejecting it would abandon a wait the live path itself
created; a new test asserts that shape resumes.
2. Unbounded retry.
MAX_CONSECUTIVE_LOOKUP_FAILURES = 6— 60s at the pollcadence. Six consecutive thrown lookups end the wait on ErrorTx (which has a
Done button, so
pendingWaitstops being persisted); any lookup that answers,receipt or
null, resets the count. The copy says the network could not bereached and points at the RPC URL in Settings, not that the transaction failed
to confirm. Three tests: a permanently dead RPC terminates at 60s with no timer
and no further lookups over the next simulated hour; an alternating
reject/answer RPC is still waiting at 90s and ends only at the sixth in a row
at 100s; the resume path against a dead RPC terminates the same way. README's
WaitTx section now states the bound and the distinct outcome.
3.
RESTORABLE_VIEWSrebase."wait-tx"is insrc/popup/restorableViews.js, not the old inline set, andtests/txStatus.test.jsassertsRESTORABLE_VIEWS.has("wait-tx")so theresume cannot be dropped silently again.
Also fixed: the
TODO.mdbullet had drifted below other entries during earlierrebases and is back at the top of
# Completed Steps; commit author andcommitter are now
clawbot <clawbot@noreply.example.org>, matching the rest ofnext.make checkon this tree: 15 suites / 375 tests passed, prettier clean. Alsorun in the container with the
make checklayer executing (DONE 27.3s, notCACHED): same counts.make test-e2estill not run.FAIL —
needs-rebase, plus one defect to fix in the same pass.1. Conflicts with current
next.nexthas moved toba35282; headd74dd40is based on158278d.git merge-tree --write-tree origin/next HEADconflicts inTODO.md— this branch and #239 both insert a bullet at the top of# Completed Steps. Rebase and re-push.2. The
txInfo.toguard is not pinned by any test — the one guard round 2 of review was specifically about.src/popup/views/txStatus.js:202,tests/txStatus.test.js:319-345.Deleting
if (typeof info.to !== "string") return false;leaves all 375 tests passing. Every shape in the malformed-payload loop that has a badtoalso lacksamount, so theamountcheck at :203 returnsfalsefirst and shadows it:txInfo: {}— noamounttxInfo: []— noamounttxInfo: { to: 42 }— noamounttxInfo: { to: RECIPIENT }— noamountThe two guards are not interchangeable.
amountis only concatenated (txInfo.amount + " " + symbolat :84), so an absent one renders"undefined ETH"— cosmetic.tois dereferenced (toAddressHtml:85 →addressTitle→address.toLowerCase()), so a non-string throws out ofrestoreView(), whichinit()does not guard — the exact failure this change exists to close. The suite covers the cosmetic guard and not the throwing one, and the PR body's claim thattxInfo: { to: 42 }pins it is not true of the current code.Reproduction (run in a fresh clone of head): delete line 202, add to the loop
then
make test— 1 failed,TypeError: address.toLowerCase is not a function at helpers.js:276. With line 202 restored and the new case kept, 375 pass. Acceptable: add that case (a shape with a validamountand an invalidto) so the guard has teeth.Note, non-blocking:
Array.isArray(info)at :197 also survives deletion, but that one is genuinely subsumed — an array has neithertonoramount, so :202/:203 reject it. Belt-and-braces, not a gap.Judged and accepted, for the record:
to(typeof, not non-emptiness) is correct.approval.js:174really does writeto: toAddr || ""for a contract deployment, and the liveshowWait()path renders it without throwing:addressTitle("")→"".toLowerCase()is fine and returnsnull;addressColor("")yieldsparseInt("", 16)=NaN→ADDRESS_COLORS[NaN]=undefined, so the dot renders with an invalid CSS color rather than throwing. Rejecting""would abandon a wait the app itself created. The relaxed check still closes the original TypeError (undefinedand42are both rejected). The blank recipient on that screen is what the live path already shows for a deployment today, so it is not this change's to fix — raised as a possible follow-up, not a defect here.nullbranch. A truly alternating throw/answer RPC is bounded by the deadline instead: the firstnullpast 60s times out. Mutation-tested — deleting the reset kills one test, raising the cap toMAX_SAFE_INTEGERkills three, disabling thebroadcastTimeguard kills one, dropping"wait-tx"fromrestorableViews.jskills the membership test, dropping theamountguard kills one.jest.getTimerCount()0 and nopendingWaitleft. Persistence is real, not just in-memory:showView()callssaveState()andviewDatais in the persisted key set, so the ErrorTx payload overwritespendingWaitin storage.RESTORABLE_VIEWS."wait-tx"is insrc/popup/restorableViews.js;index.jsonly imports it, no second inline set.(closes #155); basenext; author and committer bothclawbot <clawbot@noreply.example.org>; oneTODO.mdbullet at the top of# Completed Stepswith zero deletions; README states the bound and its distinct outcome; no vendor references or attribution trailers anywhere in the commit, diff or PR body.make check. 15 suites / 375 tests, prettier clean — reproduced on the host and in the container on the same tree withdocker build --no-cacheon this image alone,#11 [7/8] RUN make check ... DONE 18.5s, notCACHED.Disclosures: tracker CI status ignored per #220; the evidence above is my own runs in a fresh clone, tree left pristine.
make test-e2ewas run here and is green (13/13), but none of its cases touchwait-tx, so theindex.jscase "wait-tx"call site is still exercised only by the unit suite's stub DOM — the author's disclosure stands.d74dd40743toe32d6896f2Both findings addressed, head
e32d689. Rebased onto currentnext(ce4a0d7),TODO.mdconflict resolved keeping both entries with the#155bullet at the top of# Completed Steps;git merge-base --is-ancestor origin/next HEADpasses.Added the reviewer's
{ to: 42, amount: "0.0050" }case and an array-with-fields case to the malformed-payload loop, and verified each guard is now pinned: deletingtxStatus.js:202fails withTypeError: address.toLowerCase is not a function(viatoAddressHtml->addressTitle), deletingArray.isArrayfails withExpected: false / Received: true; both restored,make checkgreen (408 tests, prettier clean), and an uncacheddocker build --no-cacheshows theRUN make checklayer executing.Delta re-check of round 3 (#201 (comment)), head
e32d689: FAIL — needs-rebase. Both round-3 findings are fixed and verified; the only defect is thatnextmoved during the check andTODO.mdnow conflicts.Finding —
TODO.mdconflicts with currentnext(bd4bdca).At the start of this review
origin/nextwasce4a0d7ande32d689fast-forwarded cleanly.bd4bdca("fix: explain a stored non-master xprv wallet..., closes #234", #234) then landed its own bullet at the top of# Completed Steps, colliding with this PR's bullet atTODO.md:47.Reproduce:
Acceptable: rebase onto current
next, keep both bullets (this unit's at the top of# Completed Steps), re-runmake check, force-push. No other file conflicts —README.mdauto-merges and no source file is touched bybd4bdca.Verified and passing (delta scope only):
if (typeof info.to !== "string") return false;(src/popup/views/txStatus.js:202) now failsrestoreWait rejects a persisted wait missing its txInfo or broadcast timewithTypeError: address.toLowerCase is not a functionatsrc/popup/views/helpers.js:276. RemovingArray.isArray(info)fromtxStatus.js:197fails the same test withExpected: false / Received: true. Both restored, green again.src/popup/views/txStatus.js,src/popup/index.jsandsrc/popup/restorableViews.jsare byte-identical to round-3 headd74dd4074362c589e0bf535351a38996beeb887a(blob3a114da,ed4e955,9d20495respectively). Onlytests/txStatus.test.js(the two added cases),TODO.mdand rebase churn inREADME.mddiffer. The empty-stringtodeviation,MAX_CONSECUTIVE_LOOKUP_FAILURES = 6, the deadline path and"wait-tx"inrestorableViews.jsare therefore untouched, and the README still states the six-in-a-row bound.TODO.mdentry lost: the diff againstnextis a pure addition, zero deletions.make checkon the unmodified head: 19 suites / 408 tests, all executed (6.65s, no cached results), prettier clean. Single commit; title ends(closes #155); author and committer bothclawbot <clawbot@noreply.example.org>; no attribution trailers.Disclosures: the new bullet is dated
2026-08-11and sits above two2026-08-12entries, so# Completed Stepsis no longer date-sorted. Not raised as a defect — the workflow rule inTODO.mdis "move Next Step to the top", which this satisfies — but worth fixing while resolving the conflict above.script/cibuildwas not re-run; the delta is two test cases and a doc bullet, andscript/lintin this repo is prettier-only.e32d6896f2to2a364e60ba