Skip to content

fix: WALLET-1364 — tie an approval request to the windows displaying it - #1427

Merged
Comp0te merged 29 commits into
release/2.7.0from
WALLET-1364-request-lifecycle
Aug 6, 2026
Merged

fix: WALLET-1364 — tie an approval request to the windows displaying it#1427
Comp0te merged 29 commits into
release/2.7.0from
WALLET-1364-request-lifecycle

Conversation

@ost-ptk

@ost-ptk ost-ptk commented Jul 28, 2026

Copy link
Copy Markdown
Member

Ticket: https://make-software.atlassian.net/browse/WALLET-1364 (P0, and P1 items 1, 2, 5–8)

Items 3 (export-keys window failure feedback) and 4 (CSP_NONCE build reproducibility) are not in this change — they ship in #1428 and #1429. The earlier "items 1–8" here was wrong.

The bug

A request's approval UI was not tied to any window, and "the old request is gone" was inferred from a 250 ms wall-clock timer. cancelRequests picked victims by status === 'open' alone, and PendingRequestDescriptor was {tabId, origin, method} — no window link at all.

The Ledger permission flow opens a separate window carrying a fully functional signing page with the same requestId, and device confirmation takes seconds to minutes. So when a second dapp sent any request:

  1. dapp A's request was force-cancelled and A received signResponse({ cancelled: true });
  2. the device approval then produced a real signResponse({ signatureHex }), which hit the dedup guard, saw status 'responded', and was dropped with no log at all;
  3. the UI closed as if it had succeeded. The user approved; the signature was gone; nothing was emitted anywhere.

The same defect existed on the close path: closing the tracked approval window cancelled a request the Ledger window was still displaying.

The fix

A request stays open while at least one window still displays it. Cancellation is triggered by "window W stopped displaying requests" — never by a timer, never by "a new request arrived".

  • requests and pendingRequests collapse into one discriminated union, and each open request carries windowIds: number[].
  • Two events dispatch the same routine: windows.onRemoved (now for any window, not only the tracked one) and the resolution of openWindow with reused: true.
  • Candidates are only requests that had that window and are left with none. A request the Ledger window still shows survives; a request that never had the window can never cancel itself.
  • The trigger moves out of the six SDK method branches into openWindow, so the old "call cancel before dispatching the open" invariant is structural instead of a matter of statement order.
  • The 250 ms grace now means only what its comment always claimed: let an in-flight genuine response land first.

Also fixed, all in the same paths:

  • windows.create rejection now cancels the incoming request and tells the dapp, instead of leaving its promise pending for the 30-minute timeout; likewise a resolved window with no id, and a window that closes during the reuse round-trips.
  • The dedup drop is logged — identifiers only, never the action payload, which carries signatureHex / encryptedMessage.
  • tabs.update targets the resolved tab id instead of "the active tab of the current window".
  • isNewWindow (the import-account flows) no longer retargets the shared approval slot.
  • The sagaError banner is suppressed when the same-origin fallback actually delivered, so the user no longer reads an untranslated red error on top of the signing screen for a different transaction.
  • PopupState.windowManagement becomes Pick<…, 'windowId'> — fail-closed, so a future slice field is excluded from the replica broadcast by default.

Review round — 15 follow-up commits

All 26 review comments were verified against the branch; none was a false positive. 24 are fixed here, one was already closed by #1430's Pick/broadcast-shape test, and two are taken partially on purpose (see Deliberately partial below).

The one that mattered most: cancelRequests re-filtered its snapshot on status alone after the grace, so a request that regained a window inside those 250 ms was still cancelled. That is this PR's own P0, reached from inside the fix — the Ledger attach crosses a runtime.sendMessage round trip and routinely lands after the candidates were snapshotted. It now re-checks the current descriptor.

The rest, by cluster:

Silent skips the Ledger attach could be skipped three ways with nothing in any log (missing requestId indistinguishable from the import flow, no try/catch around the effect, dispatchToMainStore swallowing the cause) — all three closed, the decision extracted into a tested unit, and params given a named type so renaming requestId is a compile error; a throw in openWindow's success arm was caught by nothing; the onRemoved listener had no test at all and is now an exported handleWindowRemoved with one.
Error surfaces the cancel banner was suppressed on the close path too, where the supersede rationale does not apply; failRequestOnWindowError told the user "cancelled" without checking whether the dapp had been told; openWindow logged error.name (always the string "Error") instead of a redacted message; a dropped duplicate no longer conflates "a cancel raced a cancel" with "a signature was destroyed".
Unstated invariants the detach dispatched on every window close in the browser, each one a full replica broadcast plus a full storage.local rewrite; the tombstone map was unbounded on the two MV2 targets whose background page never restarts; a replayed or reused requestId opened a fully functional approval screen for a request that could never be answered.
Types that lied windowIds was mutable and handed out by reference; selectOpenRequests used an unchecked type predicate; the reducer's existence guards were dead code to the compiler; SagaError.source was still string. Each is now enforced — probes confirm that reverting any of them is a compile error.
Trust boundaries the window attach was the one handler path with no sender gate; .match() does not validate a payload, so a payload-less message threw out of the handler; the liveness probe accepted any live browser window and treated any rejection as "gone"; windowIdChanged/windowIdCleared were still forwardable with no legitimate UI dispatcher.

Deliberately partial

  • Request union. {status: 'open', windowIds: []} is both the legitimate transient state and the terminal stranded one. The suggested 'awaiting-window' member with a non-empty tuple is a refactor through the reducer, selectors, both cancel paths, fixtures and every slice test. Here the entry paths are closed instead (required requestId, the post-open .catch, the duplicate guard), so the stranded state is reachable only under openWindow's control, whose failure branches both lead to failRequestOnWindowError. The union itself wants its own ticket.
  • Sender gating. Applied to the windowRequestWindowAttached branch. Gating the whole FORWARDED_ACTION_TYPES is a behaviour change for ~60 actions and needs its own verification pass.

Verification

npm run ci-check passes: 66 suites / 473 tests, knip clean, coverage floors held.

Mutation probes were run to prove the new tests are load-bearing rather than decorative:

  • swapping the supersede and attach blocks in openWindow makes open-window.supersede.test.ts fail;
  • widening the dedup condition from === 'responded' to != null fails exactly the new 'still open' case, and nothing else;
  • collapsing handleWindowRemoved's two independent try/catches fails exactly its two error-handling tests;
  • flipping the discriminant in selectOpenRequests, or dropping the reducer's null guard, are now compile errors — neither was before.

One bug was caught by its own test during this round: the tombstone eviction used slice(0, length - CAP), and a negative end slices from the back, so it evicted almost everything until the map was full.

Manual QA — cannot be automated

  1. Ledger, long confirmation. Start a sign from dapp A, reach device confirmation, then trigger any request from dapp B. Expected: the Ledger window stays functional and dapp A resolves with its signature. Note the repro requires the permission-prompt path — with an already-paired device the confirmation happens inside the main window.
  2. Same as (1), but trigger B's request in the narrow gap while the Ledger window is opening — this is the race the review found, and the one the grace re-check now covers.
  3. Close the approval window via "X" while a Ledger window is open — the request must survive.
  4. Ordinary supersede: A is cancelled exactly once, B's screen shows with no error banner over it, and the reused window now genuinely navigates to B.
  5. Open "Import account with file" while a dapp approval is open — the approval must not be cancelled.

Known follow-ups — not filed yet, not caused by this PR

  • closeNewLedgerWindowsAndClearState closes all popup windows, including the main approval window, so after a supersede dapp B is force-cancelled when the Ledger flow finishes. Pre-existing and unchanged by this PR (B died before it too), but it wants its own ticket and its own QA.
  • The Request union refactor and full forwarding-set gating described under Deliberately partial.

ost-ptk added 7 commits July 28, 2026 11:42
…ership

Collapse the parallel requests/pendingRequests maps into one discriminated union
and give every open request the set of window ids displaying it. A request can be
shown by more than one window at a time (the Ledger permission window carries the
same requestId), so ownership has to be a set, not a single id.

windowRequestOpened no longer overwrites an existing entry: requestId is
page-generated and therefore dapp-controlled, so a repeat must neither resurrect
a responded tombstone nor clobber a live descriptor.

PopupState.windowManagement becomes Pick<..., 'windowId'> so a future slice field
is excluded from the replica broadcast by default instead of included.
…yed it

cancelRequestsDisplacedBy becomes the single cancellation trigger: a window
stopped displaying requests. Only requests whose windowIds was exactly that one
window are cancelled, so a request the Ledger permission window still shows
survives, and a request that never had the window can never cancel itself.

failRequestOnWindowError covers the case where no window will ever exist: it
marks the request responded and delivers the method-correct cancel, instead of
leaving the dapp promise pending until its own 30-minute timeout.

Error handling in the delivery loop: bind and log the error with identifiers
only (never the SDK action, which carries signatureHex/encryptedMessage), use
allSettled so one failed delivery cannot abort the batch, and raise the
sagaError banner only when the same-origin fallback delivered nothing.
cancelOpenRequestsForClosedWindow becomes a thin wrapper over the shared routine,
and the windows.onRemoved listener now runs for EVERY removed window instead of
only the tracked one — the handler decides, by window ownership, whether anything
should be cancelled. Closing the main approval window while the Ledger permission
window is still up therefore leaves the request alive.

The listener is also rejection-safe: a failure in the cancel path can no longer
skip the exportKeysWindowIdCleared dispatch that follows it.
createOpenWindow now resolves { window, reused } so the caller can tell a reused
window from a fresh one — that distinction is what drives cancellation.

Two latent bugs in the same function: tabs.update omitted the tab id and so
targeted "the active tab of the current window", which lands correctly only
because the focus call is awaited first; and setWindowId ran even for
isNewWindow, letting the import-account flows retarget the shared approval slot
so their close could cancel an unrelated dapp approval.
Move the trigger out of the six SDK method branches and into openWindow, the
only place that knows whether a window was reused. The supersede runs before the
incoming request is attached and snapshots its candidates synchronously, so the
incoming request — which has no window yet — can never be among them. That makes
the old "must call cancel before dispatching the open" invariant structural
instead of a matter of statement order.

A windows.create rejection now cancels the incoming request and tells the dapp
rather than leaving its promise pending. A resolved window with no id, and a
window that closes during the reuse round-trips, are both recovered the same way
instead of stranding the request with no window and no reaper.

open-window.supersede.test.ts pins the ordering against the real reducer and the
real cancel module: swapping the two blocks makes it fail.
The Ledger permission window displays a second copy of the approval page
carrying the same requestId, and device confirmation takes far longer than any
grace period. Registering that window against the request is what stops the main
approval window's reuse or close from discarding an approval the user is in the
middle of confirming — after which the genuine signature was silently dropped by
the response dedup.

windowRequestWindowAttached is dispatched from the UI, so it has to be in the
background forwarding allow-list; without it dispatchToMainStore throws
"Unknown redux action" at runtime with nothing to catch it at build time. The
parity guard now asserts that membership directly.
The dedup drop was completely silent. A dropped cancellation is harmless; a
dropped signature means the user approved and the response vanished with no
trace anywhere in the system, which is what made this bug class undebuggable.

Log identifiers only — requestId, tabId and the action type. The action payload
carries signatureHex/encryptedMessage and must never reach the console.

Adds the missing 'open'-status case to the dedup tests: no test had ever used
the status every live request has when the user approves, so widening the
condition to a null check would have passed the whole suite while dropping real
signatures.
Attaching a window is the only way a request gains a display, which makes it the
only way a request can be made permanently uncancellable: cancellation fires
only for a request whose windowIds was exactly the window that went away, so an
id nothing will ever raise windows.onRemoved for keeps the set oversized
forever. The request then survives every close and every reuse and hangs its
dapp's promise until the dapp's own timeout.

Two callers attach, and only one was guarded. openWindow ran a liveness probe
inline; the Ledger hook dispatches through dispatchToMainStore — a
runtime.sendMessage round trip, by far the wider race of the two — and was
forwarded blindly. The permission window closing during that hop left the
request bound to a dead id.

Both now go through attachWindowToRequest, which dispatches and then confirms
the window exists, repairing the miss with exactly what onRemoved would have
run. windowRequestWindowAttached therefore leaves the blind forwarding set and
gets a dedicated branch in handleReduxAction, mirroring resetVault: a UI page
crosses a message boundary to reach it, so the payload is validated rather than
trusted. That also puts the guarantee in background code, where this repo's
conventions allow it to be tested — the hook itself cannot be.

The same check covers an invented windowId from a compromised extension page,
which could otherwise pin windowIds above one and disable the request's cancel
path for good.
@ost-ptk
ost-ptk requested a review from Comp0te July 29, 2026 12:51

@Comp0te Comp0te left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the window-lifecycle rework: the windowIds model, the onRemoved and supersede cancellation paths, attachWindowToRequest, and the new discriminated union in the windowManagement slice.

The through-line of the comments below is that the new model makes "a window stopped displaying this request" the thing that decides a request's fate, while several paths still decide it from status alone, or register a window through a channel that can fail without saying so. A second, smaller cluster is about error surfaces the rework narrowed — a few places now log less, or stop short of telling the user, than the code they replace.

One note that has no line to sit on: the description opens with P0 + P1 items 1–8, but items 3 (export-keys window failure feedback) and 4 (CSP_NONCE build reproducibility) are not in this change. src/background/redux/sagas/export-keys-window-saga.ts still has the bare console.error with its "there is no popup left to show it in" comment, and webpack.config.js:71 is still crypto.randomBytes(16) with the unconditional DefinePlugin entry at :212 — gh pr diff --name-only lists neither file. Worth narrowing the body to the items actually implemented.

Comment thread src/background/handlers/cancel-requests.ts
Comment thread src/hooks/use-ledger.ts Outdated
Comment thread src/background/index.ts
Comment thread src/background/index.ts Outdated
void (async () => {
const store = await getExistingMainStoreSingletonOrInit();

try {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Separate point from the .catch comment above: nothing tests any of this listener.

Grepping onRemoved across every test file at this head returns four hits — redux-actions.parity.test.ts:146,150, attach-window-to-request.test.ts:46, open-window.test.ts:219 — and all four are inside prose comments. No test file imports the background entry point, so the rewritten listener's shape, this try/catch, and the removal of the tracked-id guard are all unverified. cancel-open-requests-on-close.test.ts drives the handler directly and is indifferent to every one of them.

Concretely: reverting this listener to the previous async form — undoing both the guard removal and the try/catch — leaves the suite green. The two things that re-breaks are each the subject of an explicit ticket item.

Suggest extracting the body into an exported handleWindowRemoved(store, removedWindowId) and unit-testing it in the style of cancel-open-requests-on-close.test.ts.

Comment thread src/background/handlers/cancel-requests.ts Outdated
Comment thread src/background/redux/windowManagement/reducer.ts Outdated
// second window carrying the same requestId (`src/hooks/use-ledger.ts`), so a
// request can outlive the loss of any single window. Cancellation is driven by
// "this window displays it no longer", never by a timer.
export type Request =

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 'open' variant permits windowIds: [], which is simultaneously the legitimate transient state (registered, window not yet resolved) and the terminal stranded one (nothing will ever display this).

The consequence is that the stranded case has no exit. cancelRequestsDisplacedBy filters on windowIds.length === 1 (cancel-requests.ts:137-140), so an empty set can never be cancelled by any window event; the only escape is failRequestOnWindowError, and grepping it at this head shows it is reachable only from openWindow's two failure branches (open-window.ts:57 and :97, via failIncomingRequest). There is no reaper — once a request reaches {status: 'open', windowIds: []} outside openWindow's control, it stays there for the life of the background context.

This union was introduced precisely to make illegal states unrepresentable, and this is the one illegal-ish state it still represents. It is also where two other paths flagged in this review land.

Suggest encoding the displayed case as non-empty — windowIds: readonly [number, ...number[]] on a 'displayed' member, with a separate {status: 'awaiting-window'} for the gap between windowRequestOpened and the first attach — so the stranded case becomes visible to selectOpenRequests consumers instead of indistinguishable from a healthy one.

Comment thread src/background/redux/windowManagement/types.ts Outdated
Comment thread src/background/redux/windowManagement/selectors.ts Outdated
Comment thread src/hooks/use-ledger.ts Outdated
// opened it. Registering it is what keeps the request alive when the
// shared approval window is reused or closed while the user is still
// confirming on the device.
const requestId = askPermissionUrlData.params?.requestId;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Distinct from the error-handling point on the line below: this requestId read — the only thing in the codebase that ever puts a second entry in windowIds, i.e. the UI half of the P0 fix — has no test at all.

At this head, grepping use-ledger/useLedger across src returns 13 files and none of them is a test that imports or exercises the hook; grepping windowRequestWindowAttached returns 11, of which the only non-background hit is this file. Deleting lines 190-195 leaves the suite green.

There is a cheaper way to break it than deletion, which is what makes the gap worth closing: rename the requestId key at sign-transaction/index.tsx:294-295 or sign-message/index.tsx:189-190. askPermissionUrlData.params is Record<string, string> (:39), so the lookup here silently yields undefined, tsc does not catch it, and the permission window is never registered. The background half is well covered (cancel-requests-displaced-by.test.ts:63-80, reducer.test.ts:587); nothing proves the Ledger flow ever produces the second entry.

The repo has no React-hook harness, so suggest either extracting the requestId → dispatch decision into a testable unit, or asserting at the page level that params.requestId is threaded. Typing params as { requestId?: string; … } would additionally make the rename case a compile error.

ost-ptk added 16 commits August 5, 2026 10:36
`cancelRequests` re-filtered its snapshot against status alone, so a request
that GAINED a window during the 250 ms grace was still marked responded and
still had a cancel delivered to its dapp.

That is the P0 this model exists to close, reached from inside it: request R is
displayed by shared approval window W; the user triggers Ledger, so
`openNewSeparateWindow` resolves and the attach begins its `runtime.sendMessage`
round trip; dapp B reuses W inside that gap, so `cancelRequestsDisplacedBy`
snapshots R as a candidate and detaches W; the Ledger attach then lands and R is
genuinely displayed again, with the user confirming on the device. 250 ms later
R was cancelled and the device signature dropped by the dedup.

Re-check against the CURRENT descriptor instead: still open, and displayed by
nothing but the window that went away.
…unexplained probe rejection

Two ways the attach path could cancel a live approval it had no business
touching.

`windowRequestWindowAttached.match(action)` is RTK's `isAction(action) &&
action.type === type` — it does not validate the payload. Reading
`action.payload.requestId` before `attachWindowToRequest`'s own shape guard runs
turned a payload-less message into a TypeError out of `handleReduxAction`, which
the router reports as a generic sendError. Read it defensively so the existing
guard produces its intended "ignoring malformed attach" instead.

The liveness probe's `.catch` did not bind its error and treated ANY rejection
as "the window is gone", running the full repair. A transient extension-context
error or a Safari window-type quirk would then cancel an approval still on
screen and tell the dapp it was cancelled. Narrowing on the error's text would
just move the problem — the wording differs per browser and such a guard stops
matching silently — so confirm against `windows.getAll()` instead, and fail
closed (no repair) when even that is unavailable: an uncancellable request hangs
until its own timeout, which is recoverable, while a wrongly cancelled one
destroys a signature the user already approved.
The two-arm `.then(onFulfilled, onRejected)` form is deliberate — the recovery
must not catch itself — but it left the success arm covered by nothing, and
there is no `unhandledrejection` handler anywhere in `src/`.

`attachWindowToRequest` is what is genuinely unprotected there. If it throws,
the window opened but was never attached: `windowIds` stays empty, and per the
candidate filter a request with an empty set can never be cancelled by any
window event. The dapp then hangs for its full 30-minute timeout with nothing
logged.
`windows.onRemoved` now fires for ANY window, not only the tracked approval one,
and this dispatch was unconditional. The candidate filter ran four lines above,
but the dispatch was not gated on its result — it fired even when no open
request held the window and the reducer returned the identical state object.

Redux invokes subscribers per dispatch regardless of what the reducer returned,
and the store subscriber does no state-change comparison: every one of these
was a `popupStateUpdated` broadcast to every replica plus a full storage.local
re-write of vault cipher, keys, settings, contacts, app events, trusted wasm and
CSPR-name expirations. Before this listener changed, an unrelated window close
produced zero dispatches.
…e truth when nothing was delivered

Two places where the rework stopped short of telling the user.

`cancelRequests` suppressed the banner whenever `deliverViaOrigin` reported a
delivery, and the comment justifying it reasons entirely from the supersede path
("this fires while the user is already looking at the NEXT approval screen").
But the routine is shared: the close path reaches it too, and there the user is
not looking at a replacement screen. `deliverViaOrigin` is also a weaker signal
than its name suggests — it counts same-origin sends to active tabs that did not
throw, which is not proof the tab owning the pending dapp promise received
anything. Gate the suppression on the source instead.

`failRequestOnWindowError` discarded `deliverViaOrigin`'s return value and
dispatched its banner BEFORE attempting delivery. When both routes fail the
request is tombstoned, the dapp received nothing and will hang, and the user was
told it was cancelled — with `sdk-response-to-tab` dropping anything that
arrives later, so it is unrecoverable. Dispatch after the attempt and say which
of the two happened.
Registering the Ledger permission window is the UI half of the window-ownership
fix — skip it and `windowIds` stays `[approvalWindow]`, so the next dapp request
reusing that window cancels this one while the user is confirming on the device.
It could be skipped three ways, none of which left anything in any log:

- `if (requestId)` does nothing when the param is absent. That is correct for
  the import-account flow, which legitimately passes `params: {}` — but `params`
  was `Record<string, string>`, so a signing page arriving without a `requestId`
  was indistinguishable from it at runtime.
- the effect body was a bare async IIFE with no `try/catch`; `openNewSeparateWindow`
  is an awaited call that can reject, and a rejection skipped everything below it.
- `dispatchToMainStore` discarded the error and logged only the action type, and
  a send to a sleeping or restarting MV3 service worker is a routine failure.

Spell the params out as a named type so renaming `requestId` at a call site is a
compile error, extract the decision into `registerLedgerPermissionWindow` so it
has a seam to test (the repo has no React-hook harness, and deleting those three
lines left the suite green), branch explicitly on the flow, and give
`dispatchToMainStore` an error-bearing log. The effect's catch logs the error's
NAME only: `url` embeds the plaintext `signMessage` message.
Nothing tested the `windows.onRemoved` listener. Grepping `onRemoved` across
every test file returned four hits, all inside prose comments; no test imports
the background entry point, so the rewritten listener's shape, its error
handling and the removal of the old tracked-id guard were all unverified.
Reverting the listener to its previous form left the suite green.

Its error handling was also narrower than it looked. The `try/catch` enclosed
only `cancelOpenRequestsForClosedWindow`; both the store init above it and the
export-keys branch below were outside, discarded by `void`, with no
`unhandledrejection` handler anywhere in `src/`. What was never logged is that
the export-keys cleanup got SKIPPED when the cancel threw — the tracked window
id stays set, and the next export-keys open focuses a dead id.

Move the body into `handleWindowRemoved`, give the two concerns independent
try/catches, guarantee it never rejects, and cover it. A mutation probe confirms
the two error-handling tests fail without the fix.
`handleReduxAction` had no sender gate, and the new `windowRequestWindowAttached`
branch put a lifecycle-authority decision on it. The asymmetry is deliberate
elsewhere in the router: `handleSdkResponseToTab` and `handleLegacyImport` are
both pulled OUT of the generic loop specifically to gate on `sender`, each with
a comment naming a content-script-world compromise as the reason — and
`@bringweb3/chrome-extension-kit` runs in exactly that world.

Both directions are reachable from there. A live-but-unrelated `windowId` gives
a set that never shrinks to empty, i.e. a request nothing can cancel; a dead one
attached in the gap before the real window makes `windowIds` exactly `[dead]`,
which the cancel path then selects. Not dapp-reachable today — the page-world
relay pins SDK_REQUEST_TYPES — but that allowlist is precisely the
defence-in-depth the sibling handlers' comments say they do not rely on.

Thread `sender` through and gate the attach branch. Gating the whole forwarding
set is a behaviour change for ~60 actions and stays a separate ticket.
The liveness probe asked only "does a window with this id exist" — no
`{ populate: true }`, no check that the window belongs to this extension, no
check that it displays this request. Any live browser window passed. With
`windowIds: [W, F]` where `F` is an unrelated window, closing the real approval
window `W` is filtered out (the set has two entries), leaving `[F]` — so the
request's fate now hangs on a window the user may never close.

Populate the window and require its first tab's URL to start with
`runtime.getURL('')`. The checks are deliberately asymmetric: a URL that is
provably not ours is a verdict and undoes the attach, while anything
inconclusive is not. On the reuse path `tabs.update` resolves when the
navigation STARTS, so a legitimate window can be probed before its URL settles;
repairing on that would cancel a live approval, which is the failure this whole
model exists to prevent. Same reason a requestId mismatch only warns.
Three places where the slice's types said less than the code relied on.

`windowIds: number[]` was mutable and `selectOpenRequests` hands entries out
with a shallow spread, so the store's own array left by reference — and
`configureStore` runs with `immutableCheck: false`, so nothing caught an
in-place mutation at runtime either. Since `cancelRequestsDisplacedBy` decides a
request's fate purely from that array, a `push` on a selector result would
silently rewrite the decision with no action dispatched. `readonly` throughout.

`selectOpenRequests` opted out of checked narrowing with a type predicate.
TypeScript verifies only that the asserted type is assignable to the parameter
type, not that the predicate body agrees — writing `=== 'responded'` compiled
identically and everything downstream stayed typed as open descriptors. Narrow
on the discriminant instead; a probe confirms the flipped comparison is now a
compile error.

`requests: Record<string, Request>` with `strict` but no
`noUncheckedIndexedAccess` types every lookup as `Request`, never
`Request | undefined` — which made the reducer's existence guards dead code as
far as the compiler is concerned, including the P2 fix "a repeated
dapp-controlled requestId must not overwrite a live request nor resurrect a
tombstone". `Partial<Record<…>>` models the lookup honestly; a probe confirms
removing the guard is now a compile error.
`CancelSource` constrained a parameter with only two reachable values and left
unconstrained the field where sources actually land. `'open-window-failed'` was
in the union but no call site produced it AS a `CancelSource` — `cancelRequests`
is module-private and its only caller passes one of the other two; the literal
appears only inside `failRequestOnWindowError`'s `sagaError`. Meanwhile
`SagaError.source` was still `string`, which is what WALLET-1364 #18 asked to
have made a union, and another live source, `'sdk-response-to-tab'`, was not in
the union at all.

Move a `SagaErrorSource` covering all fourteen producers into
`app-events/types.ts` and type `SagaError.source` with it; `CancelSource`
becomes an `Extract` of the two window-driven ones. The union immediately caught
three invented sources in the app-events reducer tests.
The comment claimed the map "stays proportional to in-flight requests, not to
the lifetime total" and that "an MV3 service-worker restart wipes it". Neither
holds on all three build targets: `windowRequestResponded` writes a tombstone
and nothing anywhere deleted a key, and both `manifest.v2.json` and
`manifest.v2.safari.json` declare `"persistent": true`, so the Firefox and
Safari builds have a background page that is never torn down. On those two the
map grew by one permanent entry per request, keyed by a dapp-supplied string,
for the whole browser session — and that comment was also the sole justification
given for keeping the tombstone at all.

State the real bound and add FIFO eviction above a cap that a dedup could
plausibly need. Open requests are never evicted.
`requestId` is page-generated, i.e. dapp-controlled. The slice reducer refuses
to overwrite a live request or resurrect a tombstone — but that no-op was
invisible to the caller, and all six method branches called `openWindow`
regardless. Two shapes followed. A dapp replaying a finished id hits the
tombstone, the window still opens fully, `deployPayloadReceived` still populates
`jsonById` so the screen renders normally — and the user's approval is then
silently dropped by the dedup. A dapp reusing a LIVE id under another method
keeps the first descriptor, so `buildCancelResponse` later builds the wrong
response type.

Refuse both before anything is dispatched, and do it once for the whole set of
approval methods rather than in each branch, so a seventh flow cannot forget it.

`OpenApprovalWindowProps.requestId` becomes required in the same spirit: there
is no non-approval caller, and every branch that saves a request from being
stranded was gated on the field being present.
The `!isNewWindow` clause added to `createOpenWindow` made three of
`useWindowManager`'s inputs dead. Both consumers of its `openWindow` pass
`isNewWindow: true`, which forces `id = null` — so the reuse block and
`clearWindowId` are unreachable, `setWindowId` is unreachable through the new
guard, and the `useSelector(selectWindowId)` feeds nothing. `knip` cannot flag
any of it: these are object properties, not exports.

The live edge is downstream. `windowIdChanged` and `windowIdCleared` remained in
`FORWARDED_ACTION_TYPES` with no legitimate UI dispatcher left — the background
dispatches its own — so any extension UI page could `runtime.sendMessage` a
`windowIdChanged(<arbitrary id>)` and retarget the shared approval-window slot
the request lifecycle depends on. The parity test reasons carefully about
exactly this hazard for `windowRequestWindowAttached`; these two now meet the
same background-only criterion, so move them to EXCLUSIONS with the reason
recorded.
This rejection arm is the sole cause-bearing diagnostic on the "no approval
window could be opened" path — the hard-to-reproduce failure the rest of this
work adds `failRequestOnWindowError` to handle. The reason for withholding the
raw error is right: a `signMessage` window URL embeds the user's plaintext
message as a query param, and a rejection's text can echo the URL it failed on.
But the mitigation dropped the whole diagnostic rather than redacting it:
`.name` on a `windows.create` / `windows.update` / `tabs.update` rejection is
the string "Error" in the realistic cases and `undefined` for a non-`Error`
rejection, so the message — the only field that says WHY — was gone.

Cut everything from the first `?` (where any secret would be), cap the length,
and add `windowApp`. The secret stays out and the cause stays in.
Unrelated to the request-lifecycle work in this PR — no commit here touches
package.json or the lockfile. The branch was green on this same lockfile on
2026-07-29; `npm audit` queries the live registry, so two advisories published
since then turned it red:

- GHSA-rgw5-rvv9-x895 — brace-expansion, DoS via unbounded intermediate arrays,
  which bypasses the CVE-2026-14257 mitigation. Fixed in 1.1.18.
- GHSA-7p8r-x3mc-p8w7 — fast-uri, host confusion via a backslash authority
  introducer. Fixed in 3.1.5.

Both packages were already pinned through `overrides`, and both now have a
patched release reachable from the existing range, so this is a version bump
rather than a new allowlist entry.

That also retires the ACCEPTED entry for GHSA-mh99-v99m-4gvg: it reasoned that
"no patched 1.x/2.x/3.x exists", which the 1.1.18 maintenance release makes
false, and the gate itself now reports the entry as stale. Removing it keeps the
allowlist to advisories that are genuinely unfixable here.
@ost-ptk

ost-ptk commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

Thanks for the depth here — this is one review rather than 26, so one reply.

I checked every comment against the branch before touching anything. None was a false positive, including the claims that needed independent verification: "persistent": true really is in both MV2 manifests, SagaError.source really was still string, both useWindowManager consumers really do pass isNewWindow: true, connectResponse's payload really is a bare boolean, the store subscriber really does no state-change comparison, and export-keys-window-saga.ts / webpack.config.js really are absent from the diff.

24 of the 26 are fixed here (15 commits, 8e0e1747..7368e935), 2 are on the stacked #1430 branch because they touch files that PR already changed, 1 was already closed by #1430, and 2 are taken partially on purpose.

The one that mattered

cancel-requests.ts:91 was right, and it is the reason this PR should not have merged as it stood. The re-check after the grace consulted status alone, so a request that regained a window inside those 250 ms was still cancelled — this PR's own P0, reached from inside the fix. Your repro drove it exactly: getState returning windowIds: [7] at snapshot time and [9] after it produces one tabs.sendMessage and one windowRequestResponded for a request holding a live window. That is now the first commit, and the re-check runs against the current descriptor.

Resolution map

Comment Resolution
cancel-requests.ts:91 — grace re-check ignores windowIds f921039
attach-window-to-request.ts:49 — unbound .catch, any rejection repairs
redux-actions.ts:209.match() does not validate the payload
291d2a9
open-window.ts:51 — throw in the success arm caught by nothing c5fa6de
cancel-requests.ts:144 — unconditional detach dispatch 548e7c0
cancel-requests.ts:107 — banner suppressed on the close path too
cancel-requests.ts:172deliverViaOrigin result discarded
8c9f9e7
use-ledger.ts:191 — three silent skips
use-ledger.ts:190 — the UI half has no test
480ae3d
index.ts:185 — IIFE without .catch
index.ts:188 — the listener is untested
7c0bd95
redux-actions.ts:200 — no sender gate 6770b21
attach-window-to-request.ts:50 — any live window passes 9ebf8f7
types.ts:22 — mutable windowIds
selectors.ts:26 — unchecked type predicate
reducer.ts:70 — guards are dead code to the compiler
f890ec8
cancel-requests.ts:27CancelSource constrains the wrong thing 1549b0d
reducer.ts:116 — the map's stated bound is wrong on MV2 0234e7c
reducer.ts:45 — reducer no-op invisible to the caller
open-window.ts:17requestId optional with no such caller
2f8751f
create-open-window.ts:147 — dead useWindowManager inputs 47ef457
open-window.ts:91.name drops the diagnosis 7368e93
sdk-response-to-tab.ts:92 — the two cases are not distinguished baf6d07 (on #1430)
cancel-requests.ts:125 — "the single cancellation trigger" e4417c5 (on #1430)
types.d.ts:30Pick does not close the spread bypass already closed by #1430's 4b4c0929 — the broadcast-shape test asserts the exact key set and that no dapp origin reaches a replica. Nothing to add.
review summary — the body claims P1 #3 and #4 PR description corrected; those two ship in #1428 / #1429.

Where I did not take the suggestion as written

types.ts:16windowIds: [] means two different things. Agreed on the diagnosis. But 'awaiting-window' + readonly [number, ...number[]] is a refactor through the reducer, selectors, both cancel paths, fixtures and every slice test, and the actual harm — a request stranded with nothing that can ever cancel it — has identifiable entry paths. Those are closed instead: requestId is required, the post-open .catch exists, and a duplicate requestId no longer opens a window. The stranded state is now reachable only under openWindow's control, whose two failure branches both lead to failRequestOnWindowError. The union itself wants its own ticket, and I have not filed it yet.

redux-actions.ts:200 — gate the whole forwarding set. Gated the windowRequestWindowAttached branch here. Doing it for all ~60 forwardable actions is a behaviour change that needs its own verification that every UI dispatcher passes isTrustedUiSender, so it is also a separate ticket rather than a rider on this one.

Two implementation notes worth flagging back

attach-window-to-request.ts:50 — I did not add the requestId match as a verdict. The ownership check is in ({ populate: true } plus an extension-origin prefix), but on the reuse path tabs.update resolves when the navigation starts, so a legitimate window can be probed while tab.url is still the previous request's. A false-positive repair there cancels a live approval — the exact failure this model exists to prevent — so the checks are deliberately asymmetric: a URL that is provably not ours is a verdict, a requestId mismatch is a console.warn, and anything inconclusive leaves the attach standing.

attach-window-to-request.ts:49 — I did not narrow on the error's text. "Narrow the repair to rejections that actually mean no such window" is right, but the only handle is the message, and the wording differs per browser — the same trap #1430's own description flags for broadcastToReplicas. It confirms against windows.getAll() instead, and fails closed if even that is unavailable: an uncancellable request hangs until its own timeout, which is recoverable, while a wrongly cancelled one destroys a signature the user already approved.

Verification

npm run ci-check is green on both branches (66 suites / 473 tests here, 67 / 488 on #1430). Four mutation probes confirm the new tests are load-bearing, and two of the type changes are now compile-enforced: flipping the discriminant in selectOpenRequests, or deleting the reducer's null guard, are both errors now and neither was before.

One bug was caught by its own test while implementing: the tombstone eviction used slice(0, length - CAP), and a negative end slices from the back, so it evicted almost everything until the map was full. Worth mentioning as evidence that these tests do something.

The red CI on this PR was unrelated — two advisories published after the branch last ran green (GHSA-rgw5-rvv9-x895, GHSA-7p8r-x3mc-p8w7). Both now have patched releases inside the existing overrides ranges, so d923973c bumps them and retires the GHSA-mh99-v99m-4gvg allowlist entry, whose "no patched 1.x exists" reasoning the 1.1.18 maintenance release makes false.

@ost-ptk
ost-ptk requested a review from Comp0te August 5, 2026 09:24

@Comp0te Comp0te left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second pass over the push that answers the first round — the incremental diff 8e0e1747..d923973c plus the state it leaves behind. Most of the earlier comments are closed and I've resolved those threads.

What's left is two ways a request can reach the lifecycle model with nothing registered in it, two new failure paths that log but leave the user or the request stranded, and one banner arm that no test pins.

Comment thread src/background/handlers/sdk-methods.ts Outdated
// anything is dispatched.
if (
APPROVAL_REQUEST_TYPES.has(action.type) &&
selectRequestStatus(store.getState(), action.meta.requestId) != null

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This guard and the reducer's "register once" guard read the same dapp-controlled key off a plain object with different operators. selectRequestStatus is requests[requestId]?.status; the reducer at windowManagement/reducer.ts:51 is state.requests[requestId] != null.

For requestId__proto__, toString, constructor, valueOf, hasOwnProperty the two disagree. The first sees undefined and lets the request through; the second sees an inherited Object.prototype member, concludes the id is already registered, and returns state unchanged — so nothing is registered. handleSdkMethod then calls openWindow anyway, attachWindowToRequest's dispatch is likewise a no-op, and failRequestOnWindowError returns early because there is no descriptor.

The result is a live sign / signMessage / connect / decryptMessage approval window sitting outside the entire lifecycle model this PR builds — not cancellable on close, not cancellable on supersede, not deduped, not recoverable on window-open failure. That is the stranded state the change exists to eliminate, reachable by picking one of five string literals, with no race involved. isSDKMethod (src/content/sdk-method.ts:129) only checks typeof requestId === 'string', so nothing upstream rejects them.

The divergence on its own: node -e "const m = {}; console.log(m['__proto__']?.status, m['__proto__'] != null)"undefined true.

Suggested: validate action.meta.requestId against a UUID shape at the handleSdkMethod entry, and build the map with Object.create(null) or read it via Object.prototype.hasOwnProperty.call.

// when the navigation STARTS, so a legitimate window can be probed before its
// URL settles — repairing on that would cancel a live approval, the exact
// failure this model exists to prevent.
void windows.get(windowId, { populate: true }).then(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is void windows.get(windowId, { populate: true }).then(onFulfilled, onRejected) — a two-arm .then with no trailing .catch. A throw inside the fulfilled arm becomes an unhandled rejection.

runtime.getURL('') at :74 is a live candidate: it is precisely the API that fails on an invalidated extension context, which the comment a few lines below cites as the reason the rejected arm must not trust itself.

This is the same shape you repaired one file over in this push — open-window.ts:132, void chain.catch(...). The inner windows.getAll() chain here has a .catch; the outer one does not. Nothing logs, nothing repairs, and the attach stands with a windowId whose liveness was never established — this file's own header calls that the permanently-uncancellable state. There is no unhandledrejection handler anywhere in src/, so in an MV3 service worker it leaves no trace at all.

(delivered > 0
? '; delivered via same-origin fallback'
: '; not delivered')
delivered > 0

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The delivered === 0 arms of both ternaries are covered — fail-request-on-window-error.test.ts:80-100 asserts stringContaining('could not be told') and cancel-requests-displaced-by.test.ts:161-182 asserts stringContaining('not delivered'), both with deliverViaOrigin mocked to zero.

The other arm has no assertion anywhere. '…recovered via the page' here at :151 and '…the request was cancelled' at :234 appear nowhere outside the source; the only other hit is a test title at cancel-requests-displaced-by.test.ts:184, which pins suppression rather than text.

That gap matters because saga-error-banner.tsx:87 renders {error.source}: {error.message} verbatim, and the two arms say opposite things about whether the dapp was told. Collapsing either ternary to its pessimistic constant leaves the whole suite green and makes the banner tell the user the site may still be waiting when it was in fact informed — the direction these commits set out to fix.

Suggested: add a delivered > 0 case to each, asserting stringContaining('recovered via the page') and stringContaining('the request was cancelled').

Comment thread src/hooks/use-ledger.ts
if (w.id) {
dispatchToMainStore(ledgerNewWindowIdChanged(w.id));
triggeredRef.current = true;
if (w.id == null) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both new failure paths — this w.id == null early return and the effect's .catch at :237-246 — log and return without calling setLedgerEventStatusToRender or setting triggeredRef (set only at :228, on the success path). For the openNewSeparateWindow rejection the comment at :238-239 names, nothing in the dependency array at :247-253 has changed at that point, so the effect will not re-run and the status stays LedgerPermissionRequired.

To be fair to what's already there: that status is in the ledger-error map (errors.ts:22-26), so the page does render an error view with a working "Got it" CTA (ledger-footer.tsx:44-48). The problem is that the error is the wrong one. It tells the user to grant permission in a window that was never opened and never will be, with no signal that the open failed and no retry path — the only exit is the CTA that abandons the approval.

Suggested: set a distinct rendered error status in both branches so the screen reflects "the permission window could not be opened" rather than "waiting for permission".

// the FIFO cap below: the descriptor is dropped as before, and the oldest
// tombstones are evicted once there are more than a dedup could plausibly
// need. Open requests are never evicted.
windowRequestResponded: (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

windowRequestOpened (:51-53) and windowRequestWindowAttached (:78-84) both refuse to act on a missing or wrong-status entry. windowRequestResponded here is an unguarded upsert, so it writes a tombstone for a requestId that has no descriptor at all — reachable from sdk-response-to-tab.ts:117,134 for any id the extension UI forwards.

The union models ∅ → open → responded; the reducer permits ∅ → responded. A concrete route: an MV3 service-worker restart wipes requests between registration and the UI's response, and the response then tombstones an id the store has never seen. That entry consumes a slot in the new 50-entry cap, and until it is evicted the Duplicate requestId throw at sdk-methods.ts:68-73 will reject that id — selectRequestStatus returns 'open' | 'responded' | undefined, so a tombstone trips it just as a live entry does.

Bounded, to be clear: the FIFO cap at :147-156 evicts it after 50 further responses, and generateRequestId is crypto.randomUUID, so this is not a collision path. The point is the asymmetry itself — two of the three cases make the illegal transition unrepresentable and the third does not.

Suggested: guard the upsert the way its two siblings are guarded — only tombstone a requestId that is currently 'open'.

ost-ptk added 5 commits August 5, 2026 14:24
`requestId` is dapp-controlled and `requests` is a plain object, so
`requests[requestId]` can read an INHERITED `Object.prototype` member. Two
readers then disagreed about the same key: `requests[id] != null` in the
reducer saw the inherited member and concluded "already registered", while
`selectRequestStatus`'s `requests[id]?.status` saw `undefined` and concluded
"fresh".

Picking one of five string literals — `__proto__`, `toString`, `constructor`,
`valueOf`, `hasOwnProperty` — therefore produced an approval window for a
request the store never registered: not cancellable on close, not on supersede,
not deduped, and not recoverable by `failRequestOnWindowError`, which returns
early with no descriptor to find. No race involved, and nothing upstream
rejects them — `isSDKMethod` only checks `typeof requestId === 'string'`.

Every read now goes through `getRequest`, which answers both questions the same
way. Four of the five then behave like any other id.

`__proto__` is different and cannot be stored at all: building the next map
copies entries by assignment, and assigning `__proto__` sets the object's
PROTOTYPE instead of adding an entry — the descriptor would be inherited by
every later lookup. So it is refused, in the reducer and again at the SDK entry
so the dapp gets a definite error instead of a window nothing can close.
`windowRequestOpened` and `windowRequestWindowAttached` both refuse to act on a
missing or wrong-status entry. `windowRequestResponded` wrote unconditionally,
so the union modelled ∅ → open → responded while the reducer permitted
∅ → responded.

Reachable whenever the UI forwards a response for an id the store no longer
holds — an MV3 service-worker restart between registration and the response.
The orphan tombstone then consumes a slot in the 50-entry cap, and until it is
evicted the SDK entry guard rejects that id as a duplicate, since
`selectRequestStatus` cannot tell a tombstone from a live entry.

Bounded either way, and not a collision path — `generateRequestId` is
`crypto.randomUUID`. The point is the asymmetry: two of the three transitions
made the illegal one unrepresentable and the third did not.
…ay when a window carries no requestId

The probe is a two-arm `.then(onFulfilled, onRejected)` with no trailing
`.catch`, so a throw in the fulfilled arm has nothing to catch it — the same
shape repaired one file over in `open-window.ts`, where the inner chain had a
`.catch` and the outer one did not. `runtime.getURL` is the live candidate:
failing on an invalidated extension context is precisely what the rejected
arm's own comment says it must not trust. Nothing would log, nothing would
repair, and the attach would stand with a windowId whose liveness was never
established — this file's own permanently-uncancellable state, with no
`unhandledrejection` handler anywhere in `src/` to leave a trace.

The asymmetry between "provably not ours" (repair) and "inconclusive" (leave it
alone) is deliberate and stays. But a URL carrying no `requestId` param
produced no signal at all, which left the check establishing "one of our
windows" and silently saying nothing about "the window showing THIS request".
Both diagnostics now warn; neither repairs, for the same reuse-round-trip
reason as the mismatch branch.
Only the `delivered === 0` arms were asserted. `'…recovered via the page'` and
`'…the request was cancelled'` appeared nowhere outside the source, so
collapsing either ternary to its pessimistic constant left the whole suite
green — while `saga-error-banner.tsx` renders the message verbatim and the two
arms say opposite things about whether the dapp was told.

Verified by doing exactly that: collapsing both fails these two tests and
nothing else.
Both new failure paths — the `w.id == null` early return and the effect's
`.catch` — logged and returned without touching the rendered status, so the
screen kept showing `LedgerPermissionRequired`. That status IS in the error map,
so an error view with a working CTA did render — but the wrong error: it tells
the user to grant permission in a window that was never opened and never will
be. Nothing in the effect's dependency array changes on that path, so it does
not re-run, and the only exit was the CTA that abandons the approval.

Add a distinct `PermissionWindowFailed` status and set it in both branches, so
the screen says the window could not be opened and what to do about it. The
Ledger error map holds plain strings rather than i18n keys, so this needs no
catalog round trip.
@ost-ptk

ost-ptk commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

Second round: all five actionable comments fixed, and an answer to the question on attach-window-to-request.ts:50. d923973c..15f8311f, ci-check green (486 tests here, 501 on #1430).

sdk-methods.ts:70 — the prototype-key divergence

Reproduced exactly as described, and it is worse in one place than the comment says.

selectRequestStatus sees : undefined
reducer guard sees       : true

for all five of __proto__, toString, constructor, valueOf, hasOwnProperty. Fixed by routing every read of the map through a single getRequest that uses hasOwnProperty, so the two questions can no longer be answered differently — c9feb3a5.

But four of the five and __proto__ are not the same problem. Once reads agree, toString/constructor/valueOf/hasOwnProperty store as ordinary own properties and behave like any other id; I have tests for registration, second-registration refusal and window attach on them. __proto__ cannot be stored at all: building the next map copies entries by assignment, and assigning __proto__ sets the object's prototype instead of adding an entry, so the descriptor would be inherited by every later lookup in the map. Measured:

__proto__        own: false | keys: [] | protoChanged: true
toString         own: true  | keys: ["toString"] | protoChanged: false

So __proto__ is refused rather than stored — in the reducer, and again at the SDK entry with Invalid requestId so the dapp gets a definite answer instead of a window nothing can close.

I did not add the UUID-shape validation. With the reads agreed and __proto__ refused, it buys nothing here, and it can reject legitimate traffic: it would be the wallet, not the SDK, deciding the id format, and a dapp on an older bundled SDK has no recourse. If you think the entry should own that constraint anyway, I will take it — but I would rather it be a deliberate compatibility decision than a side effect of this fix.

The rest

Comment Fix
attach-window-to-request.ts:60 — two-arm .then with no trailing .catch 063737e4. Correct, and I had just repaired the same shape one file over — the inner getAll chain had a .catch and the outer one did not. runtime.getURL is indeed the live candidate.
reducer.ts:136 — unguarded tombstone upsert 4984ca04. Guarded on 'open' like its two siblings, so ∅ → responded is no longer representable. The eviction tests now drive the real ∅ → open → responded path rather than tombstoning ids that were never registered — which is also what let the old shape through unnoticed.
cancel-requests.ts:150 — the delivered > 0 arms are unpinned bd016c0e. Both now assert the text. Verified by collapsing both ternaries to their pessimistic constants: exactly these two tests fail and nothing else.
use-ledger.ts:210 — the wrong error is rendered 15f8311f. New PermissionWindowFailed status set in both branches, so the screen says the window could not be opened rather than telling the user to grant permission in a window that will never exist. The Ledger error map holds plain strings rather than i18n keys, so no catalog round trip.

attach-window-to-request.ts:50 — the gap is intentional, but it was too quiet

You are right that the check establishes "one of our windows", not "the window showing this request", and right that the mismatch branch is the only thing that would distinguish them.

That is deliberate: a requestId mismatch during the reuse round trip is expected, not anomalous, so treating it as a verdict would cancel live approvals. But "no requestId param at all" producing no signal was not deliberate — it left the distinction silently unobservable. Both diagnostics now warn (063737e4); neither repairs.

Every approval window is opened with requestId in its search params, so a window of ours without one is genuinely anomalous and worth a line.

Still not filed

The two follow-up tickets from the first round — the Request union refactor and gating the whole FORWARDED_ACTION_TYPES — still need Jira keys.

@Comp0te Comp0te left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

@Comp0te
Comp0te merged commit c7a69f0 into release/2.7.0 Aug 6, 2026
1 check passed
@Comp0te
Comp0te deleted the WALLET-1364-request-lifecycle branch August 6, 2026 10:03
Comp0te pushed a commit that referenced this pull request Aug 6, 2026
… saga into SagaErrorSource (#1437)

* fix(app-events): admit the export-keys saga into SagaErrorSource

#1427 narrowed SagaError.source from a bare string to a closed union; #1428
was green against a base that predated it and landed a producer the union
does not list. Neither PR's CI could see the other — GitHub does not re-run
a PR when its base moves — and git found no textual conflict, since the two
touch different files. release/2.7.0 has been failing tsc since #1428 landed,
which also blocks #1432 and #1433.

'openExportKeysWindowSaga' is a real producer: it is the ERROR_SOURCE constant
that export-keys-window-saga.ts dispatches both of its sagaError payloads with.

The invented 'sagaA'/'sagaB' test fixtures become real sources too — a test
asserting the union's own behaviour has no business inventing members it
forbids.

* refactor(app-events): close the other half of the SagaErrorSource contract

dismissSagaErrorsBySource still took PayloadAction<string> while
SagaError.source is a closed union, so a producer could retract errors under
a spelling no producer can ever write — exactly the drift the union was
introduced to make impossible, left in place on the retraction side.

The only production caller already passes the saga's ERROR_SOURCE constant,
so this narrows nothing that exists. It does remove one test: "no-op for an
unknown source" asserted a state that is no longer representable; the
reachable case — a real producer with nothing on screen — replaces it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants