Skip to content

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

Merged
Comp0te merged 2 commits into
developfrom
WALLET-1394-ledger-supersede
Aug 19, 2026
Merged

fix: WALLET-1394 — keep the approval window out of reuse during a Ledger confirmation#1476
Comp0te merged 2 commits into
developfrom
WALLET-1394-ledger-supersede

Conversation

@ost-ptk

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

Copy link
Copy Markdown
Member

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. ledger is a per-document singleton (libs/services/ledger/ledger.ts), so the navigation destroys the document, the HID session and the pending signTransaction promise together.

Confirmed on hardware, 3 runs. The ticket describes a second mechanism — the signature reaching sdk-response-to-tab and 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 in signResponse({ 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

  • awaitingDeviceConfirmation on the open Request descriptor. Deliberately not a slice-level set: the tombstone replaces the whole entry and windowDetachedFromRequests shrinks windowIds, 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.
  • openWindow withholds the tracked window while it is busy. Passing null redirects the reuse rather than suppressing it: the new-window branch still runs setWindowId (create-open-window.ts:154), so the shared slot retracks to the newcomer and only the protected window leaves the rotation.
  • UI half: runWithDeviceConfirmationReported(requestId, run) brackets the device call. The bracket lives in its own module for the reason register-ledger-permission-window.ts does — 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-awaited ledgerAction() a catch.
  • The action is intercepted in handleReduxAction, not added to FORWARDED_ACTION_TYPES (which checks no sender at all). Gates: isTrustedUiSender plus payload.requestId === recoverRequestId(sender.url) — the same pair closeLedgerFlowWindows uses, 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 windowIds is [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.sendMessage hop 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:216 responds only when the result carries a response key, and the branch dispatches synchronously before returning). The drop paths return { handled: true } with no response key, 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 from signingPublicKeyHex plus 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.create callback, 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 (selectWindowId is null at cold start, so concurrent calls all take the create branch and each calls setWindowId); 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 near MAX_STORED_PAYLOADS for sign; signMessage has 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 bare ledgerAction() leaves the whole suite green — same gap registerLedgerPermissionWindow lives with, for the same reason.

Testing

tsc, lint, format:check, knip clean; 1047 unit tests pass; the coverage gate passes (windowManagement 100%, handlers above 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):

  1. Dapp A → sign from a Ledger account, stop at "Confirm on device".
  2. Dapp B (second tab) → signMessage.
  3. Before: A's window is repainted with B's request; A's dapp receives 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.WaitingToSignPrevDeploy already exists with exactly the right copy for this situation — B just never gets to it.

It cannot be arbitrated inside a page (the ledger singleton 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 no requestId and so are not covered by that flag.

Investigated and filed as WALLET-1429. Two findings from it are worth knowing while reviewing this PR:

  • Request A hangs rather than fails. There is no timeout anywhere on the signing path — TransportWebHID never consults exchangeTimeout, and exchangeAtomicImpl's 15 s unresponsiveTimeout only emits an event. So runWithDeviceConfirmationReported's finally never runs and awaitingDeviceConfirmation stays 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.WaitingToSignPrevDeploy is unreachable, and not only because of ordering: its trigger returnCode === 65535 (ledger.ts:135) cannot fire for a busy device, since Zondax's processErrorResponse returns the real status code whenever the error carries one and 0xffff only as the fallback for errors without one. Any future fix leaning on that copy must trigger it from a wallet-held fact.

…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.
@ost-ptk
ost-ptk requested a review from Comp0te August 18, 2026 14:18

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

Comment thread src/hooks/ledger-device-confirmation.ts
Comment thread src/hooks/ledger-device-confirmation.ts
Comment thread src/background/open-window.ts
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.
@ost-ptk
ost-ptk requested a review from Comp0te August 19, 2026 07:22
@Comp0te
Comp0te merged commit 2210941 into develop Aug 19, 2026
6 checks passed
@Comp0te
Comp0te deleted the WALLET-1394-ledger-supersede branch August 19, 2026 11:16
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