fix: WALLET-1394 — keep the approval window out of reuse during a Ledger confirmation - #1476
Merged
Conversation
…ger 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.
Comp0te
reviewed
Aug 18, 2026
Comp0te
left a comment
Collaborator
There was a problem hiding this comment.
Read this against the WALLET-1394 failure it closes, along with the reducer, selector and openWindow tests it adds. The fix is sound and the tests pin it. The comments below all sit on one seam: awaitingDeviceConfirmation is a single boolean whose truth has to survive a message hop, a chain of browser round-trips, and any concurrent holder — each comment is one place where it does not.
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.
Comp0te
approved these changes
Aug 19, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes WALLET-1394.
The bug
An already-permitted Ledger device opens no permission window, so the device call runs in the page inside the shared approval window. When the next dapp request arrives, that window is reused — and reuse runs
tabs.update(tab.id, { url })on its tab (create-open-window.ts:100-110), a full navigation.ledgeris a per-document singleton (libs/services/ledger/ledger.ts), so the navigation destroys the document, the HID session and the pendingsignTransactionpromise together.Confirmed on hardware, 3 runs. The ticket describes a second mechanism — the signature reaching
sdk-response-to-taband being dropped by the supersede tombstone — and that one is real too, but it happened in only 1 of 3 runs. Which one wins is a race with how fast the replacement page commits its navigation (bundle cache, disk, load). Both end insignResponse({ cancelled: true })to the first dapp.This matters for the fix: nothing on the tombstone side can help, including WALLET-1364 P2 #10 — in the dominant case there is no longer anything to deliver. Only preventing the reuse works.
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.
The fix
awaitingDeviceConfirmationon the openRequestdescriptor. Deliberately not a slice-level set: the tombstone replaces the whole entry andwindowDetachedFromRequestsshrinkswindowIds, so the flag cannot outlive what it describes and nothing has to remember to clear it.selectIsWindowBusyWithDevice(state, windowId)composes the answer from the request side.openWindowwithholds the tracked window while it is busy. Passingnullredirects the reuse rather than suppressing it: the new-window branch still runssetWindowId(create-open-window.ts:154), so the shared slot retracks to the newcomer and only the protected window leaves the rotation.runWithDeviceConfirmationReported(requestId, run)brackets the device call. The bracket lives in its own module for the reasonregister-ledger-permission-window.tsdoes — there is no React-hook harness in this repo, and a start without its end would withhold the shared window for the rest of that request's life. It also gives the previously un-awaitedledgerAction()acatch.handleReduxAction, not added toFORWARDED_ACTION_TYPES(which checks no sender at all). Gates:isTrustedUiSenderpluspayload.requestId === recoverRequestId(sender.url)— the same paircloseLedgerFlowWindowsuses, so a page can only speak for the request its own URL names.Reviewer notes
Deliberate behaviour change in the permission-window flow. During the device call the request's
windowIdsis[shared, permission], so both now read as busy and a second request opens a third window instead of reusing the shared one. Previously the reuse navigated the shared window and the request merely survived it. Strictly safer — the waiting screen is no longer navigated away — but it is a change, and it is pinned by a selector test.Residual race, knowingly left open. The flag is dispatched from the page and reduced in the background one
runtime.sendMessagehop later. A second dapp request processed inside that hop (single-digit ms) still reuses the window. Previously the exposure was the entire device confirmation — seconds to minutes.It was not closed by awaiting the dispatch ack, though the ack does mean the flag is in the store (
background/index.ts:216responds only when the result carries aresponsekey, and the branch dispatches synchronously before returning). The drop paths return{ handled: true }with noresponsekey, so their promise never resolves — awaiting it before the device call would mean that any future tightening of the gate silently stops Ledger signing altogether. Bad trade against a millisecond window. Closing it properly means moving "this request is Ledger-signed" into the background, which it could derive fromsigningPublicKeyHexplus the vault at window-open time; that is a wider behaviour change and belongs in its own ticket.A burst opens one window per request while the device is busy. The slot is only rewritten in the
windows.createcallback, so several requests arriving during a Ledger confirmation all see "tracked but busy" and each opens its own window. This is not new machinery — the same thing happens on base whenever no window is tracked yet (selectWindowIdisnullat cold start, so concurrent calls all take the create branch and each callssetWindowId); what changes is the condition that triggers it. Base collapsed such a burst into one window only by cancelling each previous request in turn, which for a Ledger confirmation is the exact P0 this PR exists to stop. Bounded nearMAX_STORED_PAYLOADSforsign;signMessagehas no equivalent cap, which is a pre-existing gap of its own.One line is covered only by the manual test: the wiring at
use-ledger.ts:135. Reverting it to a bareledgerAction()leaves the whole suite green — same gapregisterLedgerPermissionWindowlives with, for the same reason.Testing
tsc,lint,format:check,knipclean; 1047 unit tests pass; the coverage gate passes (windowManagement100%,handlersabove its floor).Manual, on a real Ledger with the HID permission already granted (so no permission window appears — that is the precondition for this bug):
signfrom a Ledger account, stop at "Confirm on device".signMessage.cancelled: true~250 ms later; pressing Approve on the device does nothing. After: A's window is untouched, B opens its own window, A signs through, then B signs through.Found while testing — not fixed here
Two Ledger flows can now be live at once, which was unreachable before this change because the second request used to consume the first one's window. If the user leaves A waiting on the device and presses Sign with Ledger in B, B's document opens the device and sends its own APDUs while A's prompt is up. The Casper app has no queue: it tears down A's prompt and answers B with
0x6985, so B shows "You rejected to sign the deploy" (which the user did not do) and A can no longer be completed — the window has to be closed and the flow restarted.This is contention over one physical device, not window management, and it is not made worse by this PR: previously A was lost unconditionally and silently, whereas now it is lost only if the user deliberately starts a second signing flow, and the failure is visible. Note that
LedgerEventStatus.WaitingToSignPrevDeployalready exists with exactly the right copy for this situation — B just never gets to it.It cannot be arbitrated inside a page (the
ledgersingleton is per document); the only shared authority is the background store, which is where this PR's flag already lives. It also affects the internal flows (transfer, staking, import-from-ledger), which carry norequestIdand so are not covered by that flag.Investigated and filed as WALLET-1429. Two findings from it are worth knowing while reviewing this PR:
TransportWebHIDnever consultsexchangeTimeout, andexchangeAtomicImpl's 15 sunresponsiveTimeoutonly emits an event. SorunWithDeviceConfirmationReported'sfinallynever runs andawaitingDeviceConfirmationstays set. The blast radius is still what this PR describes — the slot retracks to the newcomer's window and later requests reuse that normally, so it costs exactly one held window until A's window is closed, not one per request.LedgerEventStatus.WaitingToSignPrevDeployis unreachable, and not only because of ordering: its triggerreturnCode === 65535(ledger.ts:135) cannot fire for a busy device, since Zondax'sprocessErrorResponsereturns the real status code whenever the error carries one and0xffffonly as the fallback for errors without one. Any future fix leaning on that copy must trigger it from a wallet-held fact.