Skip to content

feat: add blockstream jade hardware wallet - #765

Open
coreyphillips wants to merge 15 commits into
masterfrom
feat/jade-hardware-wallet
Open

coreyphillips wants to merge 15 commits into
masterfrom
feat/jade-hardware-wallet

Conversation

@coreyphillips

Copy link
Copy Markdown
Contributor

This PR:

  1. Adds Blockstream Jade as a second hardware wallet vendor, over Bluetooth
  2. Generalises the hardware wallet layer so every device call is routed by the vendor of the paired entry
  3. Releases an open Jade Bluetooth link while Bitkit is backgrounded and reconnects silently on return

It is the iOS counterpart to bitkit-android#1231 and requires bitkit-core 0.5.17, which carries the Jade module (synonymdev/bitkit-core#153).

Description

A Jade can now be paired from Connect Hardware, unlocked with the PIN entered on the device, and used like a paired Trezor: watch-only balances, on-device receive address verification, and on-device signing for both a send and a transfer to spending. The protocol, the pinserver round trip and every deadline live in bitkit-core. This app supplies the byte transport over Bluetooth and the UI that drives the flows.

  • Bumps bitkit-core to 0.5.17 so the Jade protocol, the PIN unlock and signing come from core, as on Android.
  • Adds a Jade Bluetooth transport over the Nordic UART Service with its own central, so Trezor links and scans are untouched. It subscribes to indications (the Jade's TX characteristic is indicate-only), writes with response, sizes chunks from the link MTU, and only starts Bluetooth when a Jade is actually used, so there is no new permission prompt at launch.
  • Adds a Jade session manager ported from Android: pairing with the PIN on the device, finding a paired Jade again by its advertised name after its Bluetooth identifier changes, rejecting a different Jade before it asks for a PIN, silent reconnects that never ask for one, and a retry without taproot on old firmware.
  • Stores the vendor on every paired entry and reads entries saved before this change as Trezor, so existing Trezor wallets keep their ids and names, and the same seed on a Trezor and a Jade stays two separate wallets.
  • Routes connect, verify, compose and sign by the wallet's vendor and keeps one vendor session at a time, releasing the other vendor first.
  • Gives Jade its own copy for a wrong PIN, an unreachable pinserver, a busy device, firmware that is too old, a device with no wallet yet, a network mismatch and a transaction too large to sign.
  • Lets Connect Hardware find Jades next to Trezors, shows a hint to enter the PIN on the Jade while it unlocks, and hides the passphrase option for a Jade, which has one wallet per device.
  • Shows the Jade illustration and "Sign With Jade" on send and transfer, and allows 5 minutes to reach a Jade that may be waiting for its PIN.
  • Lets the send sheet be closed while the device connects, and releases the device session when a send, a receive verification or a pairing is left part-way, for both vendors, matching Android.
  • Labels the receive tab with the wallet's vendor.
  • Releases a Jade Bluetooth link 30 seconds after Bitkit is backgrounded and on termination, because a Jade left holding a dead link can refuse connections until it is power cycled. The release runs inside a background task since iOS suspends the app. Coming back reconnects without a prompt.
  • Makes the Bluetooth permission and Bluetooth-off copy vendor neutral.

Differences from Android worth knowing:

  • Bluetooth only. There is no USB on iOS.
  • The receive tab identifier follows the vendor: Tab-trezor for a Trezor wallet (unchanged) and Tab-jade for a Jade. bitkit-android#1231 renames its tag to Tab-hardware for both, while the shared helper in bitkit-e2e-tests and Android's ReceiveHardwareFlowTest still tap Tab-trezor.
  • The transfer to spending sign button also reads "Sign With Jade" for a Jade. Android changed only the illustration there.

Out of Scope

  • Bitkit/Assets.xcassets/Illustrations/jade-placeholder.imageset: the Jade illustration is a placeholder vector until design supplies the real asset.
  • USB connections to a Jade.
  • Signet: Jade does not support it, so that combination throws rather than mapping to a network.
  • journeys/hardware-wallet: no Jade journeys, since there is no Jade emulator in bitkit-docker and the simulator has no Bluetooth.
  • A PIN hint outside the Found step. Send, receive and transfer rely on the prompt on the device, as on Android.

Design

N/A: no design available.

Preview

Recording of the Bluetooth pair, verify and sign flow to be attached from a physical Jade.

QA Notes

Manual Tests

These need a physical Jade and a physical iPhone, since the simulator has no Bluetooth.

  • 1. Jade on → Connect Hardware → Search → Connect: the Found step shows "Enter your PIN on your Jade to unlock it." while the Jade asks for its PIN. After unlocking, the Paired step reads "Paired Jade" with no Passphrase button and the wallet tile appears.
  • 2. Connect Hardware → Connect on a Jade → Cancel while the Jade waits for its PIN: the sheet closes and no PIN prompt comes back later.
  • 3. Enter a wrong PIN while pairing: shows "Wrong PIN. Try again on your Jade."
  • 4. Jade wallet → Receive: the hardware tab reads "Jade". Show Details → Verify on Device: the address on the Jade matches the one in the app.
  • 5a. Jade wallet → Send → Sign With Jade with a locked Jade: the Jade asks for its PIN and the sheet can be swiped away while it waits.
    • 5b. Sign again and unlock: the transaction shows on the Jade, the sheet stays locked while it signs, and confirming broadcasts it.
  • 6. Jade wallet → Transfer to Spending → sign on the Jade: the transfer completes.
  • 7. Power cycle the Jade → Send → Sign With Jade: Bitkit finds it under its new Bluetooth identifier and connects.
  • 8. Connected Jade → background Bitkit for about 45 seconds → reopen: the link was released and Bitkit reconnects with no pairing prompt and no PIN prompt.
  • 9. Settings → Hardware Wallets: rename the Jade wallet, then remove it.
  • 10. Pair a Trezor and a Jade holding the same seed: they show as two wallets.
  • 11. regression: Trezor → pair, Receive → Verify on Device, Send and Transfer to Spending: all work as before, and the receive tab reads "Trezor".
  • 12. regression: Trezor connected → background and reopen Bitkit: the Trezor reconnects.

Automated Checks

  • New tests:
    • JadeManagerTests.swift: pairing, unlock, account export with the taproot fallback, identity checks, reconnect by advertised name, silent reconnect, the reconnect loop, cancel and teardown ordering, the disconnect notice, and background release. It ports the Bluetooth-relevant cases of Android's JadeRepoTest.kt.
    • JadeTransportTests.swift, JadeBLELinkStateTests.swift and BlockingQueueTests.swift: chunk sizing, read timeouts, close waking every waiter, error codes, the stale bond message, and that Bluetooth stays off until a Jade is used.
    • JadeServiceTests.swift: finalizePsbt reaches core instead of recursing.
    • JadeDeviceIdentityTests.swift: device ids, models, advertised name matching and network mapping.
    • HwWalletManagerVendorTests.swift: vendor routing, one vendor session at a time, the Jade signing identity check, on-device verification, the Jade fingerprint on compose, passphrase refusal for a Jade, per-vendor reconnect timeouts, and foreground reconnect.
    • HwErrorPredicateTests.swift and HwErrorPresenterTests.swift: Jade error classification and copy.
    • HwEngagedSessionTests.swift: the receive sheet releases only a session it used.
    • TrezorManagerVendorIsolationTests.swift: Trezor loads, renames and forgets never touch a Jade entry.
    • HwWalletVendorPresentationTests.swift: vendor assets, copy and the receive tab identifier.
  • Modified tests:
    • HwKnownDeviceMatchingTests.swift and HwKnownDeviceStorageTests.swift (renamed from their Trezor names): vendor slices, migration of entries saved before this change, and entries of an unknown vendor being kept.
    • HwConnectViewModelTests.swift: Jade discovery, cancel and unlock hint.
    • HwFundingSignerTests.swift: when the send sheet can be left, cancel release, and the attempt counter.
    • TransferViewModelHwTests.swift, HwWalletIdTests.swift, HwWalletNameTests.swift, HwWalletManagerTests.swift and HwWalletManagerPassphraseTests.swift.
  • Local: the unit test suite with CI's skip list passes (1,565 tests, 0 failures), node scripts/validate-translations.js reports 0 errors, and SwiftFormat passes on the changed files.

@coreyphillips coreyphillips self-assigned this Sep 18, 2026
@coreyphillips coreyphillips added the enhancement New feature or request label Sep 18, 2026
@greptile-apps

greptile-apps Bot commented Sep 18, 2026

Copy link
Copy Markdown

RetriggerConfidence Score: 3/5

The PR is not yet safe to merge because Trezor foreground reconnects can escape cross-vendor serialization and survive an app wipe.

Findings

  1. P1 Reconnect Escapes Session Lock
  2. P1 Wipe Leaves Trezor Running

Summary

This PR adds Bluetooth support for Blockstream Jade and generalizes hardware-wallet persistence, routing, signing, presentation, and lifecycle management across Jade and Trezor.

  • Adds the Jade Nordic UART Bluetooth transport, core service adapter, session manager, reconnect handling, and device identity checks.
  • Routes pairing, address verification, funding composition, signing, rename, removal, and foreground reconnect by stored vendor.
  • Introduces vendor-aware wallet identifiers and migrates existing paired entries as Trezor while preserving unknown vendors.
  • Adds Jade-specific UI, localization, error handling, background-link release, and extensive unit coverage.
  • Two lifecycle serialization gaps remain around foreground Trezor reconnects and app wipe.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    UI[Hardware wallet operation] --> WM[HwWalletManager]
    WM --> V{Stored wallet vendor}
    V -->|Trezor| TS[TrezorManager]
    V -->|Jade| JS[JadeManager]
    TS --> TB[Trezor Bluetooth session]
    JS --> JB[Jade Nordic UART session]
    WM --> L[Shared vendor-session lock]
    FG[Foreground lifecycle] --> L
    L --> RT[Schedule Trezor reconnect task]
    RT -. escapes lock .-> TS
    WIPE[App wipe] --> RESET[resetForWipe]
    RESET --> JS
    RESET -. Trezor omitted .-> TS
    TS --> STORE[Hardware device UserDefaults]
    WIPE --> CLEAR[Clear UserDefaults domain]
Loading

Reviews (1) · Last reviewed commit: "fix: stop cancelled jade connects and re..."

Comment thread Bitkit/Managers/HwWalletManager.swift Outdated
Comment thread Bitkit/Managers/HwWalletManager.swift
…wipe

The foreground Trezor reconnect was launched in a task that only marked
the session active once it ran, so a Jade operation taking the lock in
between could dial alongside it. The reconnect now registers before the
lock is released and is cancelled when the session is released.

A wipe only tore down the Jade, so a Trezor reconnect finishing after
the wipe could save its device back. The Trezor session is now released
and its loaded devices dropped before the wipe clears storage.
ovi-reviewer[bot]

This comment was marked as resolved.

ovi-reviewer[bot]

This comment was marked as resolved.

@ovitrif
ovitrif dismissed ovi-reviewer[bot]’s stale review September 18, 2026 18:43

addressed - reaudit confirmed

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

No HIGH/MEDIUM findings, and no LOW worth posting.

Checked:

  • Funds. The amount and address on HwSendSignView are the values passed to signAndBroadcast. The fee shown comes from the compose that produced the PSBT. The PSBT is built app-side from the stored xpub, and the device contributes only the fingerprint. Jade results go through finalizePsbt(originalPsbt:signedPsbt:), and core 0.5.17's guards pin the unsigned tx and each input's previous_output and run interpreter_check. No double broadcast: pendingPayment caches the signed tx per request, re-sign happens only when no result reached the app, and cancel() is a no-op once isBroadcastUnresolved. The transfer path does the same via pendingHwFundingBroadcast.
  • Device identity. requireJadeSession(holding:) runs before signPsbt. rejectUnusableDevice fails closed on a nil efuse MAC. Known-device reconnects are efuse-checked before unlock, and the advertised name is only a hint.
  • Key material. No xpub, PSBT, fingerprint or PIN in logs. HwKnownDevice only gains vendor and jadeDeviceId. The backup snapshot is still {walletId: customLabel}.
  • Upgrade from Trezor-only state. Storage keys are unchanged. A missing vendor decodes as Trezor unless the id has the jade: prefix, and unknown vendors are preserved on rewrite. bitkit-core 0.5.14 → 0.5.17 adds only Jade code and the new finalize_psbt guards, with no storage or migration change.
  • Lifecycle. A pairing failure before addOrUpdateKnownDevice stores nothing. resetForWipe covers both vendors ahead of removePersistentDomain, and the connectEpoch bump stops a late connect from persisting after a wipe. Both greptile threads are fixed at head.
  • Parity with synonymdev/bitkit-android#1231. Signing identity check, efuse nil check, and the Receive tab not tearing down a live Trezor session: present. Activity-teardown cache and USB-attach disconnect: absent, since iOS has no activity lifecycle or USB. Locked Jade with no exit from Send: canLeave covers it.

Not verified: device-only behaviour, i.e. link release during on-device confirm at 30s in background, and firmware dropping the confirm screen. That needs a real Jade and iPhone (manual tests 5b/8).

@ovi-reviewer ovi-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Verdict: ✅ Approve


Reaudit: diff 6 files.
Counterpart synonymdev/bitkit-android#1231: not compared.

Findings:
N/A

Audit:
Already done in comment.

QA:
Tested on sim-1 iPhone 17 Pro simulator on iOS 26.5

Test 1 ⚠️ hardware absent: Physical Jade and Bluetooth-capable iPhone unavailable; pairing and PIN unlock could not run.

Test 2 ⚠️ hardware absent: Physical Jade and Bluetooth-capable iPhone unavailable; cancellation during PIN wait could not run.

Test 3 ⚠️ hardware absent: Physical Jade and Bluetooth-capable iPhone unavailable; wrong-PIN handling could not run.

Test 4 ⚠️ hardware absent: Physical Jade unavailable; its receive address could not be compared with the app.

Test 5 ⚠️ hardware absent: Physical locked Jade and Bluetooth-capable iPhone unavailable; dismissing the signing PIN sheet could not run.

Test 6 ⚠️ hardware absent: Physical Jade unavailable; unlock, confirmation, signing, and broadcast could not run.

Test 7 ⚠️ hardware absent: Physical Jade unavailable; Transfer to Spending signing could not run.

Test 8 ⚠️ hardware absent: Physical Jade unavailable; Bluetooth rediscovery after power cycling could not run.

Test 9 ⚠️ hardware absent: Physical Jade and Bluetooth-capable iPhone unavailable; background link release and silent reconnection could not run.

Test 10 ⚠️ hardware absent: Physical Jade unavailable; the paired wallet required for rename and removal could not be created.

Test 11 ⚠️ hardware absent: Physical Jade, Trezor, and compatible iPhone unavailable; same-seed dual-wallet pairing could not run.

Test 12 ⚠️ hardware absent: Physical Trezor and compatible iPhone unavailable; pairing, address verification, send, and transfer regression could not run.

Test 13 ⚠️ hardware absent: Physical paired Trezor and compatible iPhone unavailable; foreground reconnection could not run.

Approve.

Coverage:
Unit tests: 100% - Focused tests cover synchronous reconnect activity, cross-vendor release, wipe cancellation, both-vendor reset, and loaded-device clearing.
QA: 0 of 13 manual tests passed


Reviewed by gpt-5.6-sol-high via gh-pr-review-loop skill
Commands: @ovi-reviewer review · test · retest · audit (author or owner)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants