Skip to content

WALLET-1416 — close the windows a Ledger flow owns - #1462

Merged
Comp0te merged 17 commits into
developfrom
WALLET-1416-cw-completing-a-ledger-signature-closes-every-popup-window-in-the-profile-cancelling-unrelated-dapp-approvals-and-the-secret-key-export-window
Aug 17, 2026
Merged

WALLET-1416 — close the windows a Ledger flow owns#1462
Comp0te merged 17 commits into
developfrom
WALLET-1416-cw-completing-a-ledger-signature-closes-every-popup-window-in-the-profile-cancelling-unrelated-dapp-approvals-and-the-secret-key-export-window

Conversation

@ost-ptk

@ost-ptk ost-ptk commented Aug 13, 2026

Copy link
Copy Markdown
Member

WALLET-1416 — close the windows a Ledger flow owns

https://make-software.atlassian.net/browse/WALLET-1416

Base note. This work was cut from release/2.7.0, which was merged into develop and
deleted while it was in progress, so the PR targets develop. develop has since been merged
into the branch twice and the tree is conflict-free.

The bug

On a dapp sign / signMessage with a Ledger account, when the device needs a WebHID permission
prompt the wallet opens a second window. The flow's cleanup asked the browser for every popup
window in the profile and removed all of them — other dapps' approval windows (each removal
reaching the cancel-on-close path and cancelling a live request), the secret-key export window,
import-account-with-file windows, and popup windows belonging to ordinary web pages. It never
closed its own permission window, because that one is created type: 'normal' while the filter
asked for 'popup'.

The model: ownership is proved, never inferred

state.ledger.windowId is one global scalar with no per-request keying
(redux/ledger/reducer.ts:5-19). It says a Ledger permission window exists somewhere in the
profile; it says nothing about whose. Everything below follows from refusing to read it as an
answer to "is this window mine".

A caller can always produce that answer honestly, in one of two ways: it opened the window, or
it is the window. useLedger derives ownPermissionWindowId from exactly those two facts —
a ref set at creation (use-ledger.ts:267) and windows.getCurrent() for a page rendering inside
the permission window (use-ledger.ts:209) — and cross-checks the result against the slot
(use-ledger.ts:236-240), so a window it opened and then lost stops counting once the background
clears the stale id. A takeover reads as null — "no permission window of mine" — never as
someone else's. The hook no longer returns the raw slot at all, so no page can branch on it.

What is in this PR

  1. The sweep is gone (use-ledger.ts), replaced by a background command that closes only the
    caller's own permission window ∪ requests[requestId].windowIds. That is the CTA/abandon path.
  2. The background handler verifies before it removes (close-ledger-flow-windows.ts). It never
    reads state.ledger.windowId for targeting; it takes permissionWindowId from the payload
    (:32), subtracts every window another open request still claims (:60-69 — the same
    subtraction the response-path sibling does, and for the same reason: a shared window is that
    request's only display), and dispatches ledgerStateCleared() only when the slice still names
    the id and that id survived the subtraction (:74-80), so a flow that took the slot over
    keeps the deploy/transaction it is signing.
  3. The message binds its requestId to the sender (redux-actions.ts:275). isTrustedUiSender
    admits every wallet page and stops there, and this branch decides a request's lifecycle without
    consulting methodconnect, switchAccount and decryptMessage were treated like a Ledger
    sign. Every legitimate dispatcher's page URL already carries the id, including the permission
    window's (use-ledger builds that URL from the same params), so the comparison against
    new URL(sender.url).searchParams.get('requestId') needs no special case.
  4. The response path closes the flow. The earlier fix fired from a Ledger-event subscriber, and
    every subscriber in the codebase sits behind debounceTime(300) (ledger.ts:44-45), so on a
    successful sign the command arrived ~300 ms after the descriptor had already collapsed to
    { status: 'responded' } and dropped windowIds. close-windows-on-response.ts snapshots the
    window set in the same synchronous block that marks the request responded, delivers the
    response, then re-checks ownership against selectOpenRequests and removes what is still ours.
  5. The approval window's controls stop cancelling a live signature. 'Got it' and the header
    back-arrow shared one handler that tore down the whole flow; in the approval window that made it
    the last removal, so the dapp was answered {cancelled:true} 250 ms later while the user was
    confirming on the device. With a permission window of this flow live, both controls now
    dismiss only the approval window — candidates requires windowIds.length === 1
    (cancel-requests.ts:178), so the request survives and the signature still answers the dapp.
  6. The decision is a value, not a call. decideLedgerFlowControl returns
    'end-flow' | 'dismiss-this-window' | 'return-to-main' and each page switches on it. The
    effects-object it replaced had three () => void members, so every permutation of its bodies
    type-checked — and the swap that binds dismissThisWindow to the permission window turns the
    user's only exit into a silent no-op (closeCurrentWindow removes a window only when
    windows.getCurrent().type === 'popup', while the permission window is type: 'normal') with
    tsc, the suite and CI all green.
  7. The error CTA restores page state on every path, as it did before this work.
    closeCurrentWindow rejects, and resolves having done nothing on a non-popup window; either
    one used to leave the user on a dead error screen with no route back to the transaction details.
  8. The raw-JSON back arrow is no longer a flow control. It rendered for RowDataContent too,
    so leaving that view ran the Ledger teardown — reachable with no Ledger error involved at all.
    It now sets page state and nothing else.
  9. A stale ledger.windowId is cleared by the background on windows.onRemoved. That slot is
    a mutex; without this, (5) would trade a wrong cancel for a session-long Ledger lockout. The two
    are not separable.

What is deliberately not in this PR

  • Making the approval window reflect the permission window's progress. It still renders "Please
    provide permission to connect your Ledger device" for the whole flow. Follow-up ticket.
  • MV3 service-worker restart mid-flow: the request map and the ledger slice are memory-only, so
    after a restart nothing is closed and both windows stay on screen. The signature still reaches
    the dapp and nothing is cancelled. Accepted and documented; follow-up ticket.
  • If the user dismisses the approval window and then simply leaves the permission window open,
    nothing answers the dapp until they act in that window. The permission window is the focused one
    throughout the device phase and every way of closing it answers the dapp, but the wait is
    unbounded under user inaction.

What is NOT proven

  • There is no Ledger e2e coverage at all (grep -rli ledger e2e-tests returns nothing), and
    the popup e2e suite runs with MOCK_STATE=true. Nothing end-to-end exercises this flow.
  • src/hooks and src/apps are outside the coverage gate (jest.config.js
    collectCoverageFrom covers only src/background/redux/**/reducer.ts,
    src/background/handlers/**/*.ts and src/background/redux/sagas/**/*.ts). No test imports
    either signature-request page or use-ledger.ts, and there is still no React-hook harness in the
    tree. decideLedgerFlowControl has its own unit tests and, returning a value rather than calling
    effects, has no permutations left to get wrong — but the page wiring around it is verified by
    nothing beyond tsc.
  • windows.getCurrent() resolves asynchronously, so for a few ms after mount a page that IS the
    permission window has no ownPermissionWindowId and its teardown control is a no-op. It logs a
    console.warn rather than returning silently. Nothing drives this window, and the interval is far
    shorter than any user interaction, but it is unmeasured.
  • That the surviving permission window keeps signing after its sibling document is removed. That is
    hardware behaviour; QA item 11 below is the only instrument.
  • That the fix works on Firefox or Safari. The permission window is reachable only through a
    transport probe those targets do not pass today, so this path is effectively Chrome-only.
  • Real windows.remove semantics on a dead id, on any target. The unit tests prove control flow
    against jest.fn()s and nothing about the browser.
  • That windows.onRemoved fires for a window the background removed itself (premise E6). It is
    a browser API contract, not provable from this tree. This PR makes it load-bearing: it is the only
    recovery for a stale ledger.windowId, so if it did not hold, the mutex would stay set and no
    later Ledger flow could open a permission window for the rest of the session. QA item 13 is the
    only instrument.
  • Whether any target browser recycles numeric window ids. The ownership check compares ids for
    equality, so a recycled id could make a caller's proof read true for a window it does not own.
    Bounded — the caller would have to have opened, or be rendering in, a window that has since been
    destroyed and its id reissued — but unmeasured.
  • Ordering conclusions were derived from control flow, not measured.

Known, unchanged by this PR: closing the wallet's windows does not clear a pending prompt on
the Ledger device — it is resolved only on the device or by the transport closing. That was already
true when the permission window was orphaned instead of closed.

QA status — Chrome + real Ledger

The hardware run below predates the ownership rework (commits up to 6875327b). The rework
changes which window each control acts on, so every item that involves a permission window needs to
be run again against the current head. Treat the list as unrun.

The earlier run passed: 1 (the ticket), 2 (signMessage), 5 (a second dapp's request survives,
verified with the HID permission revoked so a permission window was genuinely in play), 6
(export-keys window and an ordinary web popup survive — the regression check), 9 (disconnect
mid-flow), 11 and 12 (both approval-window controls), 13 (no lockout), 14 (the permission window's
control is still the teardown), 15 (no permission window, no behaviour change). Items 3, 4, 7, 8
and 10 were never run.

QA checklist — Chrome with a real Ledger device

  1. Dapp sign with a Ledger account, device not yet permitted → permission window opens →
    confirm on device. Both windows close and the dapp receives the signature. (The ticket.)
  2. Same for signMessage.
  3. Reject on the device, then press "Got it" in the permission window → both windows close and
    the dapp receives {cancelled:true} after ~250 ms. Repeat and press "Got it" in the approval
    window
    instead: only that window closes, the dapp is still pending, and the permission
    window's own control finishes the job.
  4. Dapp sign with the device already permitted (no permission window) → the approval window
    closes itself; nothing else is touched.
  5. Precondition — revoke the extension's HID permission first (chrome://settings/content/hidDevices),
    and confirm a permission window actually opens. WebHID permission persists once granted, so
    after any earlier item on this list the flow runs with NO permission window, the request then
    has a single window, and reusing it for a second request cancels it — correct pre-existing
    supersede behaviour, not this fix. With both windows open, raise a second dapp request from
    another tab (it reuses the shared approval window, which then shows the second request), then
    confirm the first signature on the device → the second dapp's approval window must survive and
    stay usable.
  6. With a Ledger sign pending, open the secret-key export window and an ordinary
    window.open-style popup → confirm on the device → neither may close. (The WALLET-1416
    regression check.)
  7. Internal transfer with Ledger via sign-with-ledger-in-new-window → Success → Close → only
    that window closes.
  8. import-account-from-ledger → import → Close → only that window closes.
  9. With a Ledger sign pending and the permission window open, disconnect the signing account from
    the dapp origin in the extension popup so the approval window's footer flips to the
    Connect/Cancel branch, then press Cancel. The dapp receives {cancelled:true} and BOTH
    windows close
    — before this work the permission window was orphaned and ledger.windowId
    stayed set, blocking every later Ledger flow. Then reconnect the account (it was just
    disconnected) and start a fresh Ledger sign: a permission window must still open.
  10. Observation only, no pass/fail. With the permission window open, focus the approval window:
    no Sign button, a single 'Got it' footer button, and the text still says "Please provide
    permission…" even after permission was granted. Screenshot it for the progress-channel
    follow-up.
  11. R8 minimum — pass/fail. Grant permission and reach the device-confirmation screen. Without
    touching the device, switch to the approval window and press 'Got it'. Only the approval
    window closes; the permission window stays and the device prompt is still live; confirming on
    the device still delivers the signature to the dapp.
    On the branch point b9170bf8 (now
    inside develop) both windows close and the dapp gets {cancelled:true} — run it there first
    to see the difference.
  12. Same, with the header back-arrow instead of 'Got it' — identical expectation.
  13. Lockout check. Dismiss the approval window as in 11, then close the permission window with
    the OS control (the dapp should receive {cancelled:true}). Then start a fresh dapp Ledger
    sign: a permission window must still open.
  14. The permission window's controls are still the teardown. With both windows open at the
    device prompt, press the back-arrow in the permission window: both windows close and the
    dapp receives {cancelled:true}.
  15. No permission window, no behaviour change. Trigger a device error (device locked) with the
    device already permitted, press 'Got it' → the approval window returns to the transaction
    details with a working Cancel and Sign; nothing closes.
  16. New — the raw-JSON view is not a flow control. On a Ledger dapp sign, open "Show raw JSON"
    and press the header back-arrow. The page returns to the transaction details and the dapp
    request stays pending.
    Before this rework that arrow ran the Ledger teardown whenever any
    permission window was tracked, including another flow's.

Firefox and Safari QA of this fix is close to worthless (the permission window is not reachable
there through a supported transport), but a smoke test that non-Ledger dapp flows are unaffected on
both is still worth running.

Coverage

close-ledger-flow-windows.test.ts pins the removal set, the ownership refusals (a second flow holding the global slot) and the subtraction of a window a second open request still claims.
redux-actions.test.ts pins the sender binding in three directions: a page naming a request it
does not display, a page omitting the id its own URL carries, and an absent sender.url.
close-ledger-flow-windows.integration.test.ts runs the abandon path over a real store through
handleReduxActionhandleWindowRemoved → the grace timer, and pins that the abandoned request
is answered exactly once with {cancelled:true} and that a second dapp's approval window survives
with its descriptor still open — the assertion the mocked unit suite cannot make.

Cited rather than duplicated: cancel-requests-displaced-by.test.ts:67-83 and
cancel-open-requests-on-close.test.ts:62-69 already pin the invariant items 3 and 11 depend on —
two windowIds means a removal detaches but never cancels. Both pass unchanged.

ost-ptk and others added 9 commits August 10, 2026 12:03
… request

The close-on-response path reads `state.ledger.windowId` and returns early when
the descriptor is missing, so the fixtures need a ledger slice and an open
descriptor before that production change lands. Seeding `makeStatefulStore`
also removes a 5s timeout that would otherwise read as flake rather than as the
dedupe contract being unproven.
…lands

The branch's close command is dispatched from a subscriber sitting behind
`debounceTime(300)` (ledger.ts:44-45), so on every successful sign it reaches
the background ~300ms after `windowRequestResponded` has already collapsed the
descriptor to `{ status: 'responded' }` and dropped `windowIds`. It therefore
took its degraded branch and left the dapp approval window on screen.

Move the trigger to the response itself. `markRequestResponded` snapshots
`windowIds` in the same synchronous block that marks the request responded —
the last instant the array exists — and derives the Ledger scope from state
that already exists (`ledger.windowId` being one of the request's windows), so
no new redux action, descriptor field or UI dispatch is needed. After delivery,
ownership is re-checked against `selectOpenRequests`, so a window a second
request reused during the dapp-controlled `tabs.sendMessage` is never removed.

The gate is deliberately per-request rather than per-response-kind: only a
`sign`/`signMessage` request can satisfy it, and every response those two pages
send is terminal — including `signError`, whose error screen would otherwise
strand an unclosable `type: 'normal'` window and a permanent `ledger.windowId`.
Re-checking ownership after delivery is correct only because our own request is
already `responded` by then; otherwise `selectOpenRequests` would claim these
very windows and the target list would come back empty.

The ledger slice is cleared before the removals, and only when the slot still
names the window being removed, so a flow that claimed the slot during delivery
keeps its state.

`closeLedgerWindowsAfterResponse` wraps its whole body in one `try` and is
called as a bare `void`: a `.catch` arrow at each call site would add two
uncoverable functions to a group already at 100% `functions`.

Behavioural delta: a response for a request the store no longer knows (MV3
restart) now delivers without dispatching, instead of dispatching a
`windowRequestResponded` the reducer would reject anyway.
… handler

The handler's comment asserted that SignatureCompleted reaches the background
before the SDK response because it is emitted synchronously before
`signTransaction` returns. `.next()` being synchronous says nothing about
delivery: the single subscribe helper pipes `debounceTime(300)`
(ledger.ts:44-45), so every subscriber sees it ~300ms late and this handler
runs, when it runs at all, after the response path already collapsed the
descriptor. Whether it runs on a successful sign is a race — that path removes
both windows, destroying the document whose timer would fire — so the branch is
described by what it observes, not by an ordering this tree cannot settle.

Trim the header JSDoc to the summary and the never-rejects contract; the
ownership rationale and the history of the removed `windows.getAll` sweep
belong here, not in the source.

Also replace the prototype-pollution test case: with an empty requests map a
bare `requests['hasOwnProperty']` yields a Function whose `.status` is
undefined, so the unsafe read passed the test that exists to forbid it. The
replacement builds the map with `Object.create` and asserts the log line that
proves which branch was taken.
…ive signature

`onErrorCtaPressed` drives both the 'Got it' footer button and the header
back-arrow, and it tore down the whole flow. In the approval window that made
the approval window the last removal, so cancel-on-close answered the dapp
`{cancelled:true}` 250ms later — while the user was confirming the transaction
on the device. 'Got it' is the only footer control that window offers for the
entire permission phase, so this was not an off-path gesture.

Route both controls through a pure decision function. In the permission window
(`initialEventToRender` in the URL) they stay the flow's teardown, because that
window is `type: 'normal'` and cannot close itself. In the approval window with
a permission window live they dismiss only this window; `candidates` requires
`windowIds.length === 1` (cancel-requests.ts:177-179), so the request stays open
with the permission window attached and the signature still answers the dapp.
With no permission window they restore the details screen, exactly as today.

The decision lives in the UI because the background cannot make it: both
controls dispatch an identical command from one call site, no store field
records device progress, and `sender.tab.windowId` is not read anywhere in this
repo. Both pages already compute the discriminator.
…emoved

`ledger.windowId` is a mutex: use-ledger refuses to open a permission window
while it is set (use-ledger.ts:206-211), and the only clearer tied to a window
closing lives inside the document that opened it. Whenever that document dies
first the slot is stale for the rest of the session and no later Ledger flow
can open a permission window at all. The previous commit makes one such
sequence routine — dismissing the approval window takes its close tracker with
it — so the two must ship together.

Gate on strict id equality against live state, exactly like the export-keys
block below it, which also makes it a free compare-and-clear: a slot another
flow took over cannot match. Its own try, because a throw in the cancel path
must not skip it. Placed before the awaited cancel, which sleeps
CANCEL_GRACE_MS and then awaits a dapp-page tabs.sendMessage — a stale
exportKeysWindowId self-heals, a stale ledger.windowId does not, so that delay
is the lockout.

Reuses `ledgerStateCleared` rather than adding a windowId-only action: a
partial clear strands `recipientToSaveOnSuccess`, which `stakes` never
overwrites and `sign-with-ledger-in-new-window` writes into recent recipients.
…ure-closes-every-popup-window-in-the-profile-cancelling-unrelated-dapp-approvals-and-the-secret-key-export-window
@ost-ptk
ost-ptk marked this pull request as ready for review August 13, 2026 09:10
@ost-ptk
ost-ptk requested a review from Comp0te August 13, 2026 09:10
…ure-closes-every-popup-window-in-the-profile-cancelling-unrelated-dapp-approvals-and-the-secret-key-export-window
…ure-closes-every-popup-window-in-the-profile-cancelling-unrelated-dapp-approvals-and-the-secret-key-export-window

@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.

Read as the Ledger window-ownership change: the new close-ledger-flow-windows handler, its redux-actions branch, the ledger-flow-controls decision function and the two page call sites, against the sibling close-windows-on-response written alongside it.

The through-line is state.ledger.windowId — one global scalar slot that both the UI decision and the background teardown treat as "this flow's permission window". Where a second Ledger flow holds the slot, the branch at ledger-flow-controls.ts:22 and the removal set at close-ledger-flow-windows.ts:20 both act on someone else's window. The sibling handler in this same PR already carries the ownership checks the new one is missing, which makes that asymmetry the quickest thing to act on. The rest: the dismiss path lost a recovery the old code guaranteed, the new message branch doesn't bind its requestId to the sender, and the decisions this PR makes are covered by no check in the repo.

Comment thread src/apps/signature-request/ledger-flow-controls.ts Outdated
Comment thread src/background/handlers/close-ledger-flow-windows.ts Outdated
Comment thread src/background/handlers/redux-actions.ts
Comment thread src/apps/signature-request/pages/sign-transaction/index.tsx Outdated
closeNewLedgerWindowsAndClearState();
};
const onErrorCtaPressed = () =>
runLedgerFlowControl(isLedgerNewWindow, ledgerPermissionWindowId, {

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.

Four decisions this PR makes that no check in the repository covers — grouped because they share one fix shape.

This wiring is the clearest instance: all three members of LedgerFlowControlEffects are () => void, so every permutation of this object literal type-checks. Swap the bodies bound to returnToMain and dismissThisWindow and the permission branch calls closeCurrentWindow(), which only removes a window when windows.getCurrent().type === 'popup' (close-current-window.ts:13) — while openNewSeparateWindow creates the permission window type: 'normal' (create-open-window.ts:189,194). The user's only exit from a failed Ledger flow becomes a silent no-op button, and tsc, the suite and CI all stay green. ledger-flow-controls.test.ts drives only jest.fn()s and never renders either page; the same wiring is at sign-transaction/index.tsx:329.

The other three:

  • use-ledger.ts:303 — the single dispatch site of closeLedgerFlowWindows, whose requestId is what makes the handler's approval-window half run. closeLedgerFlowWindows({}) type-checks (the payload is { requestId?: string }) and leaves the suite green, including redux-actions.test.ts:215 and close-ledger-flow-windows.test.ts:85 — which pin both shapes as valid and would then simply describe the wrong flow. At runtime the dapp approval window stops being closed, half of what the ticket asks for, with nothing failing.
  • use-ledger.ts:300-308closeNewLedgerWindowsAndClearState returns early on a falsy windowId (:301), and nothing exercises it in that state, so "a permission window whose slot was released cannot end its flow" is unpinned. There is no use-ledger test file in the tree at all.
  • close-ledger-flow-windows.test.ts:151 — the suite pins which ids are removed, ledgerStateCleared and console output, but never that the abandoned request still gets answered; it mocks webextension-polyfill down to windows.remove and never drives handleWindowRemoved. A "tidy the descriptor up" change inside the handler that tombstones it would build no cancel candidate, and the dapp's promise would never settle. close-windows-on-response.test.ts:571-604 has exactly this integration for the response path.

Underneath the first three: src/apps/** and src/hooks/** are outside jest.config.js collectCoverageFrom, and no test imports either page or use-ledger.ts. The repo's own precedent is src/hooks/register-ledger-permission-window.ts, extracted from this same hook because "the repo has no React-hook harness" — the same move fits, or have runLedgerFlowControl return a 'return-to-main' | 'dismiss-this-window' | 'end-flow' decision the page switches on, which has no permutations. For the last one, a real-store case in the style of close-windows-on-response.test.ts (makeRealStore + openWith).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Took the second of your two suggestions — the decision return — since it removes the permutation space rather than testing it.

runLedgerFlowControl(isPermissionWindow, id, effects) is now decideLedgerFlowControl(isPermissionWindow, ownPermissionWindowId): 'end-flow' | 'dismiss-this-window' | 'return-to-main', and each page switches on the result. There is no object literal left to permute, and the swap you described stops being expressible.

On the other three:

  • use-ledger.ts:303closeLedgerFlowWindows({}) no longer type-checks its way past the problem, because the payload is now the ownership proof: the guard is if (ownPermissionWindowId == null) return, and the dispatch passes both fields. A missing requestId is still a valid shape for the internal flows, but the background now rejects a requestId that disagrees with sender.url, so "the wrong flow described" is caught at the boundary rather than only by a reader.
  • use-ledger.ts:300-308 — still no React-hook harness, so the falsy-id early return is still not driven by a test. What it does now is console.warn instead of returning silently, so the case you named — a permission window whose slot was released cannot end its flow — leaves evidence rather than a dead button. It is also reachable for a few ms on mount before windows.getCurrent() resolves, which the warning names explicitly.
  • close-ledger-flow-windows.test.ts:151 — added close-ledger-flow-windows.integration.test.ts, built the way you pointed at (makeRealStore + openWith, real reducers, driving handleReduxAction then handleWindowRemoved then the grace timer). It pins that the abandoned request is answered exactly once with {cancelled: true}, and that a second dapp's approval window survives the abandon and keeps its own descriptor open. That is the assertion the mocked unit suite could not make.

src/apps/** and src/hooks/** are still outside collectCoverageFrom, and neither page is rendered by any test — that part of your note stands.

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.

Thanks — taking the four in turn, since they came in as one.

close-ledger-flow-windows.test.ts:151 — closed. close-ledger-flow-windows.integration.test.ts is the assertion the mocked suite could not make, and it holds under mutation: dispatch windowRequestResponded({ requestId }) inside handleCloseLedgerFlowWindows and the integration file fails at :99 with Expected number of calls: 1, Received number of calls: 0. The same edit against the previous head left all 23 tests in both files green. That gap is pinned.

ledger-flow-controls.ts decision — mostly closed. The permutation space is genuinely gone: decideLedgerFlowControl returning a union means a mistyped decision is a compile error. What is not closed is the surface the note was filed against — the page wiring. sign-transaction/index.tsx:339-348 and sign-message/index.tsx:213-222 are still hand-bound with nothing checking them, and neither consumes the union exhaustively: both are two ifs with 'return-to-main' as an implicit fall-through, so moving closeCurrentWindow() into the 'end-flow' branch type-checks and ships green.

use-ledger.ts dispatch — the type argument does not hold. closeLedgerFlowWindows({}) still compiles: ledger/actions.ts:30-33 declares both payload fields optional, so the payload is not yet the ownership proof the type would have to make it. What that mutation now produces is different but not better — the message is dropped at redux-actions.ts:275-277 and nothing is closed at all. The boundary failing closed is a real improvement over the previous head; the seam is still absent.

ledger-flow-controls.test.ts case — half. As a pure return-value assertion at :10-12 it no longer reads as end-to-end coverage, and the console.warn at :347-353 does leave evidence where the old code returned silently. But nothing in the tree drives closeNewLedgerWindowsAndClearState, so its reachable surface is still unchecked — and it grew: ownPermissionWindowId is null both when the slot was released and, for every page that is the permission window, until windows.getCurrent() resolves.

Agreed on the last line of your reply, and it is the through-line here: src/apps/** and src/hooks/** outside collectCoverageFrom is what leaves all three of the remaining halves unenforced.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Both halves you left open are closed in 0da4daea, and you were right that the type argument did not hold.

The page wiring. Two ifs with 'return-to-main' as an implicit fall-through is exactly the surface the note was filed against, and the union did nothing about it. Both sites are now an exhaustive switch with an assertNever default (sign-transaction/index.tsx:342-358, sign-message/index.tsx:216-229, ledger-flow-controls.ts:26-30). Moving closeCurrentWindow() into the 'end-flow' branch is still expressible — nothing types away a wrong body — but a new member of the union is now a compile error at each call site instead of silently taking the fall-through, which is the half that was actually unguarded.

closeLedgerFlowWindows({}). Agreed, and fixed at the declaration rather than around it: permissionWindowId is required in the payload type (ledger/actions.ts), required in CloseLedgerFlowWindowsTarget, and the background drops a message that lacks one with a warning (redux-actions.ts:281-286) — the id is the ownership proof, so a payload without it names nothing the handler may close. {} is now a compile error, and the runtime guard is the layer that does not depend on the type staying right. The old "payload-less is routed with undefined" test now pins the drop, plus a case for a payload naming a request but no window.

closeNewLedgerWindowsAndClearState. This is the one your other note turned out to be about, and it is answered there: the ownership rule is now resolveOwnPermissionWindowId in src/hooks/ledger-window-ownership.ts with its own test file, so the reachable surface you named — including the case where ownPermissionWindowId is null while the slot is set — is pinned as a rule even though the hook still is not rendered by anything.

The integration test. Thanks for running the mutation; I read that as closed too.

And yes on the through-line: src/apps/** and src/hooks/** are still outside collectCoverageFrom, and neither page is rendered by any test. Extracting the rule is a way around that for one decision at a time, not a fix for it.

`state.ledger.windowId` is one global scalar with no per-request keying, and
both the UI control and the background teardown were reading it as "this
flow's permission window". A second flow holding the slot made the approval
window dismiss itself — cancelling a live dapp request — and made a teardown
remove the other flow's window mid device-confirmation while `ledgerStateCleared`
wiped the deploy it was signing.

Ownership is now proved by the caller. `use-ledger` derives
`ownPermissionWindowId` from the window it opened, or from `windows.getCurrent()`
for a page that IS the permission window, and passes it in the
`closeLedgerFlowWindows` payload. The handler no longer reads the slot for
targeting, subtracts windows other open requests still claim, and clears the
slice only when it still names a window being removed.

Also:

- `runLedgerFlowControl(effects)` becomes `decideLedgerFlowControl` returning
  a decision the page switches on. An object of three `() => void` members
  type-checks under every permutation of its bodies, and no test in this tree
  can render either page.
- The error CTA restores page state on every path again. `closeCurrentWindow`
  rejects, and on a non-popup window resolves having done nothing; either left
  the user on a dead screen with no route back.
- The raw-JSON back arrow no longer routes through the Ledger flow control, so
  leaving that view cannot cancel the dapp request.
- The `closeLedgerFlowWindows` message binds `payload.requestId` to the id in
  `sender.url`. The sender gate admits every wallet page and stops there, and
  this branch decides a request's lifecycle regardless of its `method`.
- A real-store test drives the abandon path through `handleWindowRemoved` and
  pins that the abandoned request is still answered, which the unit suite could
  not see.
…er-signature-closes-every-popup-window-in-the-profile-cancelling-unrelated-dapp-approvals-and-the-secret-key-export-window' into WALLET-1416-cw-completing-a-ledger-signature-closes-every-popup-window-in-the-profile-cancelling-unrelated-dapp-approvals-and-the-secret-key-export-window
@ost-ptk
ost-ptk requested a review from Comp0te August 14, 2026 08:26

@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.

Re-read the increment against the round-1 notes. Four of the five threads are closed by c3e9640a — the ownership derivation, the handler's targeting and its claimedByOthers subtraction, the sender binding on requestId, and the unconditional page-state reset all check out at the lines you named. The one new note below is the other side of that same ownership question: the two witnesses ownPermissionWindowId is derived from are both per-document, so an instance that is neither the opener nor the window itself reads as a non-owner.

Comment thread src/hooks/use-ledger.ts Outdated
// the two flows reads as "no permission window of mine" rather than as someone
// else's. Still derived from the slot, so a window this instance opened and
// then lost stops counting once the background clears the stale id.
const ownPermissionWindowId =

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.

ownPermissionWindowId (:236-241) recognises two owners: the instance that called openNewSeparateWindow (openedPermissionWindowIdRef, assigned only at :267) and the instance rendering inside the window (hostWindowId, from the one-shot windows.getCurrent() at :211-230 — empty deps, and the .catch at :221-225 only logs). Both are per-document, so an instance that is neither computes null while the slot is still set, and closeNewLedgerWindowsAndClearState takes the early return at :347-353: one console.warn, no dispatch.

Repro: popup → Import account from Ledger → the permission window opens and takes focus, so the popup document is torn down → reopen the popup and navigate back to the Ledger screen (navigation-menu/index.tsx:191-204app-router.tsx:387-388) → press the X in LedgerConnectionView. Nothing happens but the console line. That is a regression in this control: base use-ledger.ts:285-292 gated on if (windowId) — the same global slot, so any instance passed — and closed the window and dispatched ledgerStateCleared() from the hook itself.

Two things bound it, and both are worth stating because they are why this is not a stuck wallet: the permission window's own X still works (hostWindowId === windowId there), and window-removed.ts:22-25, new in this PR, clears the slice however that window goes away — so the !windowId gate at :246-251 unblocks the next flow as soon as it closes. LedgerDisconnectedFooter's Connect CTA on the same screen also clears it (:66-68, :90-92). The cost is a silently dead control on the popup side, not an unrecoverable state.

The fix the codebase already suggests is a third witness that survives a remount: isLedgerNewWindow is derived from the URL (sign-transaction/index.tsx:93) and import-account-from-ledger/index.tsx:13-16 reads the same param, so an opener id carried in the URL or persisted in the slice would make the re-mounted popup a legitimate owner again.

One open question rather than a claim: can windows.getCurrent() reject in an extension page? If it can, hostWindowId stays null for that window's whole life and the comment at :348-350 ("briefly, on mount") would not hold — the X, the end-flow branch at sign-transaction/index.tsx:339-341 and the auto-close at :363-372 would all be dead there, and the .catch at :221-225 has no retry.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed, including the repro — popup → Import account from Ledger → the popup document goes away when the permission window takes focus → reopen → the X does nothing but log. Fixed in 0da4daea.

Both witnesses were per-document, which is the whole defect: neither of them can describe a document that no longer exists. The third one rides in the slice instead, next to the id it qualifies — ledgerNewWindowIdChanged now carries { windowId, openerWindowId } (redux/ledger/reducer.ts:21-25), where openerWindowId is the window the opening instance was rendering in. A remounted popup re-derives that same id from its own windows.getCurrent(), so it is an owner again.

The decision moved out of the hook into src/hooks/ledger-window-ownership.tsresolveOwnPermissionWindowId({ slotWindowId, openerWindowId, openedWindowId, hostWindowId }), eight cases in ledger-window-ownership.test.ts. That is the seam your last note asked for twice: the hook has no harness, but the rule does.

One narrowing worth naming rather than leaving to be found: the opener witness is per window, not per instance, so a different instance rendering in the same browser window also reads as an owner. Base gated on if (windowId) and so admitted every instance anywhere; this is narrower than that, not wider — but it is not "only the flow that started it" either.

On your open question — can windows.getCurrent() reject in an extension page. It resolves for any rendered document: no permission is involved and the call is answered by the window containing the caller. The failure mode is a caller with no containing window (a service worker with no open windows), which is not this. So the "briefly, on mount" comment holds for the popup side.

What I am not claiming is that it cannot fail. If it did, the page that IS the permission window would still be stuck, because hostWindowId is its only witness — it did not open itself, and its openerWindowId names the popup. What bounds that is the same thing that bounds the case you found: the window's own titlebar close still works, and window-removed.ts:22-25 clears the slice however it goes away, so the next flow is not blocked. I left it there rather than adding a retry with no reachable trigger to test it against.

ost-ptk and others added 3 commits August 17, 2026 11:54
…pleting-a-ledger-signature-closes-every-popup-window-in-the-profile-cancelling-unrelated-dapp-approvals-and-the-secret-key-export-window

# Conflicts:
#	src/background/handlers/sdk-response-to-tab.test.ts
`ownPermissionWindowId` recognised two owners, both per-document: the instance
that opened the permission window (a ref) and the instance rendering inside it
(`windows.getCurrent()`). The popup document is torn down when the permission
window takes focus, so a reopened popup back on the Ledger screen was neither:
its X produced a console warning and nothing else, where the pre-ownership code
gated on the global slot and so admitted any instance.

Add a third witness that survives a remount — `openerWindowId`, persisted in the
ledger slice beside `windowId` and matched against the remounted instance's own
window. Ownership moves out of the hook into `resolveOwnPermissionWindowId`,
which has the seam the hook cannot get: the repo has no React-hook harness.

Also from the same review round:

- `permissionWindowId` is required in the `closeLedgerFlowWindows` payload, in
  `CloseLedgerFlowWindowsTarget`, and behind a background guard that drops a
  message without one — the id is the ownership proof, so a payload that omits
  it names nothing the handler may close.
- Both pages consume `LedgerFlowControlDecision` through an exhaustive `switch`
  with `assertNever`, instead of two `if`s with an implicit fall-through that
  let a moved `closeCurrentWindow()` type-check.
…ure-closes-every-popup-window-in-the-profile-cancelling-unrelated-dapp-approvals-and-the-secret-key-export-window
@ost-ptk
ost-ptk requested a review from Comp0te August 17, 2026 09:13
…quest

The opener witness matched on the browser window alone, and approval windows
are a single tracked slot the next request reuses in place. So a fresh
document inherited the previous request's claim on the permission window:
`decideLedgerFlowControl` returned 'dismiss-this-window', the back arrow
closed the approval window, and the dapp got a cancellation the user never
asked for — the collateral cancel this branch exists to close, by a second
route.

Record the opening flow's requestId next to `openerWindowId` and require both
to match. Two flows sharing one browser window are now separated; a flow
remounting in its own window still owns what it opened, and the internal
flows, which carry no request, are unaffected.

Both guards on that branch are now pinned: dropping the `openerWindowId`
null-check fails one case, dropping the request-id conjunct fails two. The
case asserting that a sibling in the opener window owns the slot was
asserting the defect, and is now its inverse.
@Comp0te
Comp0te merged commit aa63852 into develop Aug 17, 2026
6 checks passed
@Comp0te
Comp0te deleted the WALLET-1416-cw-completing-a-ledger-signature-closes-every-popup-window-in-the-profile-cancelling-unrelated-dapp-approvals-and-the-secret-key-export-window branch August 17, 2026 20:02
Comp0te pushed a commit that referenced this pull request Aug 19, 2026
…ger confirmation (#1476)

* fix: WALLET-1394 — keep the approval window out of reuse during a Ledger confirmation

An already-permitted Ledger device opens no permission window, so the device
call runs in the page inside the SHARED approval window. Reusing that window
for the next dapp request runs `tabs.update` on its tab — a full navigation
that destroys the document, and with it the per-document HID session and the
pending signature. When the page does outlive the 250 ms grace, the signature
instead reaches `sdk-response-to-tab` and is dropped by the supersede
tombstone. Either way the user's confirmation is lost and the dapp is told
`cancelled: true`.

PR #1427/#1462 covered only the other path, where a separate permission window
gives the request a second display and the reuse leaves it alive.

Mark the request as awaiting the device for the duration of the call and
withhold the window it runs in from reuse. Passing a null windowId REDIRECTS
the reuse rather than suppressing it: the new-window branch still runs
`setWindowId`, so the shared slot retracks to the newcomer and only the
protected window leaves the rotation.

The flag lives on the open request descriptor, not in a slice-level set, so it
cannot outlive what it describes — the tombstone replaces the whole entry and a
detach shrinks `windowIds`, which is the other half of the question.

* fix: WALLET-1394 — hold the device flag per bracket, not per call

Neither signing page disables its submit control while a Ledger call is in
flight, and the page state that hides it is only flipped after
`getPreferredTransport()` and `beforeLedgerActionCb()` resolve. A second click
in that gap starts a second bracket, fails fast on the busy transport, and its
`finally` released the window while the first call was still on the device.

Count the holders per requestId so overlapping brackets report the flag once
between them. Module scope is the right scope: a transport is per document, so
brackets can only overlap within one.
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