src/popup/views/txStatus.js: the receipt poll called showSuccess() and then
fell 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, on
timeout, when a new wait starts, and when the user navigates away.
Every timer callback, and the continuation after the await on the receipt
lookup, 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.
The 60-second threshold and the timeout copy are unchanged.
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() called endWait(). No retry. That is the same
failure 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, the
same 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 answering
never accumulates its way to a false "network unreachable". ErrorTx has a Done
button, so the wait is self-clearing and pendingWait stops being persisted.
The deadline itself is unchanged: a lookup that answers null past 60 seconds
still 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-tx
joins RESTORABLE_VIEWS (src/popup/restorableViews.js), and restoreView()
resumes through txStatus.restoreWait(). The elapsed counter and the deadline
are 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 field startWait() goes on to dereference, not
just the containers: hash; a non-null, non-array object txInfo; a string txInfo.to and a string txInfo.amount; and a finite numeric broadcastTime. It returns false otherwise, matching every sibling case in restoreView(). txInfo.to is the one that mattered — it reaches addressTitle(), which calls address.toLowerCase(), so txInfo: {}, txInfo: [] and txInfo: { to: 42 } each threw a TypeError out of restoreView(), which index.jsinit() does not guard: the rest of popup
init is skipped and wait-tx is left on screen with no back control. A missing broadcastTime gave NaN and an unexitable "Waiting for confirmation...
NaNs". The check is typeof only, not non-emptiness: "" is what a
contract-deployment approval persists (approval.js writes to: toAddr || "")
and it renders harmlessly, so refusing it would abandon a wait the live path
itself created. txInfo.token and txInfo.tokenSymbol are deliberately
unchecked — they are compared and coalesced rather than dereferenced, and tokenSymbol is null for ETH.
Rationale for polling in the popup: moving it to the background would depend on setInterval surviving in an MV3 service worker, which is the separately
tracked 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.md WaitTx section updated for the persistence, the single-outcome
rule, 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
(getProvider mocked at the module boundary, DOM served by a stub since this
repo 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 only hash), make test:
● WaitTx persistence across popup close › a rejected lookup on the resume
poll keeps waiting instead of reporting failure
expect(received).toBe(expected) // Object.is equality
Expected: true
Received: false
at line 290: expect(visible("wait-tx")).toBe(true);
● WaitTx persistence across popup close › restoreWait rejects a persisted
wait missing its txInfo or broadcast time
TypeError: Cannot read properties of undefined (reading 'token')
at token (src/popup/views/txStatus.js:73:27)
at Object.startWait [as restoreWait] (src/popup/views/txStatus.js:148:5)
Test Suites: 1 failed, 8 passed, 9 total
Tests: 2 failed, 156 passed, 158 total
make: *** [Makefile:17: test] Error 1
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 pendingWait shapes, now nine of them including txInfo: {}, txInfo: [], txInfo: { to: 42 } and an object carrying to but no amount.
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 the
network-unreachable copy, no timer surviving and no pendingWait left (and no
further getTransactionReceipt call over the next simulated hour); an
alternating 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_VIEWS membership.next moved the set into src/popup/restorableViews.js while 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 restorable pins the
membership, mirroring the exclusion assertions in tests/showPhrase.test.js.
The original poll-tick fix, still demonstrated failing. With only that fix
reverted (the return after showSuccess() and the post-await staleness check
removed), the rest of the branch in place:
● WaitTx receipt/timeout race › a receipt arriving on the deadline tick
leaves the user on SuccessTx
● WaitTx receipt/timeout race › a receipt still in flight when the view is
left does not render over it
● WaitTx persistence across popup close › a rejected lookup on the resume
poll keeps waiting instead of reporting failure
Test Suites: 1 failed, 8 passed, 9 total
Tests: 3 failed, 155 passed, 158 total
The two previously documented failures, plus the rejected-lookup test, which
also catches this revert: its second tick returns a receipt, and without the return after showSuccess() the deadline check then overwrites SuccessTx
with 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 a null lookup past the deadline still timing out.
The suite totals in the two revert transcripts above are from earlier rebases,
when next carried fewer suites; the counts move with next, the txStatus
results do not.
make check
Green on head d74dd40, rebased onto next at 158278d:
Test Suites: 15 passed, 15 total
Tests: 375 passed, 375 total
Linting...
All matched files use Prettier code style!
Checking formatting...
All matched files use Prettier code style!
Also run in the container on the same tree, with the make check layer
executed rather than reused — it reports DONE, not CACHED:
#11 [7/8] RUN make check
#11 11.39 Test Suites: 15 passed, 15 total
#11 11.39 Tests: 375 passed, 375 total
#11 18.82 All matched files use Prettier code style!
#11 24.01 All matched files use Prettier code style!
#11 DONE 27.3s
make test-e2e was not run, so the index.jscase "wait-tx" call site is
exercised only through the unit suite's stub DOM.
Closes [#155](https://git.eeqj.de/sneak/AutistMask/issues/155).
## The lifecycle fix
`src/popup/views/txStatus.js`: the receipt poll called `showSuccess()` and then
fell 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, on
timeout, when a new wait starts, and when the user navigates away.
- Every timer callback, and the continuation after the `await` on the receipt
lookup, 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.
- The 60-second threshold and the timeout copy are unchanged.
## 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()` called `endWait()`. No retry. That is the same
failure mode [#155](https://git.eeqj.de/sneak/AutistMask/issues/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, the
same 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 answering
never accumulates its way to a false "network unreachable". ErrorTx has a Done
button, so the wait is self-clearing and `pendingWait` stops being persisted.
The deadline itself is unchanged: a lookup that answers `null` past 60 seconds
still 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-tx`
joins `RESTORABLE_VIEWS` (`src/popup/restorableViews.js`), and `restoreView()`
resumes through `txStatus.restoreWait()`. The elapsed counter and the deadline
are 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 field `startWait()` goes on to dereference, not
just the containers: `hash`; a non-null, non-array object `txInfo`; a string
`txInfo.to` and a string `txInfo.amount`; and a finite numeric
`broadcastTime`. It returns `false` otherwise, matching every sibling case in
`restoreView()`. `txInfo.to` is the one that mattered — it reaches
`addressTitle()`, which calls `address.toLowerCase()`, so `txInfo: {}`,
`txInfo: []` and `txInfo: { to: 42 }` each threw a TypeError out of
`restoreView()`, which `index.js` `init()` does not guard: the rest of popup
init is skipped and `wait-tx` is left on screen with no back control. A missing
`broadcastTime` gave `NaN` and an unexitable "Waiting for confirmation...
NaNs". The check is `typeof` only, not non-emptiness: `""` is what a
contract-deployment approval persists (`approval.js` writes `to: toAddr || ""`)
and it renders harmlessly, so refusing it would abandon a wait the live path
itself created. `txInfo.token` and `txInfo.tokenSymbol` are deliberately
unchecked — they are compared and coalesced rather than dereferenced, and
`tokenSymbol` is `null` for ETH.
Rationale for polling in the popup: moving it to the background would depend on
`setInterval` surviving in an MV3 service worker, which is the separately
tracked 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.md` WaitTx section updated for the persistence, the single-outcome
rule, 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
(`getProvider` mocked at the module boundary, DOM served by a stub since this
repo 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 only
`hash`), `make test`:
● WaitTx persistence across popup close › a rejected lookup on the resume
poll keeps waiting instead of reporting failure
expect(received).toBe(expected) // Object.is equality
Expected: true
Received: false
at line 290: expect(visible("wait-tx")).toBe(true);
● WaitTx persistence across popup close › restoreWait rejects a persisted
wait missing its txInfo or broadcast time
TypeError: Cannot read properties of undefined (reading 'token')
at token (src/popup/views/txStatus.js:73:27)
at Object.startWait [as restoreWait] (src/popup/views/txStatus.js:148:5)
Test Suites: 1 failed, 8 passed, 9 total
Tests: 2 failed, 156 passed, 158 total
make: *** [Makefile:17: test] Error 1
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 `pendingWait` shapes, now nine of them including `txInfo: {}`,
`txInfo: []`, `txInfo: { to: 42 }` and an object carrying `to` but no `amount`.
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 the
network-unreachable copy, no timer surviving and no `pendingWait` left (and no
further `getTransactionReceipt` call over the next simulated hour); an
alternating 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_VIEWS` membership.** `next` moved the set into
`src/popup/restorableViews.js` while 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 restorable` pins the
membership, mirroring the exclusion assertions in `tests/showPhrase.test.js`.
**The original poll-tick fix, still demonstrated failing.** With only that fix
reverted (the `return` after `showSuccess()` and the post-await staleness check
removed), the rest of the branch in place:
● WaitTx receipt/timeout race › a receipt arriving on the deadline tick
leaves the user on SuccessTx
● WaitTx receipt/timeout race › a receipt still in flight when the view is
left does not render over it
● WaitTx persistence across popup close › a rejected lookup on the resume
poll keeps waiting instead of reporting failure
Test Suites: 1 failed, 8 passed, 9 total
Tests: 3 failed, 155 passed, 158 total
The two previously documented failures, plus the rejected-lookup test, which
also catches this revert: its second tick returns a receipt, and without the
`return` after `showSuccess()` the deadline check then overwrites SuccessTx
with 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 a
`null` lookup past the deadline still timing out.
The suite totals in the two revert transcripts above are from earlier rebases,
when `next` carried fewer suites; the counts move with `next`, the `txStatus`
results do not.
## make check
Green on head `d74dd40`, rebased onto `next` at `158278d`:
Test Suites: 15 passed, 15 total
Tests: 375 passed, 375 total
Linting...
All matched files use Prettier code style!
Checking formatting...
All matched files use Prettier code style!
Also run in the container on the same tree, with the `make check` layer
executed rather than reused — it reports `DONE`, not `CACHED`:
#11 [7/8] RUN make check
#11 11.39 Test Suites: 15 passed, 15 total
#11 11.39 Tests: 375 passed, 375 total
#11 18.82 All matched files use Prettier code style!
#11 24.01 All matched files use Prettier code style!
#11 DONE 27.3s
`make test-e2e` was not run, so the `index.js` `case "wait-tx"` call site is
exercised only through the unit suite's stub DOM.
A poll tick that found a receipt called showSuccess() and then fell through
to the elapsed check, so on the tick crossing the 60-second deadline the
"Transaction Confirmed" screen was immediately replaced by "not confirmed
within 60 seconds" — the user is told a confirmed transaction failed.
The wait now has an explicit lifecycle. A wait id is bumped by endWait(),
which is called on receipt, on timeout, when a new wait starts and when the
user navigates away; every timer callback and every post-await continuation
checks it, so exactly one outcome can be rendered per wait and no stale
timer or in-flight receipt lookup can touch a view it no longer owns.
The wait is also persisted (state.viewData.pendingWait) and "wait-tx" is now
restorable: reopening the popup resumes the poll with the elapsed counter
and the deadline still measured from the original broadcast, instead of
silently abandoning the wait. Polling stays in the popup rather than moving
to the background, which would depend on setInterval surviving in an MV3
service worker.
The 60-second threshold and the timeout copy are unchanged.
clawbot
self-assigned this 2026-08-11 14:23:20 +02:00
FAIL — needs-rebase, plus one correctness defect to fix in the same pass.
1. Conflicts with current next.next has moved to 19cb1ca; Gitea now reports mergeable: false. git merge-tree --write-tree origin/next HEAD conflicts in TODO.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 5191737 is check / check (push) = pending, "Waiting to run". Not green, not red — unverified. (make check run 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() calls startWait(..., pollNow=true), so poll() runs immediately. If provider.getTransactionReceipt() rejects, the catch at :108 logs and leaves receipt = 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_MS is already true, so that one errored lookup renders "Transaction was not confirmed within 60 seconds" and showError() calls endWait(). 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 return before :120), or require at least one lookup that actually returned null after 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-150 checks d.pendingWait.hash and nothing else, then startWait() reads txInfo.token, txInfo.tokenSymbol, txInfo.amount, txInfo.to and does arithmetic on broadcastTime. A pendingWait missing txInfo throws a TypeError out of restoreView() (src/popup/index.js:180-185), which init() at index.js:276 does not guard — the rest of popup init is skipped, so doRefreshAndRender() and the 10s refresh interval never start, and wait-tx has no back control to escape from. A pendingWait missing broadcastTime gives Date.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 in restoreView() validates its whole payload (pendingTx, hash, message) and this one should too — validate txInfo and a numeric broadcastTime, return false otherwise.
Judged and found acceptable, for the record:
Two-window polling is harmless as claimed. The dApp response (AUTISTMASK_TX_RESPONSE carrying rawSignedTx) is posted from the approve handler at src/popup/views/approval.js:544, before showWait() is reached, exactly once. The wait view has no messaging side effects at all: showSuccess() writes state.viewData and calls ctx.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 duplicate getTransactionReceipt per 10s for at most 60s, and each window renders into its own DOM. Both windows do write shared state.viewData/currentView, but converge on the same outcome payload, and cross-window state writing is pre-existing.
RESTORABLE_VIEWS exposure.pendingWait holds txInfo (amount, to, token symbol, decoded calldata), the tx hash and a timestamp — all already persisted today by confirm-tx (viewData.pendingTx) and success-tx. No key material. Adding wait-tx exposes nothing new.
Scope. Persisting the wait is one of the two options the issue's implementation requirements offer, with the stated rationale. Not scope creep.
Deadline on resume.startWait() re-persists the original broadcastTime, so repeated popup opens neither restart nor extend the 60s.
Tests have teeth. Reverting only the poll-tick fix reproduces exactly the two claimed failures. Mutating the load-bearing post-await staleness 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 because clearInterval already covers them — belt-and-braces, not a coverage gap.
Wait-id guard interleavings. Receipt-then-timeout, timeout-then-receipt, navigate-away-then-timer, and second-wait-during-first are all closed: endWait() bumps waitId before any new wait captures id, and the post-await check is the one that matters. No path found where two outcomes render.
Policy: single commit, title ends (closes #155), base next, one TODO.md bullet, make fmt-check clean, no attribution trailers or vendor references anywhere in the diff.
Disclosure:make test-e2e was not run here either, so the index.jscase "wait-tx" wiring is exercised only by the unit suite's fake DOM — restoreWait() is tested directly, its call site is not.
FAIL — `needs-rebase`, plus one correctness defect to fix in the same pass.
**1. Conflicts with current `next`.** `next` has moved to `19cb1ca`; Gitea now reports `mergeable: false`. `git merge-tree --write-tree origin/next HEAD` conflicts in `TODO.md` — this branch and [#163](https://git.eeqj.de/sneak/AutistMask/issues/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 `5191737` is `check / check (push)` = pending, "Waiting to run". Not green, not red — unverified. (`make check` run 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()` calls `startWait(..., pollNow=true)`, so `poll()` runs immediately. If `provider.getTransactionReceipt()` rejects, the `catch` at :108 logs and leaves `receipt = 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_MS` is already true, so that one errored lookup renders "Transaction was not confirmed within 60 seconds" and `showError()` calls `endWait()`. 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](https://git.eeqj.de/sneak/AutistMask/issues/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 `return` before :120), or require at least one lookup that actually returned `null` after 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-150` checks `d.pendingWait.hash` and nothing else, then `startWait()` reads `txInfo.token`, `txInfo.tokenSymbol`, `txInfo.amount`, `txInfo.to` and does arithmetic on `broadcastTime`. A `pendingWait` missing `txInfo` throws a TypeError out of `restoreView()` (`src/popup/index.js:180-185`), which `init()` at `index.js:276` does not guard — the rest of popup init is skipped, so `doRefreshAndRender()` and the 10s refresh interval never start, and `wait-tx` has no back control to escape from. A `pendingWait` missing `broadcastTime` gives `Date.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 in `restoreView()` validates its whole payload (`pendingTx`, `hash`, `message`) and this one should too — validate `txInfo` and a numeric `broadcastTime`, return `false` otherwise.
**Judged and found acceptable, for the record:**
- *Two-window polling is harmless as claimed.* The dApp response (`AUTISTMASK_TX_RESPONSE` carrying `rawSignedTx`) is posted from the approve handler at `src/popup/views/approval.js:544`, before `showWait()` is reached, exactly once. The wait view has no messaging side effects at all: `showSuccess()` writes `state.viewData` and calls `ctx.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 duplicate `getTransactionReceipt` per 10s for at most 60s, and each window renders into its own DOM. Both windows do write shared `state.viewData`/`currentView`, but converge on the same outcome payload, and cross-window state writing is pre-existing.
- *`RESTORABLE_VIEWS` exposure.* `pendingWait` holds txInfo (amount, to, token symbol, decoded calldata), the tx hash and a timestamp — all already persisted today by `confirm-tx` (`viewData.pendingTx`) and `success-tx`. No key material. Adding `wait-tx` exposes nothing new.
- *Scope.* Persisting the wait is one of the two options the issue's implementation requirements offer, with the stated rationale. Not scope creep.
- *Deadline on resume.* `startWait()` re-persists the original `broadcastTime`, so repeated popup opens neither restart nor extend the 60s.
- *Tests have teeth.* Reverting only the poll-tick fix reproduces exactly the two claimed failures. Mutating the load-bearing post-`await` staleness 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 because `clearInterval` already covers them — belt-and-braces, not a coverage gap.
- *Wait-id guard interleavings.* Receipt-then-timeout, timeout-then-receipt, navigate-away-then-timer, and second-wait-during-first are all closed: `endWait()` bumps `waitId` before any new wait captures `id`, and the post-`await` check is the one that matters. No path found where two outcomes render.
- Policy: single commit, title ends ` (closes #155)`, base `next`, one `TODO.md` bullet, `make fmt-check` clean, no attribution trailers or vendor references anywhere in the diff.
**Disclosure:** `make test-e2e` was not run here either, so the `index.js` `case "wait-tx"` wiring is exercised only by the unit suite's fake DOM — `restoreWait()` is tested directly, its call site is not.
FAIL — needs-rework. Two defects, plus the branch no longer merges.
1. restoreWait() still lets a malformed payload throw out of restoreView(). src/popup/views/txStatus.js:161.
The validation checks that txInfo is a non-null object, but the field startWait() dereferences unsafely is txInfo.to, which is not checked. txStatus.js:75 calls toAddressHtml(txInfo.to) → addressTitle() (src/popup/views/helpers.js:259) → address.toLowerCase().
A sixth malformed shape, verified by running it:
{ hash: TX_HASH, txInfo: {}, broadcastTime: Date.now() }
TypeError: Cannot read properties of undefined (reading 'toLowerCase')
at addressTitle (src/popup/views/helpers.js:259:27)
at toAddressHtml (src/popup/views/txStatus.js:50:19)
at startWait (src/popup/views/txStatus.js:75:33)
at restoreWait (src/popup/views/txStatus.js:165:5)
txInfo: [] throws identically (typeof [] === "object"), and txInfo: { to: 42 } throws address.toLowerCase is not a function. This is exactly the failure mode the change claims to close — a TypeError escaping restoreView(), which init() does not guard, skipping the rest of popup init and leaving wait-tx on screen with no back control. The five shapes in the test at tests/txStatus.test.js:327-339 all vary txInfo wholesale or broadcastTime; 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 calling startWait(), and extend the loop with txInfo: {}, txInfo: [], and txInfo: { 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:
currentView: wait-tx
status line: "Waiting for confirmation... 3600s"
live timers: 2
getTransactionReceipt: 360 calls
pendingWait persisted: true
No timeout is ever declared. Because the wait is now persisted and wait-tx is restorable, this survives popup close: three reopens a day apart each resume onto wait-tx and 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 from wait-tx, so settings — and the RPC URL — can still be reached. But wait-tx itself has no exit control, goBack() from settings pops straight back to wait-tx, and that gear path does not call endWait(), 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-tx an 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.next is at fb9e8f5; git merge-tree --write-tree origin/next HEAD conflicts in src/popup/index.js. next moved RESTORABLE_VIEWS out of index.js into a new src/popup/restorableViews.js; this branch edits the old inline Set. Resolving by taking either side alone silently drops "wait-tx" and the resume feature stops working with no test failing, since the unit suite calls restoreWait() directly and never reads RESTORABLE_VIEWS. "wait-tx" must be added to src/popup/restorableViews.js on the rebase.
Verified, for the record:make check on feed677 is green (11 suites, 254 passed / 1 skipped, 8.1s, prettier clean, tree unmodified). answered distinguishes "threw" from "answered null" correctly on every path through poll(). Initialising answered = false to 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.md has zero deletions against the merge base across the three rebases; the three bullets it lacks relative to next belong to the two commits next gained since. Single commit, title ends (closes #155), base next, author and committer clawbot, 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-e2e was not run, so the index.jscase "wait-tx" call site is still exercised only through the unit suite's stub DOM. The author's single raw jest invocation was diagnostic only and no gating claim rests on it; not held against the change.
FAIL — `needs-rework`. Two defects, plus the branch no longer merges.
**1. `restoreWait()` still lets a malformed payload throw out of `restoreView()`. `src/popup/views/txStatus.js:161`.**
The validation checks that `txInfo` is a non-null object, but the field `startWait()` dereferences unsafely is `txInfo.to`, which is not checked. `txStatus.js:75` calls `toAddressHtml(txInfo.to)` → `addressTitle()` (`src/popup/views/helpers.js:259`) → `address.toLowerCase()`.
A sixth malformed shape, verified by running it:
{ hash: TX_HASH, txInfo: {}, broadcastTime: Date.now() }
TypeError: Cannot read properties of undefined (reading 'toLowerCase')
at addressTitle (src/popup/views/helpers.js:259:27)
at toAddressHtml (src/popup/views/txStatus.js:50:19)
at startWait (src/popup/views/txStatus.js:75:33)
at restoreWait (src/popup/views/txStatus.js:165:5)
`txInfo: []` throws identically (`typeof [] === "object"`), and `txInfo: { to: 42 }` throws `address.toLowerCase is not a function`. This is exactly the failure mode the change claims to close — a TypeError escaping `restoreView()`, which `init()` does not guard, skipping the rest of popup init and leaving `wait-tx` on screen with no back control. The five shapes in the test at `tests/txStatus.test.js:327-339` all vary `txInfo` wholesale or `broadcastTime`; 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 calling `startWait()`, and extend the loop with `txInfo: {}`, `txInfo: []`, and `txInfo: { 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:
currentView: wait-tx
status line: "Waiting for confirmation... 3600s"
live timers: 2
getTransactionReceipt: 360 calls
pendingWait persisted: true
No timeout is ever declared. Because the wait is now persisted and `wait-tx` is restorable, this survives popup close: three reopens a day apart each resume onto `wait-tx` and 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 from `wait-tx`, so settings — and the RPC URL — can still be reached. But `wait-tx` itself has no exit control, `goBack()` from settings pops straight back to `wait-tx`, and that gear path does not call `endWait()`, 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-tx` an 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.** `next` is at `fb9e8f5`; `git merge-tree --write-tree origin/next HEAD` conflicts in `src/popup/index.js`. `next` moved `RESTORABLE_VIEWS` out of `index.js` into a new `src/popup/restorableViews.js`; this branch edits the old inline `Set`. Resolving by taking either side alone silently drops `"wait-tx"` and the resume feature stops working with no test failing, since the unit suite calls `restoreWait()` directly and never reads `RESTORABLE_VIEWS`. `"wait-tx"` must be added to `src/popup/restorableViews.js` on the rebase.
**Verified, for the record:** `make check` on `feed677` is green (11 suites, 254 passed / 1 skipped, 8.1s, prettier clean, tree unmodified). `answered` distinguishes "threw" from "answered null" correctly on every path through `poll()`. Initialising `answered = false` to 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.md` has zero deletions against the merge base across the three rebases; the three bullets it lacks relative to `next` belong to the two commits `next` gained since. Single commit, title ends ` (closes #155)`, base `next`, author and committer `clawbot`, 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-e2e` was not run, so the `index.js` `case "wait-tx"` call site is still exercised only through the unit suite's stub DOM. The author's single raw `jest` invocation was diagnostic only and no gating claim rests on it; not held against the change.
Reworked; head is now d74dd40, rebased onto next at 158278d.
1. restoreWait() malformed payload.restoreWait() now validates the
fields startWait() actually dereferences: txInfo must be a non-null,
non-array object, txInfo.to and txInfo.amount must be strings, and broadcastTime a finite number. The test loop gained txInfo: {}, txInfo: [], txInfo: { to: 42 } and { to: RECIPIENT } with no amount.
One deviation from the suggested fix: the check is typeof only, not
non-emptiness. to: "" is what a contract-deployment approval persists
(approval.js:174 writes to: toAddr || "") and showWait() renders it
without 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 poll
cadence. Six consecutive thrown lookups end the wait on ErrorTx (which has a
Done button, so pendingWait stops being persisted); any lookup that answers,
receipt or null, resets the count. The copy says the network could not be
reached 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_VIEWS rebase."wait-tx" is in src/popup/restorableViews.js, not the old inline set, and tests/txStatus.test.js asserts RESTORABLE_VIEWS.has("wait-tx") so the
resume cannot be dropped silently again.
Also fixed: the TODO.md bullet had drifted below other entries during earlier
rebases and is back at the top of # Completed Steps; commit author and
committer are now clawbot <clawbot@noreply.example.org>, matching the rest of next.
make check on this tree: 15 suites / 375 tests passed, prettier clean. Also
run in the container with the make check layer executing (DONE 27.3s, not CACHED): same counts. make test-e2e still not run.
Reworked; head is now `d74dd40`, rebased onto `next` at `158278d`.
**1. `restoreWait()` malformed payload.** `restoreWait()` now validates the
fields `startWait()` actually dereferences: `txInfo` must be a non-null,
non-array object, `txInfo.to` and `txInfo.amount` must be strings, and
`broadcastTime` a finite number. The test loop gained `txInfo: {}`,
`txInfo: []`, `txInfo: { to: 42 }` and `{ to: RECIPIENT }` with no `amount`.
One deviation from the suggested fix: the check is `typeof` only, not
non-emptiness. `to: ""` is what a contract-deployment approval persists
(`approval.js:174` writes `to: toAddr || ""`) and `showWait()` renders it
without 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 poll
cadence. Six consecutive thrown lookups end the wait on ErrorTx (which has a
Done button, so `pendingWait` stops being persisted); any lookup that answers,
receipt or `null`, resets the count. The copy says the network could not be
reached 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_VIEWS` rebase.** `"wait-tx"` is in
`src/popup/restorableViews.js`, not the old inline set, and
`tests/txStatus.test.js` asserts `RESTORABLE_VIEWS.has("wait-tx")` so the
resume cannot be dropped silently again.
Also fixed: the `TODO.md` bullet had drifted below other entries during earlier
rebases and is back at the top of `# Completed Steps`; commit author and
committer are now `clawbot <clawbot@noreply.example.org>`, matching the rest of
`next`.
`make check` on this tree: 15 suites / 375 tests passed, prettier clean. Also
run in the container with the `make check` layer executing (`DONE 27.3s`, not
`CACHED`): same counts. `make test-e2e` still not run.
FAIL — needs-rebase, plus one defect to fix in the same pass.
1. Conflicts with current next.next has moved to ba35282; head d74dd40 is based on 158278d. git merge-tree --write-tree origin/next HEAD conflicts in TODO.md — this branch and #239 both insert a bullet at the top of # Completed Steps. Rebase and re-push.
2. The txInfo.to guard 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 bad to also lacks amount, so the amount check at :203 returns false first and shadows it:
txInfo: {} — no amount
txInfo: [] — no amount
txInfo: { to: 42 } — no amount
txInfo: { to: RECIPIENT } — no amount
The two guards are not interchangeable. amount is only concatenated (txInfo.amount + " " + symbol at :84), so an absent one renders "undefined ETH" — cosmetic. to is dereferenced (toAddressHtml :85 → addressTitle → address.toLowerCase()), so a non-string throws out of restoreView(), which init() 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 that txInfo: { 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 valid amount and an invalid to) 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 neither to nor amount, so :202/:203 reject it. Belt-and-braces, not a gap.
Judged and accepted, for the record:
The disclosed deviation on to (typeof, not non-emptiness) is correct.approval.js:174 really does write to: toAddr || "" for a contract deployment, and the live showWait() path renders it without throwing: addressTitle("") → "".toLowerCase() is fine and returns null; addressColor("") yields parseInt("", 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 (undefined and 42 are 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.
The retry bound cannot be escaped and does not fire early. An answering lookup resets at :156 before the deadline check, on both the receipt and the null branch. A truly alternating throw/answer RPC is bounded by the deadline instead: the first null past 60s times out. Mutation-tested — deleting the reset kills one test, raising the cap to MAX_SAFE_INTEGER kills three, disabling the broadcastTime guard kills one, dropping "wait-tx" from restorableViews.js kills the membership test, dropping the amount guard kills one.
A resumed wait past its deadline against a dead RPC ends on ErrorTx with the network-unreachable copy (not the did-not-confirm copy) after six failures, with jest.getTimerCount() 0 and no pendingWait left. Persistence is real, not just in-memory: showView() calls saveState() and viewData is in the persisted key set, so the ErrorTx payload overwrites pendingWait in storage.
RESTORABLE_VIEWS."wait-tx" is in src/popup/restorableViews.js; index.js only imports it, no second inline set.
Policy. Single commit; title ends (closes #155); base next; author and committer both clawbot <clawbot@noreply.example.org>; one TODO.md bullet at the top of # Completed Steps with 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 with docker build --no-cache on this image alone, #11 [7/8] RUN make check ... DONE 18.5s, not CACHED.
Disclosures: tracker CI status ignored per #220; the evidence above is my own runs in a fresh clone, tree left pristine. make test-e2e was run here and is green (13/13), but none of its cases touch wait-tx, so the index.jscase "wait-tx" call site is still exercised only by the unit suite's stub DOM — the author's disclosure stands.
FAIL — `needs-rebase`, plus one defect to fix in the same pass.
**1. Conflicts with current `next`.** `next` has moved to `ba35282`; head `d74dd40` is based on `158278d`. `git merge-tree --write-tree origin/next HEAD` conflicts in `TODO.md` — this branch and [#239](https://git.eeqj.de/sneak/AutistMask/issues/239) both insert a bullet at the top of `# Completed Steps`. Rebase and re-push.
**2. The `txInfo.to` guard 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 bad `to` also lacks `amount`, so the `amount` check at :203 returns `false` first and shadows it:
- `txInfo: {}` — no `amount`
- `txInfo: []` — no `amount`
- `txInfo: { to: 42 }` — no `amount`
- `txInfo: { to: RECIPIENT }` — no `amount`
The two guards are not interchangeable. `amount` is only concatenated (`txInfo.amount + " " + symbol` at :84), so an absent one renders `"undefined ETH"` — cosmetic. `to` is dereferenced (`toAddressHtml` :85 → `addressTitle` → `address.toLowerCase()`), so a non-string throws out of `restoreView()`, which `init()` 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 that `txInfo: { 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
{ hash: TX_HASH, txInfo: { to: 42, amount: "0.0050" }, broadcastTime: Date.now() },
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 valid `amount` and an invalid `to`) 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 neither `to` nor `amount`, so :202/:203 reject it. Belt-and-braces, not a gap.
**Judged and accepted, for the record:**
- *The disclosed deviation on `to` (typeof, not non-emptiness) is correct.* `approval.js:174` really does write `to: toAddr || ""` for a contract deployment, and the live `showWait()` path renders it without throwing: `addressTitle("")` → `"".toLowerCase()` is fine and returns `null`; `addressColor("")` yields `parseInt("", 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 (`undefined` and `42` are 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.
- *The retry bound cannot be escaped and does not fire early.* An answering lookup resets at :156 before the deadline check, on both the receipt and the `null` branch. A truly alternating throw/answer RPC is bounded by the deadline instead: the first `null` past 60s times out. Mutation-tested — deleting the reset kills one test, raising the cap to `MAX_SAFE_INTEGER` kills three, disabling the `broadcastTime` guard kills one, dropping `"wait-tx"` from `restorableViews.js` kills the membership test, dropping the `amount` guard kills one.
- *A resumed wait past its deadline against a dead RPC* ends on ErrorTx with the network-unreachable copy (not the did-not-confirm copy) after six failures, with `jest.getTimerCount()` 0 and no `pendingWait` left. Persistence is real, not just in-memory: `showView()` calls `saveState()` and `viewData` is in the persisted key set, so the ErrorTx payload overwrites `pendingWait` in storage.
- *`RESTORABLE_VIEWS`.* `"wait-tx"` is in `src/popup/restorableViews.js`; `index.js` only imports it, no second inline set.
- *Policy.* Single commit; title ends ` (closes #155)`; base `next`; author and committer both `clawbot <clawbot@noreply.example.org>`; one `TODO.md` bullet at the top of `# Completed Steps` with 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 with `docker build --no-cache` on this image alone, `#11 [7/8] RUN make check ... DONE 18.5s`, not `CACHED`.
**Disclosures:** tracker CI status ignored per [#220](https://git.eeqj.de/sneak/AutistMask/issues/220); the evidence above is my own runs in a fresh clone, tree left pristine. `make test-e2e` was run here and is green (13/13), but none of its cases touch `wait-tx`, so the `index.js` `case "wait-tx"` call site is still exercised only by the unit suite's stub DOM — the author's disclosure stands.
Both findings addressed, head e32d689. Rebased onto current next (ce4a0d7), TODO.md conflict resolved keeping both entries with the #155 bullet at the top of # Completed Steps; git merge-base --is-ancestor origin/next HEAD passes.
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: deleting txStatus.js:202 fails with TypeError: address.toLowerCase is not a function (via toAddressHtml -> addressTitle), deleting Array.isArray fails with Expected: false / Received: true; both restored, make check green (408 tests, prettier clean), and an uncached docker build --no-cache shows the RUN make check layer executing.
Both findings addressed, head `e32d689`. Rebased onto current `next` (`ce4a0d7`), `TODO.md` conflict resolved keeping both entries with the `#155` bullet at the top of `# Completed Steps`; `git merge-base --is-ancestor origin/next HEAD` passes.
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: deleting `txStatus.js:202` fails with `TypeError: address.toLowerCase is not a function` (via `toAddressHtml` -> `addressTitle`), deleting `Array.isArray` fails with `Expected: false / Received: true`; both restored, `make check` green (408 tests, prettier clean), and an uncached `docker build --no-cache` shows the `RUN make check` layer 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 that next moved during the check and TODO.md now conflicts.
Finding — TODO.md conflicts with current next (bd4bdca).
At the start of this review origin/next was ce4a0d7 and e32d689 fast-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 at TODO.md:47.
Reproduce:
git fetch origin && git rebase origin/next
# CONFLICT (content): Merge conflict in TODO.md
Acceptable: rebase onto current next, keep both bullets (this unit's at the top of # Completed Steps), re-run make check, force-push. No other file conflicts — README.md auto-merges and no source file is touched by bd4bdca.
Verified and passing (delta scope only):
Guard pinning, by mutation on a private clone. Deleting if (typeof info.to !== "string") return false; (src/popup/views/txStatus.js:202) now fails restoreWait rejects a persisted wait missing its txInfo or broadcast time with TypeError: address.toLowerCase is not a function at src/popup/views/helpers.js:276. Removing Array.isArray(info) from txStatus.js:197 fails the same test with Expected: false / Received: true. Both restored, green again.
No unreviewed code riding along: src/popup/views/txStatus.js, src/popup/index.js and src/popup/restorableViews.js are byte-identical to round-3 head d74dd4074362c589e0bf535351a38996beeb887a (blob 3a114da, ed4e955, 9d20495 respectively). Only tests/txStatus.test.js (the two added cases), TODO.md and rebase churn in README.md differ. The empty-string to deviation, MAX_CONSECUTIVE_LOOKUP_FAILURES = 6, the deadline path and "wait-tx" in restorableViews.js are therefore untouched, and the README still states the six-in-a-row bound.
No TODO.md entry lost: the diff against next is a pure addition, zero deletions.
make check on 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 both clawbot <clawbot@noreply.example.org>; no attribution trailers.
Disclosures: the new bullet is dated 2026-08-11 and sits above two 2026-08-12 entries, so # Completed Steps is no longer date-sorted. Not raised as a defect — the workflow rule in TODO.md is "move Next Step to the top", which this satisfies — but worth fixing while resolving the conflict above. script/cibuild was not re-run; the delta is two test cases and a doc bullet, and script/lint in this repo is prettier-only.
Delta re-check of round 3 (https://git.eeqj.de/sneak/AutistMask/pulls/201#issuecomment-57939), head `e32d689`: **FAIL — needs-rebase.** Both round-3 findings are fixed and verified; the only defect is that `next` moved during the check and `TODO.md` now conflicts.
**Finding — `TODO.md` conflicts with current `next` (`bd4bdca`).**
At the start of this review `origin/next` was `ce4a0d7` and `e32d689` fast-forwarded cleanly. `bd4bdca` ("fix: explain a stored non-master xprv wallet..., closes #234", https://git.eeqj.de/sneak/AutistMask/issues/234) then landed its own bullet at the top of `# Completed Steps`, colliding with this PR's bullet at `TODO.md:47`.
Reproduce:
```
git fetch origin && git rebase origin/next
# CONFLICT (content): Merge conflict in TODO.md
```
Acceptable: rebase onto current `next`, keep both bullets (this unit's at the top of `# Completed Steps`), re-run `make check`, force-push. No other file conflicts — `README.md` auto-merges and no source file is touched by `bd4bdca`.
**Verified and passing** (delta scope only):
- Guard pinning, by mutation on a private clone. Deleting `if (typeof info.to !== "string") return false;` (`src/popup/views/txStatus.js:202`) now fails `restoreWait rejects a persisted wait missing its txInfo or broadcast time` with `TypeError: address.toLowerCase is not a function` at `src/popup/views/helpers.js:276`. Removing `Array.isArray(info)` from `txStatus.js:197` fails the same test with `Expected: false / Received: true`. Both restored, green again.
- No unreviewed code riding along: `src/popup/views/txStatus.js`, `src/popup/index.js` and `src/popup/restorableViews.js` are byte-identical to round-3 head `d74dd4074362c589e0bf535351a38996beeb887a` (blob `3a114da`, `ed4e955`, `9d20495` respectively). Only `tests/txStatus.test.js` (the two added cases), `TODO.md` and rebase churn in `README.md` differ. The empty-string `to` deviation, `MAX_CONSECUTIVE_LOOKUP_FAILURES = 6`, the deadline path and `"wait-tx"` in `restorableViews.js` are therefore untouched, and the README still states the six-in-a-row bound.
- No `TODO.md` entry lost: the diff against `next` is a pure addition, zero deletions.
- `make check` on 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 both `clawbot <clawbot@noreply.example.org>`; no attribution trailers.
Disclosures: the new bullet is dated `2026-08-11` and sits above two `2026-08-12` entries, so `# Completed Steps` is no longer date-sorted. Not raised as a defect — the workflow rule in `TODO.md` is "move Next Step to the top", which this satisfies — but worth fixing while resolving the conflict above. `script/cibuild` was not re-run; the delta is two test cases and a doc bullet, and `script/lint` in this repo is prettier-only.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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