Skip to content

feat(wallet): add Ledger hardware wallet support via WebHID - #850

Merged
Jaydbrown merged 1 commit into
conduit-protocol:mainfrom
oyinda-11:feat/792-ledger-webhid
Oct 22, 2026
Merged

Jaydbrown merged 1 commit into
conduit-protocol:mainfrom
oyinda-11:feat/792-ledger-webhid

Conversation

@oyinda-11

Copy link
Copy Markdown
Contributor

Summary

Adds a Ledger option to the connect modal. It talks to the device over WebHID, so treasuries that keep their funds on a hardware wallet can connect and sign directly.

Why not @stellar/transport-webhid? The issue suggests that package, but it isn't published on npm (npm view @stellar/transport-webhid returns E404). This PR uses Ledger's official packages instead: @ledgerhq/hw-transport-webhid for the WebHID transport and @ledgerhq/hw-app-str for the Stellar app client. Both are lazy-loaded with dynamic import(), so they're only fetched the first time someone picks Ledger and aren't in the main bundle.

Changes

  • lib/ledger-wallet.ts (new)
    • isWebHIDSupported(): an SSR-safe feature check that also requires a secure context.
    • connectLedger(): reuses a device this site already has permission for (openConnected()). It only shows the browser's device picker when there isn't one. It reads the first account (44'/148'/0') and returns its G-address.
    • signWithLedger(): first checks that the device still holds the session's account. It then sends the transaction's signature base to the Stellar app for on-device review and attaches the signature with addSignature, which verifies it. Fee-bump transactions work through the same path.
    • All device access goes through one queue. The device handles one exchange at a time, so concurrent signTx() calls wait their turn instead of failing with TransportRaceCondition.
    • toLedgerError() turns every failure into a LedgerError with a stable code and a message the user can act on:
      • unsupported browser (no WebHID)
      • picker cancelled (TransportOpenUserCancelled)
      • device locked (0x5515 / LockedDeviceError)
      • Stellar app not open (0x6511, 0x6e00, 0x6d00, ...)
      • rejected on the device (0x6985)
      • unplugged (DisconnectedDevice*)
      • busy / in use by Ledger Live
      • hash signing disabled, and data too large or unparseable
    • onLedgerDisconnect() / closeLedger() manage the transport's lifecycle.
  • contexts/WalletContext.tsx
    • connect(walletId?): 'ledger' connects the device, and anything else keeps the existing Freighter flow. The session is stored as Ledger with the same saveWalletSession and session-expiry handling as Freighter.
    • signTx signs Ledger sessions on the device. It keeps the existing semaphore, abort and stale-account checks, with a 120s timeout because the user reviews the transaction on the device.
    • Unplugging the Ledger shows a toast and tears the session down the same way Disconnect does.
    • The Freighter WatchWalletChanges callback now ignores Ledger sessions. Without this, a locked or missing Freighter extension would disconnect a Ledger user.
    • disconnect() releases the device.
  • components/MultiWalletModal.tsx: new Ledger entry. In browsers without WebHID it's disabled and shows the reason (linked with aria-describedby). Feature detection runs after mount to avoid a hydration mismatch.
  • components/ConnectButton.tsx
    • Passes the selected provider id to connect(). It used to call connect() with no argument for every entry.
    • Auto-reconnect passes the cached provider. It skips Ledger, because WebHID needs a user gesture; the restored session reopens the permitted device on the first signature.
    • Skips the 20s "Connection timed out" message while the Ledger device picker is open.
  • package.json / package-lock.json: adds the two @ledgerhq dependencies. Regenerating the lockfile also brings it back in sync with package.json. On main, npm ci currently fails with Missing: playwright-core@1.64.1 from lock file and similar errors (see below).
  • CHANGELOG.md and README.md entries.

How it was tested

New Vitest suites (42 tests) mock the WebHID transport and the Stellar app, since there's no device in CI:

  • lib/ledger-wallet.test.ts
    • Error mapping for every case listed above.
    • Picker vs. reusing an already-permitted device, and non-interactive mode.
    • Unsupported browser.
    • Disconnect handler on unplug, and no handler call after an explicit close.
    • Real signature attachment, verified against the transaction hash.
    • Refusal when the device holds a different account.
    • Concurrent signing never overlaps on the device.
  • contexts/WalletContext.ledger.test.tsx: connect and persist a Ledger session, locked-device error, signTx routing, the Freighter watcher not tearing down a Ledger session, unplug teardown, and releasing the device on disconnect.
  • components/__tests__/MultiWalletModal.test.tsx: enabled with WebHID, disabled with an explanation without it.
  • components/__tests__/ConnectButton.test.tsx: provider id routing, error display, no silent re-prompt for a cached Ledger.

The existing contexts/WalletContext.test.tsx (Freighter) still passes. Lint and tsc report no errors in any file this PR touches. The only lint output there is the existing trackOperation exhaustive-deps warning in WalletContext.tsx.

⚠️ Not tested on a physical Ledger. Everything below the transport is mocked. Before merging, a maintainer should do a manual check with a real device on Chrome or Edge: connect, sign a payment, sign a Soroban invoke, lock the device, close the Stellar app and unplug.

CI status on main

CI on main is currently red for reasons unrelated to this change, for example run 39464762854 on cf5b2b0, where every job fails at npm ci because the lockfile is out of sync. This PR fixes that step. The steps after it still fail on main, and I left them alone to keep this PR focused:

  • Typecheck / lint / build: app/dashboard/page.tsx has a bad merge (a duplicated date-range JSX fragment around line 439), which is a parse error. Behind it, tsc reports about 70 more errors in about 30 files (lib/stellar-address.ts, lib/developer-mode.ts, app/stream/[id]/page.tsx, several tests, and others). next lint also flags CopyableAddress is undefined and duplicate react imports. None of these are in files this PR touches.
  • Unit tests: main has 85 failed / 1158 passed across 26 failing test files. With this PR it's 85 failed / 1200 passed: the same 26 failing files plus the 42 new passing tests.
  • Dependency audit: high/critical advisories in brace-expansion, braces, follow-redirects, sharp, source-map-js, tinypool, undici and vite. None of them come from the new @ledgerhq/* dependency tree.

Closes #792

🤖 Generated with Claude Code

Add a Ledger option to the connect modal that talks to the device over
WebHID using @ledgerhq/hw-transport-webhid and @ledgerhq/hw-app-str.
The issue suggested @stellar/transport-webhid, but no such package is
published on npm.

- lib/ledger-wallet.ts: WebHID feature detection, lazy-loaded transport
  and Stellar app client, serialised device access, disconnect
  notifications, and mapping of picker-cancel / locked device / Stellar
  app not open / user rejection / unplug errors to user-facing messages
- WalletContext: connect(walletId) routes 'ledger' to the device, signTx
  signs Ledger sessions on the device, an unplugged Ledger tears the
  session down, and the Freighter watcher ignores Ledger sessions
- MultiWalletModal: Ledger entry, disabled with an explanation when the
  browser has no WebHID
- ConnectButton: pass the selected provider to connect(), skip the
  silent reconnect and the 20s UI timeout for Ledger
- Tests for the library, context, modal and button with a mocked
  transport

Closes conduit-protocol#792

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Jaydbrown
Jaydbrown merged commit 302d5c6 into conduit-protocol:main Oct 22, 2026
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.

components/ConnectButton.tsx: add support for Ledger hardware wallet via WebHID

2 participants