diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj
index 191c32332..3751dc3e5 100644
--- a/Bitkit.xcodeproj/project.pbxproj
+++ b/Bitkit.xcodeproj/project.pbxproj
@@ -949,7 +949,7 @@
INFOPLIST_FILE = Bitkit/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Bitkit;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
- INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Bluetooth access is included as part of a library used for wallet operations. Bitkit does not actively use Bluetooth.";
+ INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Bitkit uses Bluetooth to connect to your hardware wallet for signing transactions.";
INFOPLIST_KEY_NSCameraUsageDescription = "Bitkit needs access to the camera to scan QR codes";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
@@ -998,7 +998,7 @@
INFOPLIST_FILE = Bitkit/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Bitkit;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
- INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Bluetooth access is included as part of a library used for wallet operations. Bitkit does not actively use Bluetooth.";
+ INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Bitkit uses Bluetooth to connect to your hardware wallet for signing transactions.";
INFOPLIST_KEY_NSCameraUsageDescription = "Bitkit needs access to the camera to scan QR codes";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
@@ -1214,7 +1214,7 @@
repositoryURL = "https://github.com/synonymdev/bitkit-core";
requirement = {
kind = exactVersion;
- version = 0.5.14;
+ version = 0.5.17;
};
};
96E20CD22CB6D91A00C24149 /* XCRemoteSwiftPackageReference "CodeScanner" */ = {
diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
index ce1844188..12f7a3b73 100644
--- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -6,8 +6,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/synonymdev/bitkit-core",
"state" : {
- "revision" : "890502f241e12305325cdd0f5e46b2752b9aedf4",
- "version" : "0.5.14"
+ "revision" : "9724efdf13fa141819b52d6337de60a4228831a6",
+ "version" : "0.5.17"
}
},
{
diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift
index 86e18d2b6..5ec83106a 100644
--- a/Bitkit/AppScene.swift
+++ b/Bitkit/AppScene.swift
@@ -207,6 +207,7 @@ struct AppScene: View {
@State private var keyboardManager = KeyboardManager()
@State private var trezorManager: TrezorManager
@State private var trezorViewModel: TrezorViewModel
+ @State private var jadeManager: JadeManager
@State private var hwWalletManager: HwWalletManager
@State private var calculatorInputManager = CalculatorInputManager()
@State private var paykitPaymentRequestManager = PaykitPaymentRequestManager()
@@ -258,7 +259,8 @@ struct AppScene: View {
// Created ahead of `transfer` so the hardware-wallet transfer flow can reach the funding
// (compose/sign/broadcast) and device-session (reconnect) capabilities.
let trezorManager = TrezorManager()
- let hwWalletManager = HwWalletManager(session: trezorManager)
+ let jadeManager = JadeManager()
+ let hwWalletManager = HwWalletManager(trezorSession: trezorManager, jadeSession: jadeManager)
_transfer = StateObject(wrappedValue: TransferViewModel(
transferService: transferService,
@@ -283,6 +285,8 @@ struct AppScene: View {
let trezorViewModel = TrezorViewModel(connection: trezorManager)
_trezorManager = State(initialValue: trezorManager)
_trezorViewModel = State(initialValue: trezorViewModel)
+ // Held here because `HwWalletManager` keeps its vendor sessions weakly.
+ _jadeManager = State(initialValue: jadeManager)
_hwWalletManager = State(initialValue: hwWalletManager)
CoreService.shared.activity.setPrivatePaykitContactResolvers(
@@ -326,12 +330,13 @@ struct AppScene: View {
.onChange(of: scenePhase, initial: true) { _, newValue in handleScenePhaseChange(newValue) }
.onChange(of: network.isConnected) { _, isConnected in handleNetworkChange(isConnected) }
.onOpenURL { url in app.retainDeepLink(url) }
- // Bridge Trezor device state into the watch-only manager without coupling the two:
- // TrezorManager bumps devicesRevision on any device/connection change.
+ // Bridge the vendor managers' device state into the watch-only manager without coupling them:
+ // each bumps devicesRevision on any device or connection change.
.onChange(of: trezorManager.devicesRevision) { _, _ in pushHardwareDevices() }
+ .onChange(of: jadeManager.devicesRevision) { _, _ in pushHardwareDevices() }
.onChange(of: isPinVerified) { _, verified in
if verified {
- Task { await trezorManager.autoReconnect() }
+ Task { await hwWalletManager.reconnectOnForeground() }
}
}
.onReceive(settings.settingsPublisher) { _ in hwWalletManager.reconcileForSettingsChange() }
@@ -379,6 +384,7 @@ struct AppScene: View {
.environment(keyboardManager)
.environment(trezorManager)
.environment(trezorViewModel)
+ .environment(jadeManager)
.environment(hwWalletManager)
.environment(calculatorInputManager)
.environment(paykitPaymentRequestManager)
@@ -789,6 +795,7 @@ struct AppScene: View {
@Sendable
private func setupTask() async {
+ AppReset.hardwareWallets = hwWalletManager
do {
// Handle orphaned keychain before anything else
handleOrphanedKeychain()
@@ -800,6 +807,7 @@ struct AppScene: View {
// watchers start at launch (no-op until a device is paired). loadKnownDevices() also
// bumps devicesRevision, but push explicitly so the initial state is delivered.
trezorManager.loadKnownDevices()
+ jadeManager.loadKnownDevices()
pushHardwareDevices()
// Setup TimedSheetManager with all timed sheets
@@ -938,12 +946,16 @@ struct AppScene: View {
// If PIN is enabled, lock the app when the app goes to the background
isPinVerified = false
}
+ hwWalletManager.onAppBackgrounded()
}
+ // `.inactive` is left alone: the iOS Bluetooth pairing alert puts the app there mid-connect.
if newPhase == .active {
+ // Called even behind the PIN screen, so a background release still pending is called off.
+ hwWalletManager.onAppBecameActive()
// Reconnect a known hardware device so its connection indicator turns green again;
if isPinVerified || !settings.pinEnabled {
- Task { await trezorManager.autoReconnect() }
+ Task { await hwWalletManager.reconnectOnForeground() }
}
if wallet.walletExists == true {
Task {
@@ -1344,13 +1356,20 @@ struct AppScene: View {
center.removeDeliveredNotifications(withIdentifiers: deliveredNotifications.map(\.request.identifier))
}
- /// Feed the current Trezor device snapshot into the watch-only manager. This is the only link
- /// between the two managers, kept in the composition root so neither type references the other.
+ /// Feed both vendors' device snapshots into the watch-only manager. This is the only link between
+ /// the vendor managers and it, kept in the composition root so none of them references another.
private func pushHardwareDevices() {
+ let connected: (deviceId: String, walletId: String?)? = if let trezorDevice = trezorManager.connectedDevice {
+ (trezorDevice.id, trezorManager.connectedWalletId)
+ } else if let jadeDevice = jadeManager.connected {
+ (jadeDevice.id, jadeDevice.walletId)
+ } else {
+ nil
+ }
hwWalletManager.updateDevices(
- knownDevices: trezorManager.knownDevices,
- connectedDeviceId: trezorManager.connectedDevice?.id,
- connectedWalletId: trezorManager.connectedWalletId
+ knownDevices: (trezorManager.knownDevices + jadeManager.knownDevices).sorted { $0.lastConnectedAt > $1.lastConnectedAt },
+ connectedDeviceId: connected?.deviceId,
+ connectedWalletId: connected?.walletId
)
}
diff --git a/Bitkit/Assets.xcassets/Illustrations/jade-placeholder.imageset/Contents.json b/Bitkit/Assets.xcassets/Illustrations/jade-placeholder.imageset/Contents.json
new file mode 100644
index 000000000..7ca008ca5
--- /dev/null
+++ b/Bitkit/Assets.xcassets/Illustrations/jade-placeholder.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "jade-placeholder.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true
+ }
+}
diff --git a/Bitkit/Assets.xcassets/Illustrations/jade-placeholder.imageset/jade-placeholder.svg b/Bitkit/Assets.xcassets/Illustrations/jade-placeholder.imageset/jade-placeholder.svg
new file mode 100644
index 000000000..284ba95b1
--- /dev/null
+++ b/Bitkit/Assets.xcassets/Illustrations/jade-placeholder.imageset/jade-placeholder.svg
@@ -0,0 +1,10 @@
+
diff --git a/Bitkit/BitkitApp.swift b/Bitkit/BitkitApp.swift
index 00127fddc..b3f1881a4 100644
--- a/Bitkit/BitkitApp.swift
+++ b/Bitkit/BitkitApp.swift
@@ -117,6 +117,8 @@ class AppDelegate: NSObject, UIApplicationDelegate {
func applicationWillTerminate(_ application: UIApplication) {
try? StateLocker.unlock(.lightning)
+ // A Jade left holding a link when the process ends can drop its Bluetooth bond.
+ JadeTransport.shared.releaseAllImmediately()
}
}
@@ -178,7 +180,7 @@ struct BitkitApp: App {
init() {
UIWindow.appearance().overrideUserInterfaceStyle = .dark
if Env.shouldResetTrezorEmulatorState {
- TrezorKnownDeviceStorage.removeAll()
+ HwKnownDeviceStorage.removeAll()
TrezorCredentialStorage.deleteAll()
}
_ = ToastWindowManager.shared
diff --git a/Bitkit/Components/SegmentedControl.swift b/Bitkit/Components/SegmentedControl.swift
index 462f91ac3..20ef16a23 100644
--- a/Bitkit/Components/SegmentedControl.swift
+++ b/Bitkit/Components/SegmentedControl.swift
@@ -2,16 +2,27 @@ import SwiftUI
struct TabItem {
let tab: T
+ /// Shown in place of the tab's own description, for a tab whose name depends on what it shows.
+ let label: String?
let activeColor: Color?
let badge: Int?
let accessibilityIdentifier: String?
- init(_ tab: T, activeColor: Color? = nil, badge: Int? = nil, accessibilityIdentifier: String? = nil) {
+ init(_ tab: T, label: String? = nil, activeColor: Color? = nil, badge: Int? = nil, accessibilityIdentifier: String? = nil) {
self.tab = tab
+ self.label = label
self.activeColor = activeColor
self.badge = badge
self.accessibilityIdentifier = accessibilityIdentifier
}
+
+ var title: String {
+ label ?? tab.description
+ }
+
+ var resolvedAccessibilityIdentifier: String {
+ accessibilityIdentifier ?? "Tab-\(title.lowercased())"
+ }
}
struct SegmentedControl: View {
@@ -51,7 +62,7 @@ struct SegmentedControl: View {
badge(for: tabItem)
.hidden()
CaptionBText(
- tabItem.tab.description,
+ tabItem.title,
textColor: selectedTab == tabItem.tab ? .white : inactiveColor ?? .secondary
)
badge(for: tabItem)
@@ -75,7 +86,7 @@ struct SegmentedControl: View {
.contentShape(Rectangle())
}
.buttonStyle(PlainButtonStyle())
- .accessibilityIdentifier(tabItem.accessibilityIdentifier ?? "Tab-\(tabItem.tab.description.lowercased())")
+ .accessibilityIdentifier(tabItem.resolvedAccessibilityIdentifier)
}
}
.frame(maxWidth: .infinity)
diff --git a/Bitkit/Components/TabBar/TabBar.swift b/Bitkit/Components/TabBar/TabBar.swift
index 6c5d2b703..e5779010e 100644
--- a/Bitkit/Components/TabBar/TabBar.swift
+++ b/Bitkit/Components/TabBar/TabBar.swift
@@ -65,7 +65,7 @@ struct TabBar: View {
sheets.showSheet(
.receive,
data: ReceiveConfig(
- view: .qr(cjitInvoice: nil, tab: .trezor),
+ view: .qr(cjitInvoice: nil, tab: .hardware),
hardwareWalletId: walletId
)
)
diff --git a/Bitkit/Components/Trezor/TrezorDeviceRow.swift b/Bitkit/Components/Trezor/TrezorDeviceRow.swift
index 04adeda98..959da1866 100644
--- a/Bitkit/Components/Trezor/TrezorDeviceRow.swift
+++ b/Bitkit/Components/Trezor/TrezorDeviceRow.swift
@@ -81,7 +81,7 @@ struct TrezorDeviceRow: View {
/// Row displaying a previously connected (known) Trezor device
struct KnownDeviceRow: View {
- let device: TrezorKnownDevice
+ let device: HwKnownDevice
let isConnecting: Bool
let onConnect: () -> Void
let onForget: () -> Void
diff --git a/Bitkit/Extensions/HwError+Cancellation.swift b/Bitkit/Extensions/HwError+Cancellation.swift
new file mode 100644
index 000000000..28512be6f
--- /dev/null
+++ b/Bitkit/Extensions/HwError+Cancellation.swift
@@ -0,0 +1,31 @@
+/// Vendor-neutral views over the Trezor and Jade error predicates, for code shared by every vendor.
+/// A `TrezorError` is never a `JadeError`, so for a Trezor error each one answers as its `isTrezor*`
+/// counterpart does.
+extension Error {
+ func isHwUserCancellation() -> Bool {
+ isTrezorUserCancellation() || isJadeUserCancellation()
+ }
+
+ func isHwDeviceBusy() -> Bool {
+ isTrezorDeviceBusy() || isJadeDeviceBusy()
+ }
+
+ func isHwFirmwareError() -> Bool {
+ isTrezorFirmwareError() || isJadeFirmwareError()
+ }
+
+ func isHwSessionFailure() -> Bool {
+ isTrezorSessionFailure() || isJadeSessionFailure()
+ }
+
+ /// The vendor of the busy or locked device this error reports, so its busy copy can name it.
+ var hwBusyVendor: HwWalletVendor? {
+ if isJadeDeviceBusy() {
+ return .blockstream
+ }
+ if isTrezorDeviceBusy() {
+ return .trezor
+ }
+ return nil
+ }
+}
diff --git a/Bitkit/Extensions/HwWalletVendor+UI.swift b/Bitkit/Extensions/HwWalletVendor+UI.swift
new file mode 100644
index 000000000..def5ba30b
--- /dev/null
+++ b/Bitkit/Extensions/HwWalletVendor+UI.swift
@@ -0,0 +1,63 @@
+import Foundation
+
+/// How each vendor's devices are shown and named across the hardware wallet screens.
+extension HwWalletVendor {
+ /// The upright device illustration; the Jade one is a placeholder until design supplies the asset.
+ var deviceImageName: String {
+ switch self {
+ case .trezor: "trezor-device"
+ case .blockstream: "jade-placeholder"
+ }
+ }
+
+ /// The illustration shown while the device signs a transaction.
+ var signImageName: String {
+ switch self {
+ case .trezor: "trezor-card"
+ case .blockstream: "jade-placeholder"
+ }
+ }
+
+ var modelName: String {
+ switch self {
+ case .trezor: t("hardware__device_model_trezor")
+ case .blockstream: t("hardware__device_model_jade")
+ }
+ }
+
+ var foundHeader: String {
+ switch self {
+ case .trezor: t("hardware__found_header")
+ case .blockstream: t("hardware__found_header_jade")
+ }
+ }
+
+ var pairedHeader: String {
+ switch self {
+ case .trezor: t("hardware__paired_header")
+ case .blockstream: t("hardware__paired_header_jade")
+ }
+ }
+
+ var sendSignButtonTitle: String {
+ switch self {
+ case .trezor: t("hardware__send_open_connect")
+ case .blockstream: t("hardware__send_open_connect_jade")
+ }
+ }
+
+ var transferSignButtonTitle: String {
+ switch self {
+ case .trezor: t("lightning__transfer_hw__open_connect")
+ case .blockstream: t("hardware__send_open_connect_jade")
+ }
+ }
+
+ /// Passphrase (hidden) wallets are a Trezor feature; a Jade holds one wallet per device.
+ var supportsPassphraseWallets: Bool {
+ switch self {
+ case .trezor: true
+ case .blockstream: false
+ }
+ }
+}
diff --git a/Bitkit/Extensions/JadeError+Cancellation.swift b/Bitkit/Extensions/JadeError+Cancellation.swift
new file mode 100644
index 000000000..0f4a36678
--- /dev/null
+++ b/Bitkit/Extensions/JadeError+Cancellation.swift
@@ -0,0 +1,50 @@
+import BitkitCore
+
+extension Error {
+ /// The `JadeError` this error is or wraps. `ServiceQueue` boxes core errors into an `AppError`
+ /// before they reach the caller, so the preserved underlying error is unwrapped as well.
+ var underlyingJadeError: JadeError? {
+ if let jadeError = self as? JadeError {
+ return jadeError
+ }
+ if let appError = self as? AppError, let underlyingError = appError.underlyingError {
+ return underlyingError.underlyingJadeError
+ }
+ return nil
+ }
+
+ /// Whether the user declined the request on the Jade. Callers treat it as a silent no-op so the
+ /// user can retry on the same screen.
+ func isJadeUserCancellation() -> Bool {
+ guard case .UserCancelled? = underlyingJadeError else { return false }
+ return true
+ }
+
+ /// Whether the Jade cannot serve the request until the user acts on it: busy with another prompt,
+ /// or locked.
+ func isJadeDeviceBusy() -> Bool {
+ guard let jadeError = underlyingJadeError else { return false }
+ switch jadeError {
+ case .DeviceBusy, .DeviceLocked:
+ return true
+ default:
+ return false
+ }
+ }
+
+ func isJadeFirmwareError() -> Bool {
+ guard case .UnsupportedFirmware? = underlyingJadeError else { return false }
+ return true
+ }
+
+ /// Whether the current Jade channel can no longer be used and must be re-established.
+ func isJadeSessionFailure() -> Bool {
+ guard let jadeError = underlyingJadeError else { return false }
+ switch jadeError {
+ case .TransportError, .DeviceDisconnected, .ConnectionError, .Timeout, .NotConnected, .NotInitialized, .IoError:
+ return true
+ default:
+ return false
+ }
+ }
+}
diff --git a/Bitkit/Extensions/LDKNode+Jade.swift b/Bitkit/Extensions/LDKNode+Jade.swift
new file mode 100644
index 000000000..5bdcf13e6
--- /dev/null
+++ b/Bitkit/Extensions/LDKNode+Jade.swift
@@ -0,0 +1,36 @@
+import BitkitCore
+import LDKNode
+
+extension LDKNode.Network {
+ /// The network a Jade is asked to use. Jade has no signet; its regtest is named "localtest" on
+ /// the wire, which bitkit-core maps.
+ func toJadeNetwork() throws -> JadeNetwork {
+ switch self {
+ case .bitcoin: .mainnet
+ case .testnet: .testnet
+ case .regtest: .regtest
+ case .signet: throw AppError(message: "Signet is not supported by Jade", debugMessage: nil)
+ }
+ }
+}
+
+extension LDKNode.AddressType {
+ /// The Jade script variant of this address type, as used to verify an address on the device.
+ var jadeVariant: JadeAddressVariant {
+ switch self {
+ case .legacy: .pkh
+ case .nestedSegwit: .shWpkh
+ case .nativeSegwit: .wpkh
+ case .taproot: .tr
+ }
+ }
+
+ init(jadeVariant: JadeAddressVariant) {
+ switch jadeVariant {
+ case .pkh: self = .legacy
+ case .shWpkh: self = .nestedSegwit
+ case .wpkh: self = .nativeSegwit
+ case .tr: self = .taproot
+ }
+ }
+}
diff --git a/Bitkit/Extensions/TrezorDevice+DisplayName.swift b/Bitkit/Extensions/TrezorDevice+DisplayName.swift
index b1f5a6bed..d08247c61 100644
--- a/Bitkit/Extensions/TrezorDevice+DisplayName.swift
+++ b/Bitkit/Extensions/TrezorDevice+DisplayName.swift
@@ -1,12 +1,22 @@
import BitkitCore
-/// Canonical Trezor display name: the Bitkit-side custom name when set, otherwise the device's own
-/// label when it differs from the factory model, otherwise the vendor-prefixed model, falling back
-/// to "Trezor".
-func resolveHwWalletName(label: String?, model: String?, customLabel: String? = nil) -> String {
+/// Canonical hardware wallet display name: the Bitkit-side custom name when set. For a Trezor,
+/// otherwise the device's own label when it differs from the factory model, otherwise the
+/// vendor-prefixed model, falling back to "Trezor". A Jade has no label of its own and its model
+/// already carries its name ("Jade", "Jade Plus"), so it shows the model, falling back to "Jade".
+func resolveHwWalletName(
+ label: String?,
+ model: String?,
+ customLabel: String? = nil,
+ vendor: HwWalletVendor = .trezor
+) -> String {
if let customLabel, !customLabel.isEmpty {
return customLabel
}
+ if vendor == .blockstream {
+ let trimmedModel = model?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ return trimmedModel.isEmpty ? "Jade" : trimmedModel
+ }
if let label, !label.isEmpty, label != model {
return label
}
@@ -14,9 +24,9 @@ func resolveHwWalletName(label: String?, model: String?, customLabel: String? =
return model.hasPrefix("Trezor") ? model : "Trezor \(model)"
}
-extension TrezorKnownDevice {
+extension HwKnownDevice {
var displayName: String {
- resolveHwWalletName(label: label, model: model, customLabel: customLabel)
+ resolveHwWalletName(label: label, model: model, customLabel: customLabel, vendor: vendor)
}
}
diff --git a/Bitkit/Info.plist b/Bitkit/Info.plist
index 5e1652411..7c4646b3a 100644
--- a/Bitkit/Info.plist
+++ b/Bitkit/Info.plist
@@ -47,9 +47,9 @@
NSBluetoothAlwaysUsageDescription
- Bitkit uses Bluetooth to connect to your Trezor hardware wallet for signing transactions.
+ Bitkit uses Bluetooth to connect to your hardware wallet for signing transactions.
NSBluetoothPeripheralUsageDescription
- Bitkit uses Bluetooth to connect to your Trezor hardware wallet for signing transactions.
+ Bitkit uses Bluetooth to connect to your hardware wallet for signing transactions.
NSFaceIDUsageDescription
Bitkit uses Face ID to securely authenticate access to your wallet and protect your Bitcoin.
UIAppFonts
diff --git a/Bitkit/Managers/HwDeviceSessioning.swift b/Bitkit/Managers/HwDeviceSessioning.swift
index 4b063e4fe..3c148c15a 100644
--- a/Bitkit/Managers/HwDeviceSessioning.swift
+++ b/Bitkit/Managers/HwDeviceSessioning.swift
@@ -7,14 +7,17 @@ import BitkitCore
/// Everything here is device-level on purpose: resolving a wallet identity to the transport it is
/// reachable over is the watch-only layer's job, since it owns the wallet grouping.
@MainActor
-protocol HwDeviceSessioning: AnyObject, Sendable {
+protocol TrezorSessioning: AnyObject, Sendable {
/// Stored entries read fresh. A connect that just wrote one lands here before the
/// `updateDevices(...)` push does, so session operations must not read the pushed snapshot.
- var storedDevices: [TrezorKnownDevice] { get }
+ var storedDevices: [HwKnownDevice] { get }
var connectedDeviceId: String? { get }
/// Identity the live session opened; nil when no session is open or none could be resolved.
var connectedWalletId: String? { get }
var connectedFeatures: TrezorFeatures? { get }
+ /// Whether a session is open, being opened or being restored, so another vendor has to wait for
+ /// it to be released before it can use the radio.
+ var isSessionActive: Bool { get }
func ensureConnected(deviceId: String) async throws
/// Opens `deviceId` with an explicit wallet selection, with or without a live session.
@@ -25,6 +28,14 @@ protocol HwDeviceSessioning: AnyObject, Sendable {
passphrase: String
) async throws -> TrezorFeatures
func disconnectStaleSession(deviceId: String) async
+ /// Closes the session, after any connection work already running, so another vendor can take
+ /// over. Runs to completion even when the caller is cancelled.
+ func releaseSession() async
+ /// Starts a silent reconnect of a known device, as on returning to the foreground, unless one is
+ /// running. The session reads as active from this call on, and releasing it cancels the reconnect.
+ func startAutoReconnect()
+ /// Drops the session and any pending reconnect ahead of a wallet wipe.
+ func resetForWipe() async
func isKnownBluetoothDevice(deviceId: String) -> Bool
func warmUpConnection(deviceId: String)
/// Forgets every stored entry of `walletId`, keeping transport credentials while another
@@ -34,10 +45,11 @@ protocol HwDeviceSessioning: AnyObject, Sendable {
/// device restores it, or nil to drop any name kept for it. It rides the same store write that
/// forgets the entries, so the device list is never published while the name is missing.
func forgetWallet(walletId: String, pendingName: PendingHwWalletName?) async
+ func renameWallet(walletId: String, newName: String)
}
-extension TrezorManager: HwDeviceSessioning {
- var storedDevices: [TrezorKnownDevice] {
+extension TrezorManager: TrezorSessioning {
+ var storedDevices: [HwKnownDevice] {
knownDevices
}
@@ -49,3 +61,50 @@ extension TrezorManager: HwDeviceSessioning {
deviceFeatures
}
}
+
+/// The live Jade session and its stored entries, as the watch-only layer needs them.
+///
+/// The Jade counterpart of `TrezorSessioning`, injected into `HwWalletManager` so it can route every
+/// device call by the vendor stored on a paired entry. A Jade holds one wallet and has no passphrase
+/// wallets, so there is no wallet selection here; it compares receive addresses on the device itself
+/// and signs a PSBT that the session finalizes.
+@MainActor
+protocol JadeSessioning: AnyObject, Sendable {
+ /// Stored Jade entries read fresh, for the same reason as `TrezorSessioning.storedDevices`.
+ var storedDevices: [HwKnownDevice] { get }
+ var connectedDeviceId: String? { get }
+ /// Identity the live session opened; nil when no session is open or none could be resolved.
+ var connectedWalletId: String? { get }
+ /// Whether a session is open, being opened, or a background reconnect is pending.
+ var isSessionActive: Bool { get }
+
+ /// Reuses a live session of `deviceId`, else reconnects it. A locked session is unlocked, which
+ /// waits for the PIN on the device.
+ func ensureConnected(deviceId: String) async throws
+ /// Shows `expectedAddress` on the device, which compares it itself and throws
+ /// `JadeError.AddressMismatch` when it derives another one.
+ func verifyAddress(addressType: AddressScriptType, derivationPath: String, expectedAddress: String) async throws
+ func masterFingerprint() async throws -> String
+ /// Signs on the device, then finalizes the signed PSBT into a broadcastable transaction.
+ func signPsbt(_ psbtBase64: String) async throws -> CompletedTransaction
+ /// Closes the link together with the core session, so a device call waiting on the link lets go of
+ /// core. A no-op while another Jade is connected.
+ func disconnectStaleSession(deviceId: String) async
+ /// Disconnects, or cancels a pending connect and background reconnect, so another vendor can take
+ /// over the radio.
+ func releaseSession() async
+ func isKnownBluetoothDevice(deviceId: String) -> Bool
+ /// Best-effort silent pre-connect before signing. Never unlocks.
+ func warmUpConnection(deviceId: String)
+ /// Forgets every stored entry of `walletId`, closing the session when it holds that wallet. See
+ /// `TrezorSessioning.forgetWallet` for `pendingName`.
+ func forgetWallet(walletId: String, pendingName: PendingHwWalletName?) async
+ func renameWallet(walletId: String, newName: String)
+ /// Starts a silent background reconnect of the most recently used Jade, unless one is running.
+ /// Never unlocks.
+ func startAutoReconnect()
+ func onAppBackgrounded()
+ func onAppBecameActive()
+ /// Drops the session and any background work ahead of a wallet wipe.
+ func resetForWipe() async
+}
diff --git a/Bitkit/Managers/HwWalletManager.swift b/Bitkit/Managers/HwWalletManager.swift
index f2419e415..1fa4d16d3 100644
--- a/Bitkit/Managers/HwWalletManager.swift
+++ b/Bitkit/Managers/HwWalletManager.swift
@@ -2,7 +2,7 @@ import BitkitCore
import Combine
import Foundation
-/// Production hardware-wallet business layer. Tracks paired Trezor wallets as watch-only
+/// Production hardware-wallet business layer. Tracks paired Trezor and Jade wallets as watch-only
/// balances by running one on-chain xpub watcher per (wallet, address type), aggregating the
/// per-wallet balance in memory, and persisting each wallet's on-chain activity into
/// bitkit-core scoped by its `walletId` (core 0.3.x wallet-scoped storage).
@@ -11,11 +11,15 @@ import Foundation
/// wallet plus one identity per hidden wallet, all reached over the same transport id.
///
/// Tile and watcher state come solely from `updateDevices(...)`, fed by the composition root
-/// (`AppScene`). The identity-aware session operations — opening a passphrase wallet, proving the
-/// live session belongs to the wallet being spent from — additionally read the device through the
-/// injected `HwDeviceSessioning` seam, and read the stored entries fresh from it: a connect that
-/// just wrote one lands there before the push does. Never references `TrezorManager` concretely.
+/// (`AppScene`). The identity-aware session operations (opening a passphrase wallet, proving the
+/// live session belongs to the wallet being spent from) additionally read the device through the
+/// injected `TrezorSessioning` and `JadeSessioning` seams, and read the stored entries fresh from
+/// them: a connect that just wrote one lands there before the push does. Never references a vendor
+/// manager concretely.
///
+/// Every device call is routed by the vendor stored on the wallet's entries. Only one vendor holds a
+/// session at a time: the operations that open one run under a FIFO session lock and release the
+/// other vendor's session first, so the two never compete for the radio.
@Observable
@MainActor
final class HwWalletManager {
@@ -27,6 +31,12 @@ final class HwWalletManager {
AccountType
) async throws -> AccountInfoResult
typealias AddressProvider = @MainActor (TrezorGetAddressParams) async throws -> TrezorAddressResponse
+ typealias ComposeProvider = @MainActor (ComposeParams) async throws -> [ComposeResult]
+
+ /// How long reaching a wallet's device may take before the flow gives up. A Jade may be waiting
+ /// for its PIN, which is entered on the device.
+ static let trezorReconnectTimeout: Double = 30
+ static let jadeReconnectTimeout: Double = 300
private enum Constants {
static let watcherIdSeparator = "|"
@@ -65,15 +75,17 @@ final class HwWalletManager {
private let networkProvider: () -> TrezorCoinType
private let accountInfoProvider: AccountInfoProvider
private let addressProvider: AddressProvider
+ private let composeProvider: ComposeProvider
private let persistSnapshot: @MainActor (HwWalletSnapshot) async throws -> Void
private let deleteActivities: @MainActor (String) async throws -> Void
private let readTagMetadata: @MainActor (String) async throws -> [PreActivityMetadata]
private let writeTagMetadata: @MainActor ([PreActivityMetadata]) async throws -> Void
- /// The live device session. Only the identity-aware operations need it; tile and watcher state
- /// still come solely from `updateDevices(...)`. Nil in previews and in tests that don't reach
- /// the device.
- private weak var session: HwDeviceSessioning?
+ /// The live device sessions, one per vendor. Only the identity-aware operations need them; tile
+ /// and watcher state still come solely from `updateDevices(...)`. Nil in previews and in tests
+ /// that don't reach the device. Weak because the composition root owns the vendor managers.
+ private weak var trezorSession: TrezorSessioning?
+ private weak var jadeSession: JadeSessioning?
/// One chain per wallet id, shared by both writes: a snapshot landing after the delete it was
/// racing would resurrect the wallet `removeDevice` just wiped.
@@ -81,7 +93,7 @@ final class HwWalletManager {
// MARK: - Internal state
- private var knownDevices: [TrezorKnownDevice] = []
+ private var knownDevices: [HwKnownDevice] = []
private var connectedDeviceId: String?
private var connectedWalletId: String?
private var watcherData: [String: HwWatcherData] = [:]
@@ -98,7 +110,7 @@ final class HwWalletManager {
private var lastSyncedMonitored: Set?
private var lastSyncedElectrumUrl: String?
- /// Memoized `HwWalletId.derive` results keyed by an xpubs signature. The mapping is
+ /// Memoized `HwWalletId.derive` results keyed by vendor and xpubs signature. The mapping is
/// deterministic and immutable, so caching avoids repeated FFI derivations on every watcher
/// event and sync. Pruned to the live device set on `updateDevices`/`removeDevice`.
private var walletIdCache: [String: String] = [:]
@@ -121,9 +133,15 @@ final class HwWalletManager {
private var emittedReceivedTxIds: Set = []
private var listeners: [String: TrezorEventListener] = [:]
private var staleSessionCleanupTasks: [String: Task] = [:]
+ @ObservationIgnored private var isSessionOperationActive = false
+ @ObservationIgnored private var sessionOperationWaiters: [CheckedContinuation] = []
+ @ObservationIgnored private var isAppActive = true
+ @ObservationIgnored private var jadeBluetoothPoweredOnSubscription: AnyCancellable?
init(
- session: HwDeviceSessioning? = nil,
+ trezorSession: TrezorSessioning? = nil,
+ jadeSession: JadeSessioning? = nil,
+ jadeBluetoothPoweredOn: AnyPublisher = JadeTransport.shared.bluetoothPoweredOn,
watcherService: OnChainWatcherServicing = OnChainHwService.shared,
monitoredTypes: (() -> Set)? = nil,
electrumUrl: (() -> String)? = nil,
@@ -140,12 +158,16 @@ final class HwWalletManager {
addressProvider: @escaping AddressProvider = { params in
try await TrezorService.shared.getAddress(params: params)
},
+ composeProvider: @escaping ComposeProvider = { params in
+ try await OnChainHwService.shared.composeTransaction(params: params)
+ },
persistSnapshot: (@MainActor (HwWalletSnapshot) async throws -> Void)? = nil,
deleteActivities: (@MainActor (String) async throws -> Void)? = nil,
readTagMetadata: (@MainActor (String) async throws -> [PreActivityMetadata])? = nil,
writeTagMetadata: (@MainActor ([PreActivityMetadata]) async throws -> Void)? = nil
) {
- self.session = session
+ self.trezorSession = trezorSession
+ self.jadeSession = jadeSession
self.watcherService = watcherService
networkProvider = network ?? { OnChainHwService.appDefaultCoinType }
monitoredTypesProvider = monitoredTypes ?? {
@@ -154,6 +176,7 @@ final class HwWalletManager {
electrumUrlProvider = electrumUrl ?? { OnChainHwService.getElectrumUrl() }
self.accountInfoProvider = accountInfoProvider
self.addressProvider = addressProvider
+ self.composeProvider = composeProvider
// Both seams are plain writes: queueing, failure handling and cache repair live in
// `persist(_:)` / `delete(walletId:)`, so an injected seam exercises them too.
self.persistSnapshot = persistSnapshot ?? { snapshot in
@@ -174,6 +197,12 @@ final class HwWalletManager {
self.writeTagMetadata = writeTagMetadata ?? { records in
try await CoreService.shared.activity.upsertPreActivityMetadata(records)
}
+ // The transport publishes on its Bluetooth queue.
+ jadeBluetoothPoweredOnSubscription = jadeBluetoothPoweredOn
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] _ in
+ self?.onJadeBluetoothRestored()
+ }
}
// MARK: - Device input
@@ -185,7 +214,7 @@ final class HwWalletManager {
/// `connectedWalletId` is the identity the live session opened. A device holds one wallet open
/// at a time, so it is what decides which tile shows as connected.
func updateDevices(
- knownDevices: [TrezorKnownDevice],
+ knownDevices: [HwKnownDevice],
connectedDeviceId: String?,
connectedWalletId: String? = nil
) {
@@ -210,7 +239,7 @@ final class HwWalletManager {
/// Stop watching a paired hardware wallet and delete its stored activities. Other wallets on the
/// same physical device are left untouched. The caller is responsible for forgetting the stored
- /// entries (via `TrezorManager`).
+ /// entries (via the vendor's session).
///
/// - Parameter keptMetadata: tag metadata to re-apply after each delete of this wallet, or empty
/// to keep nothing. Passed on every call so a removal that keeps nothing clears what an earlier
@@ -223,7 +252,7 @@ final class HwWalletManager {
delete(walletId: walletId)
lastPersisted[walletId] = nil
for device in knownDevices where device.resolvedWalletId == walletId {
- walletIdCache[xpubsSignature(device.xpubs)] = nil
+ walletIdCache[walletIdCacheKey(xpubs: device.xpubs, vendor: device.vendor)] = nil
}
// Dropped here rather than left to the next `updateDevices(...)` push. Until the wallet leaves
// `hwWalletIds`, the push's own cleanup deletes its activities a second time — after any kept
@@ -278,27 +307,64 @@ final class HwWalletManager {
}
}
+ // Read before the removal: without sessions the vendor comes from the entries it drops.
+ let vendor = vendor(walletId: walletId)
removeDevice(walletId: walletId, keptMetadata: keptMetadata)
// The name rides the same store write that forgets the entries carrying it. A nil name is
// passed deliberately when keeping nothing: it drops a name an earlier removal kept.
- await session?.forgetWallet(
- walletId: walletId,
- pendingName: PendingHwWalletName(walletId: walletId, name: keptName)
- )
+ let pendingName = PendingHwWalletName(walletId: walletId, name: keptName)
+ switch vendor {
+ case .trezor:
+ await trezorSession?.forgetWallet(walletId: walletId, pendingName: pendingName)
+ case .blockstream:
+ await jadeSession?.forgetWallet(walletId: walletId, pendingName: pendingName)
+ }
+ }
+
+ /// Sets the Bitkit-side name of `walletId` on the entries of its vendor.
+ func renameWallet(walletId: String, newName: String) {
+ switch vendor(walletId: walletId) {
+ case .trezor:
+ trezorSession?.renameWallet(walletId: walletId, newName: newName)
+ case .blockstream:
+ jadeSession?.renameWallet(walletId: walletId, newName: newName)
+ }
}
// MARK: - Wallet identity & the device session
/// Stored entries tracking one wallet identity, read fresh: a connect that just wrote one lands
/// there before the `updateDevices(...)` push does.
- private func entries(for walletId: String) -> [TrezorKnownDevice] {
- (session?.storedDevices ?? knownDevices).filter { $0.resolvedWalletId == walletId }
+ private func entries(for walletId: String) -> [HwKnownDevice] {
+ storedDevices.filter { $0.resolvedWalletId == walletId }
+ }
+
+ /// Every vendor's stored entries, read fresh from the sessions, or the pushed snapshot when there
+ /// are none.
+ private var storedDevices: [HwKnownDevice] {
+ guard trezorSession != nil || jadeSession != nil else { return knownDevices }
+ return (trezorSession?.storedDevices ?? []) + (jadeSession?.storedDevices ?? [])
+ }
+
+ /// The vendor of the device holding `walletId`, which decides how it is reached and signed with,
+ /// or Trezor when no stored entry holds it.
+ func vendor(walletId: String) -> HwWalletVendor {
+ entries(for: walletId).first?.vendor ?? .trezor
+ }
+
+ /// How long reaching `walletId`'s device may take before the flow gives up.
+ func reconnectTimeout(walletId: String) -> Double {
+ switch vendor(walletId: walletId) {
+ case .trezor: Self.trezorReconnectTimeout
+ case .blockstream: Self.jadeReconnectTimeout
+ }
}
/// Transport id to reach `walletId` with: the connected entry, else the most recently used one.
private func transportDeviceId(for walletId: String) -> String? {
let entries = entries(for: walletId)
- if let connected = entries.first(where: { $0.id == session?.connectedDeviceId }) {
+ let connectedIds = [trezorSession?.connectedDeviceId, jadeSession?.connectedDeviceId].compactMap { $0 }
+ if let connected = entries.first(where: { connectedIds.contains($0.id) }) {
return connected.id
}
return entries.max(by: { $0.lastConnectedAt < $1.lastConnectedAt })?.id
@@ -321,7 +387,7 @@ final class HwWalletManager {
/// Only the passphrase reopens a hidden wallet, so that is what a hidden target asks for. For any
/// other wallet the device is simply not holding it, which no passphrase can fix.
private func requireIdentity(of walletId: String) throws {
- let opened = session?.connectedWalletId
+ let opened = trezorSession?.connectedWalletId
guard opened != walletId else { return }
guard !entries(for: walletId).contains(where: \.passphraseProtected) else {
throw HwPassphraseError.required
@@ -333,14 +399,45 @@ final class HwWalletManager {
}
private func watchedWalletIds() -> Set {
- Set((session?.storedDevices ?? knownDevices).compactMap(\.resolvedWalletId))
+ Set(storedDevices.compactMap(\.resolvedWalletId))
+ }
+
+ /// Fails unless the live Jade session may sign for `walletId`. No session passes, since core then
+ /// reports it as not connected and the signer reconnects. A session whose accounts could not be
+ /// read reports no identity, so its entry has to belong to `walletId` instead.
+ ///
+ /// A foreground reconnect can replace the session between composing and signing, and a different
+ /// Jade would be asked to sign inputs it holds no keys for.
+ private func requireJadeSession(holding walletId: String) throws {
+ guard let jadeSession, let connectedDeviceId = jadeSession.connectedDeviceId else { return }
+ let holdsWallet = if let opened = jadeSession.connectedWalletId {
+ opened == walletId
+ } else {
+ entries(for: walletId).contains { $0.id == connectedDeviceId }
+ }
+ guard !holdsWallet else { return }
+ throw AppError(
+ message: "Reconnect Hardware Device",
+ debugMessage: "A different hardware wallet is connected than the one holding '\(walletId)'"
+ )
}
/// Opens the passphrase (hidden) wallet of an already paired device and starts watching it as
/// its own identity, returning its wallet id. The passphrase is bound to a fresh Trezor session
- /// and is never persisted; re-entering it is what makes the wallet reachable again.
+ /// and is never persisted; re-entering it is what makes the wallet reachable again. A Jade has no
+ /// passphrase wallets, so it is refused as a device with passphrase protection turned off.
func connectWithPassphrase(deviceId: String, passphrase: String) async throws -> String {
- guard let session else {
+ try await withSessionLock {
+ let isJade = jadeSession?.connectedDeviceId == deviceId
+ || storedDevices.contains { $0.id == deviceId && $0.vendor == .blockstream }
+ guard !isJade else { throw HwPassphraseError.protectionDisabled }
+ await disconnectOtherVendor(.trezor)
+ return try await connectWithPassphraseLocked(deviceId: deviceId, passphrase: passphrase)
+ }
+ }
+
+ private func connectWithPassphraseLocked(deviceId: String, passphrase: String) async throws -> String {
+ guard let session = trezorSession else {
throw AppError(message: "Unavailable", debugMessage: "No device session to open a passphrase wallet with")
}
await waitForStaleSessionCleanup(deviceId: deviceId)
@@ -378,10 +475,20 @@ final class HwWalletManager {
/// otherwise be accepted and sign with the wrong seed. The standard wallet needs no secret to
/// reopen; a passphrase wallet does, which the caller has to collect.
func ensureConnected(walletId: String) async throws {
- guard let session else {
+ try await withSessionLock {
+ try await ensureConnectedLocked(walletId: walletId)
+ }
+ }
+
+ private func ensureConnectedLocked(walletId: String) async throws {
+ if vendor(walletId: walletId) == .blockstream {
+ return try await ensureJadeConnected(walletId: walletId)
+ }
+ guard let session = trezorSession else {
throw AppError(message: "Unavailable", debugMessage: "No device session for wallet '\(walletId)'")
}
let deviceId = try requireTransportDeviceId(for: walletId)
+ await disconnectOtherVendor(.trezor)
await waitForStaleSessionCleanup(deviceId: deviceId)
try await session.ensureConnected(deviceId: deviceId)
if session.connectedWalletId == walletId {
@@ -399,11 +506,31 @@ final class HwWalletManager {
try requireIdentity(of: walletId)
}
+ /// A Jade holds a single wallet, so a live session that resolved to another one cannot be
+ /// reopened for this wallet: the device itself is the wrong one.
+ private func ensureJadeConnected(walletId: String) async throws {
+ guard let jadeSession else {
+ throw AppError(message: "Unavailable", debugMessage: "No Jade session for wallet '\(walletId)'")
+ }
+ let deviceId = try requireTransportDeviceId(for: walletId)
+ await disconnectOtherVendor(.blockstream)
+ await waitForStaleSessionCleanup(deviceId: deviceId)
+ // A cancelled send schedules the cleanup awaited above, so it must stop here rather than dial.
+ try Task.checkCancellation()
+ try await jadeSession.ensureConnected(deviceId: deviceId)
+ if let opened = jadeSession.connectedWalletId, opened != walletId {
+ throw AppError(
+ message: "Reconnect Hardware Device",
+ debugMessage: "Device '\(deviceId)' is not holding wallet '\(walletId)'"
+ )
+ }
+ }
+
/// Whether reaching `walletId` needs the passphrase again. The device only holds one hidden
/// wallet open at a time and forgets the passphrase with the session, so a passphrase wallet that
/// is not the live session cannot be reconnected — or signed with — without it.
func needsPassphrase(walletId: String) -> Bool {
- entries(for: walletId).contains(where: \.passphraseProtected) && session?.connectedWalletId != walletId
+ entries(for: walletId).contains(where: \.passphraseProtected) && trezorSession?.connectedWalletId != walletId
}
func disconnectStaleSession(walletId: String) async {
@@ -412,7 +539,7 @@ final class HwWalletManager {
await cleanup.value
return
}
- await performStaleSessionCleanup(deviceId: deviceId)
+ await performStaleSessionCleanup(deviceId: deviceId, vendor: vendor(walletId: walletId))
}
/// Starts timeout recovery without blocking the current UI operation. Any subsequent connect
@@ -420,10 +547,12 @@ final class HwWalletManager {
func scheduleStaleSessionCleanup(walletId: String) {
guard let deviceId = transportDeviceId(for: walletId) else { return }
guard staleSessionCleanupTasks[deviceId] == nil else { return }
+ // Captured now: a removal landing before the task runs would leave nothing to read it from.
+ let vendor = vendor(walletId: walletId)
staleSessionCleanupTasks[deviceId] = Task { @MainActor [weak self] in
guard let self else { return }
- await performStaleSessionCleanup(deviceId: deviceId)
+ await performStaleSessionCleanup(deviceId: deviceId, vendor: vendor)
staleSessionCleanupTasks[deviceId] = nil
}
}
@@ -432,13 +561,23 @@ final class HwWalletManager {
await staleSessionCleanupTasks[deviceId]?.value
}
- private func performStaleSessionCleanup(deviceId: String) async {
- await session?.disconnectStaleSession(deviceId: deviceId)
+ private func performStaleSessionCleanup(deviceId: String, vendor: HwWalletVendor) async {
+ switch vendor {
+ case .trezor:
+ await trezorSession?.disconnectStaleSession(deviceId: deviceId)
+ case .blockstream:
+ await jadeSession?.disconnectStaleSession(deviceId: deviceId)
+ }
}
func isKnownBluetoothDevice(walletId: String) -> Bool {
guard let deviceId = transportDeviceId(for: walletId) else { return false }
- return session?.isKnownBluetoothDevice(deviceId: deviceId) ?? false
+ switch vendor(walletId: walletId) {
+ case .trezor:
+ return trezorSession?.isKnownBluetoothDevice(deviceId: deviceId) ?? false
+ case .blockstream:
+ return jadeSession?.isKnownBluetoothDevice(deviceId: deviceId) ?? false
+ }
}
func warmUpConnection(walletId: String) {
@@ -449,7 +588,15 @@ final class HwWalletManager {
guard !needsPassphrase(walletId: walletId) else { return }
guard let deviceId = transportDeviceId(for: walletId) else { return }
guard staleSessionCleanupTasks[deviceId] == nil else { return }
- session?.warmUpConnection(deviceId: deviceId)
+ // Best effort only: taking the radio from the other vendor is left to an explicit connect.
+ let vendor = vendor(walletId: walletId)
+ guard !isOtherVendorActive(vendor) else { return }
+ switch vendor {
+ case .trezor:
+ trezorSession?.warmUpConnection(deviceId: deviceId)
+ case .blockstream:
+ jadeSession?.warmUpConnection(deviceId: deviceId)
+ }
}
/// Reopens a watched passphrase wallet for signing. A wrong passphrase is not rejected by the
@@ -457,7 +604,15 @@ final class HwWalletManager {
/// its accounts resolve back to `walletId`; anything else is torn down again and reported as
/// `HwPassphraseError.mismatch` rather than signing from the wrong wallet.
func reconnectWithPassphrase(walletId: String, passphrase: String) async throws {
- guard let session else {
+ try await withSessionLock {
+ guard vendor(walletId: walletId) == .trezor else { throw HwPassphraseError.protectionDisabled }
+ await disconnectOtherVendor(.trezor)
+ try await reconnectWithPassphraseLocked(walletId: walletId, passphrase: passphrase)
+ }
+ }
+
+ private func reconnectWithPassphraseLocked(walletId: String, passphrase: String) async throws {
+ guard let session = trezorSession else {
throw AppError(message: "Unavailable", debugMessage: "No device session for wallet '\(walletId)'")
}
let deviceId = try requireTransportDeviceId(for: walletId)
@@ -497,6 +652,123 @@ final class HwWalletManager {
throw HwPassphraseError.mismatch
}
+ // MARK: - Vendor sessions
+
+ /// Runs `operation` with the radio to `vendor` alone, releasing the other vendor's session first.
+ /// Pairing goes through here, so it queues behind the other session operations.
+ func withVendorSession(_ vendor: HwWalletVendor, _ operation: @MainActor () async throws -> T) async throws -> T {
+ try await withSessionLock {
+ await disconnectOtherVendor(vendor)
+ // Releasing the other vendor can take seconds, and a cancel landing then must not dial.
+ try Task.checkCancellation()
+ return try await operation()
+ }
+ }
+
+ /// Silently reconnects the most relevant paired device after the app returns to the foreground.
+ /// Best effort: failures are logged by the vendor managers.
+ func reconnectOnForeground() async {
+ try? await withSessionLock {
+ let target = preferredReconnectVendor()
+ await disconnectOtherVendor(target)
+ switch target {
+ case .trezor:
+ // Started rather than awaited so a scan for a Trezor that is not around does not hold
+ // the lock. It reads as active before the lock is released, so the next operation
+ // releases it instead of dialling alongside it.
+ trezorSession?.startAutoReconnect()
+ case .blockstream:
+ jadeSession?.startAutoReconnect()
+ }
+ }
+ }
+
+ /// Starts a silent Jade reconnect once Bluetooth is back on, when a Jade is paired, the Trezor is
+ /// idle and the app is in the foreground.
+ func onJadeBluetoothRestored() {
+ guard isAppActive, let jadeSession, !jadeSession.storedDevices.isEmpty else { return }
+ guard !isOtherVendorActive(.blockstream) else { return }
+ jadeSession.startAutoReconnect()
+ }
+
+ func onAppBackgrounded() {
+ isAppActive = false
+ jadeSession?.onAppBackgrounded()
+ }
+
+ func onAppBecameActive() {
+ isAppActive = true
+ jadeSession?.onAppBecameActive()
+ }
+
+ func resetForWipe() async {
+ await trezorSession?.resetForWipe()
+ await jadeSession?.resetForWipe()
+ }
+
+ /// The vendor a foreground reconnect targets: the one already connected, else the one whose
+ /// Bluetooth entry was used most recently. Read from the saved entries, because on a cold launch
+ /// this runs before the vendor managers have loaded theirs.
+ private func preferredReconnectVendor() -> HwWalletVendor {
+ if trezorSession?.connectedDeviceId != nil {
+ return .trezor
+ }
+ if jadeSession?.connectedDeviceId != nil {
+ return .blockstream
+ }
+ return HwKnownDeviceStorage.loadAll()
+ .filter { $0.transportType == "bluetooth" }
+ .max { $0.lastConnectedAt < $1.lastConnectedAt }?
+ .vendor ?? .trezor
+ }
+
+ private func isOtherVendorActive(_ vendor: HwWalletVendor) -> Bool {
+ switch vendor {
+ case .trezor: jadeSession?.isSessionActive == true
+ case .blockstream: trezorSession?.isSessionActive == true
+ }
+ }
+
+ /// Releases the other vendor's session so the radio and core belong to `vendor` alone. A release
+ /// cannot fail from here: each vendor drops its session state even when closing the device
+ /// fails, so the operation carries on regardless.
+ private func disconnectOtherVendor(_ vendor: HwWalletVendor) async {
+ guard isOtherVendorActive(vendor) else { return }
+ switch vendor {
+ case .trezor:
+ await jadeSession?.releaseSession()
+ case .blockstream:
+ await trezorSession?.releaseSession()
+ }
+ }
+
+ private func withSessionLock(_ operation: @MainActor () async throws -> T) async throws -> T {
+ await acquireSessionLock()
+ defer { releaseSessionLock() }
+ try Task.checkCancellation()
+ return try await operation()
+ }
+
+ private func acquireSessionLock() async {
+ guard isSessionOperationActive else {
+ isSessionOperationActive = true
+ return
+ }
+
+ await withCheckedContinuation { continuation in
+ sessionOperationWaiters.append(continuation)
+ }
+ }
+
+ private func releaseSessionLock() {
+ guard !sessionOperationWaiters.isEmpty else {
+ isSessionOperationActive = false
+ return
+ }
+
+ sessionOperationWaiters.removeFirst().resume()
+ }
+
// MARK: - Watcher orchestration
/// Reconcile watchers in response to a settings change, but only when the monitored address
@@ -572,25 +844,30 @@ final class HwWalletManager {
/// The wallet identity a stored entry belongs to: the id it was saved with, or one derived from
/// its xpubs for entries written before the id was persisted. Returns nil when neither is
/// available (no captured xpubs), so callers skip the entry.
- private func resolvedWalletId(for device: TrezorKnownDevice) -> String? {
+ private func resolvedWalletId(for device: HwKnownDevice) -> String? {
if let walletId = device.walletId, !walletId.isEmpty {
return walletId
}
- return walletId(for: device.xpubs)
+ return walletId(for: device.xpubs, vendor: device.vendor)
}
- /// Derive (and memoize) the wallet id for a device's xpubs. Returns nil when derivation fails
- /// (e.g. no captured xpubs — `HwWalletId.derive` throws on empty), so callers skip the device.
- private func walletId(for xpubs: [String: String]) -> String? {
- let signature = xpubsSignature(xpubs)
- if let cached = walletIdCache[signature] {
+ /// Derive (and memoize) the wallet id for a device's xpubs in its vendor's namespace. Returns nil
+ /// when derivation fails (e.g. no captured xpubs, as `HwWalletId.derive` throws on empty), so
+ /// callers skip the device.
+ private func walletId(for xpubs: [String: String], vendor: HwWalletVendor) -> String? {
+ let cacheKey = walletIdCacheKey(xpubs: xpubs, vendor: vendor)
+ if let cached = walletIdCache[cacheKey] {
return cached
}
- guard let derived = try? HwWalletId.derive(xpubs: xpubs) else { return nil }
- walletIdCache[signature] = derived
+ guard let derived = try? HwWalletId.derive(xpubs: xpubs, vendor: vendor) else { return nil }
+ walletIdCache[cacheKey] = derived
return derived
}
+ private func walletIdCacheKey(xpubs: [String: String], vendor: HwWalletVendor) -> String {
+ "\(vendor.deviceType)\u{1e}\(xpubsSignature(xpubs))"
+ }
+
private func xpubsSignature(_ xpubs: [String: String]) -> String {
xpubs.sorted { $0.key < $1.key }
.map { dedupKey(addressType: $0.key, xpub: $0.value) }
@@ -600,8 +877,10 @@ final class HwWalletManager {
/// Drop cache entries for devices no longer in the snapshot, so the caches stay bounded to
/// live devices.
private func pruneCaches() {
- let liveSignatures = Set(knownDevices.filter { !$0.xpubs.isEmpty }.map { xpubsSignature($0.xpubs) })
- walletIdCache = walletIdCache.filter { liveSignatures.contains($0.key) }
+ let liveCacheKeys = Set(
+ knownDevices.filter { !$0.xpubs.isEmpty }.map { walletIdCacheKey(xpubs: $0.xpubs, vendor: $0.vendor) }
+ )
+ walletIdCache = walletIdCache.filter { liveCacheKeys.contains($0.key) }
lastPersisted = lastPersisted.filter { hwWalletIds.contains($0.key) }
}
@@ -893,7 +1172,8 @@ final class HwWalletManager {
balanceSats: walletWatchers.reduce(UInt64(0)) { $0.saturatingAdd($1.balanceSats) },
fundingBalanceSats: fundingBalance(group: group, addressType: hwFundingDefaultAddressType),
deviceIds: group.ids,
- passphraseProtected: group.devices.contains { $0.passphraseProtected }
+ passphraseProtected: group.devices.contains { $0.passphraseProtected },
+ vendor: device.vendor
)
}
@@ -906,7 +1186,7 @@ final class HwWalletManager {
/// without captured xpubs are skipped.
private func deviceGroups() -> [DeviceGroup] {
var order: [String] = []
- var grouped: [String: [TrezorKnownDevice]] = [:]
+ var grouped: [String: [HwKnownDevice]] = [:]
for device in knownDevices where !device.xpubs.isEmpty {
guard let walletId = resolvedWalletId(for: device) else { continue }
if grouped[walletId] == nil {
@@ -995,6 +1275,9 @@ final class HwWalletManager {
/// Displays the exact address currently shown by Bitkit on the device and rejects a mismatch.
func verifyReceiveAddress(walletId: String, receiveAddress: HwReceiveAddress) async throws {
+ if vendor(walletId: walletId) == .blockstream {
+ return try await verifyJadeReceiveAddress(walletId: walletId, receiveAddress: receiveAddress)
+ }
try await ensureConnected(walletId: walletId)
let response: TrezorAddressResponse
@@ -1022,6 +1305,46 @@ final class HwWalletManager {
}
}
+ /// A Jade compares on the device itself: it shows the address and answers with a mismatch error.
+ private func verifyJadeReceiveAddress(walletId: String, receiveAddress: HwReceiveAddress) async throws {
+ guard let jadeSession else {
+ throw AppError(message: "Unavailable", debugMessage: "No Jade session for wallet '\(walletId)'")
+ }
+ func verifyOnDevice() async throws {
+ try await jadeSession.verifyAddress(
+ addressType: receiveAddress.addressType,
+ derivationPath: receiveAddress.path,
+ expectedAddress: receiveAddress.address
+ )
+ }
+
+ do {
+ try await ensureConnected(walletId: walletId)
+ do {
+ try await verifyOnDevice()
+ } catch {
+ guard error.isHwSessionFailure() else { throw error }
+ await disconnectStaleSession(walletId: walletId)
+ try await ensureConnected(walletId: walletId)
+ try Task.checkCancellation()
+ do {
+ try await verifyOnDevice()
+ } catch {
+ if error.isHwSessionFailure() {
+ await disconnectStaleSession(walletId: walletId)
+ }
+ throw error
+ }
+ }
+ } catch {
+ guard case let .AddressMismatch(_, returned)? = error.underlyingJadeError else { throw error }
+ throw AppError(
+ message: t("hardware__verify_address_error"),
+ debugMessage: "Jade returned '\(returned)' for '\(receiveAddress.path)', expected '\(receiveAddress.address)'"
+ )
+ }
+ }
+
private func readAddressOnDevice(_ receiveAddress: HwReceiveAddress) async throws -> TrezorAddressResponse {
try await addressProvider(
TrezorGetAddressParams(
@@ -1056,7 +1379,7 @@ final class HwWalletManager {
feeRates: [Float(satsPerVByte)],
coinSelection: .branchAndBound
)
- let results = try await OnChainHwService.shared.composeTransaction(params: params)
+ let results = try await composeProvider(params)
for result in results {
if case let .success(_, fee, _, totalSpent) = result {
return totalSpent > fee ? totalSpent - fee : 0
@@ -1077,7 +1400,8 @@ final class HwWalletManager {
/// Compose the exact on-chain funding payment before prompting for the on-device signature.
/// Requires the device to be connected (the fingerprint drives the PSBT derivation paths); the
- /// caller must ensure the Trezor is connected first (via `TrezorManager`).
+ /// caller must ensure a Trezor is connected first (via `ensureConnected`). A Jade is connected
+ /// here, since its fingerprint is read from the live session.
func composeFundingTransaction(
walletId: String,
address: String,
@@ -1085,7 +1409,7 @@ final class HwWalletManager {
satsPerVByte: UInt64,
addressType: AddressScriptType = hwFundingDefaultAddressType
) async throws -> HwFundingTransaction {
- let fingerprint = try await TrezorService.shared.getDeviceFingerprint()
+ let fingerprint = try await signingFingerprint(walletId: walletId)
return try await composeFundingTransactionInternal(
walletId: walletId,
address: address,
@@ -1096,6 +1420,26 @@ final class HwWalletManager {
)
}
+ private func signingFingerprint(walletId: String) async throws -> String {
+ guard vendor(walletId: walletId) == .blockstream else {
+ return try await TrezorService.shared.getDeviceFingerprint()
+ }
+ guard let jadeSession else {
+ throw AppError(message: "Unavailable", debugMessage: "No Jade session for wallet '\(walletId)'")
+ }
+ // Without the key origins this carries, the Jade finds nothing of its own to sign.
+ try await ensureConnected(walletId: walletId)
+ do {
+ return try await jadeSession.masterFingerprint()
+ } catch {
+ // Core keeps a failed link marked connected, so release it or every retry reuses it.
+ if error.isJadeSessionFailure() {
+ await disconnectStaleSession(walletId: walletId)
+ }
+ throw error
+ }
+ }
+
/// Offline coin-selection for the exact funding amount; returns the mining fee only.
func estimateOfflineFundingMiningFee(
walletId: String,
@@ -1137,7 +1481,7 @@ final class HwWalletManager {
feeRates: [Float(satsPerVByte)],
coinSelection: .branchAndBound
)
- let results = try await OnChainHwService.shared.composeTransaction(params: params)
+ let results = try await composeProvider(params)
for result in results {
if case let .success(psbt, fee, feeRate, totalSpent) = result {
return HwFundingTransaction(
@@ -1164,12 +1508,15 @@ final class HwWalletManager {
/// Sign a composed funding payment on the device. Requires the device to be connected. On signing
/// failure the caller is responsible for clearing the stale session (via
- /// `TrezorManager.disconnectStaleSession`). Broadcasting is a separate step so a device-signing
+ /// `disconnectStaleSession`). Broadcasting is a separate step so a device-signing
/// timeout is never conflated with an in-flight broadcast.
func signFunding(
walletId: String,
funding: HwFundingTransaction
) async throws -> HwFundingSignedTx {
+ if vendor(walletId: walletId) == .blockstream {
+ return try await signJadeFunding(walletId: walletId, funding: funding)
+ }
// The session can change between connecting and signing, and signing from the wrong seed
// would produce signatures that do not match the inputs being spent.
try requireIdentity(of: walletId)
@@ -1183,6 +1530,20 @@ final class HwWalletManager {
)
}
+ private func signJadeFunding(walletId: String, funding: HwFundingTransaction) async throws -> HwFundingSignedTx {
+ guard let jadeSession else {
+ throw AppError(message: "Unavailable", debugMessage: "No Jade session for wallet '\(walletId)'")
+ }
+ try requireJadeSession(holding: walletId)
+ let signed = try await jadeSession.signPsbt(funding.psbt)
+ return HwFundingSignedTx(
+ serializedTx: signed.serializedTx,
+ miningFeeSats: funding.miningFeeSats,
+ feeRate: funding.feeRate,
+ totalSpent: funding.totalSpent
+ )
+ }
+
/// Broadcast a signed funding transaction and return its txid. Does not require a connected device.
func broadcastFunding(serializedTx: String) async throws -> String {
try await OnChainHwService.shared.broadcastRawTx(
@@ -1220,13 +1581,13 @@ final class HwWalletManager {
private struct DeviceGroup {
let walletId: String
- let devices: [TrezorKnownDevice]
+ let devices: [HwKnownDevice]
var ids: Set {
Set(devices.map(\.id))
}
- var representative: TrezorKnownDevice {
+ var representative: HwKnownDevice {
devices.max(by: { $0.lastConnectedAt < $1.lastConnectedAt }) ?? devices[0]
}
}
diff --git a/Bitkit/Managers/JadeManager.swift b/Bitkit/Managers/JadeManager.swift
new file mode 100644
index 000000000..c1ff47c7e
--- /dev/null
+++ b/Bitkit/Managers/JadeManager.swift
@@ -0,0 +1,904 @@
+import BitkitCore
+import Combine
+import Foundation
+import LDKNode
+import UIKit
+
+/// Device sessions for Blockstream Jade wallets: discovery, connect and unlock, the paired entries,
+/// and the device operations Bitkit needs (address verification, the master fingerprint and PSBT
+/// signing). Watchers, compose and broadcast are vendor neutral and stay in `HwWalletManager`.
+///
+/// A Jade locks on every power cycle and unlocks with a PIN entered on the device, which needs the
+/// pinserver round trip bitkit-core performs. Silent reconnects never unlock: only the user's own
+/// action (pairing, verifying an address, signing) puts the PIN screen on the device.
+///
+/// Core cancels a connect whenever a disconnect or cancel reaches it after that connect started, so a
+/// teardown landing late would kill the connect that follows it. Every teardown therefore clears the
+/// session state before its first suspension, invalidates the attempts in flight by bumping
+/// `connectEpoch`, and queues its device work on `sessionTeardownTask`, which each connect waits for.
+@Observable
+@MainActor
+final class JadeManager {
+ struct Timing {
+ /// How long the app may sit in the background before an open link is released. A Jade still
+ /// holding a link when the app is suspended can drop its bond, so the link is closed cleanly
+ /// first; the delay keeps a brief switch to another app during a PIN or signing prompt from
+ /// cancelling it.
+ var backgroundRelease: TimeInterval = 30
+ /// Kept back from the background time left, so the release itself still fits in it.
+ var expirationMargin: TimeInterval = 5
+ var reconnectBackoff: TimeInterval = 2
+ var reconnectAttempts = 4
+ var connectPollInterval: TimeInterval = 0.25
+ var connectMaxWait: TimeInterval = 28
+ }
+
+ static let allAccountTypes: [AccountType] = [.legacy, .wrappedSegwit, .nativeSegwit, .taproot]
+
+ private static let walletNameMaxLength = 50
+ private static let backgroundTaskName = "JadeBluetoothRelease"
+ private nonisolated static let logContext = "JadeManager"
+
+ private(set) var isScanning = false
+ private(set) var isConnecting = false
+ private(set) var isAutoReconnecting = false
+ /// Set while the device waits for its PIN.
+ private(set) var isUnlocking = false
+
+ private(set) var knownDevices: [HwKnownDevice] = [] {
+ didSet {
+ devicesRevision &+= 1
+ transport.setPairedPaths(Set(knownDevices.map(\.path).filter { HwDevicePath.isBle($0) }))
+ }
+ }
+
+ /// Jades the last scan found that are not paired yet.
+ private(set) var nearbyDevices: [JadeDeviceInfo] = []
+
+ private(set) var connected: ConnectedJadeDevice? {
+ didSet { devicesRevision &+= 1 }
+ }
+
+ /// Bumped whenever the paired entries or the session change, so observers can push them on.
+ private(set) var devicesRevision = 0
+
+ var isConnectInProgress: Bool {
+ isConnecting || isAutoReconnecting
+ }
+
+ private let service: JadeServicing
+ private let transport: JadeTransportControlling
+ private let store: JadeKnownDeviceStoring
+ private let backgroundTasks: BackgroundTaskScheduling
+ private let timing: Timing
+ private let now: () -> Date
+ private let network: () -> LDKNode.Network
+
+ @ObservationIgnored private var isSetup = false
+ @ObservationIgnored private var connectingPath: String?
+ @ObservationIgnored private var connectEpoch: UInt64 = 0
+ @ObservationIgnored private var attemptTokenCounter: UInt64 = 0
+ @ObservationIgnored private var attemptTokens: [AttemptFlag: UInt64] = [:]
+ @ObservationIgnored private var sessionTeardownTask: Task?
+ @ObservationIgnored private var transportReconnectTask: Task?
+ @ObservationIgnored private var reconnectLoopGeneration: UInt64 = 0
+ @ObservationIgnored private var backgroundReleaseTask: Task?
+ @ObservationIgnored private var backgroundTaskId: UIBackgroundTaskIdentifier = .invalid
+ @ObservationIgnored private var cancellables = Set()
+
+ private enum AttemptFlag {
+ case connecting
+ case autoReconnecting
+ case unlocking
+ }
+
+ init(
+ service: JadeServicing = JadeService.shared,
+ transport: JadeTransportControlling = JadeTransport.shared,
+ store: JadeKnownDeviceStoring = JadeKnownDeviceStore(),
+ backgroundTasks: BackgroundTaskScheduling? = nil,
+ timing: Timing = Timing(),
+ now: @escaping () -> Date = Date.init,
+ network: @escaping () -> LDKNode.Network = { Env.network }
+ ) {
+ self.service = service
+ self.transport = transport
+ self.store = store
+ self.backgroundTasks = backgroundTasks ?? UIApplicationBackgroundTasks()
+ self.timing = timing
+ self.now = now
+ self.network = network
+
+ transport.externalDisconnects
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] path in
+ self?.handleExternalDisconnect(path: path)
+ }
+ .store(in: &cancellables)
+ }
+
+ // MARK: - Paired devices
+
+ func loadKnownDevices() {
+ knownDevices = store.loadAll()
+ }
+
+ /// Whether `deviceId` names a paired Jade. `advertisedName` is the name a scan reported for it: a
+ /// rebooted Jade advertises under a new Bluetooth identifier, so matching on the id alone would
+ /// treat a paired device as a stranger.
+ func hasKnownDevice(deviceId: String, advertisedName: String? = nil) -> Bool {
+ allKnownDevices().contains { $0.matches(deviceId: deviceId) || $0.advertisesAs(advertisedName) }
+ }
+
+ func isKnownBluetoothDevice(deviceId: String) -> Bool {
+ knownDevice(deviceId)?.transportType == "bluetooth"
+ }
+
+ // MARK: - Scan and pair
+
+ /// Every Jade found nearby. Core refuses to scan while a session is open, so the devices of the
+ /// last scan are listed instead of failing the search.
+ func scan() async throws -> [JadeDeviceInfo] {
+ try await awaitSetup()
+ isScanning = true
+ defer { isScanning = false }
+ let devices = try await scanOrListDevices()
+ let paired = allKnownDevices()
+ nearbyDevices = devices.filter { device in !paired.contains { $0.isSameJade(as: device) } }
+ return devices
+ }
+
+ /// Pairs a discovered Jade: connects, unlocks with the PIN entered on the device, reads its
+ /// accounts and stores it. A background reconnect or warm-up in flight is cancelled first, so its
+ /// teardown cannot cancel this connect.
+ @discardableResult
+ func connect(path: String) async throws -> ConnectedJadeDevice {
+ cancelReconnectLoop()
+ if isConnectInProgress {
+ await cancelPendingConnection(deviceId: "")
+ }
+ try await awaitSetup()
+ // A cancel before the epoch below is captured would otherwise be missed.
+ try Task.checkCancellation()
+ let token = beginAttempt(.connecting)
+ defer { endAttempt(.connecting, token: token) }
+ let epoch = connectEpoch
+ do {
+ let device = try await resolveDevice(path: path)
+ try requireCurrent(epoch)
+ let session = try await connectDevice(device, unlock: true, expected: nil, epoch: epoch)
+ nearbyDevices.removeAll { $0.path == path || $0.path == device.path }
+ return session
+ } catch {
+ Logger.error("Jade connect failed: \(error)", context: Self.logContext)
+ throw error
+ }
+ }
+
+ /// Reconnects a paired Jade, which is found again by its stored path or, after a reboot, by the
+ /// name it advertises. With `unlock` off the session stays locked, as silent reconnects want.
+ @discardableResult
+ func connectKnownDevice(deviceId: String, forceSession: Bool = false, unlock: Bool = true) async throws -> ConnectedJadeDevice {
+ guard !isConnectInProgress else {
+ throw AppError(message: "Connection already in progress", debugMessage: "A Jade connect is already running")
+ }
+ return try await connectKnownDeviceUnguarded(deviceId: deviceId, forceSession: forceSession, unlock: unlock)
+ }
+
+ /// Silent reconnect of the most recently used Jade; never asks for the PIN. Reads the saved entries
+ /// when none are loaded yet, since a cold launch reconnects before they are.
+ @discardableResult
+ func autoReconnect() async throws -> ConnectedJadeDevice {
+ guard !isConnectInProgress else {
+ throw AppError(message: "Connection already in progress", debugMessage: "A Jade connect is already running")
+ }
+ guard let entry = savedOrLoadedDevices()
+ .filter({ $0.transportType == "bluetooth" })
+ .max(by: { $0.lastConnectedAt < $1.lastConnectedAt })
+ else {
+ throw AppError(message: "Reconnect Hardware Device", debugMessage: "No paired Jade to reconnect")
+ }
+
+ let token = beginAttempt(.autoReconnecting)
+ defer { endAttempt(.autoReconnecting, token: token) }
+ do {
+ try await awaitSetup()
+ if let current = connected, service.isConnected() {
+ return current
+ }
+ if service.isConnected() {
+ await beginTeardown(nil, releasingAttempts: false, core: Self.disconnectCore).value
+ }
+ return try await connectKnownDeviceUnguarded(deviceId: entry.id, forceSession: false, unlock: false)
+ } catch {
+ Logger.error("Jade auto-reconnect failed: \(error)", context: Self.logContext)
+ throw error
+ }
+ }
+
+ /// Closes the session: the link and core at once, since a request still waiting on the link holds
+ /// core until the link goes.
+ func disconnect() async {
+ let link = connected.map { LinkRelease.path($0.path) }
+ await beginTeardown(link, releasingAttempts: true, core: Self.disconnectCore).value
+ }
+
+ /// Stops a connect in flight, including one waiting for the PIN. A Swift task cancel never reaches
+ /// core, so a cancel from the UI has to come through here.
+ func cancelPendingConnection(deviceId: String) async {
+ let fallbackPath = deviceId.isEmpty ? nil : knownDevice(deviceId)?.path ?? deviceId
+ let path = connectingPath ?? connected?.path ?? fallbackPath
+ await beginTeardown(path.map(LinkRelease.path), releasingAttempts: true) { service in
+ do {
+ try await service.cancel()
+ } catch {
+ Logger.warn("Failed to cancel the Jade request in flight: \(error)", context: JadeManager.logContext)
+ }
+ await JadeManager.disconnectCore(service)
+ }.value
+ }
+
+ // MARK: - Device events
+
+ /// A link dropped without the app closing it. The notice to core is queued ahead of the next
+ /// connect, since a notice arriving after that connect would tear the new session down.
+ func handleExternalDisconnect(path: String) {
+ guard connected?.path == path || connectingPath == path else { return }
+ Logger.warn("External disconnect for Jade '\(path)'", context: Self.logContext)
+ connected = nil
+ let previous = sessionTeardownTask
+ let service = service
+ sessionTeardownTask = Task {
+ await previous?.value
+ await service.notifyDisconnected(path: path)
+ }
+ }
+
+ // MARK: - Connect internals
+
+ private func connectKnownDeviceUnguarded(deviceId: String, forceSession: Bool, unlock: Bool) async throws -> ConnectedJadeDevice {
+ let token = beginAttempt(.connecting)
+ defer { endAttempt(.connecting, token: token) }
+ var epoch = connectEpoch
+ do {
+ try await awaitSetup()
+ if forceSession, let staleTeardown = beginStaleSessionTeardown(deviceId: deviceId, releasingAttempts: false) {
+ // This attempt's own teardown moved the epoch; anything moving it again cancels the attempt.
+ epoch = connectEpoch
+ await staleTeardown.value
+ }
+ try requireCurrent(epoch)
+ guard let entry = knownDevice(deviceId) else {
+ throw AppError(message: "Reconnect Hardware Device", debugMessage: "Unknown Jade '\(deviceId)'")
+ }
+ let candidate = await knownDeviceCandidate(for: entry)
+ try requireCurrent(epoch)
+ let session = try await connectDevice(candidate, unlock: unlock, expected: entry, epoch: epoch)
+ Logger.info("Reconnected known Jade '\(entry.id)'", context: Self.logContext)
+ return session
+ } catch {
+ Logger.error("Jade reconnect failed: \(error)", context: Self.logContext)
+ if connectEpoch == epoch {
+ await beginStaleSessionTeardown(deviceId: deviceId, releasingAttempts: false)?.value
+ }
+ throw error
+ }
+ }
+
+ private func connectDevice(
+ _ device: JadeDeviceInfo,
+ unlock: Bool,
+ expected: HwKnownDevice?,
+ epoch: UInt64
+ ) async throws -> ConnectedJadeDevice {
+ connectingPath = device.path
+ defer {
+ if connectEpoch == epoch, connectingPath == device.path {
+ connectingPath = nil
+ }
+ }
+ do {
+ await awaitSessionTeardown()
+ try requireCurrent(epoch)
+ var version = try await service.connect(path: device.path)
+ try requireCurrent(epoch)
+ Logger.info(
+ "Connected Jade '\(device.path)' firmware '\(version.jadeVersion)' state '\(version.jadeState)'",
+ context: Self.logContext
+ )
+ try rejectUnusableDevice(version, expected: expected)
+ if unlock, version.jadeState == .locked {
+ version = try await unlockConnected()
+ try requireCurrent(epoch)
+ }
+ let known = try await knownEntry(for: device, version: version, expected: expected, epoch: epoch)
+ let session = ConnectedJadeDevice(id: known.id, path: device.path, versionInfo: version, walletId: known.resolvedWalletId)
+ connected = session
+ return session
+ } catch {
+ if connectEpoch == epoch {
+ await beginTeardown(.path(device.path), releasingAttempts: false, core: Self.disconnectCore).value
+ }
+ throw error
+ }
+ }
+
+ /// The entry this connect lands on: refreshed with the accounts an unlocked device reports, or,
+ /// for a device still locked, the entry that already holds its keys.
+ private func knownEntry(
+ for device: JadeDeviceInfo,
+ version: JadeVersionInfo,
+ expected: HwKnownDevice?,
+ epoch: UInt64
+ ) async throws -> HwKnownDevice {
+ if version.jadeState.isUnlocked {
+ let xpubs = try await exportAccounts()
+ try requireCurrent(epoch)
+ return addOrUpdateKnownDevice(device, version: version, fetchedXpubs: xpubs)
+ }
+ let entryId = JadeDeviceIdentity.deviceId(efuseMac: version.efuseMac) ?? device.path
+ guard let entry = expected ?? knownDevice(entryId) else {
+ throw JadeError.DeviceLocked
+ }
+ return refreshKnownDevice(entry, path: device.path)
+ }
+
+ /// Refuses a Jade with no wallet yet, and one that is not the paired device expected. Runs before
+ /// unlocking, so a wrong Jade never shows a PIN prompt.
+ private func rejectUnusableDevice(_ version: JadeVersionInfo, expected: HwKnownDevice?) throws {
+ if version.jadeState == .uninit {
+ throw JadeError.DeviceUninitialized
+ }
+ // A device reporting no efuse MAC cannot prove it is the paired one, so it fails closed.
+ guard let expectedMac = expected?.jadeDeviceId, !expectedMac.isEmpty else { return }
+ if expectedMac != version.efuseMac {
+ throw AppError(message: "Reconnect Hardware Device", debugMessage: "A different Jade is connected")
+ }
+ }
+
+ private func unlockConnected() async throws -> JadeVersionInfo {
+ let token = beginAttempt(.unlocking)
+ defer { endAttempt(.unlocking, token: token) }
+ // Core enforces the five minute unlock deadline; the PIN is typed on the device.
+ try await service.unlock(network: jadeNetwork())
+ return try await service.refreshVersionInfo()
+ }
+
+ private func exportAccounts() async throws -> [String: String] {
+ let network = try jadeNetwork()
+ let export: JadeAccountExport
+ do {
+ export = try await service.getAccountExport(network: network, accountTypes: Self.allAccountTypes, accountIndex: 0)
+ } catch let error where error.isJadeFirmwareError() {
+ Logger.warn("Retrying the Jade account export without taproot: \(error)", context: Self.logContext)
+ let withoutTaproot = Self.allAccountTypes.filter { $0 != .taproot }
+ export = try await service.getAccountExport(network: network, accountTypes: withoutTaproot, accountIndex: 0)
+ }
+ var xpubs: [String: String] = [:]
+ for account in export.accounts {
+ xpubs[AddressScriptType(jadeVariant: account.variant).stringValue] = account.xpub
+ }
+ guard !xpubs.isEmpty else {
+ throw AppError(
+ message: "Could not read any account keys from your Jade. Reconnect and try again.",
+ debugMessage: "The Jade account export held no accounts"
+ )
+ }
+ return xpubs
+ }
+
+ private func addOrUpdateKnownDevice(_ device: JadeDeviceInfo, version: JadeVersionInfo, fetchedXpubs: [String: String]) -> HwKnownDevice {
+ let devices = allKnownDevices()
+ let id = JadeDeviceIdentity.deviceId(efuseMac: version.efuseMac) ?? device.path
+ let previous = HwKnownDeviceMatching.previous(in: devices, deviceId: id, fetchedXpubs: fetchedXpubs)
+ let xpubs = (previous?.xpubs ?? [:]).merging(fetchedXpubs) { _, fetched in fetched }
+ let walletKey = HwKnownDevice.walletKey(for: xpubs, fallback: id)
+ let named = HwKnownDeviceMatching.named(in: devices, previous: previous, walletKey: walletKey)
+ let walletId = resolvedWalletId(previous: previous, walletKey: walletKey, xpubs: xpubs, in: devices)
+ // A name restored from a backup, or kept when this wallet was removed, is adopted here; the
+ // store masks a pending name out once a paired entry carries it, so adopting it consumes it.
+ let pendingName = walletId.flatMap { store.loadPendingNames()[$0] }.flatMap { $0.isEmpty ? nil : $0 }
+
+ let known = HwKnownDevice(
+ id: id,
+ name: device.name ?? previous?.name ?? JadeDeviceIdentity.defaultName,
+ path: device.path,
+ transportType: device.transport == .bluetooth ? "bluetooth" : "usb",
+ label: nil,
+ model: JadeDeviceIdentity.model(boardType: version.boardType),
+ lastConnectedAt: now(),
+ xpubs: xpubs,
+ customLabel: named?.customLabel ?? pendingName,
+ walletId: walletId,
+ passphraseProtected: false,
+ vendor: .blockstream,
+ jadeDeviceId: version.efuseMac
+ )
+ let updated = HwKnownDeviceMatching.merged(devices, with: known, refreshed: previous)
+ store.saveAll(updated, pendingName: nil)
+ setKnownDevices(updated)
+ return known
+ }
+
+ /// The wallet id this identity already carries, else one derived from its keys. Never the device
+ /// id: an id derived later would then disagree with the one stored.
+ private func resolvedWalletId(previous: HwKnownDevice?, walletKey: String, xpubs: [String: String], in devices: [HwKnownDevice]) -> String? {
+ if let carried = previous?.walletId ?? devices.first(where: { $0.walletKey == walletKey })?.walletId, !carried.isEmpty {
+ return carried
+ }
+ return try? HwWalletId.derive(xpubs: xpubs, vendor: .blockstream)
+ }
+
+ private func refreshKnownDevice(_ entry: HwKnownDevice, path: String) -> HwKnownDevice {
+ let refreshed = entry.refreshed(path: path, at: now())
+ let updated = allKnownDevices().map { $0.id == entry.id && $0.walletKey == entry.walletKey ? refreshed : $0 }
+ store.saveAll(updated, pendingName: nil)
+ setKnownDevices(updated)
+ return refreshed
+ }
+
+ /// The device a pairing connect dials. Core only connects to devices of its last scan, so a path
+ /// it has not seen is scanned for, and a paired Jade that came back under a new Bluetooth
+ /// identifier is dialled there: its stored identifier would only fail once the connect timed out.
+ private func resolveDevice(path: String) async throws -> JadeDeviceInfo {
+ if let nearby = nearbyDevices.first(where: { $0.path == path }) {
+ return nearby
+ }
+ if let listed = await service.listDevices().first(where: { $0.path == path }) {
+ return listed
+ }
+ let scanned = try await scanOrListDevices()
+ if let match = scanned.first(where: { $0.path == path }) {
+ return match
+ }
+ if let entry = knownDevice(path),
+ let readvertised = scanned.first(where: { $0.transport == .bluetooth && entry.advertisesAs($0.name) })
+ {
+ Logger.info("Resolved Jade '\(path)' to its new path '\(readvertised.path)'", context: Self.logContext)
+ return readvertised
+ }
+ return JadeDeviceInfo(path: path, transport: .bluetooth, name: nil, serialNumber: nil)
+ }
+
+ /// Where a paired Jade is dialled: its stored path when a scan still sees it there, else the Jade
+ /// advertising under its name, else the stored path, which the transport can reach without a scan.
+ private func knownDeviceCandidate(for entry: HwKnownDevice) async -> JadeDeviceInfo {
+ let scanned: [JadeDeviceInfo]
+ do {
+ scanned = try await scanOrListDevices()
+ } catch {
+ Logger.warn("Scan before the Jade reconnect failed: \(error)", context: Self.logContext)
+ scanned = []
+ }
+ let bluetooth = scanned.filter { $0.transport == .bluetooth }
+ if let exact = bluetooth.first(where: { $0.path == entry.path }) {
+ return exact
+ }
+ if let readvertised = bluetooth.first(where: { entry.advertisesAs($0.name) }) {
+ return readvertised
+ }
+ return JadeDeviceInfo(path: entry.path, transport: .bluetooth, name: entry.name, serialNumber: nil)
+ }
+
+ private func scanOrListDevices() async throws -> [JadeDeviceInfo] {
+ if service.isConnected() {
+ return await service.listDevices()
+ }
+ return try await service.scan(timeoutMs: JadeService.scanTimeoutMs)
+ }
+
+ // MARK: - Teardown
+
+ /// Clears the session and invalidates every attempt in flight before the first suspension, then
+ /// queues closing the link and `core` behind any earlier teardown. Both start together: core
+ /// signals its change at once, while the link close still interrupts a request stuck on the link.
+ /// The work runs in its own task, so a cancelled caller cannot cut it short.
+ ///
+ /// - Parameter releasingAttempts: whether the caller gives the device up, which also stops the
+ /// background reconnect and clears every in-progress flag. An attempt cleaning up after itself
+ /// leaves them to its own unwinding.
+ @discardableResult
+ private func beginTeardown(
+ _ link: LinkRelease?,
+ releasingAttempts: Bool,
+ core: @escaping @Sendable (JadeServicing) async -> Void
+ ) -> Task {
+ connectEpoch &+= 1
+ connected = nil
+ connectingPath = nil
+ if releasingAttempts {
+ cancelReconnectLoop()
+ resetAttempts()
+ }
+ let previous = sessionTeardownTask
+ let service = service
+ let transport = transport
+ let teardown = Task {
+ await previous?.value
+ async let linkReleased: Void = LinkRelease.release(link, on: transport)
+ async let coreReleased: Void = core(service)
+ _ = await (linkReleased, coreReleased)
+ }
+ sessionTeardownTask = teardown
+ return teardown
+ }
+
+ /// Tears the session of `deviceId` down, or of the device being connected. Nil while another Jade
+ /// holds the session, which is left alone.
+ private func beginStaleSessionTeardown(deviceId: String, releasingAttempts: Bool) -> Task? {
+ if let current = connected, !current.matches(deviceId) {
+ return nil
+ }
+ let path = connected?.path ?? connectingPath ?? knownDevice(deviceId)?.path ?? deviceId
+ return beginTeardown(.path(path), releasingAttempts: releasingAttempts, core: Self.disconnectCore)
+ }
+
+ private func awaitSessionTeardown() async {
+ var awaited: Task?
+ while let pending = sessionTeardownTask, pending != awaited {
+ await pending.value
+ awaited = pending
+ }
+ }
+
+ private nonisolated static let disconnectCore: @Sendable (JadeServicing) async -> Void = { service in
+ do {
+ try await service.disconnect()
+ } catch {
+ Logger.warn("Failed to close the Jade core session: \(error)", context: logContext)
+ }
+ }
+
+ private func requireCurrent(_ epoch: UInt64) throws {
+ guard connectEpoch == epoch else { throw JadeError.UserCancelled }
+ }
+
+ // MARK: - Connection upkeep
+
+ private func awaitConnectedOrNull(deviceId: String) async throws -> ConnectedJadeDevice? {
+ if let current = liveSession(deviceId: deviceId) {
+ return current
+ }
+ guard isConnectInProgress else { return nil }
+ let deadline = Date().addingTimeInterval(timing.connectMaxWait)
+ while isConnectInProgress, liveSession(deviceId: deviceId) == nil, Date() < deadline {
+ try await Task.sleep(for: .seconds(timing.connectPollInterval))
+ }
+ return liveSession(deviceId: deviceId)
+ }
+
+ private func liveSession(deviceId: String) -> ConnectedJadeDevice? {
+ guard let current = connected, current.matches(deviceId), service.isConnected() else { return nil }
+ return current
+ }
+
+ private func retryAutoReconnect() async {
+ for attempt in 0 ..< timing.reconnectAttempts {
+ if connected != nil || isConnectInProgress {
+ return
+ }
+ do {
+ try await Task.sleep(for: .seconds(timing.reconnectBackoff * Double(attempt + 1)))
+ } catch {
+ return
+ }
+ if Task.isCancelled || connected != nil || isConnectInProgress {
+ return
+ }
+ Logger.info("Attempting Jade auto-reconnect, attempt \(attempt + 1)", context: Self.logContext)
+ do {
+ try await autoReconnect()
+ return
+ } catch {
+ if Task.isCancelled || error.isJadeDeviceBusy() {
+ return
+ }
+ }
+ }
+ }
+
+ private func cancelReconnectLoop() {
+ transportReconnectTask?.cancel()
+ transportReconnectTask = nil
+ }
+
+ private func beginAttempt(_ flag: AttemptFlag) -> UInt64 {
+ attemptTokenCounter &+= 1
+ attemptTokens[flag] = attemptTokenCounter
+ setAttemptFlag(flag, to: true)
+ return attemptTokenCounter
+ }
+
+ /// Clears `flag` unless a release already did, or a newer attempt owns it now.
+ private func endAttempt(_ flag: AttemptFlag, token: UInt64) {
+ guard attemptTokens[flag] == token else { return }
+ attemptTokens[flag] = nil
+ setAttemptFlag(flag, to: false)
+ }
+
+ private func resetAttempts() {
+ attemptTokens.removeAll()
+ isConnecting = false
+ isAutoReconnecting = false
+ isUnlocking = false
+ }
+
+ private func setAttemptFlag(_ flag: AttemptFlag, to value: Bool) {
+ switch flag {
+ case .connecting: isConnecting = value
+ case .autoReconnecting: isAutoReconnecting = value
+ case .unlocking: isUnlocking = value
+ }
+ }
+
+ // MARK: - Background
+
+ private func releaseInBackground(taskId: UIBackgroundTaskIdentifier) async {
+ Logger.info("Releasing the Jade Bluetooth link while the app is in the background", context: Self.logContext)
+ await releaseSession()
+ endBackgroundTask(taskId)
+ }
+
+ /// The background time ran out: every link is cancelled without waiting, and core is told behind
+ /// the teardown chain so the next connect still waits for it.
+ private func releaseBeforeSuspension() {
+ backgroundReleaseTask?.cancel()
+ backgroundReleaseTask = nil
+ transport.releaseAllImmediately()
+ beginTeardown(nil, releasingAttempts: true, core: Self.disconnectCore)
+ endBackgroundTask(backgroundTaskId)
+ }
+
+ private func backgroundReleaseDelay(for taskId: UIBackgroundTaskIdentifier) -> TimeInterval {
+ guard taskId != .invalid else { return 0 }
+ let budget = backgroundTasks.backgroundTimeRemaining - timing.expirationMargin
+ return max(0, min(timing.backgroundRelease, budget))
+ }
+
+ /// Ends `taskId` if it is still the one running, so no task is ended twice.
+ private func endBackgroundTask(_ taskId: UIBackgroundTaskIdentifier) {
+ guard taskId != .invalid, backgroundTaskId == taskId else { return }
+ backgroundTaskId = .invalid
+ backgroundTasks.endBackgroundTask(taskId)
+ }
+
+ // MARK: - Helpers
+
+ private func awaitSetup() async throws {
+ guard !isSetup else { return }
+ try await service.initialize()
+ guard !isSetup else { return }
+ loadKnownDevices()
+ isSetup = true
+ }
+
+ private func jadeNetwork() throws -> JadeNetwork {
+ try network().toJadeNetwork()
+ }
+
+ /// The stored entries plus any in memory the store does not hold, so a failed write never loses one.
+ private func allKnownDevices() -> [HwKnownDevice] {
+ let stored = store.loadAll()
+ let storedIds = Set(stored.map(\.entryId))
+ return stored + knownDevices.filter { !storedIds.contains($0.entryId) }
+ }
+
+ private func savedOrLoadedDevices() -> [HwKnownDevice] {
+ knownDevices.isEmpty ? store.loadAll() : knownDevices
+ }
+
+ private func knownDevice(_ deviceId: String) -> HwKnownDevice? {
+ allKnownDevices().first { $0.matches(deviceId: deviceId) }
+ }
+
+ private func setKnownDevices(_ devices: [HwKnownDevice]) {
+ knownDevices = devices.sorted { $0.lastConnectedAt > $1.lastConnectedAt }
+ }
+
+ /// Retries once after unlocking when the device locked since the session was opened: a cached
+ /// unlocked state would otherwise report the Jade as busy until it is reconnected.
+ private func retryingOnceIfLocked(_ operation: () async throws -> T) async throws -> T {
+ do {
+ return try await operation()
+ } catch {
+ guard case .DeviceLocked? = error.underlyingJadeError, let current = connected else { throw error }
+ Logger.info("The Jade locked since it connected; unlocking and retrying", context: Self.logContext)
+ connected?.versionInfo.jadeState = .locked
+ try await ensureConnected(deviceId: current.id)
+ return try await operation()
+ }
+ }
+}
+
+// MARK: - JadeSessioning
+
+extension JadeManager: JadeSessioning {
+ var storedDevices: [HwKnownDevice] {
+ knownDevices
+ }
+
+ var connectedDeviceId: String? {
+ connected?.id
+ }
+
+ var connectedWalletId: String? {
+ connected?.walletId
+ }
+
+ var isSessionActive: Bool {
+ connected != nil || isConnectInProgress || transportReconnectTask != nil
+ }
+
+ /// Reuses a live session of `deviceId`, unlocking it when locked, else reconnects it. A pending
+ /// background reconnect is dropped rather than waited out; only an attempt already dialling is.
+ func ensureConnected(deviceId: String) async throws {
+ cancelReconnectLoop()
+ try await awaitSetup()
+ guard let current = try await awaitConnectedOrNull(deviceId: deviceId) else {
+ try await connectKnownDevice(deviceId: deviceId, forceSession: true)
+ return
+ }
+ guard current.isLocked else { return }
+ let epoch = connectEpoch
+ let version = try await unlockConnected()
+ try requireCurrent(epoch)
+ connected?.versionInfo = version
+ }
+
+ func verifyAddress(addressType: AddressScriptType, derivationPath: String, expectedAddress: String) async throws {
+ let network = try jadeNetwork()
+ try await retryingOnceIfLocked {
+ try await service.verifyAddress(
+ network: network,
+ variant: addressType.jadeVariant,
+ derivationPath: derivationPath,
+ expectedAddress: expectedAddress
+ )
+ }
+ }
+
+ func masterFingerprint() async throws -> String {
+ try await service.getMasterFingerprint(network: jadeNetwork())
+ }
+
+ func signPsbt(_ psbtBase64: String) async throws -> CompletedTransaction {
+ let network = try jadeNetwork()
+ let signed = try await retryingOnceIfLocked {
+ try await service.signPsbt(network: network, psbtBase64: psbtBase64)
+ }
+ return try await service.finalizePsbt(originalPsbt: psbtBase64, signedPsbt: signed)
+ }
+
+ func disconnectStaleSession(deviceId: String) async {
+ await beginStaleSessionTeardown(deviceId: deviceId, releasingAttempts: true)?.value
+ }
+
+ func releaseSession() async {
+ if connected != nil {
+ await disconnect()
+ } else if isConnectInProgress {
+ await cancelPendingConnection(deviceId: "")
+ } else {
+ cancelReconnectLoop()
+ }
+ }
+
+ func warmUpConnection(deviceId: String) {
+ guard !isConnectInProgress, liveSession(deviceId: deviceId) == nil, isKnownBluetoothDevice(deviceId: deviceId) else { return }
+ Logger.info("Warming up paired Jade '\(deviceId)'", context: Self.logContext)
+ Task {
+ do {
+ try await connectKnownDevice(deviceId: deviceId, unlock: false)
+ } catch {
+ Logger.debug("Warm-up connect failed for '\(deviceId)': \(error)", context: Self.logContext)
+ }
+ }
+ }
+
+ func forgetWallet(walletId: String, pendingName: PendingHwWalletName?) async {
+ let devices = allKnownDevices()
+ let forgotten = devices.filter { $0.resolvedWalletId == walletId }
+ guard !forgotten.isEmpty else {
+ Logger.warn("Nothing to forget for Jade wallet '\(walletId)'", context: Self.logContext)
+ return
+ }
+ let remaining = devices.filter { $0.resolvedWalletId != walletId }
+ store.saveAll(remaining, pendingName: pendingName)
+ setKnownDevices(remaining)
+ Logger.info("Forgot Jade wallet '\(walletId)'", context: Self.logContext)
+
+ if let current = connected, forgotten.contains(where: { $0.id == current.id || $0.path == current.path }) {
+ await disconnect()
+ }
+ }
+
+ func renameWallet(walletId: String, newName: String) {
+ let devices = allKnownDevices()
+ guard devices.contains(where: { $0.resolvedWalletId == walletId }) else { return }
+ let trimmed = String(newName.trimmingCharacters(in: .whitespacesAndNewlines).prefix(Self.walletNameMaxLength))
+ let customLabel = trimmed.isEmpty ? nil : trimmed
+ let updated = devices.map { device -> HwKnownDevice in
+ guard device.resolvedWalletId == walletId else { return device }
+ var renamed = device
+ renamed.customLabel = customLabel
+ return renamed
+ }
+ // Dropped before the label is written, while the entry still masks it: a pending name left
+ // behind would come back the moment the user clears this label.
+ store.setPendingName(walletId: walletId, name: nil)
+ store.saveAll(updated, pendingName: nil)
+ setKnownDevices(updated)
+ Logger.info("Renamed Jade wallet '\(walletId)'", context: Self.logContext)
+ }
+
+ func startAutoReconnect() {
+ guard connected == nil, !isConnectInProgress, transportReconnectTask == nil else { return }
+ guard savedOrLoadedDevices().contains(where: { $0.transportType == "bluetooth" }) else { return }
+ reconnectLoopGeneration &+= 1
+ let generation = reconnectLoopGeneration
+ transportReconnectTask = Task { [weak self] in
+ await self?.retryAutoReconnect()
+ guard let self, reconnectLoopGeneration == generation else { return }
+ transportReconnectTask = nil
+ }
+ }
+
+ /// Schedules releasing the link, unless the app returns first. A pending background reconnect is
+ /// dropped, and counts as a session to release in case it had already dialled.
+ func onAppBackgrounded() {
+ let hadPendingReconnect = transportReconnectTask != nil
+ cancelReconnectLoop()
+ // A release already scheduled or still running covers this one.
+ guard backgroundReleaseTask == nil, backgroundTaskId == .invalid else { return }
+ guard connected != nil || isConnectInProgress || hadPendingReconnect else { return }
+
+ let taskId = backgroundTasks.beginBackgroundTask(named: Self.backgroundTaskName) { [weak self] in
+ self?.releaseBeforeSuspension()
+ }
+ backgroundTaskId = taskId
+ let delay = backgroundReleaseDelay(for: taskId)
+ backgroundReleaseTask = Task { [weak self] in
+ do {
+ try await Task.sleep(for: .seconds(delay))
+ } catch {
+ return
+ }
+ guard !Task.isCancelled, let self else { return }
+ backgroundReleaseTask = nil
+ await releaseInBackground(taskId: taskId)
+ }
+ }
+
+ func onAppBecameActive() {
+ backgroundReleaseTask?.cancel()
+ backgroundReleaseTask = nil
+ endBackgroundTask(backgroundTaskId)
+ }
+
+ func resetForWipe() async {
+ backgroundReleaseTask?.cancel()
+ backgroundReleaseTask = nil
+ endBackgroundTask(backgroundTaskId)
+ await beginTeardown(.all, releasingAttempts: true, core: Self.disconnectCore).value
+ isSetup = false
+ nearbyDevices = []
+ knownDevices = []
+ }
+}
+
+/// The links a teardown closes.
+private enum LinkRelease {
+ case path(String)
+ case all
+
+ static func release(_ link: LinkRelease?, on transport: JadeTransportControlling) async {
+ switch link {
+ case let .path(path)?:
+ await transport.disconnectDevice(path: path)
+ case .all?:
+ await transport.closeAllConnections()
+ case nil:
+ return
+ }
+ }
+}
diff --git a/Bitkit/Managers/TrezorManager.swift b/Bitkit/Managers/TrezorManager.swift
index d50953584..6def3f1f9 100644
--- a/Bitkit/Managers/TrezorManager.swift
+++ b/Bitkit/Managers/TrezorManager.swift
@@ -84,7 +84,7 @@ final class TrezorManager {
// MARK: - Known Devices & Auto-Reconnect
- var knownDevices: [TrezorKnownDevice] = [] {
+ var knownDevices: [HwKnownDevice] = [] {
didSet { devicesRevision &+= 1 }
}
@@ -96,6 +96,11 @@ final class TrezorManager {
/// when the disconnected device list appears.
private var suppressNextAutoReconnect = false
+ /// The foreground reconnect `startAutoReconnect()` launched, set before it first runs so the
+ /// session already reads as active to another vendor.
+ private var autoReconnectTask: Task?
+ private var autoReconnectGeneration = 0
+
// MARK: - Bluetooth State
/// Reads directly from BLEManager (@Observable chaining).
@@ -429,6 +434,34 @@ final class TrezorManager {
connectedDevice != nil
}
+ var isSessionActive: Bool {
+ connectedDevice != nil || isAutoReconnecting || isConnectionOperationActive || autoReconnectTask != nil
+ }
+
+ func releaseSession() async {
+ cancelAutoReconnect()
+ cancelPairingCode()
+ // Detached because `withConnectionOperation` bails out on a cancelled task, and a caller
+ // abandoning its own work must still leave the radio free for the other vendor.
+ await Task.detached { @MainActor [weak self] in
+ guard let self else { return }
+ try? await withConnectionOperation {
+ await self.disconnect()
+ // Handing the radio to the other vendor is not a manual disconnect, so the next
+ // foreground reconnect still runs.
+ self.suppressNextAutoReconnect = false
+ }
+ }.value
+ }
+
+ /// Drops the session and a pending foreground reconnect ahead of a wallet wipe, after any
+ /// connection work already running, so none of it saves a paired device back once the wipe has
+ /// cleared them. Clearing the loaded entries keeps a later reconnect from starting at all.
+ func resetForWipe() async {
+ await releaseSession()
+ knownDevices = []
+ }
+
// MARK: - UI Callbacks
func submitPin(_ pin: String) {
@@ -585,7 +618,7 @@ final class TrezorManager {
// MARK: - Known Devices
func loadKnownDevices() {
- knownDevices = TrezorKnownDeviceStorage.loadAll()
+ knownDevices = HwKnownDeviceStorage.loadAll(vendor: .trezor)
}
/// Display name for the currently connected device, applying any Bitkit-side custom rename (from
@@ -606,7 +639,7 @@ final class TrezorManager {
/// entry sharing the target's xpub set so the same device renamed over either transport stays
/// consistent, then reloads so the snapshot re-pushes and `HwWallet.name` updates.
func renameDevice(id: String, newName: String) {
- let devices = TrezorKnownDeviceStorage.loadAll()
+ let devices = HwKnownDeviceStorage.loadAll(vendor: .trezor)
guard let target = devices.first(where: { $0.id == id }) else { return }
applyCustomLabel(newName, to: devices) { device in
@@ -618,7 +651,7 @@ final class TrezorManager {
/// Set the Bitkit-side custom name for one wallet identity. The label belongs to the wallet, not
/// to the device: renaming a passphrase wallet must leave its device's other wallets alone.
func renameWallet(walletId: String, newName: String) {
- let devices = TrezorKnownDeviceStorage.loadAll()
+ let devices = HwKnownDeviceStorage.loadAll(vendor: .trezor)
guard devices.contains(where: { $0.resolvedWalletId == walletId }) else { return }
applyCustomLabel(newName, to: devices) { $0.resolvedWalletId == walletId }
@@ -627,13 +660,13 @@ final class TrezorManager {
private func applyCustomLabel(
_ newName: String,
- to devices: [TrezorKnownDevice],
- matching isTarget: (TrezorKnownDevice) -> Bool
+ to devices: [HwKnownDevice],
+ matching isTarget: (HwKnownDevice) -> Bool
) {
let trimmed = String(newName.trimmingCharacters(in: .whitespacesAndNewlines).prefix(Self.deviceLabelMaxLength))
let customLabel = trimmed.isEmpty ? nil : trimmed
- let updated = devices.map { device -> TrezorKnownDevice in
+ let updated = devices.map { device -> HwKnownDevice in
guard isTarget(device) else { return device }
var copy = device
copy.customLabel = customLabel
@@ -643,9 +676,9 @@ final class TrezorManager {
// behind would resurface the moment the user clears this label, resurrecting a name they
// replaced. Safe to drop first — the entry already carries whatever it adopted.
for walletId in Set(devices.filter(isTarget).compactMap(\.resolvedWalletId)) {
- TrezorKnownDeviceStorage.setPendingName(walletId: walletId, name: nil)
+ HwKnownDeviceStorage.setPendingName(walletId: walletId, name: nil)
}
- TrezorKnownDeviceStorage.saveAll(updated)
+ HwKnownDeviceStorage.saveAll(updated, vendor: .trezor)
loadKnownDevices()
}
@@ -660,12 +693,12 @@ final class TrezorManager {
@discardableResult
func saveCurrentDeviceAsKnown() async -> Bool {
guard let device = connectedDevice else { return false }
- let stored = TrezorKnownDeviceStorage.loadAll()
+ let stored = HwKnownDeviceStorage.loadAll(vendor: .trezor)
let (fetched, transientFailures) = await fetchAccountXpubs()
// Not matched by transport id alone: a passphrase wallet is a separate identity on the same
// device, so that would overwrite another identity or blend two identities' xpubs into one
// record. Shared key material is the identity.
- let previous = TrezorKnownDeviceMatching.previous(in: stored, deviceId: device.id, fetchedXpubs: fetched)
+ let previous = HwKnownDeviceMatching.previous(in: stored, deviceId: device.id, fetchedXpubs: fetched)
let mergedXpubs = (previous?.xpubs ?? [:]).merging(fetched) { _, new in new }
guard !mergedXpubs.isEmpty else {
@@ -684,17 +717,17 @@ final class TrezorManager {
// The label belongs to the wallet, not to the transport it happens to be reached over, so a
// wallet showing up on a new path keeps the name the user gave it.
- let identityKey = TrezorKnownDevice.walletKey(for: mergedXpubs, fallback: device.id)
- let named = TrezorKnownDeviceMatching.named(in: stored, previous: previous, walletKey: identityKey)
+ let identityKey = HwKnownDevice.walletKey(for: mergedXpubs, fallback: device.id)
+ let named = HwKnownDeviceMatching.named(in: stored, previous: previous, walletKey: identityKey)
// A name restored from a backup, or kept when this wallet was removed, waits as a pending one
// until the wallet is paired again — which is here. A name set locally wins: it was chosen on
// this device, after the backup was written. Adopting it is all the consuming needed, since
// `loadPendingNames` masks out wallets the device list already names.
let walletId = resolvedWalletId(previous: previous, identityKey: identityKey, xpubs: mergedXpubs, in: stored)
- let pendingName = walletId.flatMap { TrezorKnownDeviceStorage.loadPendingNames()[$0] }
+ let pendingName = walletId.flatMap { HwKnownDeviceStorage.loadPendingNames()[$0] }
- let known = TrezorKnownDevice(
+ let known = HwKnownDevice(
id: device.id,
name: device.name ?? "Trezor",
path: device.path,
@@ -706,9 +739,10 @@ final class TrezorManager {
customLabel: named?.customLabel ?? pendingName,
walletId: walletId,
passphraseProtected: passphraseProtection(previous: previous),
- trezorDeviceId: deviceFeatures?.deviceId ?? previous?.trezorDeviceId
+ trezorDeviceId: deviceFeatures?.deviceId ?? previous?.trezorDeviceId,
+ vendor: .trezor
)
- TrezorKnownDeviceStorage.saveAll(TrezorKnownDeviceMatching.merged(stored, with: known, refreshed: previous))
+ HwKnownDeviceStorage.saveAll(HwKnownDeviceMatching.merged(stored, with: known, refreshed: previous), vendor: .trezor)
loadKnownDevices()
connectedWalletId = known.resolvedWalletId
trezorLog("Saved known device: \(known.name) with \(mergedXpubs.count) xpubs")
@@ -716,24 +750,24 @@ final class TrezorManager {
}
private func resolvedWalletId(
- previous: TrezorKnownDevice?,
+ previous: HwKnownDevice?,
identityKey: String,
xpubs: [String: String],
- in stored: [TrezorKnownDevice]
+ in stored: [HwKnownDevice]
) -> String? {
if let carried = previous?.walletId ?? stored.first(where: { $0.walletKey == identityKey })?.walletId,
!carried.isEmpty
{
return carried
}
- return try? HwWalletId.derive(xpubs: xpubs)
+ return try? HwWalletId.derive(xpubs: xpubs, vendor: .trezor)
}
/// The selection that derived these keys is authoritative, so a wallet wrongly marked hidden is
/// corrected the next time it is opened rather than staying gated behind a passphrase forever.
/// On-device entry cannot say which wallet was opened, so it keeps what the entry already knew
/// and assumes hidden only for one it has never seen.
- private func passphraseProtection(previous: TrezorKnownDevice?) -> Bool {
+ private func passphraseProtection(previous: HwKnownDevice?) -> Bool {
switch uiHandler.currentSelection() {
case .standard: false
case .hidden: true
@@ -809,7 +843,7 @@ final class TrezorManager {
if let device = known {
await clearCredentials(path: device.path)
}
- TrezorKnownDeviceStorage.remove(id: id)
+ HwKnownDeviceStorage.remove(id: id, vendor: .trezor)
loadKnownDevices()
trezorLog("Forgot device: \(id)")
@@ -823,7 +857,7 @@ final class TrezorManager {
/// cleared once none remains — dropping them while a sibling is still paired would leave that
/// wallet unable to reconnect.
func forgetWallet(walletId: String, pendingName: PendingHwWalletName? = nil) async {
- let stored = TrezorKnownDeviceStorage.loadAll()
+ let stored = HwKnownDeviceStorage.loadAll(vendor: .trezor)
let forgotten = stored.filter { $0.resolvedWalletId == walletId }
guard !forgotten.isEmpty else {
trezorLog("Nothing to forget for hardware wallet '\(walletId)'", level: "warn")
@@ -835,7 +869,7 @@ final class TrezorManager {
await clearCredentials(path: entry.path)
}
- TrezorKnownDeviceStorage.saveAll(remaining, pendingName: pendingName)
+ HwKnownDeviceStorage.saveAll(remaining, vendor: .trezor, pendingName: pendingName)
loadKnownDevices()
trezorLog("Forgot hardware wallet: \(walletId)")
@@ -858,6 +892,22 @@ final class TrezorManager {
// MARK: - Auto-Reconnect
+ func startAutoReconnect() {
+ guard autoReconnectTask == nil else { return }
+ autoReconnectGeneration &+= 1
+ let generation = autoReconnectGeneration
+ autoReconnectTask = Task { [weak self] in
+ await self?.autoReconnect()
+ guard let self, autoReconnectGeneration == generation else { return }
+ autoReconnectTask = nil
+ }
+ }
+
+ private func cancelAutoReconnect() {
+ autoReconnectTask?.cancel()
+ autoReconnectTask = nil
+ }
+
func autoReconnect() async {
do {
try await withConnectionOperation {
@@ -1024,7 +1074,7 @@ final class TrezorManager {
/// Reconstruct a `TrezorDeviceInfo` for reconnecting to a known BLE device when a fresh scan
/// hasn't surfaced it (BLE devices advertise intermittently).
- private func deviceInfo(from known: TrezorKnownDevice) -> TrezorDeviceInfo {
+ private func deviceInfo(from known: HwKnownDevice) -> TrezorDeviceInfo {
TrezorDeviceInfo(
id: known.id,
transportType: known.transportType == "bluetooth" ? .bluetooth : .usb,
diff --git a/Bitkit/Models/HwWallet.swift b/Bitkit/Models/HwWallet.swift
index 0a7f81a28..681c024b9 100644
--- a/Bitkit/Models/HwWallet.swift
+++ b/Bitkit/Models/HwWallet.swift
@@ -24,6 +24,8 @@ struct HwWallet: Identifiable {
let deviceIds: Set
/// Whether reaching this wallet needs a passphrase, i.e. it is a hidden wallet.
let passphraseProtected: Bool
+ /// The maker of the device holding this wallet, which decides how it is reached and signed with.
+ let vendor: HwWalletVendor
init(
id: String,
@@ -34,7 +36,8 @@ struct HwWallet: Identifiable {
balanceSats: UInt64,
fundingBalanceSats: UInt64? = nil,
deviceIds: Set? = nil,
- passphraseProtected: Bool = false
+ passphraseProtected: Bool = false,
+ vendor: HwWalletVendor = .trezor
) {
self.id = id
self.walletId = walletId
@@ -45,6 +48,7 @@ struct HwWallet: Identifiable {
self.fundingBalanceSats = fundingBalanceSats ?? balanceSats
self.deviceIds = deviceIds ?? [id]
self.passphraseProtected = passphraseProtected
+ self.vendor = vendor
}
}
diff --git a/Bitkit/Models/HwWalletId.swift b/Bitkit/Models/HwWalletId.swift
index 0f1c45cf8..26baa8c24 100644
--- a/Bitkit/Models/HwWalletId.swift
+++ b/Bitkit/Models/HwWalletId.swift
@@ -7,8 +7,9 @@ import Foundation
/// iOS and Android produce identical ids for the same device.
enum HwWalletId {
/// Deterministic id derived from the device's account xpubs (transport-independent: the
- /// same physical device shares its xpubs, hence its id). Throws if `xpubs` is empty.
- static func derive(xpubs: [String: String], deviceType: String = "trezor") throws -> String {
- try deriveWalletId(deviceType: deviceType, xpubs: Array(xpubs.values))
+ /// same physical device shares its xpubs, hence its id), in the vendor's namespace, so equal
+ /// xpubs on two vendors derive two ids. Throws if `xpubs` is empty.
+ static func derive(xpubs: [String: String], vendor: HwWalletVendor = .trezor) throws -> String {
+ try deriveWalletId(deviceType: vendor.deviceType, xpubs: Array(xpubs.values))
}
}
diff --git a/Bitkit/Models/HwWalletVendor.swift b/Bitkit/Models/HwWalletVendor.swift
new file mode 100644
index 000000000..198459164
--- /dev/null
+++ b/Bitkit/Models/HwWalletVendor.swift
@@ -0,0 +1,17 @@
+import Foundation
+
+/// The maker of a paired hardware wallet. Stored on every paired entry, so each device call is routed
+/// to the manager that speaks that vendor's protocol.
+enum HwWalletVendor: String, Codable, CaseIterable, Sendable {
+ case trezor
+ case blockstream
+
+ /// Namespace passed to bitkit-core's `deriveWalletId`. Every wallet id carries it, so it must never
+ /// change: equal seeds on two vendors then derive two wallets.
+ var deviceType: String {
+ switch self {
+ case .trezor: "trezor"
+ case .blockstream: "jade"
+ }
+ }
+}
diff --git a/Bitkit/Models/JadeDevice.swift b/Bitkit/Models/JadeDevice.swift
new file mode 100644
index 000000000..4ec567cbd
--- /dev/null
+++ b/Bitkit/Models/JadeDevice.swift
@@ -0,0 +1,81 @@
+import BitkitCore
+import Foundation
+
+/// The live Jade session: the paired entry it belongs to and the state the device last reported.
+struct ConnectedJadeDevice: Equatable {
+ let id: String
+ let path: String
+ var versionInfo: JadeVersionInfo
+ /// Wallet the session holds; nil when its accounts could not be resolved to one.
+ let walletId: String?
+
+ var isLocked: Bool {
+ versionInfo.jadeState == .locked
+ }
+
+ var model: String {
+ JadeDeviceIdentity.model(boardType: versionInfo.boardType)
+ }
+
+ func matches(_ deviceId: String) -> Bool {
+ id == deviceId || path == deviceId
+ }
+}
+
+/// How a Jade is recognised across connections. Its Bluetooth identifier changes after a reboot or a
+/// pairing reset, while its efuse MAC does not, so the MAC is the identity.
+enum JadeDeviceIdentity {
+ /// A Jade advertises as "Jade" followed by the last six hex digits of its efuse MAC.
+ static let nameSuffixLength = 6
+ static let defaultName = "Jade"
+
+ /// Stable entry id from the efuse MAC, so a Jade reached under a new Bluetooth identifier refreshes
+ /// its entry instead of adding one. Nil when the device reported no MAC.
+ static func deviceId(efuseMac: String?) -> String? {
+ guard let efuseMac, !efuseMac.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
+ return "\(HwWalletVendor.blockstream.deviceType):bluetooth:\(efuseMac)"
+ }
+
+ /// Jade Plus reports a v2 board; every other board is the original Jade.
+ static func model(boardType: String?) -> String {
+ boardType?.uppercased().contains("V2") == true ? "Jade Plus" : defaultName
+ }
+
+ /// Whether `name` is what the Jade with `jadeDeviceId` advertises as. A MAC too short to carry the
+ /// suffix proves nothing, so it never matches.
+ static func advertises(_ name: String?, jadeDeviceId: String?) -> Bool {
+ guard let name, let jadeDeviceId, jadeDeviceId.count >= nameSuffixLength else { return false }
+ let suffix = jadeDeviceId.suffix(nameSuffixLength)
+ guard !suffix.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false }
+ return name.lowercased().hasSuffix(suffix.lowercased())
+ }
+}
+
+extension JadeState {
+ /// Whether the device can serve requests that need its keys.
+ var isUnlocked: Bool {
+ self == .ready || self == .temp
+ }
+}
+
+extension HwKnownDevice {
+ func matches(deviceId: String) -> Bool {
+ id == deviceId || path == deviceId
+ }
+
+ func advertisesAs(_ name: String?) -> Bool {
+ JadeDeviceIdentity.advertises(name, jadeDeviceId: jadeDeviceId)
+ }
+
+ /// Whether a scanned Jade is this paired one, even when it came back under a new Bluetooth
+ /// identifier after a reboot.
+ func isSameJade(as device: JadeDeviceInfo) -> Bool {
+ switch device.transport {
+ case .bluetooth:
+ path == device.path || advertisesAs(device.name)
+ case .serial:
+ // A plugged-in Jade cannot be told from a paired one before connecting.
+ transportType == "usb"
+ }
+ }
+}
diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings
index 9c968fc6d..6b00f4679 100644
--- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings
@@ -43,21 +43,32 @@
"hardware__search_error" = "Could not search for hardware wallets. Check your connection and try again.";
"hardware__pairing_code_invalid" = "Incorrect pairing code. Put your Trezor back in pairing mode and try again.";
"hardware__device_busy" = "Your Trezor is busy. Unlock it on the device, then try again.";
+"hardware__device_model_jade" = "Jade";
"hardware__device_model_trezor" = "Trezor";
"hardware__bluetooth_open_settings" = "Open Settings";
"hardware__bluetooth_off_title" = "Bluetooth is Off";
-"hardware__bluetooth_off_text" = "Please enable Bluetooth in Settings to connect to your Trezor.";
+"hardware__bluetooth_off_text" = "Please enable Bluetooth in Settings to connect to your hardware wallet.";
"hardware__bluetooth_unauthorized_title" = "Bluetooth Unauthorized";
-"hardware__bluetooth_unauthorized_text" = "Bitkit needs Bluetooth permission to connect to your Trezor. Please enable it in Settings.";
+"hardware__bluetooth_unauthorized_text" = "Bitkit needs Bluetooth permission to connect to your hardware wallet. Please enable it in Settings.";
"hardware__bluetooth_unsupported_title" = "Bluetooth Unsupported";
"hardware__bluetooth_unsupported_text" = "This device does not support Bluetooth Low Energy.";
"hardware__bluetooth_unavailable_title" = "Bluetooth Unavailable";
"hardware__bluetooth_unavailable_text" = "Bluetooth is not available. Please check your device settings.";
"hardware__found_title" = "Found Device";
"hardware__found_header" = "Found\nTrezor";
+"hardware__found_header_jade" = "Found\nJade";
"hardware__found_text" = "Would you like to securely pair this {model} with Bitkit?";
+"hardware__jade_device_busy" = "Your Jade is busy. Finish what is shown on the device, then try again.";
+"hardware__jade_enter_pin" = "Enter your PIN on your Jade to unlock it.";
+"hardware__jade_firmware_outdated" = "Your Jade firmware is too old for this action. Update it with the Blockstream app, then try again.";
+"hardware__jade_invalid_pin" = "Wrong PIN. Try again on your Jade.";
+"hardware__jade_network_mismatch" = "Your Jade is set up for a different Bitcoin network.";
+"hardware__jade_pinserver_error" = "Could not reach the Jade PIN server. Check your internet connection and try again.";
+"hardware__jade_psbt_too_large" = "This transaction is too large for Jade to sign. Try sending a smaller amount.";
+"hardware__jade_uninitialized" = "This Jade has not been set up yet. Create or restore a wallet on the device, then try again.";
"hardware__paired_title" = "Device Connected";
"hardware__paired_header" = "Paired\nTrezor";
+"hardware__paired_header_jade" = "Paired\nJade";
"hardware__paired_text" = "Bitkit found funds on your device and added these to your balance.";
"hardware__paired_label" = "Label Funds";
"hardware__paired_finish" = "Finish";
@@ -75,6 +86,7 @@
"hardware__pairing_title" = "Pair Device";
"hardware__pairing_text" = "Enter the 6-digit code shown on your hardware device.";
"hardware__receive_address_error" = "Could not load the hardware wallet address.";
+"hardware__receive_tab_hardware" = "Hardware";
"hardware__remove_button" = "Remove {name}";
"hardware__remove_dialog_title" = "Remove {name}";
"hardware__remove_dialog_text" = "Don't worry, your funds are safe and your coins won't be deleted. Bitkit will simply stop displaying the amounts in the wallet.";
@@ -85,6 +97,7 @@
"hardware__send_broadcast_failed_title" = "Payment not confirmed";
"hardware__send_confirm_address" = "To address (confirm on device)";
"hardware__send_open_connect" = "Open Trezor Connect";
+"hardware__send_open_connect_jade" = "Sign With Jade";
"hardware__send_sign_title" = "Sign With Device";
"hardware__verify_address" = "Verify on Device";
"hardware__verify_address_error" = "Address verification failed. Check the address on your device and try again.";
diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift
index bbb13edbc..a5deb96cd 100644
--- a/Bitkit/Services/BackupService.swift
+++ b/Bitkit/Services/BackupService.swift
@@ -266,7 +266,7 @@ class BackupService {
// App-owned, so it takes no part in the core field migration above and never sets
// needsRewrite. Restored names wait as pending ones until each wallet is paired again.
- TrezorKnownDeviceStorage.restoreNames(payload.hwWalletNames ?? [:])
+ HwKnownDeviceStorage.restoreNames(payload.hwWalletNames ?? [:])
// Force address rotation by clearing onchain address
UserDefaults.standard.set("", forKey: "onchainAddress")
@@ -430,7 +430,7 @@ class BackupService {
// METADATA (hardware wallet names). Scoped to the names alone: the known-device store is also
// rewritten by every connect, and reconnect traffic must not re-upload the whole envelope.
- TrezorKnownDeviceStorage.namesChangedPublisher
+ HwKnownDeviceStorage.namesChangedPublisher
.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
.sink { [weak self] _ in
guard let self, !self.shouldSkipBackup() else { return }
@@ -792,7 +792,7 @@ class BackupService {
// A UserDefaults read that cannot fail, so unlike the tags above there is no partial-read
// case to guard against. Nil rather than an empty map when nothing is named, so an
// envelope this app writes stays byte-comparable with one bitkit-android writes.
- let hwWalletNames = TrezorKnownDeviceStorage.backupSnapshot()
+ let hwWalletNames = HwKnownDeviceStorage.backupSnapshot()
let payload = MetadataBackupV1(
version: 1,
diff --git a/Bitkit/Services/Trezor/TrezorKnownDeviceMatching.swift b/Bitkit/Services/HwKnownDeviceMatching.swift
similarity index 75%
rename from Bitkit/Services/Trezor/TrezorKnownDeviceMatching.swift
rename to Bitkit/Services/HwKnownDeviceMatching.swift
index f2c0e88d4..de6ba51b4 100644
--- a/Bitkit/Services/Trezor/TrezorKnownDeviceMatching.swift
+++ b/Bitkit/Services/HwKnownDeviceMatching.swift
@@ -5,15 +5,15 @@ import Foundation
/// A passphrase wallet is a separate identity on the same physical device, so the transport id
/// alone no longer identifies an entry: matching by it would overwrite another identity or blend
/// two identities' xpubs into one record. Shared key material is the identity.
-enum TrezorKnownDeviceMatching {
+enum HwKnownDeviceMatching {
/// The entry this connect refreshed: among the entries of this transport, the one whose xpubs
/// overlap the freshly read set. Only an entry stored before any xpub was captured has no
/// identity to conflict with and can be adopted instead. Anything else is a new identity.
static func previous(
- in devices: [TrezorKnownDevice],
+ in devices: [HwKnownDevice],
deviceId: String,
fetchedXpubs: [String: String]
- ) -> TrezorKnownDevice? {
+ ) -> HwKnownDevice? {
let candidates = devices.filter { $0.id == deviceId }
let fetched = Set(fetchedXpubs.values)
if let overlapping = candidates.first(where: { !Set($0.xpubs.values).isDisjoint(with: fetched) }) {
@@ -27,19 +27,19 @@ enum TrezorKnownDeviceMatching {
/// not for the transport it happens to be reached over, so a wallet showing up on a new path
/// keeps the name the user gave it instead of falling back to the device's own.
static func named(
- in devices: [TrezorKnownDevice],
- previous: TrezorKnownDevice?,
+ in devices: [HwKnownDevice],
+ previous: HwKnownDevice?,
walletKey: String
- ) -> TrezorKnownDevice? {
+ ) -> HwKnownDevice? {
previous ?? devices.first { $0.walletKey == walletKey }
}
/// The stored list after `known` supersedes what it replaces.
static func merged(
- _ devices: [TrezorKnownDevice],
- with known: TrezorKnownDevice,
- refreshed: TrezorKnownDevice?
- ) -> [TrezorKnownDevice] {
+ _ devices: [HwKnownDevice],
+ with known: HwKnownDevice,
+ refreshed: HwKnownDevice?
+ ) -> [HwKnownDevice] {
devices.filter { !isReplaced($0, by: known, refreshed: refreshed) } + [known]
}
@@ -47,20 +47,21 @@ enum TrezorKnownDeviceMatching {
/// the entry this connect refreshed, since reading a previously rejected address type changes
/// the wallet key and matching on the new key alone would leave the old entry behind as a
/// duplicate. Wallets of a seed the device no longer carries go too: nothing would ever
- /// supersede them by key material. An unknown device id proves nothing, so those are left alone.
+ /// supersede them by key material. An unknown device id proves nothing, so those are left alone,
+ /// and so is every entry of another vendor, even one holding the same seed.
private static func isReplaced(
- _ entry: TrezorKnownDevice,
- by known: TrezorKnownDevice,
- refreshed: TrezorKnownDevice?
+ _ entry: HwKnownDevice,
+ by known: HwKnownDevice,
+ refreshed: HwKnownDevice?
) -> Bool {
- guard entry.id == known.id else { return false }
+ guard entry.vendor == known.vendor, entry.id == known.id else { return false }
if entry.walletKey == known.walletKey {
return true
}
if let refreshed, entry.walletKey == refreshed.walletKey {
return true
}
- guard let knownTrezorId = known.trezorDeviceId, let entryTrezorId = entry.trezorDeviceId else { return false }
- return entryTrezorId != knownTrezorId
+ guard let knownHardwareId = known.hardwareId, let entryHardwareId = entry.hardwareId else { return false }
+ return entryHardwareId != knownHardwareId
}
}
diff --git a/Bitkit/Services/Trezor/TrezorKnownDeviceStorage.swift b/Bitkit/Services/HwKnownDeviceStorage.swift
similarity index 54%
rename from Bitkit/Services/Trezor/TrezorKnownDeviceStorage.swift
rename to Bitkit/Services/HwKnownDeviceStorage.swift
index 3b0850552..793b12603 100644
--- a/Bitkit/Services/Trezor/TrezorKnownDeviceStorage.swift
+++ b/Bitkit/Services/HwKnownDeviceStorage.swift
@@ -1,10 +1,10 @@
import Combine
import Foundation
-/// One wallet identity a Trezor holds. A device with passphrase protection carries its standard
-/// wallet plus one entry per passphrase (hidden) wallet, so `id` — the transport-level device id —
-/// is shared by several entries and no longer identifies one on its own.
-struct TrezorKnownDevice: Codable, Identifiable {
+/// One wallet identity a paired hardware wallet holds. A Trezor with passphrase protection carries
+/// its standard wallet plus one entry per passphrase (hidden) wallet, so `id` (the transport-level
+/// device id) is shared by several entries and no longer identifies one on its own.
+struct HwKnownDevice: Codable, Equatable, Identifiable {
let id: String
let name: String
let path: String
@@ -22,13 +22,22 @@ struct TrezorKnownDevice: Codable, Identifiable {
/// existed, where `resolvedWalletId` derives it from `xpubs` instead.
var walletId: String?
/// Whether this entry is a passphrase (hidden) wallet. Nothing else in the record can tell one
- /// apart from the standard wallet — the xpubs are opaque and the selected mode only lives in
+ /// apart from the standard wallet: the xpubs are opaque and the selected mode only lives in
/// memory, so reconnects would silently fall back to the standard wallet without this. The
/// passphrase itself is never persisted.
var passphraseProtected: Bool
/// The Trezor's own device id, which it regenerates when wiped. Entries of the same transport
/// reporting a different one belong to a seed the device can no longer sign for.
var trezorDeviceId: String?
+ /// The maker of the device. Entries stored before other vendors existed carry none: they are
+ /// Trezor ones, unless their ids sit in the Jade namespace.
+ let vendor: HwWalletVendor
+ /// The Jade's own device id (its efuse MAC), which outlives a change of Bluetooth identifier.
+ var jadeDeviceId: String?
+ /// The stored vendor of an entry a newer build wrote for a vendor this one does not know. Such an
+ /// entry belongs to no vendor's slice and is written back with its vendor unchanged, so rolling
+ /// back never drops a wallet paired on the newer build.
+ private(set) var unknownVendor: String?
init(
id: String,
@@ -42,7 +51,9 @@ struct TrezorKnownDevice: Codable, Identifiable {
customLabel: String? = nil,
walletId: String? = nil,
passphraseProtected: Bool = false,
- trezorDeviceId: String? = nil
+ trezorDeviceId: String? = nil,
+ vendor: HwWalletVendor = .trezor,
+ jadeDeviceId: String? = nil
) {
self.id = id
self.name = name
@@ -56,6 +67,25 @@ struct TrezorKnownDevice: Codable, Identifiable {
self.walletId = walletId
self.passphraseProtected = passphraseProtected
self.trezorDeviceId = trezorDeviceId
+ self.vendor = vendor
+ self.jadeDeviceId = jadeDeviceId
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case id
+ case name
+ case path
+ case transportType
+ case label
+ case model
+ case lastConnectedAt
+ case xpubs
+ case customLabel
+ case walletId
+ case passphraseProtected
+ case trezorDeviceId
+ case vendor
+ case jadeDeviceId
}
init(from decoder: any Decoder) throws {
@@ -72,34 +102,100 @@ struct TrezorKnownDevice: Codable, Identifiable {
walletId = try container.decodeIfPresent(String.self, forKey: .walletId)
passphraseProtected = try container.decodeIfPresent(Bool.self, forKey: .passphraseProtected) ?? false
trezorDeviceId = try container.decodeIfPresent(String.self, forKey: .trezorDeviceId)
+ jadeDeviceId = try container.decodeIfPresent(String.self, forKey: .jadeDeviceId)
+
+ // Decoded as a string, not as the enum: an unknown value would otherwise fail the whole
+ // device list, and the next write would then drop every paired wallet.
+ let storedVendor = try? container.decodeIfPresent(String.self, forKey: .vendor)
+ vendor = storedVendor.flatMap(HwWalletVendor.init(rawValue:)) ?? Self.inferredVendor(id: id, walletId: walletId)
+ unknownVendor = storedVendor.flatMap { HwWalletVendor(rawValue: $0) == nil ? $0 : nil }
+ }
+
+ func encode(to encoder: any Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ try container.encode(id, forKey: .id)
+ try container.encode(name, forKey: .name)
+ try container.encode(path, forKey: .path)
+ try container.encode(transportType, forKey: .transportType)
+ try container.encodeIfPresent(label, forKey: .label)
+ try container.encodeIfPresent(model, forKey: .model)
+ try container.encode(lastConnectedAt, forKey: .lastConnectedAt)
+ try container.encode(xpubs, forKey: .xpubs)
+ try container.encodeIfPresent(customLabel, forKey: .customLabel)
+ try container.encodeIfPresent(walletId, forKey: .walletId)
+ try container.encode(passphraseProtected, forKey: .passphraseProtected)
+ try container.encodeIfPresent(trezorDeviceId, forKey: .trezorDeviceId)
+ try container.encode(unknownVendor ?? vendor.rawValue, forKey: .vendor)
+ try container.encodeIfPresent(jadeDeviceId, forKey: .jadeDeviceId)
+ }
+
+ private static func inferredVendor(id: String, walletId: String?) -> HwWalletVendor {
+ let jadeNamespace = "\(HwWalletVendor.blockstream.deviceType):"
+ return id.hasPrefix(jadeNamespace) || walletId?.hasPrefix(jadeNamespace) == true ? .blockstream : .trezor
}
}
-extension TrezorKnownDevice {
+extension HwKnownDevice {
/// Identity of the key material this entry holds: entries sharing it are the same wallet, on
/// this device or on another transport. An entry read before any xpub was captured has no key
/// material to compare, so it falls back to its transport id.
var walletKey: String {
- TrezorKnownDevice.walletKey(for: xpubs, fallback: id)
+ HwKnownDevice.walletKey(for: xpubs, fallback: id)
}
static func walletKey(for xpubs: [String: String], fallback: String) -> String {
xpubs.isEmpty ? fallback : xpubs.values.sorted().joined(separator: "\u{1f}")
}
- /// Wallet id of this identity: the stored one, or derived from the xpubs for entries written
- /// before it was persisted. The derivation is unchanged, so those keep the id they always had.
+ /// Wallet id of this identity: the stored one, or derived from the xpubs in the vendor's namespace
+ /// for entries written before it was persisted. The derivation is unchanged, so those keep the id
+ /// they always had.
var resolvedWalletId: String? {
if let walletId, !walletId.isEmpty {
return walletId
}
- return try? HwWalletId.derive(xpubs: xpubs)
+ return try? HwWalletId.derive(xpubs: xpubs, vendor: vendor)
}
/// Stable key for lists and diffing, since `id` is shared by every identity of one device.
var entryId: String {
"\(id)\u{1f}\(walletKey)"
}
+
+ /// The vendor's own stable device id, when the device reported one.
+ var hardwareId: String? {
+ switch vendor {
+ case .trezor: trezorDeviceId
+ case .blockstream: jadeDeviceId
+ }
+ }
+
+ /// Whether this entry is part of `vendor`'s slice of the store.
+ func belongs(to vendor: HwWalletVendor) -> Bool {
+ unknownVendor == nil && self.vendor == vendor
+ }
+
+ /// This entry as last reached over `path` at `date`, keeping everything else it holds.
+ func refreshed(path: String, at date: Date) -> HwKnownDevice {
+ var refreshed = HwKnownDevice(
+ id: id,
+ name: name,
+ path: path,
+ transportType: transportType,
+ label: label,
+ model: model,
+ lastConnectedAt: date,
+ xpubs: xpubs,
+ customLabel: customLabel,
+ walletId: walletId,
+ passphraseProtected: passphraseProtected,
+ trezorDeviceId: trezorDeviceId,
+ vendor: vendor,
+ jadeDeviceId: jadeDeviceId
+ )
+ refreshed.unknownVendor = unknownVendor
+ return refreshed
+ }
}
/// A pending-name change to apply together with a device-list write; a nil `name` drops the entry.
@@ -108,60 +204,69 @@ struct PendingHwWalletName: Equatable {
let name: String?
}
-/// Persists known Trezor device metadata in UserDefaults
-/// THP credentials remain in Keychain via TrezorCredentialStorage
-enum TrezorKnownDeviceStorage {
+/// Persists paired hardware wallet entries of every vendor in UserDefaults. Each vendor's manager
+/// reads and writes only its own slice; the wallet names and their backup span every vendor.
+/// THP credentials remain in Keychain via TrezorCredentialStorage.
+enum HwKnownDeviceStorage {
/// Fires when the set of hardware wallet names changes, so the metadata backup can be marked
/// stale. Every connect rewrites the device list to refresh `lastConnectedAt`, and reconnect
/// traffic must not re-upload the whole envelope, so this only fires on a real name change.
static let namesChangedPublisher = namesChangedSubject.eraseToAnyPublisher()
+ // Named before other vendors existed; renaming them would unpair every stored wallet.
private static let key = "trezor.knownDevices"
private static let pendingNamesKey = "trezor.pendingWalletNames"
private static let namesChangedSubject = PassthroughSubject()
- /// Load all known devices, sorted by most recently connected
- static func loadAll() -> [TrezorKnownDevice] {
- guard let data = UserDefaults.standard.data(forKey: key) else { return [] }
- let devices = (try? JSONDecoder().decode([TrezorKnownDevice].self, from: data)) ?? []
- return devices.sorted { $0.lastConnectedAt > $1.lastConnectedAt }
+ /// Load the known devices of `vendor`, or of every vendor when nil, most recently connected first.
+ /// Entries of a vendor this build does not know are never returned.
+ static func loadAll(vendor: HwWalletVendor? = nil) -> [HwKnownDevice] {
+ storedDevices()
+ .filter { $0.unknownVendor == nil && (vendor == nil || $0.vendor == vendor) }
+ .sorted { $0.lastConnectedAt > $1.lastConnectedAt }
}
- /// Save or update one wallet identity. Scoped to the identity rather than to the transport it
- /// was reached over, so a passphrase wallet is stored next to the device's standard wallet
- /// instead of replacing it.
- static func save(_ device: TrezorKnownDevice) {
- var devices = loadAll()
+ /// Save or update one wallet identity within its vendor's slice. Scoped to the identity rather
+ /// than to the transport it was reached over, so a passphrase wallet is stored next to the
+ /// device's standard wallet instead of replacing it.
+ static func save(_ device: HwKnownDevice) {
+ var devices = loadAll(vendor: device.vendor)
devices.removeAll { $0.id == device.id && $0.walletKey == device.walletKey }
devices.insert(device, at: 0)
- saveAll(devices)
+ saveAll(devices, vendor: device.vendor)
}
- /// Persist the full device list as-is. Used for bulk updates (e.g. renaming every entry of a
- /// device shared across transports) without per-device reordering.
+ /// Replace `vendor`'s slice with `devices` as-is, keeping every other vendor's entries. Used for
+ /// bulk updates (e.g. renaming every entry of a device shared across transports) without
+ /// per-device reordering.
///
+ /// - Parameter vendor: the slice being replaced. Required, so a vendor's manager can never write
+ /// away the entries of another vendor.
/// - Parameter pendingName: a pending-name change to apply in the same call, or nil to leave the
/// pending names alone. It is written *first*: crashing between the two writes then leaves a name
/// recorded for a wallet that is still paired, which the next pairing masks away, rather than a
/// forgotten wallet whose name was recorded nowhere.
- static func saveAll(_ devices: [TrezorKnownDevice], pendingName: PendingHwWalletName? = nil) {
+ static func saveAll(_ devices: [HwKnownDevice], vendor: HwWalletVendor, pendingName: PendingHwWalletName? = nil) {
+ let slice = devices.filter { $0.belongs(to: vendor) }
+ assert(slice.count == devices.count, "Only \(vendor) entries belong in the \(vendor) slice")
let previousNames = backupSnapshot()
if let pendingName {
writePendingName(pendingName)
}
- writeDevices(devices)
+ writeDevices(loadAll().filter { !$0.belongs(to: vendor) } + slice)
notifyIfNamesChanged(from: previousNames)
}
/// Entries tracking one wallet identity.
- static func loadAll(walletId: String) -> [TrezorKnownDevice] {
+ static func loadAll(walletId: String) -> [HwKnownDevice] {
loadAll().filter { $0.resolvedWalletId == walletId }
}
- /// Forget every identity of a device, whichever wallets it holds.
- static func remove(id: String) {
+ /// Forget every identity of one of `vendor`'s devices, whichever wallets it holds.
+ static func remove(id: String, vendor: HwWalletVendor) {
let devices = loadAll()
- forget(devices.filter { $0.id == id }, keeping: devices.filter { $0.id != id })
+ let isTarget = { (device: HwKnownDevice) in device.id == id && device.belongs(to: vendor) }
+ forget(devices.filter(isTarget), keeping: devices.filter { !isTarget($0) })
}
/// Forget a single wallet identity, leaving the device's other wallets paired.
@@ -173,7 +278,7 @@ enum TrezorKnownDeviceStorage {
)
}
- /// Remove all remembered Trezor devices.
+ /// Remove every remembered hardware wallet, whatever its vendor.
static func removeAll() {
let previousNames = backupSnapshot()
UserDefaults.standard.removeObject(forKey: key)
@@ -181,9 +286,9 @@ enum TrezorKnownDeviceStorage {
notifyIfNamesChanged(from: previousNames)
}
- /// Check if a device is known
- static func isKnown(id: String) -> Bool {
- loadAll().contains { $0.id == id }
+ /// Whether a device is known, among `vendor`'s entries or among every vendor's when nil.
+ static func isKnown(id: String, vendor: HwWalletVendor? = nil) -> Bool {
+ loadAll(vendor: vendor).contains { $0.id == id }
}
// MARK: - Hardware wallet names
@@ -192,7 +297,7 @@ enum TrezorKnownDeviceStorage {
/// paired again, or kept when the wallet was removed.
///
/// A wallet the device list already names is masked out rather than pruned, so pairing consumes
- /// a pending name by simply adopting it — no second write that could be lost on its own.
+ /// a pending name by simply adopting it, with no second write that could be lost on its own.
static func loadPendingNames() -> [String: String] {
let paired = pairedNames()
return storedPendingNames().filter { paired[$0.key] == nil }
@@ -207,7 +312,7 @@ enum TrezorKnownDeviceStorage {
/// Every hardware wallet name this wallet knows, keyed by wallet id: the pending ones overlaid
/// with the name of each paired wallet. A paired name wins because it is what the user currently
- /// sees. Entries without a wallet id are skipped — only a device stored before any account key
+ /// sees. Entries without a wallet id are skipped: only a device stored before any account key
/// was captured has none, and such an entry is filtered out of the wallet list anyway, so it can
/// never have been named.
static func backupSnapshot() -> [String: String] {
@@ -216,7 +321,7 @@ enum TrezorKnownDeviceStorage {
/// Merges backed up names into the pending ones, so each is adopted the next time its wallet is
/// paired. Names already held locally win: they were set on this device after the backup was
- /// written. Never clears — an envelope without names predates the field and must not drop what is
+ /// written. Never clears: an envelope without names predates the field and must not drop what is
/// stored.
static func restoreNames(_ names: [String: String]) {
guard !names.isEmpty else { return }
@@ -229,7 +334,7 @@ enum TrezorKnownDeviceStorage {
/// Drop `forgotten` from the device list and with it any name kept for the wallets it held: a
/// removal that wanted to keep a name writes it back through `saveAll(_:pendingName:)` instead.
- private static func forget(_ forgotten: [TrezorKnownDevice], keeping remaining: [TrezorKnownDevice]) {
+ private static func forget(_ forgotten: [HwKnownDevice], keeping remaining: [HwKnownDevice]) {
let previousNames = backupSnapshot()
let remainingWalletIds = Set(remaining.compactMap(\.resolvedWalletId))
var pending = storedPendingNames()
@@ -241,8 +346,17 @@ enum TrezorKnownDeviceStorage {
notifyIfNamesChanged(from: previousNames)
}
- private static func writeDevices(_ devices: [TrezorKnownDevice]) {
- guard let data = try? JSONEncoder().encode(devices) else { return }
+ private static func storedDevices() -> [HwKnownDevice] {
+ guard let data = UserDefaults.standard.data(forKey: key) else { return [] }
+ return (try? JSONDecoder().decode([HwKnownDevice].self, from: data)) ?? []
+ }
+
+ /// Writes `devices` together with the entries of vendors this build does not know, which no
+ /// read ever returns and so no caller could pass back.
+ private static func writeDevices(_ devices: [HwKnownDevice]) {
+ let unknownVendorEntries = storedDevices().filter { $0.unknownVendor != nil }
+ let knownVendorEntries = devices.filter { $0.unknownVendor == nil }
+ guard let data = try? JSONEncoder().encode(knownVendorEntries + unknownVendorEntries) else { return }
UserDefaults.standard.set(data, forKey: key)
}
diff --git a/Bitkit/Services/Jade/JadeBLEError.swift b/Bitkit/Services/Jade/JadeBLEError.swift
new file mode 100644
index 000000000..0c5a3cf74
--- /dev/null
+++ b/Bitkit/Services/Jade/JadeBLEError.swift
@@ -0,0 +1,86 @@
+import BitkitCore
+import Foundation
+
+/// Why the Bluetooth link to a Jade failed.
+///
+/// Core hands these texts to the user: a failed open becomes `JadeError.ConnectionError` carrying the
+/// text, and any other failure without a transport code becomes `JadeError.TransportError` carrying
+/// it. Every case therefore reads as plain, user-facing English.
+enum JadeBLEError: LocalizedError, Equatable {
+ case invalidPath(String)
+ case bluetoothOff
+ case bluetoothUnauthorized
+ case bluetoothUnsupported
+ case bluetoothNotReady
+ case deviceNotFound
+ case connectTimeout
+ case connectFailed(String)
+ case notAJade
+ case subscribeFailed(String)
+ case pairingNotConfirmed
+ case staleBond
+ case notOpen
+ case disconnected
+ case closed
+ case writeTimeout
+ case writeFailed(String)
+
+ var errorDescription: String? {
+ switch self {
+ case let .invalidPath(path):
+ return "Invalid Jade Bluetooth path: \(path)"
+ case .bluetoothOff:
+ return "Bluetooth is turned off. Turn it on to reach your Jade."
+ case .bluetoothUnauthorized:
+ return "Bitkit is not allowed to use Bluetooth. Allow it in iOS Settings to reach your Jade."
+ case .bluetoothUnsupported:
+ return "This device does not support Bluetooth Low Energy."
+ case .bluetoothNotReady:
+ return "Bluetooth is not ready yet. Try again in a moment."
+ case .deviceNotFound:
+ return "Your Jade was not found nearby. Make sure it is on and close to your phone."
+ case .connectTimeout:
+ return "Could not reach your Jade over Bluetooth. Make sure it is on, nearby and not connected to another app."
+ case let .connectFailed(reason):
+ return "Could not connect to your Jade over Bluetooth (\(Self.detail(reason)))."
+ case .notAJade:
+ return "This Bluetooth device does not offer the Jade connection service."
+ case let .subscribeFailed(reason):
+ return "Your Jade did not accept the Bluetooth connection (\(Self.detail(reason)))."
+ case .pairingNotConfirmed:
+ return "Bluetooth pairing with your Jade was not confirmed. Try again and accept the pairing request."
+ case .staleBond:
+ return "Bluetooth pairing is no longer valid: forget the Jade in the iOS Bluetooth settings and pair it again."
+ case .notOpen:
+ return "Jade is not connected."
+ case .disconnected, .closed:
+ return "Your Jade disconnected."
+ case .writeTimeout:
+ return "Timed out sending data to your Jade."
+ case let .writeFailed(reason):
+ return "Sending data to your Jade failed (\(Self.detail(reason)))."
+ }
+ }
+
+ /// The code core turns into a typed `JadeError`. Nil keeps the text instead: core then reports a
+ /// `TransportError` carrying it, which is how the stale pairing advice reaches the user verbatim.
+ var transportErrorCode: JadeTransportErrorCode? {
+ switch self {
+ case .deviceNotFound, .notOpen:
+ return .notConnected
+ case .connectTimeout, .writeTimeout:
+ return .timeout
+ case .disconnected, .closed:
+ return .disconnected
+ case .invalidPath, .bluetoothOff, .bluetoothUnauthorized, .bluetoothUnsupported, .bluetoothNotReady, .connectFailed, .notAJade,
+ .subscribeFailed, .pairingNotConfirmed, .staleBond, .writeFailed:
+ return nil
+ }
+ }
+
+ private static func detail(_ reason: String) -> String {
+ let trimmed = reason.trimmingCharacters(in: .whitespacesAndNewlines)
+ let withoutFinalStop = trimmed.hasSuffix(".") ? String(trimmed.dropLast()) : trimmed
+ return withoutFinalStop.isEmpty ? "unknown error" : withoutFinalStop
+ }
+}
diff --git a/Bitkit/Services/Jade/JadeBLELinkState.swift b/Bitkit/Services/Jade/JadeBLELinkState.swift
new file mode 100644
index 000000000..6a3bb6d31
--- /dev/null
+++ b/Bitkit/Services/Jade/JadeBLELinkState.swift
@@ -0,0 +1,249 @@
+import Foundation
+
+/// One result handed from a CoreBluetooth delegate callback to the thread blocked on it. The first
+/// resolution wins, so a late callback can never overwrite a failure the waiter already acted on.
+final class BLEOneShot: @unchecked Sendable {
+ private let lock = NSLock()
+ private let semaphore = DispatchSemaphore(value: 0)
+ private var result: Result?
+
+ var isResolved: Bool {
+ lock.withLock { result != nil }
+ }
+
+ func resolve(_ result: Result) {
+ let isFirst = lock.withLock {
+ guard self.result == nil else { return false }
+ self.result = result
+ return true
+ }
+ if isFirst {
+ semaphore.signal()
+ }
+ }
+
+ /// Nil when nothing resolved it within `timeout`.
+ func wait(timeout: TimeInterval) -> Result? {
+ if semaphore.wait(timeout: .now() + max(timeout, 0)) == .success {
+ // Hand the permit back so a later wait on a resolved shot returns at once too.
+ semaphore.signal()
+ }
+ return lock.withLock { result }
+ }
+}
+
+/// The state of one Bluetooth link to a Jade, from being dialled until it is closed.
+///
+/// Every open creates a new instance with its own `generation`, so work queued for an earlier link to
+/// the same device never lands on a newer one. Each setup step and each write registers a one-shot
+/// waiter that a delegate callback resolves, and a drop or a close fails every waiter at once, so no
+/// Rust thread stays blocked on a link that is gone. Free of CoreBluetooth types so it can be tested.
+final class JadeBLELinkState: @unchecked Sendable {
+ enum Step: Hashable, CaseIterable {
+ case connect
+ case services
+ case characteristics
+ case subscribe
+ case write
+ case disconnect
+ }
+
+ enum ReadOutcome: Equatable {
+ case data(Data)
+ case empty
+ case down
+ }
+
+ let generation: UInt64
+
+ private let lock = NSLock()
+ private let notifications = BlockingQueue()
+ private let closedSignal = BLEOneShot()
+ private var waiters: [Step: BLEOneShot] = [:]
+ private var linkUp = false
+ private var ready = false
+ private var closing = false
+ private var dropReason: Error?
+ private var negotiatedChunkSize: UInt32?
+ private var completedWrites = 0
+
+ init(generation: UInt64) {
+ self.generation = generation
+ }
+
+ var isLinkUp: Bool {
+ lock.withLock { linkUp }
+ }
+
+ var isReady: Bool {
+ lock.withLock { ready }
+ }
+
+ var isClosing: Bool {
+ lock.withLock { closing }
+ }
+
+ /// Ready, still connected and nobody is closing it.
+ var isUsable: Bool {
+ lock.withLock { ready && linkUp && !closing }
+ }
+
+ /// Nil until the link is ready.
+ var chunkSize: UInt32? {
+ lock.withLock { negotiatedChunkSize }
+ }
+
+ var writesCompleted: Int {
+ lock.withLock { completedWrites }
+ }
+
+ /// Registers the waiter for `step`, failing any earlier waiter of the same step. On a closing or
+ /// dropped link the waiter comes back already resolved, so nobody waits on a link that is gone.
+ func begin(_ step: Step) -> BLEOneShot {
+ let waiter = BLEOneShot()
+ let (immediate, replaced): (Result?, BLEOneShot?) = lock.withLock {
+ if step == .disconnect, dropReason != nil {
+ return (.success(()), nil)
+ }
+ if step != .disconnect, closing {
+ return (.failure(JadeBLEError.closed), nil)
+ }
+ if step != .disconnect, let dropReason {
+ return (.failure(dropReason), nil)
+ }
+ return (nil, waiters.updateValue(waiter, forKey: step))
+ }
+ replaced?.resolve(.failure(JadeBLEError.closed))
+ if let immediate {
+ waiter.resolve(immediate)
+ }
+ return waiter
+ }
+
+ /// Resolves the waiter of `step`. False when nobody is waiting any more, for a late callback.
+ @discardableResult
+ func resolve(_ step: Step, error: Error?) -> Bool {
+ guard let waiter = lock.withLock({ waiters.removeValue(forKey: step) }) else { return false }
+ waiter.resolve(error.map { .failure($0) } ?? .success(()))
+ return true
+ }
+
+ /// Marks the link up when its connect is still awaited. False for a connect nobody waits for any
+ /// more, which the caller has to cancel so the Jade is not left holding a connection.
+ func markConnected() -> Bool {
+ let waiter: BLEOneShot? = lock.withLock {
+ guard !closing, dropReason == nil, let waiter = waiters.removeValue(forKey: .connect) else { return nil }
+ linkUp = true
+ return waiter
+ }
+ guard let waiter else { return false }
+ waiter.resolve(.success(()))
+ return true
+ }
+
+ /// Drops the waiter of `step` after it timed out, unless a newer waiter took its place.
+ func abandon(_ step: Step, _ waiter: BLEOneShot) {
+ lock.withLock {
+ if waiters[step] === waiter {
+ waiters[step] = nil
+ }
+ }
+ }
+
+ func markReady(chunkSize: UInt32) throws {
+ try lock.withLock {
+ if closing {
+ throw JadeBLEError.closed
+ }
+ if let dropReason {
+ throw dropReason
+ }
+ guard linkUp else { throw JadeBLEError.disconnected }
+ ready = true
+ negotiatedChunkSize = chunkSize
+ }
+ }
+
+ /// Takes the link down and fails every waiter with `reason`; a disconnect waiter succeeds instead.
+ /// Returns true when nobody was closing the link, so the drop came from outside the app.
+ @discardableResult
+ func markDown(reason: Error) -> Bool {
+ let (pending, isExternal): ([Step: BLEOneShot], Bool) = lock.withLock {
+ linkUp = false
+ ready = false
+ if dropReason == nil {
+ dropReason = reason
+ }
+ let pending = waiters
+ waiters.removeAll()
+ return (pending, !closing)
+ }
+ notifications.fail()
+ for (step, waiter) in pending {
+ waiter.resolve(step == .disconnect ? .success(()) : .failure(reason))
+ }
+ return isExternal
+ }
+
+ /// Starts closing the link: it stops being ready, and every waiter but the disconnect one fails.
+ /// False when a close is already under way.
+ func beginClosing() -> Bool {
+ let pending: [BLEOneShot]? = lock.withLock {
+ guard !closing else { return nil }
+ closing = true
+ ready = false
+ let pending = waiters.filter { $0.key != .disconnect }
+ for step in pending.keys {
+ waiters[step] = nil
+ }
+ return Array(pending.values)
+ }
+ guard let pending else { return false }
+ notifications.fail()
+ for waiter in pending {
+ waiter.resolve(.failure(JadeBLEError.closed))
+ }
+ return true
+ }
+
+ func finishClosing() {
+ closedSignal.resolve(.success(()))
+ }
+
+ /// Waits for a close that another caller started to finish.
+ @discardableResult
+ func waitUntilClosed(timeout: TimeInterval) -> Bool {
+ closedSignal.wait(timeout: timeout) != nil
+ }
+
+ /// Clears what an earlier session left unread, when the link can be reused as it is.
+ func reuseIfUsable() -> Bool {
+ lock.withLock {
+ guard ready, linkUp, !closing else { return false }
+ notifications.clear()
+ return true
+ }
+ }
+
+ func recordWrite() {
+ lock.withLock { completedWrites += 1 }
+ }
+
+ func enqueue(_ data: Data) {
+ guard !data.isEmpty else { return }
+ let isAccepting = lock.withLock { linkUp && !closing }
+ guard isAccepting else { return }
+ notifications.offer(data)
+ }
+
+ /// Waits up to `timeout` for a notification, then returns it joined with any queued behind it, in
+ /// arrival order and untouched: frames are not aligned to notifications.
+ func read(timeout: TimeInterval) -> ReadOutcome {
+ if let first = notifications.poll(timeout: max(timeout, 0)) {
+ let joined = notifications.drain().reduce(into: first) { $0.append($1) }
+ return .data(joined)
+ }
+ let isDown = lock.withLock { !linkUp || closing }
+ return isDown ? .down : .empty
+ }
+}
diff --git a/Bitkit/Services/Jade/JadeBLEManager.swift b/Bitkit/Services/Jade/JadeBLEManager.swift
new file mode 100644
index 000000000..aeecfc18c
--- /dev/null
+++ b/Bitkit/Services/Jade/JadeBLEManager.swift
@@ -0,0 +1,735 @@
+import Combine
+import CoreBluetooth
+import Foundation
+
+struct JadeBLEDiscovery: Equatable {
+ let path: String
+ let name: String
+}
+
+/// The Bluetooth byte pipe `JadeTransport` drives for core. The blocking calls run on Rust blocking
+/// threads, never on the main thread or the CoreBluetooth queue.
+protocol JadeBLEDriving: AnyObject, Sendable {
+ /// Paths whose link dropped without the app closing it: out of range, switched off, Bluetooth off.
+ var externalDisconnects: AnyPublisher { get }
+ /// Fires when Bluetooth turns back on after having been off, reset or unavailable.
+ var bluetoothPoweredOn: AnyPublisher { get }
+
+ /// Scans for Jades advertising the Nordic UART Service, blocking for `duration`.
+ func scan(duration: TimeInterval) -> [JadeBLEDiscovery]
+ /// Connects, subscribes to notifications and learns the chunk size, blocking until the link is usable.
+ func open(path: String) throws
+ /// Releases the link, waiting briefly for the disconnect. A no-op for a path that is not open.
+ func close(path: String)
+ /// Writes one chunk with response, blocking until the Jade acknowledges it.
+ func write(path: String, data: Data) throws
+ /// Returns what has arrived, waiting at most `timeout`. Empty means nothing arrived yet.
+ func read(path: String, timeout: TimeInterval) throws -> Data
+ func chunkSize(path: String) -> UInt32
+ func closeAll()
+ /// Cancels every link without waiting, for an app about to be suspended or terminated.
+ func releaseAllImmediately()
+ /// Paths that were paired before, so a stalled setup on one of them points at a stale bond.
+ func setPairedPaths(_ paths: Set)
+}
+
+/// CoreBluetooth access for Jade, over the Nordic UART Service.
+///
+/// Separate from `TrezorBLEManager` with a central of its own: the two vendors differ in almost every
+/// Bluetooth rule, and neither may disturb the other's links or scan results. The central is created
+/// only by `scan` and `open`, so nothing prompts for Bluetooth before a Jade is actually used.
+///
+/// Every CoreBluetooth call runs on `centralQueue`, where the delegate callbacks arrive too. One lock
+/// guards the manager's state and is never held while waiting, including while dispatching
+/// synchronously onto `centralQueue`, whose callbacks take the same lock.
+final class JadeBLEManager: NSObject, JadeBLEDriving, @unchecked Sendable {
+ static let shared = JadeBLEManager()
+
+ static let serviceUUID = CBUUID(string: "6e400001-b5a3-f393-e0a9-e50e24dcca9e")
+ static let writeCharacteristicUUID = CBUUID(string: "6e400002-b5a3-f393-e0a9-e50e24dcca9e")
+ static let notifyCharacteristicUUID = CBUUID(string: "6e400003-b5a3-f393-e0a9-e50e24dcca9e")
+ /// jade-client-rs `MAX_CHUNK_BYTES`.
+ static let maxChunkSize: UInt32 = 509
+ /// A default ATT MTU of 23 less the 3 byte header.
+ static let defaultChunkSize: UInt32 = 20
+ static let fallbackName = "Jade"
+
+ private static let settleTimeout: TimeInterval = 2
+ private static let connectTimeout: TimeInterval = 15
+ private static let discoveryTimeout: TimeInterval = 10
+ /// iOS pairs on the first encrypted access, which can be this subscription, so the budget covers
+ /// the passkey being confirmed on the Jade and in the iOS pairing dialog.
+ private static let subscribeTimeout: TimeInterval = 35
+ /// Same budget as the subscription, since the pairing dialog can instead appear on the first write.
+ private static let writeTimeout: TimeInterval = 35
+ private static let disconnectTimeout: TimeInterval = 3
+ private static let peripheralCacheLimit = 32
+ private static let logContext = "JadeBLE"
+
+ private final class Link {
+ let peripheral: CBPeripheral
+ let state: JadeBLELinkState
+ var writeCharacteristic: CBCharacteristic?
+
+ init(peripheral: CBPeripheral, state: JadeBLELinkState) {
+ self.peripheral = peripheral
+ self.state = state
+ }
+ }
+
+ private struct CachedPeripheral {
+ let peripheral: CBPeripheral
+ let lastSeen: Date
+ }
+
+ private final class ScanSession {
+ let finished = BLEOneShot()
+ private(set) var discoveries: [JadeBLEDiscovery] = []
+
+ /// True when the path was not listed yet.
+ func upsert(_ discovery: JadeBLEDiscovery) -> Bool {
+ if let index = discoveries.firstIndex(where: { $0.path == discovery.path }) {
+ discoveries[index] = discovery
+ return false
+ }
+ discoveries.append(discovery)
+ return true
+ }
+
+ func remove(path: String) {
+ discoveries.removeAll { $0.path == path }
+ }
+ }
+
+ private let centralQueue = DispatchQueue(label: "jade.ble.central", qos: .userInitiated)
+ private let centralQueueKey = DispatchSpecificKey()
+ private let centralCreationLock = NSLock()
+ private let stateLock = NSLock()
+
+ private var central: CBCentralManager?
+ private var centralState: CBManagerState = .unknown
+ private var sawBluetoothUnavailable = false
+ private var stateWaiters: [BLEOneShot] = []
+ private var peripheralCache: [String: CachedPeripheral] = [:]
+ private var advertisedNames: [String: String] = [:]
+ private var scanSession: ScanSession?
+ private var links: [String: Link] = [:]
+ private var pairedPaths: Set = []
+ private var lastGeneration: UInt64 = 0
+
+ private let externalDisconnectSubject = PassthroughSubject()
+ private let poweredOnSubject = PassthroughSubject()
+
+ override init() {
+ super.init()
+ centralQueue.setSpecific(key: centralQueueKey, value: ())
+ }
+
+ var externalDisconnects: AnyPublisher {
+ externalDisconnectSubject.eraseToAnyPublisher()
+ }
+
+ var bluetoothPoweredOn: AnyPublisher {
+ poweredOnSubject.eraseToAnyPublisher()
+ }
+
+ /// Whether the central exists yet. Only `scan` and `open` create it.
+ var hasCentral: Bool {
+ stateLock.withLock { central != nil }
+ }
+
+ static func chunkSize(maximumWriteLength: Int) -> UInt32 {
+ UInt32(min(max(maximumWriteLength, 1), Int(maxChunkSize)))
+ }
+
+ /// Maps the CoreBluetooth errors that mean the Bluetooth bond is broken or was never confirmed.
+ /// Nil for any other error, which the caller reports with its own description.
+ static func pairingError(for error: Error?, isPaired: Bool) -> JadeBLEError? {
+ guard let error else { return nil }
+ let nsError = error as NSError
+ switch (nsError.domain, nsError.code) {
+ case (CBErrorDomain, CBError.Code.peerRemovedPairingInformation.rawValue):
+ return .staleBond
+ case (CBErrorDomain, CBError.Code.encryptionTimedOut.rawValue):
+ return .pairingNotConfirmed
+ case (CBATTErrorDomain, CBATTError.Code.insufficientEncryption.rawValue),
+ (CBATTErrorDomain, CBATTError.Code.insufficientAuthentication.rawValue):
+ return isPaired ? .staleBond : .pairingNotConfirmed
+ default:
+ return nil
+ }
+ }
+
+ static func isJadeName(_ name: String?) -> Bool {
+ guard let name else { return true }
+ return name.range(of: fallbackName, options: [.caseInsensitive, .anchored]) != nil
+ }
+
+ // MARK: - Scanning
+
+ func scan(duration: TimeInterval) -> [JadeBLEDiscovery] {
+ dispatchPrecondition(condition: .notOnQueue(centralQueue))
+ let central = startCentral()
+ let state = waitForSettledState()
+ guard state == .poweredOn else {
+ Logger.debug("Skipped the Jade scan, Bluetooth state is \(state.rawValue)", context: Self.logContext)
+ return []
+ }
+
+ let session = ScanSession()
+ let replaced: ScanSession? = stateLock.withLock {
+ let previous = scanSession
+ scanSession = session
+ return previous
+ }
+ replaced?.finished.resolve(.success(()))
+
+ centralQueue.async {
+ guard central.state == .poweredOn else { return }
+ central.scanForPeripherals(
+ withServices: [Self.serviceUUID],
+ options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
+ )
+ }
+ _ = session.finished.wait(timeout: duration)
+
+ let (discoveries, isCurrentScan): ([JadeBLEDiscovery], Bool) = stateLock.withLock {
+ guard scanSession === session else { return (session.discoveries, false) }
+ scanSession = nil
+ return (session.discoveries, true)
+ }
+ if isCurrentScan {
+ centralQueue.async {
+ guard central.state == .poweredOn else { return }
+ central.stopScan()
+ }
+ }
+ Logger.debug("Jade scan found \(discoveries.count) device(s)", context: Self.logContext)
+ return discoveries
+ }
+
+ // MARK: - Opening
+
+ func open(path: String) throws {
+ dispatchPrecondition(condition: .notOnQueue(centralQueue))
+ guard let identifier = HwDevicePath.bleIdentifier(path) else {
+ throw JadeBLEError.invalidPath(path)
+ }
+
+ if let existing = link(for: path) {
+ if existing.state.reuseIfUsable() {
+ Logger.info("Reused the open Jade link \(path)", context: Self.logContext)
+ return
+ }
+ close(path: path)
+ }
+
+ let central = startCentral()
+ try requirePoweredOn(waitForSettledState())
+
+ guard let peripheral = cachedPeripheral(path: path) ?? retrievePeripheral(identifier, path: path, central: central) else {
+ throw JadeBLEError.deviceNotFound
+ }
+
+ let link = registerLink(path: path, peripheral: peripheral)
+ do {
+ try establish(link, path: path, central: central)
+ } catch {
+ let failure = error as? JadeBLEError ?? .connectFailed(error.localizedDescription)
+ Logger.warn("Could not open the Jade link \(path): \(failure.localizedDescription)", context: Self.logContext)
+ // Always cancel, even when setup failed after connecting: a Jade whose central went away
+ // without disconnecting can refuse new connections until it is restarted.
+ shutDown(link, path: path)
+ throw failure
+ }
+ }
+
+ private func establish(_ link: Link, path: String, central: CBCentralManager) throws {
+ let peripheral = link.peripheral
+
+ if onCentralQueue({ peripheral.state != .disconnected }) {
+ Logger.debug("Releasing a leftover connection to \(path) before dialling", context: Self.logContext)
+ let released = link.state.begin(.disconnect)
+ centralQueue.async { Self.cancelConnection(to: peripheral, on: central) }
+ if released.wait(timeout: Self.disconnectTimeout) == nil {
+ link.state.abandon(.disconnect, released)
+ Logger.warn("The leftover connection to \(path) did not report a disconnect", context: Self.logContext)
+ }
+ }
+
+ let connected = link.state.begin(.connect)
+ centralQueue.async { [weak self] in
+ guard let self, isCurrent(link, path: path) else { return }
+ peripheral.delegate = self
+ central.connect(peripheral, options: nil)
+ }
+ try wait(for: connected, step: .connect, link: link, timeout: Self.connectTimeout, timeoutError: .connectTimeout)
+
+ let servicesFound = link.state.begin(.services)
+ centralQueue.async { [weak self] in
+ guard let self, isCurrent(link, path: path) else { return }
+ peripheral.discoverServices([Self.serviceUUID])
+ }
+ try wait(for: servicesFound, step: .services, link: link, timeout: Self.discoveryTimeout, timeoutError: .notAJade)
+ guard let service = onCentralQueue({ peripheral.services?.first { $0.uuid == Self.serviceUUID } }) else {
+ Logger.warn("\(path) does not offer the Nordic UART Service", context: Self.logContext)
+ throw JadeBLEError.notAJade
+ }
+
+ let characteristicsFound = link.state.begin(.characteristics)
+ centralQueue.async { [weak self] in
+ guard let self, isCurrent(link, path: path) else { return }
+ peripheral.discoverCharacteristics([Self.writeCharacteristicUUID, Self.notifyCharacteristicUUID], for: service)
+ }
+ try wait(for: characteristicsFound, step: .characteristics, link: link, timeout: Self.discoveryTimeout, timeoutError: .notAJade)
+ let (writeCharacteristic, notifyCharacteristic) = onCentralQueue {
+ (
+ service.characteristics?.first { $0.uuid == Self.writeCharacteristicUUID },
+ service.characteristics?.first { $0.uuid == Self.notifyCharacteristicUUID }
+ )
+ }
+ // Write without response silently drops chunks on the Jade's GATT stack.
+ guard let writeCharacteristic, writeCharacteristic.properties.contains(.write) else {
+ Logger.warn("\(path) offers no write with response characteristic", context: Self.logContext)
+ throw JadeBLEError.notAJade
+ }
+ // Real hardware offers indications only on its TX characteristic; iOS subscribes to either.
+ guard let notifyCharacteristic, !notifyCharacteristic.properties.isDisjoint(with: [.notify, .indicate]) else {
+ Logger.warn("\(path) offers no notify or indicate characteristic", context: Self.logContext)
+ throw JadeBLEError.notAJade
+ }
+
+ let subscribed = link.state.begin(.subscribe)
+ centralQueue.async { [weak self] in
+ guard let self, isCurrent(link, path: path) else { return }
+ peripheral.setNotifyValue(true, for: notifyCharacteristic)
+ }
+ let unconfirmedPairing: JadeBLEError = isPaired(path) ? .staleBond : .pairingNotConfirmed
+ try wait(for: subscribed, step: .subscribe, link: link, timeout: Self.subscribeTimeout, timeoutError: unconfirmedPairing)
+
+ // The write-without-response length is the true MTU less the header, so a chunk never needs a
+ // long write even though every chunk is written with response.
+ let chunkSize = Self.chunkSize(maximumWriteLength: onCentralQueue { peripheral.maximumWriteValueLength(for: .withoutResponse) })
+ stateLock.withLock { link.writeCharacteristic = writeCharacteristic }
+ try link.state.markReady(chunkSize: chunkSize)
+ Logger.info("Opened the Jade link \(path) with chunk size \(chunkSize)", context: Self.logContext)
+ }
+
+ private func wait(
+ for waiter: BLEOneShot,
+ step: JadeBLELinkState.Step,
+ link: Link,
+ timeout: TimeInterval,
+ timeoutError: JadeBLEError
+ ) throws {
+ guard let result = waiter.wait(timeout: timeout) else {
+ link.state.abandon(step, waiter)
+ throw timeoutError
+ }
+ try result.get()
+ }
+
+ // MARK: - Reading and writing
+
+ func write(path: String, data: Data) throws {
+ dispatchPrecondition(condition: .notOnQueue(centralQueue))
+ let (link, characteristic, isPaired): (Link?, CBCharacteristic?, Bool) = stateLock.withLock {
+ (links[path], links[path]?.writeCharacteristic, pairedPaths.contains(path))
+ }
+ guard let link else { throw JadeBLEError.notOpen }
+ guard link.state.isUsable, let characteristic else { throw JadeBLEError.disconnected }
+
+ // No retries and no pause between chunks: the firmware drops a partly received message after
+ // two seconds of silence.
+ let written = link.state.begin(.write)
+ centralQueue.async { [weak self] in
+ guard let self, isCurrent(link, path: path) else { return }
+ link.peripheral.writeValue(data, for: characteristic, type: .withResponse)
+ }
+
+ guard let result = written.wait(timeout: Self.writeTimeout) else {
+ link.state.abandon(.write, written)
+ // The very first write stalling on a Jade paired before means it rejected the stored key and
+ // the new pairing was not confirmed: only pairing afresh fixes that.
+ if link.state.writesCompleted == 0, isPaired {
+ Logger.warn("The first write to \(path) stalled on a paired Jade, its bond is stale", context: Self.logContext)
+ throw JadeBLEError.staleBond
+ }
+ throw JadeBLEError.writeTimeout
+ }
+ if case let .failure(error) = result {
+ let failure = error as? JadeBLEError ?? .writeFailed(error.localizedDescription)
+ switch failure {
+ case .staleBond, .pairingNotConfirmed, .closed:
+ throw failure
+ default:
+ throw link.state.isLinkUp ? failure : JadeBLEError.disconnected
+ }
+ }
+ link.state.recordWrite()
+ Logger.debug("Wrote \(data.count) bytes to \(path)", context: Self.logContext)
+ }
+
+ func read(path: String, timeout: TimeInterval) throws -> Data {
+ dispatchPrecondition(condition: .notOnQueue(centralQueue))
+ guard let link = link(for: path) else { throw JadeBLEError.notOpen }
+ switch link.state.read(timeout: timeout) {
+ case let .data(data):
+ Logger.debug("Read \(data.count) bytes from \(path)", context: Self.logContext)
+ return data
+ case .empty:
+ return Data()
+ case .down:
+ throw JadeBLEError.disconnected
+ }
+ }
+
+ func chunkSize(path: String) -> UInt32 {
+ link(for: path)?.state.chunkSize ?? Self.defaultChunkSize
+ }
+
+ // MARK: - Closing
+
+ func close(path: String) {
+ dispatchPrecondition(condition: .notOnQueue(centralQueue))
+ guard let link = link(for: path) else { return }
+ shutDown(link, path: path)
+ }
+
+ func closeAll() {
+ dispatchPrecondition(condition: .notOnQueue(centralQueue))
+ let snapshot = stateLock.withLock { links }
+ for (path, link) in snapshot {
+ shutDown(link, path: path)
+ }
+ }
+
+ func releaseAllImmediately() {
+ let (released, releasingCentral): ([Link], CBCentralManager?) = stateLock.withLock {
+ let released = Array(links.values)
+ links.removeAll()
+ return (released, central)
+ }
+ guard !released.isEmpty else { return }
+ for link in released where link.state.beginClosing() {
+ link.state.finishClosing()
+ }
+ if let releasingCentral {
+ onCentralQueue {
+ for link in released {
+ Self.cancelConnection(to: link.peripheral, on: releasingCentral)
+ }
+ }
+ }
+ Logger.info("Released \(released.count) Jade link(s) without waiting", context: Self.logContext)
+ }
+
+ func setPairedPaths(_ paths: Set) {
+ stateLock.withLock { pairedPaths = paths }
+ }
+
+ /// Closing marks the link first, so the disconnect it causes is never reported as external.
+ private func shutDown(_ link: Link, path: String) {
+ guard link.state.beginClosing() else {
+ link.state.waitUntilClosed(timeout: Self.disconnectTimeout)
+ return
+ }
+ let disconnected = link.state.begin(.disconnect)
+ let mustWait = link.state.isLinkUp
+ if let central = stateLock.withLock({ central }) {
+ centralQueue.async { Self.cancelConnection(to: link.peripheral, on: central) }
+ }
+ if mustWait, disconnected.wait(timeout: Self.disconnectTimeout) == nil {
+ Logger.warn("The Jade link \(path) did not report a disconnect in time", context: Self.logContext)
+ }
+ link.state.abandon(.disconnect, disconnected)
+ stateLock.withLock {
+ if links[path] === link {
+ links[path] = nil
+ }
+ }
+ link.state.finishClosing()
+ Logger.info("Closed the Jade link \(path)", context: Self.logContext)
+ }
+
+ // MARK: - Helpers
+
+ private func startCentral() -> CBCentralManager {
+ centralCreationLock.withLock {
+ if let existing = stateLock.withLock({ central }) {
+ return existing
+ }
+ // The app shows its own Bluetooth guidance, so the system power alert stays off.
+ let created = CBCentralManager(
+ delegate: self,
+ queue: centralQueue,
+ options: [CBCentralManagerOptionShowPowerAlertKey: false]
+ )
+ stateLock.withLock { central = created }
+ Logger.debug("Started the Jade Bluetooth central", context: Self.logContext)
+ return created
+ }
+ }
+
+ /// Waits up to `settleTimeout` while the state is still unknown or resetting, as it is right after
+ /// the central is created or while the permission prompt is showing.
+ private func waitForSettledState() -> CBManagerState {
+ let waiter = BLEOneShot()
+ let settled: CBManagerState? = stateLock.withLock {
+ if Self.isSettled(centralState) {
+ return centralState
+ }
+ stateWaiters.append(waiter)
+ return nil
+ }
+ if let settled {
+ return settled
+ }
+ _ = waiter.wait(timeout: Self.settleTimeout)
+ return stateLock.withLock {
+ stateWaiters.removeAll { $0 === waiter }
+ return centralState
+ }
+ }
+
+ /// Call on `centralQueue`. With Bluetooth off no connection is left to cancel, and CoreBluetooth
+ /// rejects the call as misuse.
+ private static func cancelConnection(to peripheral: CBPeripheral, on central: CBCentralManager) {
+ guard central.state == .poweredOn else { return }
+ central.cancelPeripheralConnection(peripheral)
+ }
+
+ private static func isSettled(_ state: CBManagerState) -> Bool {
+ state != .unknown && state != .resetting
+ }
+
+ private func requirePoweredOn(_ state: CBManagerState) throws {
+ switch state {
+ case .poweredOn:
+ return
+ case .poweredOff:
+ throw JadeBLEError.bluetoothOff
+ case .unauthorized:
+ throw JadeBLEError.bluetoothUnauthorized
+ case .unsupported:
+ throw JadeBLEError.bluetoothUnsupported
+ default:
+ throw JadeBLEError.bluetoothNotReady
+ }
+ }
+
+ /// Runs `work` on `centralQueue` and returns its result. Must never be called while holding
+ /// `stateLock`, since the delegate callbacks queued ahead of `work` take that lock.
+ private func onCentralQueue(_ work: () -> T) -> T {
+ if DispatchQueue.getSpecific(key: centralQueueKey) != nil {
+ return work()
+ }
+ return centralQueue.sync(execute: work)
+ }
+
+ private func link(for path: String) -> Link? {
+ stateLock.withLock { links[path] }
+ }
+
+ private func isCurrent(_ link: Link, path: String) -> Bool {
+ stateLock.withLock { links[path] === link } && !link.state.isClosing
+ }
+
+ private func isPaired(_ path: String) -> Bool {
+ stateLock.withLock { pairedPaths.contains(path) }
+ }
+
+ private func registerLink(path: String, peripheral: CBPeripheral) -> Link {
+ let (link, displaced): (Link, Link?) = stateLock.withLock {
+ lastGeneration &+= 1
+ let link = Link(peripheral: peripheral, state: JadeBLELinkState(generation: lastGeneration))
+ return (link, links.updateValue(link, forKey: path))
+ }
+ if let displaced, displaced.state.beginClosing() {
+ Logger.warn("Replaced a Jade link to \(path) that was still being set up", context: Self.logContext)
+ displaced.state.finishClosing()
+ }
+ return link
+ }
+
+ private func cachedPeripheral(path: String) -> CBPeripheral? {
+ stateLock.withLock { peripheralCache[path]?.peripheral }
+ }
+
+ private func retrievePeripheral(_ identifier: UUID, path: String, central: CBCentralManager) -> CBPeripheral? {
+ guard let peripheral = onCentralQueue({ central.retrievePeripherals(withIdentifiers: [identifier]).first }) else {
+ return nil
+ }
+ stateLock.withLock { cachePeripheralLocked(peripheral, path: path) }
+ return peripheral
+ }
+
+ private func cachePeripheralLocked(_ peripheral: CBPeripheral, path: String) {
+ peripheralCache[path] = CachedPeripheral(peripheral: peripheral, lastSeen: Date())
+ guard peripheralCache.count > Self.peripheralCacheLimit else { return }
+ let oldestIdle = peripheralCache
+ .filter { links[$0.key] == nil }
+ .min { $0.value.lastSeen < $1.value.lastSeen }
+ if let oldestIdle {
+ peripheralCache[oldestIdle.key] = nil
+ }
+ }
+
+ private func delegateTarget(for peripheral: CBPeripheral) -> (link: Link, isPaired: Bool)? {
+ let path = HwDevicePath.ble(peripheral.identifier)
+ return stateLock.withLock {
+ guard let link = links[path] else { return nil }
+ return (link, pairedPaths.contains(path))
+ }
+ }
+}
+
+// MARK: - CBCentralManagerDelegate
+
+extension JadeBLEManager: CBCentralManagerDelegate {
+ func centralManagerDidUpdateState(_ central: CBCentralManager) {
+ let state = central.state
+ Logger.debug("Bluetooth state is \(state.rawValue)", context: Self.logContext)
+
+ let (waiters, droppedLinks, endedScan, isBackOn): ([BLEOneShot], [String: Link], ScanSession?, Bool) = stateLock.withLock {
+ centralState = state
+ var waiters: [BLEOneShot] = []
+ if Self.isSettled(state) {
+ waiters = stateWaiters
+ stateWaiters.removeAll()
+ }
+ if state == .poweredOn {
+ let isBackOn = sawBluetoothUnavailable
+ sawBluetoothUnavailable = false
+ return (waiters, [:], nil, isBackOn)
+ }
+ if state != .unknown {
+ sawBluetoothUnavailable = true
+ }
+ return (waiters, links, scanSession, false)
+ }
+
+ for waiter in waiters {
+ waiter.resolve(.success(()))
+ }
+ endedScan?.finished.resolve(.success(()))
+ // iOS does not promise a disconnect callback per peripheral when Bluetooth goes away.
+ let reason: JadeBLEError = state == .poweredOff ? .bluetoothOff : .disconnected
+ for (path, link) in droppedLinks where link.state.markDown(reason: reason) {
+ externalDisconnectSubject.send(path)
+ }
+ if isBackOn {
+ Logger.info("Bluetooth is back on", context: Self.logContext)
+ poweredOnSubject.send(())
+ }
+ }
+
+ func centralManager(
+ _ central: CBCentralManager,
+ didDiscover peripheral: CBPeripheral,
+ advertisementData: [String: Any],
+ rssi _: NSNumber
+ ) {
+ let path = HwDevicePath.ble(peripheral.identifier)
+ let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String
+ let newlyFound: String? = stateLock.withLock {
+ // The scan response carries "Jade XXXXXX"; a later packet without a name keeps it.
+ if let advertisedName {
+ advertisedNames[path] = advertisedName
+ }
+ let name = advertisedNames[path] ?? peripheral.name
+ // The Nordic UART Service is not unique to Jade, so other gadgets offering it are skipped.
+ guard Self.isJadeName(name) else {
+ scanSession?.remove(path: path)
+ return nil
+ }
+ cachePeripheralLocked(peripheral, path: path)
+ let discovery = JadeBLEDiscovery(path: path, name: name ?? Self.fallbackName)
+ return scanSession?.upsert(discovery) == true ? discovery.name : nil
+ }
+ if let newlyFound {
+ Logger.debug("Found \(newlyFound) at \(path)", context: Self.logContext)
+ }
+ }
+
+ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
+ let path = HwDevicePath.ble(peripheral.identifier)
+ if let target = delegateTarget(for: peripheral), target.link.state.markConnected() {
+ Logger.debug("Connected to \(path)", context: Self.logContext)
+ return
+ }
+ Logger.info("Cancelling a connection to \(path) nobody is waiting for", context: Self.logContext)
+ central.cancelPeripheralConnection(peripheral)
+ }
+
+ func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
+ guard let target = delegateTarget(for: peripheral) else { return }
+ let failure = Self.pairingError(for: error, isPaired: target.isPaired)
+ ?? .connectFailed(error?.localizedDescription ?? "unknown error")
+ let path = HwDevicePath.ble(peripheral.identifier)
+ Logger.warn("Could not connect to \(path): \(failure.localizedDescription)", context: Self.logContext)
+ target.link.state.resolve(.connect, error: failure)
+ }
+
+ func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
+ let path = HwDevicePath.ble(peripheral.identifier)
+ guard let target = delegateTarget(for: peripheral) else { return }
+ let state = target.link.state
+ guard state.isLinkUp else {
+ // A leftover connection released before dialling, or a connect cancelled while pending.
+ state.resolve(.disconnect, error: nil)
+ return
+ }
+
+ let reason: JadeBLEError = if let pairingError = Self.pairingError(for: error, isPaired: target.isPaired) {
+ pairingError
+ } else if let error, !state.isReady {
+ .connectFailed(error.localizedDescription)
+ } else {
+ .disconnected
+ }
+ if state.markDown(reason: reason) {
+ Logger.info("The Jade link \(path) dropped: \(reason.localizedDescription)", context: Self.logContext)
+ externalDisconnectSubject.send(path)
+ }
+ }
+}
+
+// MARK: - CBPeripheralDelegate
+
+extension JadeBLEManager: CBPeripheralDelegate {
+ func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
+ guard let target = delegateTarget(for: peripheral) else { return }
+ let failure = error.map { Self.pairingError(for: $0, isPaired: target.isPaired) ?? .connectFailed($0.localizedDescription) }
+ target.link.state.resolve(.services, error: failure)
+ }
+
+ func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
+ guard service.uuid == Self.serviceUUID, let target = delegateTarget(for: peripheral) else { return }
+ let failure = error.map { Self.pairingError(for: $0, isPaired: target.isPaired) ?? .connectFailed($0.localizedDescription) }
+ target.link.state.resolve(.characteristics, error: failure)
+ }
+
+ func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
+ guard characteristic.uuid == Self.notifyCharacteristicUUID, let target = delegateTarget(for: peripheral) else { return }
+ let failure: JadeBLEError? = if let error {
+ Self.pairingError(for: error, isPaired: target.isPaired) ?? .subscribeFailed(error.localizedDescription)
+ } else if !characteristic.isNotifying {
+ .subscribeFailed("notifications stayed off")
+ } else {
+ nil
+ }
+ target.link.state.resolve(.subscribe, error: failure)
+ }
+
+ func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
+ guard characteristic.uuid == Self.writeCharacteristicUUID, let target = delegateTarget(for: peripheral) else { return }
+ let failure = error.map { Self.pairingError(for: $0, isPaired: target.isPaired) ?? .writeFailed($0.localizedDescription) }
+ target.link.state.resolve(.write, error: failure)
+ }
+
+ func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
+ guard error == nil, characteristic.uuid == Self.notifyCharacteristicUUID, let value = characteristic.value else { return }
+ delegateTarget(for: peripheral)?.link.state.enqueue(value)
+ }
+}
diff --git a/Bitkit/Services/Jade/JadeKnownDeviceStore.swift b/Bitkit/Services/Jade/JadeKnownDeviceStore.swift
new file mode 100644
index 000000000..01d880ee5
--- /dev/null
+++ b/Bitkit/Services/Jade/JadeKnownDeviceStore.swift
@@ -0,0 +1,31 @@
+import Foundation
+
+/// The paired Jade entries and the pending wallet names, as `JadeManager` reads and writes them.
+protocol JadeKnownDeviceStoring {
+ /// Paired Jade entries, most recently connected first.
+ func loadAll() -> [HwKnownDevice]
+ /// Replaces the Jade entries, leaving every other vendor's alone. See `HwKnownDeviceStorage.saveAll`
+ /// for `pendingName`.
+ func saveAll(_ devices: [HwKnownDevice], pendingName: PendingHwWalletName?)
+ func loadPendingNames() -> [String: String]
+ func setPendingName(walletId: String, name: String?)
+}
+
+/// The Jade slice of `HwKnownDeviceStorage`.
+struct JadeKnownDeviceStore: JadeKnownDeviceStoring {
+ func loadAll() -> [HwKnownDevice] {
+ HwKnownDeviceStorage.loadAll(vendor: .blockstream)
+ }
+
+ func saveAll(_ devices: [HwKnownDevice], pendingName: PendingHwWalletName?) {
+ HwKnownDeviceStorage.saveAll(devices, vendor: .blockstream, pendingName: pendingName)
+ }
+
+ func loadPendingNames() -> [String: String] {
+ HwKnownDeviceStorage.loadPendingNames()
+ }
+
+ func setPendingName(walletId: String, name: String?) {
+ HwKnownDeviceStorage.setPendingName(walletId: walletId, name: name)
+ }
+}
diff --git a/Bitkit/Services/Jade/JadeService.swift b/Bitkit/Services/Jade/JadeService.swift
new file mode 100644
index 000000000..901076474
--- /dev/null
+++ b/Bitkit/Services/Jade/JadeService.swift
@@ -0,0 +1,157 @@
+import BitkitCore
+import Foundation
+
+/// bitkit-core's Jade session, as `JadeManager` drives it.
+protocol JadeServicing: AnyObject, Sendable {
+ func initialize() async throws
+ func scan(timeoutMs: UInt32) async throws -> [JadeDeviceInfo]
+ /// The devices of the last scan, without scanning again.
+ func listDevices() async -> [JadeDeviceInfo]
+ func connect(path: String) async throws -> JadeVersionInfo
+ func disconnect() async throws
+ /// Aborts the request in flight, which then fails with `JadeError.UserCancelled`.
+ func cancel() async throws
+ /// Reports a link that dropped without core closing it. Must reach core before the next connect to
+ /// the same path, or it tears that new session down.
+ func notifyDisconnected(path: String) async
+ func isConnected() -> Bool
+ func refreshVersionInfo() async throws -> JadeVersionInfo
+ func unlock(network: JadeNetwork) async throws
+ func getMasterFingerprint(network: JadeNetwork) async throws -> String
+ func getAccountExport(network: JadeNetwork, accountTypes: [AccountType], accountIndex: UInt32) async throws -> JadeAccountExport
+ func verifyAddress(network: JadeNetwork, variant: JadeAddressVariant, derivationPath: String, expectedAddress: String) async throws
+ /// Returns the signed PSBT, base64 encoded.
+ func signPsbt(network: JadeNetwork, psbtBase64: String) async throws -> String
+ func finalizePsbt(originalPsbt: String, signedPsbt: String) async throws -> CompletedTransaction
+}
+
+/// Thin wrapper over bitkit-core's `jade*` functions, each run through `ServiceQueue.background(.core)`.
+///
+/// A Swift task cancel never reaches Rust: aborting a request in flight takes `cancel()` or
+/// `disconnect()`. `ServiceQueue` does not serialise async calls either, so ordering them is left to
+/// `JadeManager`.
+final class JadeService: JadeServicing, @unchecked Sendable {
+ static let shared = JadeService()
+
+ /// Matches the Trezor scan window, so one search pass stays predictable.
+ static let scanTimeoutMs: UInt32 = 3000
+
+ private static let logContext = "JadeService"
+
+ private let transport: JadeTransportCallback
+ private let callbackLock = NSLock()
+ private var isCallbackRegistered = false
+
+ init(transport: JadeTransportCallback = JadeTransport.shared) {
+ self.transport = transport
+ }
+
+ func initialize() async throws {
+ try await ServiceQueue.background(.core) { [self] in
+ ensureCallbackRegistered()
+ }
+ }
+
+ func scan(timeoutMs: UInt32) async throws -> [JadeDeviceInfo] {
+ try await ServiceQueue.background(.core) { [self] in
+ ensureCallbackRegistered()
+ return try await jadeScan(timeoutMs: timeoutMs)
+ }
+ }
+
+ func listDevices() async -> [JadeDeviceInfo] {
+ let devices = try? await ServiceQueue.background(.core) {
+ await jadeListDevices()
+ }
+ return devices ?? []
+ }
+
+ func connect(path: String) async throws -> JadeVersionInfo {
+ try await ServiceQueue.background(.core) { [self] in
+ ensureCallbackRegistered()
+ return try await jadeConnect(transport: .bluetooth, path: path)
+ }
+ }
+
+ func disconnect() async throws {
+ try await ServiceQueue.background(.core) {
+ try await jadeDisconnect()
+ }
+ }
+
+ func cancel() async throws {
+ try await ServiceQueue.background(.core) {
+ try await jadeCancel()
+ }
+ }
+
+ func notifyDisconnected(path: String) async {
+ _ = try? await ServiceQueue.background(.core) {
+ await jadeNotifyDisconnected(path: path)
+ }
+ }
+
+ func isConnected() -> Bool {
+ jadeIsConnected()
+ }
+
+ func refreshVersionInfo() async throws -> JadeVersionInfo {
+ try await ServiceQueue.background(.core) {
+ try await jadeRefreshVersionInfo()
+ }
+ }
+
+ func unlock(network: JadeNetwork) async throws {
+ try await ServiceQueue.background(.core) {
+ try await jadeUnlock(network: network)
+ }
+ }
+
+ func getMasterFingerprint(network: JadeNetwork) async throws -> String {
+ try await ServiceQueue.background(.core) {
+ try await jadeGetMasterFingerprint(network: network)
+ }
+ }
+
+ func getAccountExport(network: JadeNetwork, accountTypes: [AccountType], accountIndex: UInt32) async throws -> JadeAccountExport {
+ try await ServiceQueue.background(.core) {
+ try await jadeGetAccountExport(network: network, accountIndex: accountIndex, accountTypes: accountTypes)
+ }
+ }
+
+ func verifyAddress(network: JadeNetwork, variant: JadeAddressVariant, derivationPath: String, expectedAddress: String) async throws {
+ try await ServiceQueue.background(.core) {
+ try await jadeVerifyAddress(
+ network: network,
+ variant: variant,
+ derivationPath: derivationPath,
+ expectedAddress: expectedAddress
+ )
+ }
+ }
+
+ func signPsbt(network: JadeNetwork, psbtBase64: String) async throws -> String {
+ try await ServiceQueue.background(.core) {
+ try await jadeSignPsbt(network: network, psbt: psbtBase64)
+ }
+ }
+
+ func finalizePsbt(originalPsbt: String, signedPsbt: String) async throws -> CompletedTransaction {
+ try await ServiceQueue.background(.core) {
+ // Module-qualified: unqualified, the name resolves to this method and it calls itself.
+ try BitkitCore.finalizePsbt(originalPsbt: originalPsbt, signedPsbt: signedPsbt)
+ }
+ }
+
+ private func ensureCallbackRegistered() {
+ callbackLock.lock()
+ defer { callbackLock.unlock() }
+
+ guard !isCallbackRegistered else { return }
+ if jadeSetTransportCallback(callback: transport) {
+ Logger.warn("Replaced a previously registered Jade transport", context: Self.logContext)
+ }
+ isCallbackRegistered = true
+ Logger.info("Jade transport registered", context: Self.logContext)
+ }
+}
diff --git a/Bitkit/Services/Jade/JadeTransport.swift b/Bitkit/Services/Jade/JadeTransport.swift
new file mode 100644
index 000000000..55a109f45
--- /dev/null
+++ b/Bitkit/Services/Jade/JadeTransport.swift
@@ -0,0 +1,144 @@
+import BitkitCore
+import Combine
+import Foundation
+
+/// The app's side of the Jade links, next to core's own use of the same transport.
+protocol JadeTransportControlling: AnyObject, Sendable {
+ /// Paths whose link dropped without the app closing it.
+ var externalDisconnects: AnyPublisher { get }
+ /// Fires when Bluetooth turns back on after having been off.
+ var bluetoothPoweredOn: AnyPublisher { get }
+
+ /// Closes the link to `path` off the main thread, waiting briefly for the disconnect.
+ func disconnectDevice(path: String) async
+ func closeAllConnections() async
+ /// Cancels every link without waiting. Safe to call on the main thread.
+ func releaseAllImmediately()
+ func setPairedPaths(_ paths: Set)
+}
+
+/// The byte pipe between core's Jade protocol and Bluetooth.
+///
+/// Core owns the CBOR framing, the pinserver exchange and every deadline; this only moves bytes over
+/// the Nordic UART Service. Core calls every callback on one of its blocking threads, so blocking here
+/// is expected, and the results carry plain text core shows to the user.
+final class JadeTransport: JadeTransportCallback, JadeTransportControlling, @unchecked Sendable {
+ static let shared = JadeTransport(driver: JadeBLEManager.shared)
+
+ private static let minimumScanDuration: TimeInterval = 0.5
+ private static let maximumScanDuration: TimeInterval = 15
+ private static let logContext = "JadeBLE"
+
+ private let driver: JadeBLEDriving
+ private let isTrezorBridgeEnabled: () -> Bool
+
+ init(driver: JadeBLEDriving, isTrezorBridgeEnabled: @escaping () -> Bool = { Env.trezorBridgeEnabled }) {
+ self.driver = driver
+ self.isTrezorBridgeEnabled = isTrezorBridgeEnabled
+ }
+
+ var externalDisconnects: AnyPublisher {
+ driver.externalDisconnects
+ }
+
+ var bluetoothPoweredOn: AnyPublisher {
+ driver.bluetoothPoweredOn
+ }
+
+ static func scanDuration(timeoutMs: UInt32) -> TimeInterval {
+ min(max(Double(timeoutMs) / 1000, minimumScanDuration), maximumScanDuration)
+ }
+
+ static func errorCode(for error: Error) -> JadeTransportErrorCode? {
+ (error as? JadeBLEError)?.transportErrorCode
+ }
+
+ // MARK: - JadeTransportControlling
+
+ func disconnectDevice(path: String) async {
+ await runOffMainThread { $0.close(path: path) }
+ }
+
+ func closeAllConnections() async {
+ await runOffMainThread { $0.closeAll() }
+ }
+
+ func releaseAllImmediately() {
+ driver.releaseAllImmediately()
+ }
+
+ func setPairedPaths(_ paths: Set) {
+ driver.setPairedPaths(paths)
+ }
+
+ // MARK: - JadeTransportCallback
+
+ func scanDevices(timeoutMs: UInt32) -> [JadeNativeDevice] {
+ // Bridge runs (journeys and E2E) have no Jade nearby, and in the simulator the central never powers
+ // on, so a Bluetooth scan would only add its wait to every search pass.
+ guard !isTrezorBridgeEnabled() else {
+ Logger.debug("Skipped the Jade Bluetooth scan while the Trezor Bridge is enabled", context: Self.logContext)
+ return []
+ }
+ let discoveries = driver.scan(duration: Self.scanDuration(timeoutMs: timeoutMs))
+ Logger.info("Found \(discoveries.count) Jade device(s)", context: Self.logContext)
+ return discoveries.map {
+ JadeNativeDevice(path: $0.path, transport: .bluetooth, name: $0.name, serialNumber: nil)
+ }
+ }
+
+ func openDevice(path: String) -> JadeTransportResult {
+ do {
+ try driver.open(path: path)
+ return Self.success
+ } catch {
+ return Self.failure(error)
+ }
+ }
+
+ func closeDevice(path: String) -> JadeTransportResult {
+ driver.close(path: path)
+ return Self.success
+ }
+
+ func writeChunk(path: String, data: Data) -> JadeTransportResult {
+ do {
+ try driver.write(path: path, data: data)
+ return Self.success
+ } catch {
+ Logger.warn("Jade write of \(data.count) bytes failed: \(error.localizedDescription)", context: Self.logContext)
+ return Self.failure(error)
+ }
+ }
+
+ func readChunk(path: String, timeoutMs: UInt32) -> JadeTransportReadResult {
+ do {
+ let data = try driver.read(path: path, timeout: Double(timeoutMs) / 1000)
+ return JadeTransportReadResult(success: true, data: data, error: "", errorCode: nil)
+ } catch {
+ return JadeTransportReadResult(success: false, data: Data(), error: error.localizedDescription, errorCode: Self.errorCode(for: error))
+ }
+ }
+
+ func getChunkSize(path: String) -> UInt32 {
+ driver.chunkSize(path: path)
+ }
+
+ // MARK: - Helpers
+
+ private static let success = JadeTransportResult(success: true, error: "", errorCode: nil)
+
+ private static func failure(_ error: Error) -> JadeTransportResult {
+ JadeTransportResult(success: false, error: error.localizedDescription, errorCode: errorCode(for: error))
+ }
+
+ private func runOffMainThread(_ work: @escaping (JadeBLEDriving) -> Void) async {
+ let driver = driver
+ await withCheckedContinuation { (continuation: CheckedContinuation) in
+ DispatchQueue.global(qos: .userInitiated).async {
+ work(driver)
+ continuation.resume()
+ }
+ }
+ }
+}
diff --git a/Bitkit/Services/Trezor/TrezorBLEManager.swift b/Bitkit/Services/Trezor/TrezorBLEManager.swift
index 7f79cb4d1..e51ad6ef4 100644
--- a/Bitkit/Services/Trezor/TrezorBLEManager.swift
+++ b/Bitkit/Services/Trezor/TrezorBLEManager.swift
@@ -828,52 +828,3 @@ enum TrezorBLEError: LocalizedError {
}
}
}
-
-// MARK: - BlockingQueue
-
-/// Thread-safe blocking queue for BLE notification data
-private class BlockingQueue {
- private var queue: [T] = []
- private let lock = NSCondition()
- private var failed = false
-
- func offer(_ item: T) {
- lock.lock()
- queue.append(item)
- lock.signal()
- lock.unlock()
- }
-
- func poll(timeout: TimeInterval) -> T? {
- lock.lock()
- defer { lock.unlock() }
-
- let deadline = Date().addingTimeInterval(timeout)
-
- while queue.isEmpty, !failed {
- if !lock.wait(until: deadline) {
- return nil
- }
- }
-
- if failed || queue.isEmpty {
- return nil
- }
-
- return queue.removeFirst()
- }
-
- func clear() {
- lock.lock()
- queue.removeAll()
- failed = false
- lock.unlock()
- }
-
- func fail() {
- lock.lock()
- failed = true
- lock.broadcast()
- lock.unlock()
- }
-}
diff --git a/Bitkit/Utilities/AppReset.swift b/Bitkit/Utilities/AppReset.swift
index 617cd3e4f..a5c5ab194 100644
--- a/Bitkit/Utilities/AppReset.swift
+++ b/Bitkit/Utilities/AppReset.swift
@@ -2,6 +2,11 @@ import BitkitCore
import SwiftUI
enum AppReset {
+ /// The live hardware wallet layer, registered by `AppScene`, which owns it. The wipe starts from
+ /// screens that have no other use for it, so it is not passed in. Weak, so it never keeps a replaced
+ /// app state tree alive.
+ @MainActor weak static var hardwareWallets: HwWalletManager?
+
@MainActor
static func wipe(
app: AppViewModel,
@@ -24,6 +29,8 @@ enum AppReset {
VssStoreIdProvider.shared.clearCache()
OnChainHwService.shared.stopAllWatchers()
+ // Before the paired devices are wiped, so a reconnect still running cannot save one back.
+ await hardwareWallets?.resetForWipe()
// Stop node and wipe LDK persistence via the wallet API.
try await wallet.wipe()
diff --git a/Bitkit/Utilities/BackgroundTaskScheduling.swift b/Bitkit/Utilities/BackgroundTaskScheduling.swift
new file mode 100644
index 000000000..b7db5e421
--- /dev/null
+++ b/Bitkit/Utilities/BackgroundTaskScheduling.swift
@@ -0,0 +1,30 @@
+import UIKit
+
+/// The time the system grants the app to finish work after it leaves the foreground.
+@MainActor
+protocol BackgroundTaskScheduling {
+ /// Seconds left before the app is suspended; very large while it is in the foreground.
+ var backgroundTimeRemaining: TimeInterval { get }
+ /// Starts a background task. `expiration` runs on the main thread when the time is nearly up, and
+ /// must end the task quickly.
+ func beginBackgroundTask(named name: String, expiration: @escaping @MainActor () -> Void) -> UIBackgroundTaskIdentifier
+ func endBackgroundTask(_ identifier: UIBackgroundTaskIdentifier)
+}
+
+@MainActor
+struct UIApplicationBackgroundTasks: BackgroundTaskScheduling {
+ var backgroundTimeRemaining: TimeInterval {
+ UIApplication.shared.backgroundTimeRemaining
+ }
+
+ func beginBackgroundTask(named name: String, expiration: @escaping @MainActor () -> Void) -> UIBackgroundTaskIdentifier {
+ UIApplication.shared.beginBackgroundTask(withName: name) {
+ // UIKit calls the expiration handler on the main thread.
+ MainActor.assumeIsolated { expiration() }
+ }
+ }
+
+ func endBackgroundTask(_ identifier: UIBackgroundTaskIdentifier) {
+ UIApplication.shared.endBackgroundTask(identifier)
+ }
+}
diff --git a/Bitkit/Utilities/BlockingQueue.swift b/Bitkit/Utilities/BlockingQueue.swift
new file mode 100644
index 000000000..0c64fdd64
--- /dev/null
+++ b/Bitkit/Utilities/BlockingQueue.swift
@@ -0,0 +1,56 @@
+import Foundation
+
+/// Thread-safe blocking queue for BLE notification data
+final class BlockingQueue: @unchecked Sendable {
+ private var queue: [T] = []
+ private let lock = NSCondition()
+ private var failed = false
+
+ func offer(_ item: T) {
+ lock.lock()
+ queue.append(item)
+ lock.signal()
+ lock.unlock()
+ }
+
+ func poll(timeout: TimeInterval) -> T? {
+ lock.lock()
+ defer { lock.unlock() }
+
+ let deadline = Date().addingTimeInterval(timeout)
+
+ while queue.isEmpty, !failed {
+ if !lock.wait(until: deadline) {
+ return nil
+ }
+ }
+
+ if failed || queue.isEmpty {
+ return nil
+ }
+
+ return queue.removeFirst()
+ }
+
+ func drain() -> [T] {
+ lock.lock()
+ defer { lock.unlock() }
+ let items = queue
+ queue.removeAll()
+ return items
+ }
+
+ func clear() {
+ lock.lock()
+ queue.removeAll()
+ failed = false
+ lock.unlock()
+ }
+
+ func fail() {
+ lock.lock()
+ failed = true
+ lock.broadcast()
+ lock.unlock()
+ }
+}
diff --git a/Bitkit/Utilities/HwDevicePath.swift b/Bitkit/Utilities/HwDevicePath.swift
new file mode 100644
index 000000000..a6f56ac7b
--- /dev/null
+++ b/Bitkit/Utilities/HwDevicePath.swift
@@ -0,0 +1,20 @@
+import Foundation
+
+/// Transport paths of hardware wallet devices. A Bluetooth device is addressed as `ble:`, where
+/// the UUID is the identifier CoreBluetooth gives the peripheral on this phone.
+enum HwDevicePath {
+ static let blePrefix = "ble:"
+
+ static func ble(_ identifier: UUID) -> String {
+ blePrefix + identifier.uuidString
+ }
+
+ static func isBle(_ path: String) -> Bool {
+ path.hasPrefix(blePrefix)
+ }
+
+ static func bleIdentifier(_ path: String) -> UUID? {
+ guard isBle(path) else { return nil }
+ return UUID(uuidString: String(path.dropFirst(blePrefix.count)))
+ }
+}
diff --git a/Bitkit/Utilities/HwEngagedSession.swift b/Bitkit/Utilities/HwEngagedSession.swift
new file mode 100644
index 000000000..2e334fefe
--- /dev/null
+++ b/Bitkit/Utilities/HwEngagedSession.swift
@@ -0,0 +1,50 @@
+import Foundation
+
+/// Closes a hardware wallet's device session without waiting for it: the next connect to that
+/// device waits for the release instead. Implemented by `HwWalletManager`.
+@MainActor
+protocol HwSessionReleasing: AnyObject {
+ func scheduleStaleSessionCleanup(walletId: String)
+}
+
+extension HwWalletManager: HwSessionReleasing {}
+
+/// The hardware wallet whose device a screen engaged by verifying an address or entering a
+/// passphrase. Only that session is released on the way out: showing a watch-only address never
+/// opens one, and dropping a live session there would ask for a passphrase again on the next send.
+///
+/// The session stays engaged after the work ends, so leaving after a verification still releases
+/// it: a device keeps its session open until something closes it.
+@MainActor
+final class HwEngagedSession {
+ private(set) var walletId: String?
+ private var runningWorkCount = 0
+
+ var isWorking: Bool {
+ runningWorkCount > 0
+ }
+
+ /// Runs device work for `walletId`, engaging its session from the moment the work starts.
+ func perform(walletId: String, _ work: () async throws -> Void) async rethrows {
+ self.walletId = walletId
+ runningWorkCount += 1
+ defer { runningWorkCount -= 1 }
+ try await work()
+ }
+
+ /// Releases the engaged session, if any, when the screen, the hardware tab or the passphrase
+ /// prompt is left.
+ func release(through releaser: HwSessionReleasing) {
+ guard let walletId else { return }
+ self.walletId = nil
+ releaser.scheduleStaleSessionCleanup(walletId: walletId)
+ }
+
+ /// The wallet or address on screen changed. Work still running was for one no longer shown, so
+ /// the session it engaged is released; a session whose work already finished stays engaged until
+ /// the screen is left.
+ func invalidate(through releaser: HwSessionReleasing) {
+ guard isWorking else { return }
+ release(through: releaser)
+ }
+}
diff --git a/Bitkit/Utilities/HwErrorPresenter.swift b/Bitkit/Utilities/HwErrorPresenter.swift
new file mode 100644
index 000000000..b00377e51
--- /dev/null
+++ b/Bitkit/Utilities/HwErrorPresenter.swift
@@ -0,0 +1,51 @@
+import BitkitCore
+import Foundation
+
+/// User-facing messages for hardware wallet errors of every vendor: the Jade copy first, then the
+/// Trezor rules.
+enum HwErrorPresenter {
+ static func userMessage(from error: Error) -> String {
+ jadeMessage(from: error) ?? TrezorErrorPresenter.userMessage(from: error)
+ }
+
+ /// Nil only when the error carries no `JadeError`. Every Jade error gets Jade or neutral copy, so
+ /// none reaches `TrezorErrorPresenter`, whose rules rewrite text into Trezor-branded messages.
+ static func jadeMessage(from error: Error) -> String? {
+ guard let jadeError = error.underlyingJadeError else { return nil }
+ switch jadeError {
+ case .InvalidPin:
+ return t("hardware__jade_invalid_pin")
+ case .DeviceUninitialized:
+ return t("hardware__jade_uninitialized")
+ case .UnsupportedFirmware:
+ return t("hardware__jade_firmware_outdated")
+ case .PsbtTooLarge:
+ return t("hardware__jade_psbt_too_large")
+ case .NetworkMismatch:
+ return t("hardware__jade_network_mismatch")
+ case .DeviceBusy, .DeviceLocked:
+ return t("hardware__jade_device_busy")
+ case .PinServerError:
+ return t("hardware__jade_pinserver_error")
+ case .AddressMismatch:
+ return t("hardware__verify_address_error")
+ case let .TransportError(details), let .ConnectionError(details):
+ // The Bluetooth transport describes what went wrong in words meant for the user.
+ if details.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ return t("hardware__connect_error")
+ }
+ return details
+ default:
+ return t("hardware__connect_error")
+ }
+ }
+
+ static func deviceBusyMessage(for vendor: HwWalletVendor) -> String {
+ switch vendor {
+ case .trezor:
+ return t("hardware__device_busy")
+ case .blockstream:
+ return t("hardware__jade_device_busy")
+ }
+ }
+}
diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift
index 32ff7ac06..4db5cd9df 100644
--- a/Bitkit/ViewModels/AppViewModel.swift
+++ b/Bitkit/ViewModels/AppViewModel.swift
@@ -402,10 +402,10 @@ extension AppViewModel {
}
func toast(_ error: Error) {
- if error is CancellationError || error.isTrezorUserCancellation() {
+ if error is CancellationError || error.isHwUserCancellation() {
return
}
- toast(type: .error, title: "Error", description: error.localizedDescription)
+ toast(type: .error, title: "Error", description: HwErrorPresenter.jadeMessage(from: error) ?? error.localizedDescription)
}
func toast(_ error: HwTransferError) {
@@ -440,8 +440,8 @@ extension AppViewModel {
title: t("hardware__send_broadcast_failed_title"),
description: t("hardware__send_broadcast_failed_text")
)
- case .deviceBusy:
- toast(type: .info, title: t("hardware__device_busy"))
+ case let .deviceBusy(vendor):
+ toast(type: .info, title: HwErrorPresenter.deviceBusyMessage(for: vendor))
case .firmwareReconnect:
toast(
type: .error,
diff --git a/Bitkit/ViewModels/HwFundingSigner.swift b/Bitkit/ViewModels/HwFundingSigner.swift
index d941469d4..2eb615118 100644
--- a/Bitkit/ViewModels/HwFundingSigner.swift
+++ b/Bitkit/ViewModels/HwFundingSigner.swift
@@ -19,7 +19,7 @@ struct HwFundingSigner {
let feeRateProvider: () async -> UInt64?
/// Provides a fee-estimation destination address (an app receive address); never broadcast to.
let addressProvider: () async throws -> String
- let timeouts: (reconnect: Double, compose: Double, sign: Double, broadcast: Double)
+ let timeouts: (compose: Double, sign: Double, broadcast: Double)
/// Conservative vbyte reserve, used only as a fallback when the real coin-selection estimate
/// (a `sendMax` compose) is unavailable.
@@ -83,17 +83,25 @@ struct HwFundingSigner {
}
/// Reconnects, composes and signs a normal on-chain payment without broadcasting it.
+ /// `onConnectingDevice` brackets every reconnect (true before it, false once it returns or throws),
+ /// including the one before a sign retry: the phases a caller may abandon because nothing is on the
+ /// device to sign yet.
func prepareSignedPayment(
walletId: String,
address: String,
sats: UInt64,
satsPerVByte: UInt64,
- onComposed: (HwFundingTransaction) -> Void = { _ in }
+ onComposed: (HwFundingTransaction) -> Void = { _ in },
+ onConnectingDevice: (Bool) -> Void = { _ in }
) async throws -> HwFundingSignedTx {
- try await ensureConnected(walletId: walletId)
+ do {
+ onConnectingDevice(true)
+ defer { onConnectingDevice(false) }
+ try await ensureConnected(walletId: walletId)
+ }
let tx = try await compose(walletId: walletId, address: address, sats: sats, satsPerVByte: satsPerVByte)
onComposed(tx)
- return try await signStep(walletId: walletId, funding: tx)
+ return try await signStep(walletId: walletId, funding: tx, onConnectingDevice: onConnectingDevice)
}
/// Broadcasts a signed funding transaction without requiring the hardware device.
@@ -127,7 +135,7 @@ struct HwFundingSigner {
private func ensureConnected(walletId: String) async throws {
do {
- try await withTimeout(timeouts.reconnect) {
+ try await withTimeout(connecting.reconnectTimeout(walletId: walletId)) {
try await connecting.ensureConnected(walletId: walletId)
}
} catch is CancellationError {
@@ -136,7 +144,7 @@ struct HwFundingSigner {
disconnectAfterTimeout(walletId: walletId)
throw HwTransferError.reconnect(isBluetooth: connecting.isKnownBluetoothDevice(walletId: walletId))
} catch {
- if error.isTrezorUserCancellation() {
+ if error.isHwUserCancellation() {
throw error
}
// Swift has no cause chain, so this must be rethrown explicitly: the catch-all below
@@ -145,8 +153,13 @@ struct HwFundingSigner {
if let passphrase = error as? HwPassphraseError {
throw passphrase
}
- if error.isTrezorDeviceBusy() {
- throw HwTransferError.deviceBusy
+ if let vendor = error.hwBusyVendor {
+ throw HwTransferError.deviceBusy(vendor)
+ }
+ // A Jade that refuses to open (wrong PIN, unreachable PIN server, wrong network) says why
+ // in words the user can act on, which the reconnect copy would hide.
+ if error.underlyingJadeError != nil, !error.isJadeSessionFailure() {
+ throw HwTransferError.generic(HwErrorPresenter.userMessage(from: error))
}
throw HwTransferError.reconnect(isBluetooth: connecting.isKnownBluetoothDevice(walletId: walletId))
}
@@ -173,13 +186,28 @@ struct HwFundingSigner {
} catch is Timeout {
disconnectAfterTimeout(walletId: walletId)
throw HwTransferError.signingTimeout
+ } catch where error.underlyingJadeError != nil {
+ // A Jade compose reconnects to read the fingerprint, so it fails the way a reconnect does
+ // and is reported the same way instead of with the raw core description.
+ if error.isJadeUserCancellation() {
+ throw error
+ }
+ if error.isJadeDeviceBusy() {
+ throw HwTransferError.deviceBusy(.blockstream)
+ }
+ let message = HwErrorPresenter.userMessage(from: error)
+ throw error.isJadeSessionFailure() ? HwTransferError.funding(message) : HwTransferError.generic(message)
} catch {
let message = (error as? AppError)?.debugMessage ?? (error as? AppError)?.message ?? error.localizedDescription
throw HwTransferError.funding(message)
}
}
- private func signStep(walletId: String, funding tx: HwFundingTransaction) async throws -> HwFundingSignedTx {
+ private func signStep(
+ walletId: String,
+ funding tx: HwFundingTransaction,
+ onConnectingDevice: (Bool) -> Void = { _ in }
+ ) async throws -> HwFundingSignedTx {
do {
return try await signOnce(walletId: walletId, funding: tx)
} catch is CancellationError {
@@ -188,10 +216,14 @@ struct HwFundingSigner {
disconnectAfterTimeout(walletId: walletId)
throw HwTransferError.signingTimeout
} catch {
- guard error.isTrezorSessionFailure() else { throw error }
+ guard error.isHwSessionFailure() else { throw error }
await connecting.disconnectStaleSession(walletId: walletId)
- try await ensureConnected(walletId: walletId)
+ do {
+ onConnectingDevice(true)
+ defer { onConnectingDevice(false) }
+ try await ensureConnected(walletId: walletId)
+ }
do {
return try await signOnce(walletId: walletId, funding: tx)
@@ -199,7 +231,7 @@ struct HwFundingSigner {
disconnectAfterTimeout(walletId: walletId)
throw HwTransferError.signingTimeout
} catch {
- if error.isTrezorSessionFailure() {
+ if error.isHwSessionFailure() {
await connecting.disconnectStaleSession(walletId: walletId)
}
throw error
@@ -316,6 +348,7 @@ final class HwSendCoordinator {
private(set) var isFundingSourceLoading = false
private(set) var isPreviewLoading = false
private(set) var isSigning = false
+ private(set) var isConnectingDevice = false
private(set) var isBroadcastUnresolved = false
private(set) var isPassphraseRequired = false
private(set) var isVerifyingPassphrase = false
@@ -323,6 +356,10 @@ final class HwSendCoordinator {
private var pendingPayment: PendingPayment?
private var operationTask: Task?
private var operationRequest: PaymentRequest?
+ private var operationSession: OperationSession?
+ /// Bumped by every sign attempt and every cancel. A cancelled task can keep running until its
+ /// device call returns, and must not write over the state of an attempt started after it.
+ private var signingAttempt = 0
private var availabilityRequestId = 0
private var previewRequestId = 0
private let signerFactory: @MainActor (HwWalletManager, String, UInt64) -> HwFundingSigner
@@ -335,6 +372,13 @@ final class HwSendCoordinator {
pendingPayment != nil
}
+ /// Whether the sign screen may be left. Reaching the device (a Jade may wait minutes for its PIN)
+ /// can be abandoned, and leaving cancels it; once the device is asked to sign, or a broadcast may
+ /// have gone out, it cannot.
+ var canLeave: Bool {
+ (!isSigning || isConnectingDevice) && !isBroadcastUnresolved
+ }
+
init(
walletId: String? = nil,
signerFactory: @escaping @MainActor (HwWalletManager, String, UInt64) -> HwFundingSigner = { manager, address, satsPerVByte in
@@ -371,6 +415,7 @@ final class HwSendCoordinator {
isFundingSourceLoading = walletId != nil && showsLoading
isPreviewLoading = false
isSigning = false
+ isConnectingDevice = false
isBroadcastUnresolved = false
isPassphraseRequired = false
isVerifyingPassphrase = false
@@ -462,15 +507,23 @@ final class HwSendCoordinator {
}
let request = PaymentRequest(address: address, sats: sats, satsPerVByte: satsPerVByte)
if let operationTask {
- guard operationRequest == request else { throw HwTransferError.deviceBusy }
+ guard operationRequest == request else { throw HwTransferError.deviceBusy(manager.vendor(walletId: walletId)) }
return try await operationTask.value
}
+ signingAttempt += 1
+ let attempt = signingAttempt
+ let signer = signerFactory(manager, address, satsPerVByte)
+ isSigning = true
+
let task = Task { @MainActor in
- isSigning = true
- defer { isSigning = false }
+ defer {
+ if signingAttempt == attempt {
+ isSigning = false
+ isConnectingDevice = false
+ }
+ }
- let signer = signerFactory(manager, address, satsPerVByte)
let signed: HwFundingSignedTx
if let pendingPayment, pendingPayment.request == request {
signed = pendingPayment.signedTx
@@ -480,13 +533,22 @@ final class HwSendCoordinator {
address: address,
sats: sats,
satsPerVByte: satsPerVByte,
- onComposed: { [weak self] in self?.previewFeeSats = $0.miningFeeSats }
+ onComposed: { [weak self] composed in
+ guard let self, signingAttempt == attempt else { return }
+ previewFeeSats = composed.miningFeeSats
+ },
+ onConnectingDevice: { [weak self] isConnecting in
+ guard let self, signingAttempt == attempt else { return }
+ isConnectingDevice = isConnecting
+ }
)
+ try Task.checkCancellation()
pendingPayment = PendingPayment(request: request, signedTx: signed)
}
if pendingPayment?.isPreparedForBroadcast != true {
try await beforeBroadcast()
+ try Task.checkCancellation()
pendingPayment?.isPreparedForBroadcast = true
}
@@ -506,9 +568,13 @@ final class HwSendCoordinator {
}
operationRequest = request
operationTask = task
+ operationSession = OperationSession(walletId: walletId, connecting: signer.connecting)
defer {
- operationRequest = nil
- operationTask = nil
+ if signingAttempt == attempt {
+ operationRequest = nil
+ operationTask = nil
+ operationSession = nil
+ }
}
return try await task.value
}
@@ -546,11 +612,20 @@ final class HwSendCoordinator {
isVerifyingPassphrase = false
isPassphraseRequired = false
guard !isBroadcastUnresolved else { return }
+ let abandonedSession = operationSession
operationTask?.cancel()
+ signingAttempt += 1
operationTask = nil
operationRequest = nil
+ operationSession = nil
pendingPayment = nil
isSigning = false
+ isConnectingDevice = false
+ // A Swift cancel never reaches the device, which would otherwise keep connecting (or wait for
+ // a PIN) for a payment nobody is waiting on. The next connect waits for this release.
+ if let abandonedSession {
+ abandonedSession.connecting.scheduleStaleSessionCleanup(walletId: abandonedSession.walletId)
+ }
}
private static func signer(
@@ -563,7 +638,7 @@ final class HwSendCoordinator {
connecting: manager,
feeRateProvider: { satsPerVByte },
addressProvider: { address },
- timeouts: (reconnect: 30, compose: 45, sign: 120, broadcast: 120)
+ timeouts: (compose: 45, sign: 120, broadcast: 120)
)
}
@@ -578,4 +653,9 @@ final class HwSendCoordinator {
let signedTx: HwFundingSignedTx
var isPreparedForBroadcast = false
}
+
+ private struct OperationSession {
+ let walletId: String
+ let connecting: HwTransferConnecting
+ }
}
diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift
index c18497cf0..3c4d0e168 100644
--- a/Bitkit/ViewModels/TransferViewModel.swift
+++ b/Bitkit/ViewModels/TransferViewModel.swift
@@ -54,8 +54,9 @@ enum HwTransferError: Error, Equatable {
case broadcastUncertain
/// Signed tx is retained but Electrum/network is unreachable — retry broadcast later.
case broadcastConnectivity
- /// Trezor is locked or otherwise busy before signing can start.
- case deviceBusy
+ /// The device is locked or otherwise busy before signing can start. Carries the vendor so the
+ /// toast can name the device.
+ case deviceBusy(HwWalletVendor)
/// Firmware error (code 99) — user must reconnect the device.
case firmwareReconnect
/// The entered passphrase opened a different wallet than the one being spent from.
@@ -96,10 +97,12 @@ protocol HwTransferFunding: Sendable {
/// The device-session capability the transfer flow needs for on-device signing, addressed by wallet
/// identity: a device holds one wallet open at a time, so reaching a given wallet is more than
-/// reaching its transport. Implemented by `TrezorManager`.
+/// reaching its transport. Implemented by `HwWalletManager`.
@MainActor
protocol HwTransferConnecting: Sendable {
func ensureConnected(walletId: String) async throws
+ /// How long `ensureConnected` may take for this wallet's device before the flow gives up.
+ func reconnectTimeout(walletId: String) -> Double
func disconnectStaleSession(walletId: String) async
func scheduleStaleSessionCleanup(walletId: String)
/// Whether the wallet is reachable over a known Bluetooth device, so a reconnect failure can show
@@ -189,7 +192,7 @@ class TransferViewModel: ObservableObject {
hwConnecting: HwTransferConnecting? = nil,
hwFeeRateProvider: (() async -> UInt64?)? = nil,
hwAddressProvider: (() async throws -> String)? = nil,
- hwTimeouts: (reconnect: Double, compose: Double, sign: Double, broadcast: Double) = (reconnect: 30, compose: 45, sign: 120, broadcast: 120),
+ hwTimeouts: (compose: Double, sign: Double, broadcast: Double) = (compose: 45, sign: 120, broadcast: 120),
onBalanceRefresh: (() async -> Void)? = nil
) {
self.coreService = coreService
@@ -240,7 +243,7 @@ class TransferViewModel: ObservableObject {
hwConnecting: HwTransferConnecting?,
hwFeeRateProvider: (() async -> UInt64?)? = nil,
hwAddressProvider: (() async throws -> String)? = nil,
- hwTimeouts: (reconnect: Double, compose: Double, sign: Double, broadcast: Double) = (reconnect: 30, compose: 45, sign: 120, broadcast: 120),
+ hwTimeouts: (compose: Double, sign: Double, broadcast: Double) = (compose: 45, sign: 120, broadcast: 120),
coreService: CoreService = .shared,
lightningService: LightningService = .shared,
sheetViewModel: SheetViewModel = SheetViewModel()
@@ -674,7 +677,7 @@ class TransferViewModel: ObservableObject {
} catch let error as HwTransferError {
self.handleHardwareTransferFailure(error, walletId: walletId)
} catch {
- if error.isTrezorUserCancellation() {
+ if error.isHwUserCancellation() {
Logger.info("Hardware transfer cancelled on device for '\(walletId)'", context: "TransferViewModel")
return
}
@@ -776,9 +779,9 @@ class TransferViewModel: ObservableObject {
case .broadcastConnectivity:
Logger.warn("Hardware funding broadcast connectivity failure for '\(walletId)'", context: "TransferViewModel")
case .deviceBusy:
- Logger.warn("Blocked hardware transfer for locked or busy Trezor '\(walletId)'", context: "TransferViewModel")
+ Logger.warn("Blocked hardware transfer for locked or busy device '\(walletId)'", context: "TransferViewModel")
case .firmwareReconnect:
- Logger.warn("Received Trezor firmware error for '\(walletId)'", context: "TransferViewModel")
+ Logger.warn("Received hardware firmware error for '\(walletId)'", context: "TransferViewModel")
case .passphraseMismatch:
Logger.warn("Rejected wrong passphrase for hardware wallet '\(walletId)'", context: "TransferViewModel")
case let .funding(message):
@@ -801,11 +804,11 @@ class TransferViewModel: ObservableObject {
hwTransferError = .passphraseMismatch
return
}
- if error.isTrezorDeviceBusy() {
- hwTransferError = .deviceBusy
+ if let vendor = error.hwBusyVendor {
+ hwTransferError = .deviceBusy(vendor)
return
}
- if error.isTrezorFirmwareError() {
+ if error.isHwFirmwareError() {
hwTransferError = .firmwareReconnect
return
}
@@ -816,7 +819,7 @@ class TransferViewModel: ObservableObject {
}
clearPendingHwFundingBroadcast()
}
- hwTransferError = .generic((error as? AppError)?.message ?? error.localizedDescription)
+ hwTransferError = .generic(HwErrorPresenter.jadeMessage(from: error) ?? (error as? AppError)?.message ?? error.localizedDescription)
}
// MARK: - Balance Calculation
diff --git a/Bitkit/ViewModels/Trezor/HwConnectService.swift b/Bitkit/ViewModels/Trezor/HwConnectService.swift
new file mode 100644
index 000000000..e44593e88
--- /dev/null
+++ b/Bitkit/ViewModels/Trezor/HwConnectService.swift
@@ -0,0 +1,195 @@
+import BitkitCore
+import Foundation
+
+/// Production `HwConnectServicing` over the vendor managers. iOS is Bluetooth only, so discovery runs
+/// a Trezor scan and a Jade scan side by side. Pairing goes through `HwWalletManager`, which releases
+/// the other vendor's session first and queues the pairing behind the other device operations.
+@MainActor
+struct HwConnectService: HwConnectServicing {
+ let trezorManager: TrezorManager
+ let jadeManager: JadeManager
+ let hwWalletManager: HwWalletManager
+
+ func scanForDevices() async throws -> [HwNearbyDevice] {
+ async let trezorScan = scanTrezor()
+ async let jadeScan = scanJade()
+ let trezor = await trezorScan
+ let jade = await jadeScan
+ return try Self.nearbyDevices(trezor: trezor, jade: jade, isPaired: isPaired)
+ }
+
+ /// Unpaired devices of either vendor first, then the paired ones. A device that is already paired
+ /// is only offered once no new one is found, so its passphrase wallets can be added afterwards;
+ /// otherwise Add Hardware Wallet would search forever on the only device in range.
+ ///
+ /// A Jade scan fails quietly (core refuses to scan while it is busy with another request), so the
+ /// search only fails when the Trezor scan failed and nothing was found.
+ static func nearbyDevices(
+ trezor: Result<[HwNearbyDevice], Error>,
+ jade: Result<[HwNearbyDevice], Error>,
+ isPaired: (HwNearbyDevice) -> Bool
+ ) throws -> [HwNearbyDevice] {
+ let found = ((try? trezor.get()) ?? []) + ((try? jade.get()) ?? [])
+ if found.isEmpty, case let .failure(error) = trezor {
+ throw error
+ }
+ let (paired, unpaired) = found.partitioned(by: isPaired)
+ return unpaired + paired
+ }
+
+ private func scanTrezor() async -> Result<[HwNearbyDevice], Error> {
+ await trezorManager.startScan()
+ if let error = trezorManager.error {
+ return .failure(AppError(message: error, debugMessage: nil))
+ }
+ return .success(trezorManager.devices.map { HwNearbyDevice(source: .trezor($0)) })
+ }
+
+ private func scanJade() async -> Result<[HwNearbyDevice], Error> {
+ do {
+ let devices = try await jadeManager.scan()
+ return .success(devices.map { HwNearbyDevice(source: .jade($0)) })
+ } catch {
+ Logger.warn("Jade scan failed: \(error)", context: "HwConnectService")
+ return .failure(error)
+ }
+ }
+
+ /// A Jade counts as paired under the name it advertises too: after a reboot it comes back under a
+ /// new Bluetooth identifier.
+ func isPaired(_ device: HwNearbyDevice) -> Bool {
+ switch device.source {
+ case let .trezor(trezor):
+ HwKnownDeviceStorage.isKnown(id: trezor.id, vendor: .trezor)
+ case let .jade(jade):
+ jadeManager.hasKnownDevice(deviceId: jade.path, advertisedName: jade.name)
+ }
+ }
+
+ func connect(to device: HwNearbyDevice) async throws -> HwConnectResult {
+ switch device.source {
+ case let .trezor(trezor):
+ try await connectTrezor(trezor)
+ case let .jade(jade):
+ try await connectJade(jade)
+ }
+ }
+
+ /// `TrezorManager.connect` returns nothing and stores its state, so success is read from its
+ /// `connectedDevice` and `deviceFeatures`, and its error is surfaced otherwise.
+ private func connectTrezor(_ device: TrezorDeviceInfo) async throws -> HwConnectResult {
+ try await hwWalletManager.withVendorSession(.trezor) {
+ await trezorManager.connect(device: device)
+ guard let connected = trezorManager.connectedDevice, connected.id == device.id else {
+ throw AppError(message: trezorManager.error ?? t("hardware__connect_error"), debugMessage: nil)
+ }
+ let walletId = trezorManager.connectedWalletId
+ let deviceDefaultName = resolveHwWalletName(
+ label: connected.label ?? trezorManager.deviceFeatures?.label,
+ model: connected.model ?? trezorManager.deviceFeatures?.model
+ )
+ return HwConnectResult(
+ deviceId: connected.id,
+ walletId: walletId,
+ name: Self.pairedName(
+ walletId: walletId,
+ storedEntries: HwKnownDeviceStorage.loadAll(),
+ deviceDefaultName: deviceDefaultName
+ ),
+ deviceDefaultName: deviceDefaultName
+ )
+ }
+ }
+
+ private func connectJade(_ device: JadeDeviceInfo) async throws -> HwConnectResult {
+ let connected = try await hwWalletManager.withVendorSession(.blockstream) {
+ try await jadeManager.connect(path: device.path)
+ }
+ let deviceDefaultName = resolveHwWalletName(label: nil, model: connected.model, vendor: .blockstream)
+ return HwConnectResult(
+ deviceId: connected.id,
+ walletId: connected.walletId,
+ name: Self.pairedName(
+ walletId: connected.walletId,
+ storedEntries: HwKnownDeviceStorage.loadAll(),
+ deviceDefaultName: deviceDefaultName
+ ),
+ deviceDefaultName: deviceDefaultName,
+ vendor: .blockstream
+ )
+ }
+
+ /// The name to show the paired step under, so re-pairing doesn't appear to rename the wallet.
+ ///
+ /// Read from the store rather than from the published wallet list: connecting has just written
+ /// this identity's entry, and the tiles only catch up on the next device push. A wallet that was
+ /// removed and is now being re-added is not in that list at all, so its name (restored from a
+ /// backup or kept through the removal, and adopted onto the entry a moment ago) would fall back
+ /// to the device's own. Finishing the step then persists that fallback over it.
+ static func pairedName(
+ walletId: String?,
+ storedEntries: [HwKnownDevice],
+ deviceDefaultName: String
+ ) -> String {
+ storedName(walletId: walletId, storedEntries: storedEntries) ?? deviceDefaultName
+ }
+
+ /// The Bitkit-side name stored for `walletId`, or nil when it has none of its own.
+ static func storedName(walletId: String?, storedEntries: [HwKnownDevice]) -> String? {
+ guard let walletId,
+ let label = storedEntries.first(where: { $0.resolvedWalletId == walletId })?.customLabel,
+ !label.isEmpty
+ else {
+ return nil
+ }
+ return label
+ }
+
+ func connectWithPassphrase(deviceId: String, passphrase: String) async throws -> String {
+ try await hwWalletManager.connectWithPassphrase(deviceId: deviceId, passphrase: passphrase)
+ }
+
+ func storedName(forWallet walletId: String) -> String? {
+ Self.storedName(walletId: walletId, storedEntries: HwKnownDeviceStorage.loadAll())
+ }
+
+ func setWalletLabel(walletId: String, label: String) {
+ hwWalletManager.renameWallet(walletId: walletId, newName: label)
+ }
+
+ func cancelPairingCode() {
+ trezorManager.cancelPairingCode()
+ }
+
+ /// The release runs in its own task, so it carries on after the sheet goes away.
+ func cancelPendingConnection(to device: HwNearbyDevice) {
+ switch device.source {
+ case let .trezor(trezor):
+ trezorManager.cancelPairingCode()
+ Task { [trezorManager] in
+ // A session another Trezor holds was not opened by this connect.
+ if let connected = trezorManager.connectedDevice, connected.id != trezor.id {
+ return
+ }
+ await trezorManager.disconnectStaleSession(deviceId: trezor.id)
+ }
+ case let .jade(jade):
+ Task { [jadeManager] in
+ await jadeManager.cancelPendingConnection(deviceId: jade.path)
+ }
+ }
+ }
+}
+
+private extension Array {
+ /// Splits into (matching, rest), preserving order within each group.
+ func partitioned(by isMatch: (Element) -> Bool) -> (matching: [Element], rest: [Element]) {
+ reduce(into: ([Element](), [Element]())) { result, element in
+ if isMatch(element) {
+ result.0.append(element)
+ } else {
+ result.1.append(element)
+ }
+ }
+ }
+}
diff --git a/Bitkit/ViewModels/Trezor/HwConnectViewModel.swift b/Bitkit/ViewModels/Trezor/HwConnectViewModel.swift
index 079c75d21..acb7e890f 100644
--- a/Bitkit/ViewModels/Trezor/HwConnectViewModel.swift
+++ b/Bitkit/ViewModels/Trezor/HwConnectViewModel.swift
@@ -6,36 +6,96 @@ import Foundation
struct HwConnectResult: Equatable {
let deviceId: String
let walletId: String?
- /// Name of the identity this session opened — its Bitkit-side label once it has one.
+ /// Name of the identity this session opened: its Bitkit-side label once it has one.
let name: String
/// The device's own name, from its label/model. A passphrase wallet has no label of its own
- /// until the user gives it one, so this is what its step is prefilled with — the label of the
+ /// until the user gives it one, so this is what its step is prefilled with; the label of the
/// identity that happened to be open before it is not its name.
let deviceDefaultName: String
+ let vendor: HwWalletVendor
- init(deviceId: String, walletId: String?, name: String, deviceDefaultName: String? = nil) {
+ init(
+ deviceId: String,
+ walletId: String?,
+ name: String,
+ deviceDefaultName: String? = nil,
+ vendor: HwWalletVendor = .trezor
+ ) {
self.deviceId = deviceId
self.walletId = walletId
self.name = name
self.deviceDefaultName = deviceDefaultName ?? name
+ self.vendor = vendor
}
}
-/// Device discovery/connection seam the Connect Hardware flow drives. `TrezorHwConnectService` is
-/// the production adapter over `TrezorManager`; tests inject a fake so the flow can be exercised
+/// A hardware wallet a scan found nearby. It carries the record its vendor's scan returned, so
+/// connecting dials the device exactly as it was found.
+struct HwNearbyDevice: Equatable, Identifiable {
+ enum Source: Equatable {
+ case trezor(TrezorDeviceInfo)
+ case jade(JadeDeviceInfo)
+ }
+
+ let source: Source
+
+ var vendor: HwWalletVendor {
+ switch source {
+ case .trezor: .trezor
+ case .jade: .blockstream
+ }
+ }
+
+ /// A Jade is known by its path until connecting reads its identity from the device.
+ var id: String {
+ switch source {
+ case let .trezor(device): device.id
+ case let .jade(device): device.path
+ }
+ }
+
+ var path: String {
+ switch source {
+ case let .trezor(device): device.path
+ case let .jade(device): device.path
+ }
+ }
+
+ var name: String? {
+ switch source {
+ case let .trezor(device): device.name
+ case let .jade(device): device.name
+ }
+ }
+
+ /// A Jade reports its model only once connected.
+ var model: String? {
+ switch source {
+ case let .trezor(device): device.model
+ case .jade: nil
+ }
+ }
+}
+
+/// Device discovery/connection seam the Connect Hardware flow drives. `HwConnectService` is the
+/// production adapter over the vendor managers; tests inject a fake so the flow can be exercised
/// without the BLE stack.
@MainActor
protocol HwConnectServicing {
- /// Reachable devices, unpaired first. Discovery normally hides paired devices; one is offered as
- /// a fallback so its passphrase wallets can be added after the initial pairing.
- func scanForDevices() async throws -> [TrezorDeviceInfo]
- func connect(to device: TrezorDeviceInfo) async throws -> HwConnectResult
+ /// Reachable devices of every vendor, unpaired first. Discovery normally hides paired devices; one
+ /// is offered as a fallback so its passphrase wallets can be added after the initial pairing.
+ func scanForDevices() async throws -> [HwNearbyDevice]
+ func connect(to device: HwNearbyDevice) async throws -> HwConnectResult
/// Opens the hidden wallet the passphrase unlocks and starts watching it; returns its wallet id.
func connectWithPassphrase(deviceId: String, passphrase: String) async throws -> String
/// The Bitkit-side name already stored for `walletId`, or nil when it has none.
func storedName(forWallet walletId: String) -> String?
func setWalletLabel(walletId: String, label: String)
func cancelPairingCode()
+ /// Stops a connect in flight to `device` and releases its session. A task cancel alone never
+ /// reaches a Jade waiting for its PIN, and a Trezor session opened for a pairing the user left
+ /// must not linger.
+ func cancelPendingConnection(to device: HwNearbyDevice)
}
/// Backs the Connect Hardware bottom-sheet flow (Intro → Searching → Found → Paired). Drives device
@@ -67,7 +127,11 @@ final class HwConnectViewModel {
private(set) var phase: Phase = .intro
private(set) var isConnecting = false
- private(set) var foundDevice: TrezorDeviceInfo?
+ private(set) var foundDevice: HwNearbyDevice?
+ /// Vendor of the device being paired, which decides the copy, illustration and steps shown.
+ private(set) var vendor: HwWalletVendor = .trezor
+ /// A Jade being connected is waiting for its PIN on the device.
+ private(set) var isUnlocking = false
private(set) var foundDeviceModel = ""
private(set) var pairedDeviceId: String?
/// Identity paired on `pairedDeviceId`; resolved once its watch-only wallet is known.
@@ -136,11 +200,12 @@ final class HwConnectViewModel {
}
}
- private func onDeviceFound(_ device: TrezorDeviceInfo) {
+ private func onDeviceFound(_ device: HwNearbyDevice) {
searchTask?.cancel()
searchTask = nil
foundDevice = device
- foundDeviceModel = resolveHwWalletName(label: nil, model: device.model)
+ vendor = device.vendor
+ foundDeviceModel = resolveHwWalletName(label: nil, model: device.model, vendor: device.vendor)
errorMessage = nil
phase = .found
}
@@ -173,6 +238,8 @@ final class HwConnectViewModel {
private func onConnected(_ result: HwConnectResult) {
isConnecting = false
+ isUnlocking = false
+ vendor = result.vendor
pairedDeviceId = result.deviceId
// The device may hold several identities, so take the one this session opened rather than
// any wallet sharing its transport id.
@@ -189,17 +256,39 @@ final class HwConnectViewModel {
private func onConnectFailed(_ error: Error) {
isConnecting = false
- errorMessage = (error as? AppError)?.message ?? t("hardware__connect_error")
+ isUnlocking = false
+ errorMessage = connectErrorMessage(for: error)
phase = .found
}
- /// The device asked for its one-time pairing code mid-connect; surface the inline step. Only
- /// while a connect is in flight, so a stray flag can't hijack the flow.
+ /// A Jade failure keeps its own copy (a wrong PIN, the pinserver, a stale Bluetooth bond), which
+ /// the generic connect message would hide.
+ private func connectErrorMessage(for error: Error) -> String {
+ guard vendor == .blockstream else {
+ return (error as? AppError)?.message ?? t("hardware__connect_error")
+ }
+ if let jadeMessage = HwErrorPresenter.jadeMessage(from: error) {
+ return jadeMessage
+ }
+ if let appError = error as? AppError, !appError.isGeneric {
+ return appError.message
+ }
+ return t("hardware__connect_error")
+ }
+
+ /// The Trezor asked for its one-time pairing code mid-connect; surface the inline step. Only
+ /// while a Trezor connect is in flight, so a stray flag can't hijack the flow.
func onPairingCodeRequested() {
- guard isConnecting else { return }
+ guard isConnecting, vendor == .trezor else { return }
phase = .pairCode
}
+ /// The device started or stopped waiting for its PIN. The hint only belongs to a Jade this flow is
+ /// connecting; a background reconnect never unlocks.
+ func onUnlockingChanged(_ isDeviceUnlocking: Bool) {
+ isUnlocking = isDeviceUnlocking && isConnecting && vendor == .blockstream
+ }
+
// MARK: - Paired
/// The paired wallet's aggregated balance/name landed; reflect it on the Paired step.
@@ -249,6 +338,7 @@ final class HwConnectViewModel {
/// Each identity is labelled on its own paired step, so the one being left is persisted before
/// the next passphrase wallet takes over the field.
func onPassphraseClick() {
+ guard vendor.supportsPassphraseWallets else { return }
persistLabel()
passphraseInput = ""
errorMessage = nil
@@ -270,7 +360,11 @@ final class HwConnectViewModel {
/// The passphrase is dropped from state as soon as the device answers: it lives in the Trezor
/// session, never in Bitkit.
func onPassphraseSubmit() {
- guard let deviceId = pairedDeviceId, !passphraseInput.isEmpty, connectTask == nil else { return }
+ guard vendor.supportsPassphraseWallets,
+ let deviceId = pairedDeviceId,
+ !passphraseInput.isEmpty,
+ connectTask == nil
+ else { return }
let passphrase = passphraseInput
isSubmittingPassphrase = true
errorMessage = nil
@@ -341,12 +435,22 @@ final class HwConnectViewModel {
// MARK: - Teardown
- /// Cancels a pending connect/pairing-code request when the user backs out mid-connect.
+ /// Cancels a pending connect when the user backs out mid-connect, releasing the device it was
+ /// opening. Nothing is released when no connect is in flight, so leaving the sheet after pairing
+ /// keeps the session the flow just opened.
func cancelConnect() {
+ let wasConnecting = connectTask != nil || isConnecting
connectTask?.cancel()
connectTask = nil
- service.cancelPairingCode()
+ if wasConnecting {
+ if let foundDevice {
+ service.cancelPendingConnection(to: foundDevice)
+ } else {
+ service.cancelPairingCode()
+ }
+ }
isConnecting = false
+ isUnlocking = false
}
/// Called when the sheet is dismissed: stop scanning/connecting and drop any pending pairing.
@@ -358,102 +462,3 @@ final class HwConnectViewModel {
isSubmittingPassphrase = false
}
}
-
-/// Production `HwConnectServicing` over `TrezorManager`. iOS is BLE-only, so discovery is a single
-/// BLE scan filtered to unpaired devices; `connect(to:)` reports success by inspecting the manager's
-/// `connectedDevice`/`deviceFeatures` (its own `connect` returns void and stores state) and surfaces
-/// the manager's error otherwise.
-@MainActor
-struct TrezorHwConnectService: HwConnectServicing {
- let trezorManager: TrezorManager
- let hwWalletManager: HwWalletManager
-
- func scanForDevices() async throws -> [TrezorDeviceInfo] {
- await trezorManager.startScan()
- if let error = trezorManager.error {
- throw AppError(message: error, debugMessage: nil)
- }
- // A device that is already paired is only offered once no new one is found, so its
- // passphrase wallets can be added afterwards — otherwise Add Hardware Wallet would search
- // forever on the only device in range.
- let (paired, unpaired) = trezorManager.devices.partitioned { TrezorKnownDeviceStorage.isKnown(id: $0.id) }
- return unpaired + paired
- }
-
- func connect(to device: TrezorDeviceInfo) async throws -> HwConnectResult {
- await trezorManager.connect(device: device)
- guard let connected = trezorManager.connectedDevice, connected.id == device.id else {
- throw AppError(message: trezorManager.error ?? t("hardware__connect_error"), debugMessage: nil)
- }
- let walletId = trezorManager.connectedWalletId
- let deviceDefaultName = resolveHwWalletName(
- label: connected.label ?? trezorManager.deviceFeatures?.label,
- model: connected.model ?? trezorManager.deviceFeatures?.model
- )
- return HwConnectResult(
- deviceId: connected.id,
- walletId: walletId,
- name: Self.pairedName(
- walletId: walletId,
- storedEntries: TrezorKnownDeviceStorage.loadAll(),
- deviceDefaultName: deviceDefaultName
- ),
- deviceDefaultName: deviceDefaultName
- )
- }
-
- /// The name to show the paired step under, so re-pairing doesn't appear to rename the wallet.
- ///
- /// Read from the store rather than from the published wallet list: connecting has just written
- /// this identity's entry, and the tiles only catch up on the next device push. A wallet that was
- /// removed and is now being re-added is not in that list at all, so its name — restored from a
- /// backup or kept through the removal, and adopted onto the entry a moment ago — would fall back
- /// to the device's own. Finishing the step then persists that fallback over it.
- static func pairedName(
- walletId: String?,
- storedEntries: [TrezorKnownDevice],
- deviceDefaultName: String
- ) -> String {
- storedName(walletId: walletId, storedEntries: storedEntries) ?? deviceDefaultName
- }
-
- /// The Bitkit-side name stored for `walletId`, or nil when it has none of its own.
- static func storedName(walletId: String?, storedEntries: [TrezorKnownDevice]) -> String? {
- guard let walletId,
- let label = storedEntries.first(where: { $0.resolvedWalletId == walletId })?.customLabel,
- !label.isEmpty
- else {
- return nil
- }
- return label
- }
-
- func connectWithPassphrase(deviceId: String, passphrase: String) async throws -> String {
- try await hwWalletManager.connectWithPassphrase(deviceId: deviceId, passphrase: passphrase)
- }
-
- func storedName(forWallet walletId: String) -> String? {
- Self.storedName(walletId: walletId, storedEntries: TrezorKnownDeviceStorage.loadAll())
- }
-
- func setWalletLabel(walletId: String, label: String) {
- trezorManager.renameWallet(walletId: walletId, newName: label)
- }
-
- func cancelPairingCode() {
- trezorManager.cancelPairingCode()
- }
-}
-
-private extension Array {
- /// Splits into (matching, rest), preserving order within each group.
- func partitioned(by isMatch: (Element) -> Bool) -> (matching: [Element], rest: [Element]) {
- reduce(into: ([Element](), [Element]())) { result, element in
- if isMatch(element) {
- result.0.append(element)
- } else {
- result.1.append(element)
- }
- }
- }
-}
diff --git a/Bitkit/Views/Sheets/HardwareConnect/HwFoundView.swift b/Bitkit/Views/Sheets/HardwareConnect/HwFoundView.swift
index 1f7e63b60..58ffec1cc 100644
--- a/Bitkit/Views/Sheets/HardwareConnect/HwFoundView.swift
+++ b/Bitkit/Views/Sheets/HardwareConnect/HwFoundView.swift
@@ -1,10 +1,13 @@
import SwiftUI
/// Found step: a discovered device with a Connect confirmation. Connect shows a spinner and
-/// surfaces an inline error on failure.
+/// surfaces an inline error on failure. A Jade waiting for its PIN adds a hint to enter it on the
+/// device; Cancel stays available throughout, so the user can back out of the PIN wait.
struct HwFoundView: View {
let deviceModel: String
+ var vendor: HwWalletVendor = .trezor
let isConnecting: Bool
+ var isUnlocking = false
let errorMessage: String?
let onConnect: () -> Void
let onCancel: () -> Void
@@ -15,10 +18,17 @@ struct HwFoundView: View {
.padding(.horizontal, 16)
VStack(alignment: .leading, spacing: 8) {
- DisplayText(t("hardware__found_header"), accentColor: .blueAccent)
+ DisplayText(vendor.foundHeader, accentColor: .blueAccent)
BodyMText(t("hardware__found_text", variables: ["model": deviceModel]))
+ if isUnlocking {
+ BodySText(t("hardware__jade_enter_pin"), textColor: .textPrimary)
+ .padding(.top, 8)
+ .transition(.opacity)
+ .accessibilityIdentifier("HwFoundUnlockHint")
+ }
+
if let errorMessage {
BodyMText(errorMessage, textColor: .redAccent)
.padding(.top, 8)
@@ -27,8 +37,9 @@ struct HwFoundView: View {
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 32)
+ .animation(.easeInOut(duration: 0.2), value: isUnlocking)
- Image("trezor-device")
+ Image(vendor.deviceImageName)
.resizable()
.scaledToFit()
.frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -64,3 +75,18 @@ struct HwFoundView: View {
.background(Color.black)
.preferredColorScheme(.dark)
}
+
+#Preview("Jade unlocking") {
+ HwFoundView(
+ deviceModel: "Jade",
+ vendor: .blockstream,
+ isConnecting: true,
+ isUnlocking: true,
+ errorMessage: nil,
+ onConnect: {},
+ onCancel: {}
+ )
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Color.black)
+ .preferredColorScheme(.dark)
+}
diff --git a/Bitkit/Views/Sheets/HardwareConnect/HwPairedView.swift b/Bitkit/Views/Sheets/HardwareConnect/HwPairedView.swift
index c41365bfe..dccd1489e 100644
--- a/Bitkit/Views/Sheets/HardwareConnect/HwPairedView.swift
+++ b/Bitkit/Views/Sheets/HardwareConnect/HwPairedView.swift
@@ -2,13 +2,15 @@ import SwiftUI
/// Paired step, shared by the standard wallet and by a passphrase wallet found afterwards: both
/// confirm the watched balance and its Bitkit-side label over the coin illustration, and both can add
-/// another passphrase wallet from the same device before finishing.
+/// another passphrase wallet from the same device before finishing. A Jade holds one wallet per
+/// device, so its step offers Finish alone.
struct HwPairedView: View {
let deviceName: String
let balanceSats: UInt64
@Binding var labelText: String
let onPassphrase: () -> Void
let onFinish: () -> Void
+ var vendor: HwWalletVendor = .trezor
/// Set for the step confirming a passphrase wallet, which says so in its own words.
var isPassphraseWallet = false
@@ -16,7 +18,7 @@ struct HwPairedView: View {
private let coinsWidthRatio: CGFloat = 256.0 / 375.0
private var header: String {
- isPassphraseWallet ? t("hardware__passphrase_paired_header") : t("hardware__paired_header")
+ isPassphraseWallet ? t("hardware__passphrase_paired_header") : vendor.pairedHeader
}
private var text: String {
@@ -67,10 +69,12 @@ struct HwPairedView: View {
Spacer(minLength: 0)
HStack(spacing: 16) {
- CustomButton(title: t("hardware__passphrase_button"), variant: .secondary, shouldExpand: true) {
- onPassphrase()
+ if vendor.supportsPassphraseWallets {
+ CustomButton(title: t("hardware__passphrase_button"), variant: .secondary, shouldExpand: true) {
+ onPassphrase()
+ }
+ .accessibilityIdentifier("HardwareWalletPairedPassphrase")
}
- .accessibilityIdentifier("HardwareWalletPairedPassphrase")
CustomButton(title: t("hardware__paired_finish"), shouldExpand: true) {
onFinish()
@@ -122,6 +126,21 @@ private struct HwPairedBalanceView: View {
.preferredColorScheme(.dark)
}
+#Preview("Jade") {
+ HwPairedView(
+ deviceName: "Jade",
+ balanceSats: 10_562_411,
+ labelText: .constant("Jade"),
+ onPassphrase: {},
+ onFinish: {},
+ vendor: .blockstream
+ )
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Color.black)
+ .environmentObject(CurrencyViewModel())
+ .preferredColorScheme(.dark)
+}
+
#Preview("Passphrase funds found") {
HwPairedView(
deviceName: "Trezor Safe 3",
diff --git a/Bitkit/Views/Sheets/HardwareConnectSheet.swift b/Bitkit/Views/Sheets/HardwareConnectSheet.swift
index 0d9628891..ec27c64c0 100644
--- a/Bitkit/Views/Sheets/HardwareConnectSheet.swift
+++ b/Bitkit/Views/Sheets/HardwareConnectSheet.swift
@@ -9,12 +9,13 @@ struct HardwareConnectSheetItem: SheetItem {
/// Entry point for the Connect Hardware flow.
struct HardwareConnectSheet: View {
@Environment(TrezorManager.self) private var trezorManager
+ @Environment(JadeManager.self) private var jadeManager
@Environment(HwWalletManager.self) private var hwWalletManager
let config: HardwareConnectSheetItem
var body: some View {
HardwareConnectFlow(
- service: TrezorHwConnectService(trezorManager: trezorManager, hwWalletManager: hwWalletManager),
+ service: HwConnectService(trezorManager: trezorManager, jadeManager: jadeManager, hwWalletManager: hwWalletManager),
config: config
)
}
@@ -22,6 +23,7 @@ struct HardwareConnectSheet: View {
private struct HardwareConnectFlow: View {
@Environment(TrezorManager.self) private var trezorManager
+ @Environment(JadeManager.self) private var jadeManager
@Environment(HwWalletManager.self) private var hwWalletManager
@EnvironmentObject private var app: AppViewModel
@EnvironmentObject private var sheets: SheetViewModel
@@ -51,6 +53,9 @@ private struct HardwareConnectFlow: View {
viewModel.onPairingCodeRequested()
}
}
+ .onChange(of: jadeManager.isUnlocking) { _, isUnlocking in
+ viewModel.onUnlockingChanged(isUnlocking)
+ }
.onChange(of: viewModel.isConnecting) { _, connecting in
sheets.hardwareConnectHandlesPairing = connecting
}
@@ -86,7 +91,9 @@ private struct HardwareConnectFlow: View {
case .found:
HwFoundView(
deviceModel: foundDeviceModel,
+ vendor: viewModel.vendor,
isConnecting: viewModel.isConnecting,
+ isUnlocking: viewModel.isUnlocking,
errorMessage: viewModel.errorMessage,
onConnect: viewModel.onConnect,
onCancel: {
@@ -100,7 +107,8 @@ private struct HardwareConnectFlow: View {
balanceSats: viewModel.balanceSats,
labelText: labelBinding,
onPassphrase: viewModel.onPassphraseClick,
- onFinish: viewModel.onFinish
+ onFinish: viewModel.onFinish,
+ vendor: viewModel.vendor
)
case .passphrase:
HwPassphraseView(
@@ -117,6 +125,7 @@ private struct HardwareConnectFlow: View {
labelText: labelBinding,
onPassphrase: viewModel.onPassphraseClick,
onFinish: viewModel.onFinish,
+ vendor: viewModel.vendor,
isPassphraseWallet: true
)
case .pairCode:
@@ -182,7 +191,7 @@ private struct HardwareConnectFlow: View {
// MARK: - Helpers
private var foundDeviceModel: String {
- viewModel.foundDeviceModel.isEmpty ? t("hardware__device_model_trezor") : viewModel.foundDeviceModel
+ viewModel.foundDeviceModel.isEmpty ? viewModel.vendor.modelName : viewModel.foundDeviceModel
}
private var labelBinding: Binding {
diff --git a/Bitkit/Views/Sheets/RenameHardwareWalletSheet.swift b/Bitkit/Views/Sheets/RenameHardwareWalletSheet.swift
index 8f5282622..e84cdf3ee 100644
--- a/Bitkit/Views/Sheets/RenameHardwareWalletSheet.swift
+++ b/Bitkit/Views/Sheets/RenameHardwareWalletSheet.swift
@@ -13,10 +13,10 @@ struct RenameHardwareWalletSheetItem: SheetItem, Equatable {
}
/// Renames a paired hardware wallet: a single NAME field pre-filled with the current name and a Save
-/// button. Persists the custom name via `TrezorManager.renameWallet`, which re-pushes the device
-/// snapshot so `HwWallet.name` updates everywhere.
+/// button. Persists the custom name via `HwWalletManager.renameWallet`, which hands it to the wallet's
+/// vendor; that re-pushes the device snapshot so `HwWallet.name` updates everywhere.
struct RenameHardwareWalletSheet: View {
- @Environment(TrezorManager.self) private var trezorManager
+ @Environment(HwWalletManager.self) private var hwWalletManager
@EnvironmentObject private var sheets: SheetViewModel
let config: RenameHardwareWalletSheetItem
@@ -67,7 +67,7 @@ struct RenameHardwareWalletSheet: View {
private func save() {
guard !trimmedName.isEmpty else { return }
- trezorManager.renameWallet(walletId: config.walletId, newName: trimmedName)
+ hwWalletManager.renameWallet(walletId: config.walletId, newName: trimmedName)
sheets.hideSheet()
}
}
diff --git a/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift b/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift
index f7730e4a0..ebed2f492 100644
--- a/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift
+++ b/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift
@@ -1,8 +1,8 @@
import BitkitCore
import SwiftUI
-/// "Sign with your device" — shows the Blocktank order fees and asks the user to sign the funding
-/// transaction on the Trezor. Reuses the existing Learn More / Advanced controls; on-device signing
+/// "Sign with your device": shows the Blocktank order fees and asks the user to sign the funding
+/// transaction on the hardware wallet. Reuses the existing Learn More / Advanced controls; on-device signing
/// replaces the local swipe-to-pay. Advances to the Signed screen on success.
struct SpendingHwSign: View {
let walletId: String
@@ -10,6 +10,7 @@ struct SpendingHwSign: View {
@EnvironmentObject var app: AppViewModel
@EnvironmentObject var navigation: NavigationViewModel
@EnvironmentObject var transfer: TransferViewModel
+ @Environment(HwWalletManager.self) private var hwWalletManager
var body: some View {
if let order = transfer.uiState.order {
@@ -31,10 +32,10 @@ struct SpendingHwSign: View {
NavigationBar(title: t("lightning__transfer__nav_title"))
.padding(.bottom, 16)
- // The Trezor is a background visual behind the content (including the bottom button), so
+ // The device is a background visual behind the content (including the bottom button), so
// it renders at its natural aspect and doesn't get squeezed by the vertical layout.
ZStack(alignment: .top) {
- trezorIllustration
+ deviceIllustration
belowNav(order: order)
}
@@ -142,11 +143,7 @@ struct SpendingHwSign: View {
Spacer()
CustomButton(
- title: t(
- transfer.hwSpending.hasPendingBroadcast
- ? "common__retry"
- : "lightning__transfer_hw__open_connect"
- ),
+ title: transfer.hwSpending.hasPendingBroadcast ? t("common__retry") : vendor.transferSignButtonTitle,
isDisabled: transfer.hwSpending.isSigning,
isLoading: transfer.hwSpending.isSigning
) {
@@ -157,10 +154,14 @@ struct SpendingHwSign: View {
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
- private var trezorIllustration: some View {
+ private var vendor: HwWalletVendor {
+ hwWalletManager.wallets.first { $0.id == walletId }?.vendor ?? .trezor
+ }
+
+ private var deviceIllustration: some View {
GeometryReader { geo in
let side = geo.size.width * illustrationWidthRatio
- Image("trezor-card")
+ Image(vendor.signImageName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: side, height: side)
diff --git a/Bitkit/Views/Trezor/TrezorDeviceListView.swift b/Bitkit/Views/Trezor/TrezorDeviceListView.swift
index 93c150f00..74ff614c7 100644
--- a/Bitkit/Views/Trezor/TrezorDeviceListView.swift
+++ b/Bitkit/Views/Trezor/TrezorDeviceListView.swift
@@ -166,7 +166,7 @@ struct TrezorDeviceListView: View {
}
}
- private func connectToKnownDevice(_ knownDevice: TrezorKnownDevice) {
+ private func connectToKnownDevice(_ knownDevice: HwKnownDevice) {
connectingDevicePath = knownDevice.path
Task {
diff --git a/Bitkit/Views/Wallets/HardwareWalletScreen.swift b/Bitkit/Views/Wallets/HardwareWalletScreen.swift
index 008c27480..813a507e5 100644
--- a/Bitkit/Views/Wallets/HardwareWalletScreen.swift
+++ b/Bitkit/Views/Wallets/HardwareWalletScreen.swift
@@ -95,18 +95,18 @@ struct HardwareWalletScreen: View {
}
.padding(.horizontal)
.background(alignment: .topTrailing) {
- trezorIllustration
+ deviceIllustration(for: wallet.vendor)
// Align the device's top with the balance header and bleed off the trailing edge.
.offset(x: 118, y: ScreenLayout.topPaddingWithoutSafeArea)
}
}
- /// The shared upright Trezor device, transformed to match the Figma "Wallet Overview" visual:
+ /// The vendor's shared upright device, transformed to match the Figma "Wallet Overview" visual:
/// cover-filled into a square, rotated -15°, and clipped to a 256pt box that bleeds off the
- /// screen's trailing edge. Reuses the generic `trezor-device` asset — no screen-specific crop is
- /// baked in, so the same image can be adapted elsewhere with SwiftUI.
- private var trezorIllustration: some View {
- Image("trezor-device")
+ /// screen's trailing edge. Reuses the generic device asset (no screen-specific crop is baked
+ /// in), so the same image can be adapted elsewhere with SwiftUI.
+ private func deviceIllustration(for vendor: HwWalletVendor) -> some View {
+ Image(vendor.deviceImageName)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 268, height: 268)
diff --git a/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift b/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift
index 1b675ebd8..1c6a889fc 100644
--- a/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift
+++ b/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift
@@ -36,7 +36,7 @@ struct ReceiveEdit: View {
return .auto
case .spending:
return .spending
- case .trezor:
+ case .hardware:
return .savings
}
}
diff --git a/Bitkit/Views/Wallets/Receive/ReceiveQr.swift b/Bitkit/Views/Wallets/Receive/ReceiveQr.swift
index 0417ca1c5..d1219eef6 100644
--- a/Bitkit/Views/Wallets/Receive/ReceiveQr.swift
+++ b/Bitkit/Views/Wallets/Receive/ReceiveQr.swift
@@ -25,6 +25,7 @@ struct ReceiveQr: View {
@State private var isVerifyingPassphrase = false
@State private var verifyTask: Task?
@State private var passphraseTask: Task?
+ @State private var engagedSession = HwEngagedSession()
init(
navigationPath: Binding<[ReceiveRoute]>,
@@ -49,7 +50,7 @@ struct ReceiveQr: View {
}
enum ReceiveTab: CaseIterable, CustomStringConvertible {
- case savings, unified, spending, trezor
+ case savings, unified, spending, hardware
var description: String {
switch self {
@@ -59,8 +60,8 @@ struct ReceiveQr: View {
return "Auto"
case .spending:
return t("lightning__spending")
- case .trezor:
- return t("hardware__device_model_trezor")
+ case .hardware:
+ return t("hardware__receive_tab_hardware")
}
}
}
@@ -76,11 +77,16 @@ struct ReceiveQr: View {
}
if selectedHardwareWalletId != nil {
- items.insert(TabItem(.trezor), at: 0)
+ items.insert(TabItem(.hardware, label: selectedHardwareWallet?.vendor.modelName), at: 0)
}
return items
}
+ private var selectedHardwareWallet: HwWallet? {
+ guard let walletId = selectedHardwareWalletId else { return nil }
+ return hwWalletManager.wallets.first { $0.id == walletId }
+ }
+
private var selectedHardwareWalletId: String? {
if let hardwareWalletId {
return hardwareWalletId
@@ -126,7 +132,7 @@ struct ReceiveQr: View {
VStack(spacing: 0) {
TabView(selection: selectedTabBinding) {
if selectedHardwareWalletId != nil {
- tabContent(for: .trezor)
+ tabContent(for: .hardware)
}
tabContent(for: .savings)
@@ -160,7 +166,7 @@ struct ReceiveQr: View {
}
} else if showDetails {
VStack(spacing: 16) {
- if selectedTab == .trezor {
+ if selectedTab == .hardware {
CustomButton(
title: t("hardware__verify_address"),
variant: .secondary,
@@ -188,7 +194,7 @@ struct ReceiveQr: View {
CustomButton(
title: t("common__show_details"),
variant: .tertiary,
- isDisabled: selectedTab == .trezor && displayedHardwareAddress == nil
+ isDisabled: selectedTab == .hardware && displayedHardwareAddress == nil
) {
showDetails.toggle()
}
@@ -199,11 +205,12 @@ struct ReceiveQr: View {
}
.onChange(of: selectedTab) { _, newTab in
showDetails = false
- if newTab == .trezor {
+ if newTab == .hardware {
Task { await loadHardwareAddress() }
} else {
verifyTask?.cancel()
verifyTask = nil
+ engagedSession.release(through: hwWalletManager)
}
}
.onAppear {
@@ -222,10 +229,17 @@ struct ReceiveQr: View {
)
}
.task(id: selectedHardwareWalletId) {
- if selectedTab == .trezor {
+ if selectedTab == .hardware {
await loadHardwareAddress()
}
}
+ .onChange(of: selectedHardwareWalletId) {
+ abandonHardwareVerification()
+ }
+ .onChange(of: displayedHardwareAddress?.address) { previousAddress, _ in
+ guard previousAddress != nil else { return }
+ abandonHardwareVerification()
+ }
.task {
do {
try await withThrowingTaskGroup(of: Void.self) { group in
@@ -258,6 +272,7 @@ struct ReceiveQr: View {
verifyTask = nil
passphraseTask?.cancel()
passphraseTask = nil
+ engagedSession.release(through: hwWalletManager)
}
}
@@ -309,7 +324,7 @@ struct ReceiveQr: View {
@ViewBuilder
func qrContent(for tab: ReceiveTab) -> some View {
- if tab == .trezor {
+ if tab == .hardware {
if let hardwareAddress = displayedHardwareAddress {
let uri = Bip21Utils.hardwareInvoice(
address: hardwareAddress.address,
@@ -322,7 +337,7 @@ struct ReceiveQr: View {
accentColor: .blueAccent,
navigationPath: $navigationPath,
copyValue: uri.contains("?") ? uri : hardwareAddress.address,
- editRoute: .edit(tab: .trezor, onchainOnly: true)
+ editRoute: .edit(tab: .hardware, onchainOnly: true)
)
} else if hardwareAddressLoadFailed {
VStack(spacing: 16) {
@@ -374,7 +389,7 @@ struct ReceiveQr: View {
imageAsset: "ln",
accentColor: .purpleAccent
)
- case .trezor:
+ case .hardware:
return (uri: "", imageAsset: "btc-circle-blue", accentColor: .blueAccent)
}
}
@@ -472,7 +487,7 @@ struct ReceiveQr: View {
)
)
}
- case .trezor:
+ case .hardware:
if let hardwareAddress = displayedHardwareAddress {
pairs.append(
CopyAddressPair(
@@ -492,7 +507,7 @@ struct ReceiveQr: View {
addresses: addressPairs,
navigationPath: $navigationPath,
editRoute: editRoute(for: tab),
- accentColor: tab == .trezor ? .blueAccent : nil
+ accentColor: tab == .hardware ? .blueAccent : nil
)
}
@@ -501,7 +516,7 @@ struct ReceiveQr: View {
}
private func editRoute(for tab: ReceiveTab) -> ReceiveRoute? {
- .edit(tab: tab, onchainOnly: tab == .trezor, replacesCurrentQr: tab == .spending && cjitInvoice != nil)
+ .edit(tab: tab, onchainOnly: tab == .hardware, replacesCurrentQr: tab == .spending && cjitInvoice != nil)
}
private struct ImageConfig {
@@ -562,15 +577,15 @@ struct ReceiveQr: View {
isVerifyingHardwareAddress = true
defer { isVerifyingHardwareAddress = false }
do {
- try await hwWalletManager.verifyReceiveAddress(walletId: walletId, receiveAddress: hardwareAddress)
+ try await engagedSession.perform(walletId: walletId) {
+ try await hwWalletManager.verifyReceiveAddress(walletId: walletId, receiveAddress: hardwareAddress)
+ }
} catch is CancellationError {
return
} catch HwPassphraseError.required {
isPassphraseRequired = true
} catch {
- if !error.isTrezorUserCancellation() {
- app.toast(error)
- }
+ showHardwareVerifyError(error)
}
}
@@ -591,18 +606,18 @@ struct ReceiveQr: View {
passphraseTask = nil
}
do {
- try await hwWalletManager.reconnectWithPassphrase(walletId: walletId, passphrase: passphrase)
- guard isPassphraseRequired else { throw CancellationError() }
- isPassphraseRequired = false
- await verifyHardwareAddress()
+ try await engagedSession.perform(walletId: walletId) {
+ try await hwWalletManager.reconnectWithPassphrase(walletId: walletId, passphrase: passphrase)
+ guard isPassphraseRequired else { throw CancellationError() }
+ isPassphraseRequired = false
+ await verifyHardwareAddress()
+ }
} catch is CancellationError {
return
} catch HwPassphraseError.mismatch {
app.toast(HwTransferError.passphraseMismatch)
} catch {
- if !error.isTrezorUserCancellation() {
- app.toast(error)
- }
+ showHardwareVerifyError(error)
}
}
}
@@ -611,6 +626,30 @@ struct ReceiveQr: View {
passphraseTask?.cancel()
isPassphraseRequired = false
isVerifyingPassphrase = false
+ engagedSession.release(through: hwWalletManager)
+ }
+
+ /// The wallet or address on screen changed, so a verification still in progress is for one no
+ /// longer shown.
+ private func abandonHardwareVerification() {
+ engagedSession.invalidate(through: hwWalletManager)
+ verifyTask?.cancel()
+ verifyTask = nil
+ passphraseTask?.cancel()
+ if isPassphraseRequired {
+ dismissPassphrase()
+ }
+ }
+
+ private func showHardwareVerifyError(_ error: Error) {
+ if error.isHwUserCancellation() {
+ return
+ }
+ if let vendor = error.hwBusyVendor {
+ app.toast(HwTransferError.deviceBusy(vendor))
+ } else {
+ app.toast(error)
+ }
}
/// Strips the lightning parameter from a BIP21 URI while keeping other parameters
diff --git a/Bitkit/Views/Wallets/Send/HwSendSignView.swift b/Bitkit/Views/Wallets/Send/HwSendSignView.swift
index 47d13c8e2..fb942e572 100644
--- a/Bitkit/Views/Wallets/Send/HwSendSignView.swift
+++ b/Bitkit/Views/Wallets/Send/HwSendSignView.swift
@@ -19,7 +19,7 @@ struct HwSendSignView: View {
VStack(alignment: .leading, spacing: 0) {
SheetHeader(
title: t("hardware__send_sign_title"),
- showBackButton: !hwSend.isSigning && !hwSend.isBroadcastUnresolved
+ showBackButton: hwSend.canLeave
)
if let invoice = app.scannedOnchainInvoice {
@@ -41,7 +41,7 @@ struct HwSendSignView: View {
Spacer(minLength: 16)
- Image("trezor-card")
+ Image(vendor.signImageName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 256, height: 256)
@@ -52,7 +52,7 @@ struct HwSendSignView: View {
Spacer(minLength: 0)
CustomButton(
- title: t(hwSend.hasPendingBroadcast ? "common__retry" : "hardware__send_open_connect"),
+ title: hwSend.hasPendingBroadcast ? t("common__retry") : vendor.sendSignButtonTitle,
isDisabled: hwSend.isSigning,
isLoading: hwSend.isSigning
) {
@@ -84,6 +84,10 @@ struct HwSendSignView: View {
.accessibilityIdentifier("HardwareSendSign")
}
+ private var vendor: HwWalletVendor {
+ hwWalletManager.wallets.first { $0.id == hwSend.walletId }?.vendor ?? .trezor
+ }
+
private var passphrasePromptBinding: Binding {
Binding(
get: { hwSend.isPassphraseRequired },
@@ -174,12 +178,12 @@ struct HwSendSignView: View {
}
private func showHardwareError(_ error: Error) {
- if error.isTrezorUserCancellation() {
+ if error.isHwUserCancellation() {
return
}
- if error.isTrezorDeviceBusy() {
- app.toast(HwTransferError.deviceBusy)
- } else if error.isTrezorFirmwareError() {
+ if let vendor = error.hwBusyVendor {
+ app.toast(HwTransferError.deviceBusy(vendor))
+ } else if error.isHwFirmwareError() {
app.toast(HwTransferError.firmwareReconnect)
} else if hwSend.hasPendingBroadcast, error.isBroadcastConnectivityFailure() {
app.toast(HwTransferError.broadcastConnectivity)
diff --git a/Bitkit/Views/Wallets/Send/SendAmountView.swift b/Bitkit/Views/Wallets/Send/SendAmountView.swift
index 11cf84e39..65d2d449f 100644
--- a/Bitkit/Views/Wallets/Send/SendAmountView.swift
+++ b/Bitkit/Views/Wallets/Send/SendAmountView.swift
@@ -61,7 +61,7 @@ struct SendAmountView: View {
t("wallet__savings__title")
case let .hardware(walletId):
hwWalletManager.wallets.first(where: { $0.id == walletId })?.name
- ?? t("hardware__device_model_trezor")
+ ?? hwWalletManager.vendor(walletId: walletId).modelName
}
}
diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift
index 8c4385039..147602a40 100644
--- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift
+++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift
@@ -77,7 +77,7 @@ struct SendConfirmationView: View {
private var hardwareWalletName: String? {
guard let walletId = hwSend.walletId else { return nil }
return hwWalletManager.wallets.first(where: { $0.id == walletId })?.name
- ?? t("hardware__device_model_trezor")
+ ?? hwWalletManager.vendor(walletId: walletId).modelName
}
private var isHardwarePreparationLoading: Bool {
diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift
index dcdd01f62..3fc48893c 100644
--- a/Bitkit/Views/Wallets/Send/SendSheet.swift
+++ b/Bitkit/Views/Wallets/Send/SendSheet.swift
@@ -197,7 +197,7 @@ struct SendSheet: View {
}
}
.animation(.easeInOut(duration: 0.3), value: shouldShowSyncOverlay)
- .interactiveDismissDisabled(hwSend.isSigning || hwSend.isBroadcastUnresolved)
+ .interactiveDismissDisabled(!hwSend.canLeave)
.sheet(isPresented: reconnectPairingBinding) {
HardwarePairingSheet(config: HardwarePairingSheetItem())
}
diff --git a/BitkitTests/BlockingQueueTests.swift b/BitkitTests/BlockingQueueTests.swift
new file mode 100644
index 000000000..87ca70bde
--- /dev/null
+++ b/BitkitTests/BlockingQueueTests.swift
@@ -0,0 +1,66 @@
+@testable import Bitkit
+import XCTest
+
+final class BlockingQueueTests: XCTestCase {
+ func testPollReturnsItemsInFifoOrder() {
+ let queue = BlockingQueue()
+ queue.offer(1)
+ queue.offer(2)
+ queue.offer(3)
+
+ XCTAssertEqual(queue.poll(timeout: 0), 1)
+ XCTAssertEqual(queue.poll(timeout: 0), 2)
+ XCTAssertEqual(queue.poll(timeout: 0), 3)
+ }
+
+ func testPollTimesOutWithNil() {
+ let queue = BlockingQueue()
+ let start = Date()
+
+ XCTAssertNil(queue.poll(timeout: 0.05))
+ XCTAssertGreaterThanOrEqual(Date().timeIntervalSince(start), 0.04)
+ }
+
+ func testPollWakesWhenAnItemArrives() {
+ let queue = BlockingQueue()
+ DispatchQueue.global().asyncAfter(deadline: .now() + 0.05) { queue.offer(7) }
+
+ XCTAssertEqual(queue.poll(timeout: 5), 7)
+ }
+
+ func testFailWakesABlockedPoll() {
+ let queue = BlockingQueue()
+ let returned = expectation(description: "blocked poll returned")
+ DispatchQueue.global().async {
+ XCTAssertNil(queue.poll(timeout: 10))
+ returned.fulfill()
+ }
+ Thread.sleep(forTimeInterval: 0.05)
+
+ queue.fail()
+
+ wait(for: [returned], timeout: 1)
+ }
+
+ func testClearResetsTheFailedFlag() {
+ let queue = BlockingQueue()
+ queue.fail()
+ queue.offer(1)
+ XCTAssertNil(queue.poll(timeout: 0))
+
+ queue.clear()
+ queue.offer(2)
+
+ XCTAssertEqual(queue.poll(timeout: 0), 2)
+ }
+
+ func testDrainRemovesEverythingWithoutWaiting() {
+ let queue = BlockingQueue()
+ queue.offer(1)
+ queue.offer(2)
+
+ XCTAssertEqual(queue.drain(), [1, 2])
+ XCTAssertEqual(queue.drain(), [])
+ XCTAssertNil(queue.poll(timeout: 0))
+ }
+}
diff --git a/BitkitTests/HwConnectViewModelTests.swift b/BitkitTests/HwConnectViewModelTests.swift
index 767550cd7..78eadbbc3 100644
--- a/BitkitTests/HwConnectViewModelTests.swift
+++ b/BitkitTests/HwConnectViewModelTests.swift
@@ -6,17 +6,24 @@ import XCTest
final class HwConnectViewModelTests: XCTestCase {
private var service: FakeHwConnectService!
private var sut: HwConnectViewModel!
+ private var jadeLog: JadeCallLog!
+ private var jadeService: FakeJadeService!
override func setUp() {
super.setUp()
service = FakeHwConnectService()
sut = HwConnectViewModel(service: service)
+ jadeLog = JadeCallLog()
+ jadeService = FakeJadeService(log: jadeLog)
}
override func tearDown() {
+ service.connectGate?.open()
sut.reset()
sut = nil
service = nil
+ jadeService = nil
+ jadeLog = nil
super.tearDown()
}
@@ -31,9 +38,18 @@ final class HwConnectViewModelTests: XCTestCase {
XCTAssertEqual(sut.phase, .found)
XCTAssertEqual(sut.foundDevice?.id, "dev1")
XCTAssertEqual(sut.foundDeviceModel, "Trezor Safe 3")
+ XCTAssertEqual(sut.vendor, .trezor)
XCTAssertNil(sut.errorMessage)
}
+ func testFoundJadeTakesItsVendorAndModelName() async {
+ await givenJadeFound()
+
+ XCTAssertEqual(sut.foundDevice, jadeDevice)
+ XCTAssertEqual(sut.vendor, .blockstream)
+ XCTAssertEqual(sut.foundDeviceModel, "Jade")
+ }
+
func testOnIntroContinueSurfacesSearchFailureWhileSearching() async {
service.scanError = TestError.stub
@@ -53,7 +69,7 @@ final class HwConnectViewModelTests: XCTestCase {
sut.onConnect()
await waitUntil { self.sut.phase == .paired }
- XCTAssertEqual(service.connectedDeviceIds, ["dev1"])
+ XCTAssertEqual(service.connectedDevices, [makeDevice(id: "dev1", model: "Safe 3")], "the scanned record is dialled as found")
XCTAssertEqual(sut.pairedDeviceId, "dev1")
XCTAssertEqual(sut.deviceName, "Trezor Safe 3")
XCTAssertEqual(sut.labelInput, "Trezor Safe 3")
@@ -89,6 +105,172 @@ final class HwConnectViewModelTests: XCTestCase {
XCTAssertEqual(sut.errorMessage, t("hardware__connect_error"))
}
+ func testConnectingAJadePairsUnderItsVendor() async {
+ await givenJadeFound()
+ service.connectResult = .success(jadeResult)
+
+ sut.onConnect()
+
+ await waitUntil { self.sut.phase == .paired }
+ XCTAssertEqual(service.connectedDevices, [jadeDevice])
+ XCTAssertEqual(sut.vendor, .blockstream)
+ XCTAssertEqual(sut.pairedDeviceId, JadeFixtures.deviceId)
+ XCTAssertEqual(sut.pairedWalletId, JadeFixtures.walletId)
+ XCTAssertEqual(sut.labelInput, "Jade")
+ }
+
+ /// A wrong PIN, the pinserver or a stale Bluetooth bond each have their own copy, which the
+ /// generic connect message would hide, whether core's error arrives as is or boxed.
+ func testJadeConnectFailureShowsJadeCopy() async {
+ await givenJadeFound()
+ service.connectResult = .failure(JadeError.InvalidPin)
+
+ sut.onConnect()
+
+ await waitUntil { self.sut.errorMessage != nil }
+ XCTAssertEqual(sut.phase, .found)
+ XCTAssertEqual(sut.errorMessage, t("hardware__jade_invalid_pin"))
+
+ service.connectResult = .failure(Bitkit.AppError(error: JadeError.InvalidPin))
+ sut.onConnect()
+ XCTAssertNil(sut.errorMessage)
+
+ await waitUntil { self.sut.errorMessage != nil }
+ XCTAssertEqual(sut.errorMessage, t("hardware__jade_invalid_pin"))
+ XCTAssertFalse(sut.isConnecting)
+ }
+
+ func testJadeConnectFailureWithoutAJadeErrorKeepsItsOwnWordsOrTheConnectError() async {
+ await givenJadeFound()
+ service.connectResult = .failure(Bitkit.AppError(message: "Could not read any account keys", debugMessage: nil))
+
+ sut.onConnect()
+
+ await waitUntil { self.sut.errorMessage != nil }
+ XCTAssertEqual(sut.errorMessage, "Could not read any account keys")
+
+ service.connectResult = .failure(Bitkit.AppError(error: TestError.stub))
+ sut.onConnect()
+
+ await waitUntil { self.sut.errorMessage != nil }
+ XCTAssertEqual(sut.errorMessage, t("hardware__connect_error"), "a generic error has no words of its own")
+ }
+
+ // MARK: - Unlocking
+
+ func testUnlockHintShowsOnlyWhileAJadeIsConnecting() async {
+ await givenJadeFound()
+ sut.onUnlockingChanged(true)
+ XCTAssertFalse(sut.isUnlocking, "no connect is in flight")
+
+ service.connectGate = AsyncGate()
+ service.connectResult = .success(jadeResult)
+ sut.onConnect()
+ sut.onUnlockingChanged(true)
+ XCTAssertTrue(sut.isUnlocking)
+
+ sut.onUnlockingChanged(false)
+ XCTAssertFalse(sut.isUnlocking)
+
+ sut.onUnlockingChanged(true)
+ service.connectGate?.open()
+ await waitUntil { self.sut.phase == .paired }
+ XCTAssertFalse(sut.isUnlocking, "pairing ends the PIN wait")
+ }
+
+ func testUnlockHintNeverShowsForATrezor() async {
+ await givenDeviceFound()
+ service.connectGate = AsyncGate()
+
+ sut.onConnect()
+ sut.onUnlockingChanged(true)
+
+ XCTAssertFalse(sut.isUnlocking)
+ }
+
+ func testAFailedJadeConnectEndsTheUnlockHint() async {
+ await givenJadeFound()
+ service.connectGate = AsyncGate()
+ service.connectResult = .failure(JadeError.InvalidPin)
+ sut.onConnect()
+ sut.onUnlockingChanged(true)
+
+ service.connectGate?.open()
+
+ await waitUntil { self.sut.errorMessage != nil }
+ XCTAssertFalse(sut.isUnlocking)
+ }
+
+ // MARK: - Cancel
+
+ func testCancelConnectCancelsThePendingJadeConnection() async {
+ await givenJadeConnecting()
+ sut.onUnlockingChanged(true)
+
+ sut.cancelConnect()
+
+ XCTAssertEqual(service.cancelledConnections, [jadeDevice])
+ XCTAssertEqual(service.cancelledConnections.first?.path, jadeDevice.path)
+ XCTAssertEqual(service.cancelPairingCount, 0, "a Jade has no pairing code to cancel")
+ XCTAssertFalse(sut.isConnecting)
+ XCTAssertFalse(sut.isUnlocking)
+ }
+
+ func testDismissingTheSheetMidConnectCancelsThePendingConnection() async {
+ await givenJadeConnecting()
+
+ sut.reset()
+
+ XCTAssertEqual(service.cancelledConnections, [jadeDevice])
+ XCTAssertFalse(sut.isConnecting)
+ }
+
+ func testCancelWithoutAConnectInFlightLeavesTheJadeAlone() async {
+ await givenJadeFound()
+
+ sut.cancelConnect()
+ sut.reset()
+
+ XCTAssertTrue(service.cancelledConnections.isEmpty)
+ XCTAssertEqual(service.cancelPairingCount, 0)
+ }
+
+ /// Leaving the sheet once pairing finished must keep the session the flow just opened.
+ func testDismissingAfterPairingKeepsTheSession() async {
+ await givenDevicePaired()
+
+ sut.reset()
+
+ XCTAssertTrue(service.cancelledConnections.isEmpty)
+ XCTAssertEqual(service.cancelPairingCount, 0)
+ }
+
+ /// The Trezor side drops its pairing code prompt and closes the session opened for the pairing
+ /// the user left.
+ func testCancelConnectReleasesTheTrezorBeingPaired() async {
+ await givenDeviceFound()
+ service.connectGate = AsyncGate()
+ sut.onConnect()
+ await waitUntil { !self.service.connectedDevices.isEmpty }
+
+ sut.cancelConnect()
+
+ XCTAssertEqual(service.cancelledConnections, [makeDevice(id: "dev1", model: "Safe 3")])
+ XCTAssertFalse(sut.isConnecting)
+ }
+
+ func testACancelledConnectDoesNotReportItsResult() async {
+ await givenJadeConnecting()
+ service.connectResult = .success(jadeResult)
+
+ sut.cancelConnect()
+ service.connectGate?.open()
+ try? await Task.sleep(nanoseconds: 50_000_000)
+
+ XCTAssertEqual(sut.phase, .found)
+ XCTAssertNil(sut.pairedDeviceId)
+ }
+
// MARK: - Pairing code
func testPairingCodeRequestSurfacesInlinePairCodeStepWhileConnecting() async {
@@ -109,6 +291,15 @@ final class HwConnectViewModelTests: XCTestCase {
XCTAssertEqual(sut.phase, .intro)
}
+ func testPairingCodeRequestIgnoredWhileConnectingAJade() async {
+ await givenJadeConnecting()
+
+ sut.onPairingCodeRequested()
+
+ XCTAssertEqual(sut.phase, .found)
+ XCTAssertTrue(sut.isConnecting)
+ }
+
// MARK: - Paired
func testConnectedWalletUpdatesBalanceOnPairedStep() async {
@@ -316,6 +507,27 @@ final class HwConnectViewModelTests: XCTestCase {
XCTAssertEqual(service.setLabelCalls.first?.label, "Standard Funds")
}
+ /// A Jade holds one wallet per device, so there are no passphrase wallets to add.
+ func testJadeHidesPassphraseAndIgnoresItsClick() async {
+ await givenJadeFound()
+ service.connectResult = .success(jadeResult)
+ sut.onConnect()
+ await waitUntil { self.sut.phase == .paired }
+ XCTAssertFalse(sut.vendor.supportsPassphraseWallets)
+
+ sut.onPassphraseClick()
+ XCTAssertEqual(sut.phase, .paired)
+ XCTAssertTrue(service.setLabelCalls.isEmpty, "nothing is left, so nothing is persisted")
+
+ sut.onPassphraseChange("secret")
+ sut.onPassphraseSubmit()
+ try? await Task.sleep(nanoseconds: 50_000_000)
+
+ XCTAssertFalse(sut.isSubmittingPassphrase)
+ XCTAssertTrue(service.passphraseCalls.isEmpty)
+ XCTAssertEqual(sut.phase, .paired)
+ }
+
func testBackFromThePassphraseStepDropsWhatWasTyped() async {
await givenDevicePaired()
sut.onPassphraseClick()
@@ -398,8 +610,123 @@ final class HwConnectViewModelTests: XCTestCase {
XCTAssertTrue(finished)
}
+ // MARK: - Connect service
+
+ func testScanOffersUnpairedDevicesOfEitherVendorBeforePairedOnes() throws {
+ let pairedTrezor = makeDevice(id: "paired-trezor", model: "Safe 3")
+ let newTrezor = makeDevice(id: "new-trezor", model: "Safe 5")
+ let pairedJade = makeJadeDevice()
+ let newJade = makeJadeDevice(path: "ble:new-jade", name: "Jade 111111")
+ let pairedIds: Set = [pairedTrezor.id, pairedJade.id]
+
+ let devices = try HwConnectService.nearbyDevices(
+ trezor: .success([pairedTrezor, newTrezor]),
+ jade: .success([pairedJade, newJade]),
+ isPaired: { pairedIds.contains($0.id) }
+ )
+
+ XCTAssertEqual(devices, [newTrezor, newJade, pairedTrezor, pairedJade])
+ }
+
+ /// A rebooted Jade advertises under a new Bluetooth identifier, so only its name ties it to its
+ /// paired entry. It is still offered, after any new device, so it can be paired again.
+ func testScanOffersAPairedJadeThatCameBackUnderANewIdentifier() throws {
+ let connectService = makeConnectService(jadeManager: makeJadeManager(knownDevices: [JadeFixtures.knownEntry()]))
+ let readvertised = makeJadeDevice(path: JadeFixtures.readvertisedPath)
+ let newJade = makeJadeDevice(path: "ble:new-jade", name: "Jade 111111")
+
+ XCTAssertTrue(connectService.isPaired(readvertised))
+ XCTAssertFalse(connectService.isPaired(newJade))
+ XCTAssertEqual(
+ try HwConnectService.nearbyDevices(trezor: .success([]), jade: .success([readvertised, newJade]), isPaired: connectService.isPaired),
+ [newJade, readvertised]
+ )
+ XCTAssertEqual(
+ try HwConnectService.nearbyDevices(trezor: .success([]), jade: .success([readvertised]), isPaired: connectService.isPaired),
+ [readvertised]
+ )
+ }
+
+ /// A Jade scan fails quietly while core is busy, so only a failed Trezor scan with nothing found
+ /// is reported.
+ func testScanFailsOnlyWhenTheTrezorScanFailedAndNothingWasFound() throws {
+ let trezorFailure = Bitkit.AppError(message: "Bluetooth scan failed", debugMessage: nil)
+ let trezor = makeDevice(id: "dev1", model: "Safe 3")
+
+ XCTAssertThrowsError(
+ try HwConnectService.nearbyDevices(trezor: .failure(trezorFailure), jade: .failure(TestError.stub), isPaired: { _ in false })
+ ) { error in
+ XCTAssertEqual((error as? Bitkit.AppError)?.message, "Bluetooth scan failed")
+ }
+ XCTAssertThrowsError(
+ try HwConnectService.nearbyDevices(trezor: .failure(trezorFailure), jade: .success([]), isPaired: { _ in false })
+ )
+ XCTAssertEqual(
+ try HwConnectService.nearbyDevices(trezor: .success([]), jade: .failure(TestError.stub), isPaired: { _ in false }),
+ []
+ )
+ XCTAssertEqual(
+ try HwConnectService.nearbyDevices(trezor: .success([trezor]), jade: .failure(TestError.stub), isPaired: { _ in false }),
+ [trezor]
+ )
+ }
+
+ func testScanKeepsJadeResultsWhenTheTrezorScanFails() throws {
+ let devices = try HwConnectService.nearbyDevices(
+ trezor: .failure(TestError.stub),
+ jade: .success([jadeDevice]),
+ isPaired: { _ in false }
+ )
+
+ XCTAssertEqual(devices, [jadeDevice])
+ }
+
+ func testTheServicePairsAJadeUnderItsVendor() async throws {
+ jadeService.stubs.scanned = [JadeFixtures.device()]
+ let jadeManager = makeJadeManager()
+ let connectService = makeConnectService(jadeManager: jadeManager)
+
+ let result = try await connectService.connect(to: jadeDevice)
+
+ XCTAssertEqual(result.vendor, .blockstream)
+ XCTAssertEqual(result.deviceId, JadeFixtures.deviceId)
+ XCTAssertEqual(result.deviceDefaultName, "Jade")
+ XCTAssertEqual(jadeService.calls.connectPaths, [JadeFixtures.blePath])
+ XCTAssertEqual(jadeManager.connected?.id, JadeFixtures.deviceId)
+ }
+
+ /// A task cancel never reaches core, so leaving the pairing has to cancel the Jade request itself.
+ func testTheServiceCancelsAPendingJadeConnectionOnTheDevice() async {
+ let jadeManager = makeJadeManager()
+ let connectService = makeConnectService(jadeManager: jadeManager)
+
+ connectService.cancelPendingConnection(to: jadeDevice)
+
+ await waitUntil { self.jadeLog.contains("service.disconnect.done") }
+ XCTAssertTrue(jadeLog.contains("service.cancel"))
+ XCTAssertTrue(jadeLog.contains("transport.disconnect:\(JadeFixtures.blePath)"))
+ }
+
// MARK: - Helpers
+ private func makeJadeManager(knownDevices: [HwKnownDevice] = []) -> JadeManager {
+ JadeManager(
+ service: jadeService,
+ transport: FakeJadeTransportControl(log: jadeLog),
+ store: InMemoryJadeKnownDeviceStore(devices: knownDevices),
+ backgroundTasks: FakeBackgroundTasks(),
+ network: { .regtest }
+ )
+ }
+
+ private func makeConnectService(jadeManager: JadeManager) -> HwConnectService {
+ HwConnectService(
+ trezorManager: TrezorManager(),
+ jadeManager: jadeManager,
+ hwWalletManager: HwWalletManager(jadeSession: jadeManager)
+ )
+ }
+
private func givenDeviceFound() async {
service.nearbyDevices = [makeDevice(id: "dev1", model: "Safe 3")]
sut.onIntroContinue()
@@ -413,12 +740,26 @@ final class HwConnectViewModelTests: XCTestCase {
await waitUntil { self.sut.phase == .paired }
}
+ private func givenJadeFound() async {
+ service.nearbyDevices = [jadeDevice]
+ sut.onIntroContinue()
+ await waitUntil { self.sut.phase == .found }
+ }
+
+ /// A Jade connect held in flight, as while the device waits for its PIN.
+ private func givenJadeConnecting() async {
+ await givenJadeFound()
+ service.connectGate = AsyncGate()
+ sut.onConnect()
+ await waitUntil { !self.service.connectedDevices.isEmpty }
+ }
+
// MARK: - Paired step name
/// Re-adding a removed wallet is the case the published wallet list cannot answer: the wallet is
/// not in it yet, but the entry pairing just wrote already carries the name kept for it.
func testPairedNameUsesTheStoredLabelOfAWalletMissingFromTheWalletList() {
- let name = TrezorHwConnectService.pairedName(
+ let name = HwConnectService.pairedName(
walletId: standardWalletId,
storedEntries: [makeStoredEntry(walletId: standardWalletId, customLabel: "No Pass")],
deviceDefaultName: "Trezor T"
@@ -428,7 +769,7 @@ final class HwConnectViewModelTests: XCTestCase {
}
func testPairedNameFallsBackToTheDeviceNameWhenTheWalletWasNeverNamed() {
- let name = TrezorHwConnectService.pairedName(
+ let name = HwConnectService.pairedName(
walletId: standardWalletId,
storedEntries: [makeStoredEntry(walletId: standardWalletId, customLabel: nil)],
deviceDefaultName: "Trezor T"
@@ -440,7 +781,7 @@ final class HwConnectViewModelTests: XCTestCase {
/// A brand-new passphrase wallet has no entry of its own yet, and must not borrow the name of the
/// identity that happened to be open before it.
func testPairedNameIgnoresAnotherIdentitysLabel() {
- let name = TrezorHwConnectService.pairedName(
+ let name = HwConnectService.pairedName(
walletId: hiddenWalletId,
storedEntries: [makeStoredEntry(walletId: standardWalletId, customLabel: "No Pass")],
deviceDefaultName: "Trezor T"
@@ -450,7 +791,7 @@ final class HwConnectViewModelTests: XCTestCase {
}
func testPairedNameFallsBackToTheDeviceNameBeforeTheIdentityResolves() {
- let name = TrezorHwConnectService.pairedName(
+ let name = HwConnectService.pairedName(
walletId: nil,
storedEntries: [makeStoredEntry(walletId: standardWalletId, customLabel: "No Pass")],
deviceDefaultName: "Trezor T"
@@ -459,8 +800,8 @@ final class HwConnectViewModelTests: XCTestCase {
XCTAssertEqual(name, "Trezor T")
}
- private func makeStoredEntry(walletId: String, customLabel: String?) -> TrezorKnownDevice {
- TrezorKnownDevice(
+ private func makeStoredEntry(walletId: String, customLabel: String?) -> HwKnownDevice {
+ HwKnownDevice(
id: "dev1",
name: "Trezor",
path: "ble://dev1",
@@ -472,8 +813,8 @@ final class HwConnectViewModelTests: XCTestCase {
)
}
- private func makeDevice(id: String, model: String?) -> TrezorDeviceInfo {
- TrezorDeviceInfo(
+ private func makeDevice(id: String, model: String?) -> HwNearbyDevice {
+ HwNearbyDevice(source: .trezor(TrezorDeviceInfo(
id: id,
transportType: .bluetooth,
name: nil,
@@ -481,7 +822,19 @@ final class HwConnectViewModelTests: XCTestCase {
label: nil,
model: model,
isBootloader: false
- )
+ )))
+ }
+
+ private func makeJadeDevice(path: String = JadeFixtures.blePath, name: String? = JadeFixtures.advertisedName) -> HwNearbyDevice {
+ HwNearbyDevice(source: .jade(JadeFixtures.device(path: path, name: name)))
+ }
+
+ private var jadeDevice: HwNearbyDevice {
+ makeJadeDevice()
+ }
+
+ private var jadeResult: HwConnectResult {
+ HwConnectResult(deviceId: JadeFixtures.deviceId, walletId: JadeFixtures.walletId, name: "Jade", vendor: .blockstream)
}
private let standardWalletId = "trezor:standard"
@@ -519,19 +872,22 @@ private enum TestError: Error {
@MainActor
private final class FakeHwConnectService: HwConnectServicing {
- var nearbyDevices: [TrezorDeviceInfo] = []
+ var nearbyDevices: [HwNearbyDevice] = []
var scanError: Error?
var connectResult: Result = .failure(TestError.stub)
+ /// Holds every connect until opened, so a test can act while one is in flight.
+ var connectGate: AsyncGate?
var passphraseResult: Result = .failure(TestError.stub)
var storedNames: [String: String] = [:]
private(set) var scanCount = 0
- private(set) var connectedDeviceIds: [String] = []
+ private(set) var connectedDevices: [HwNearbyDevice] = []
private(set) var passphraseCalls: [(deviceId: String, passphrase: String)] = []
private(set) var setLabelCalls: [(walletId: String, label: String)] = []
private(set) var cancelPairingCount = 0
+ private(set) var cancelledConnections: [HwNearbyDevice] = []
- func scanForDevices() async throws -> [TrezorDeviceInfo] {
+ func scanForDevices() async throws -> [HwNearbyDevice] {
scanCount += 1
if let scanError {
throw scanError
@@ -539,8 +895,9 @@ private final class FakeHwConnectService: HwConnectServicing {
return nearbyDevices
}
- func connect(to device: TrezorDeviceInfo) async throws -> HwConnectResult {
- connectedDeviceIds.append(device.id)
+ func connect(to device: HwNearbyDevice) async throws -> HwConnectResult {
+ connectedDevices.append(device)
+ await connectGate?.wait()
return try connectResult.get()
}
@@ -560,4 +917,8 @@ private final class FakeHwConnectService: HwConnectServicing {
func cancelPairingCode() {
cancelPairingCount += 1
}
+
+ func cancelPendingConnection(to device: HwNearbyDevice) {
+ cancelledConnections.append(device)
+ }
}
diff --git a/BitkitTests/HwEngagedSessionTests.swift b/BitkitTests/HwEngagedSessionTests.swift
new file mode 100644
index 000000000..b022089d1
--- /dev/null
+++ b/BitkitTests/HwEngagedSessionTests.swift
@@ -0,0 +1,128 @@
+@testable import Bitkit
+import XCTest
+
+/// Ports Android `HwReceiveViewModelTest`'s session rules: the receive sheet releases only a device
+/// session it engaged by verifying an address or entering a passphrase.
+@MainActor
+final class HwEngagedSessionTests: XCTestCase {
+ private let walletId = "trezor:wallet"
+ private let otherWalletId = "jade:wallet"
+
+ private var releaser: RecordingSessionReleaser!
+ private var session: HwEngagedSession!
+
+ override func setUp() async throws {
+ releaser = RecordingSessionReleaser()
+ session = HwEngagedSession()
+ }
+
+ func testCancelWithoutUsingTheDeviceKeepsItsSession() {
+ session.release(through: releaser)
+
+ XCTAssertEqual(releaser.releasedWalletIds, [])
+ XCTAssertNil(session.walletId)
+ }
+
+ func testCancelAfterVerificationClosesTheSession() async {
+ await session.perform(walletId: walletId) {}
+
+ session.release(through: releaser)
+
+ XCTAssertEqual(releaser.releasedWalletIds, [walletId])
+ XCTAssertNil(session.walletId)
+ }
+
+ func testCancelAfterAFailedVerificationStillClosesTheSession() async {
+ do {
+ try await session.perform(walletId: walletId) { throw CancellationError() }
+ XCTFail("Expected the verification to fail")
+ } catch {}
+
+ session.release(through: releaser)
+
+ XCTAssertEqual(releaser.releasedWalletIds, [walletId])
+ }
+
+ func testCancelDuringVerificationClosesTheSession() async {
+ let gate = AsyncGate()
+ let verification = Task { await session.perform(walletId: walletId) { await gate.wait() } }
+ await waitUntil { self.session.isWorking }
+
+ session.release(through: releaser)
+ gate.open()
+ await verification.value
+ session.release(through: releaser)
+
+ XCTAssertEqual(releaser.releasedWalletIds, [walletId])
+ XCTAssertFalse(session.isWorking)
+ XCTAssertNil(session.walletId)
+ }
+
+ func testWatcherAddressChangeKeepsASessionTheSheetNeverUsed() {
+ session.invalidate(through: releaser)
+
+ XCTAssertEqual(releaser.releasedWalletIds, [])
+ }
+
+ func testSelectedWalletChangeDuringVerificationReleasesTheSession() async {
+ let gate = AsyncGate()
+ let verification = Task { await session.perform(walletId: walletId) { await gate.wait() } }
+ await waitUntil { self.session.isWorking }
+
+ session.invalidate(through: releaser)
+ gate.open()
+ await verification.value
+ session.release(through: releaser)
+
+ XCTAssertEqual(releaser.releasedWalletIds, [walletId])
+ }
+
+ func testAddressChangeAfterVerificationKeepsTheSessionUntilTheSheetIsLeft() async {
+ await session.perform(walletId: walletId) {}
+
+ session.invalidate(through: releaser)
+ XCTAssertEqual(releaser.releasedWalletIds, [])
+
+ session.release(through: releaser)
+ XCTAssertEqual(releaser.releasedWalletIds, [walletId])
+ }
+
+ func testReleasesTheWalletTheLatestWorkEngaged() async {
+ await session.perform(walletId: walletId) {}
+ await session.perform(walletId: otherWalletId) {}
+
+ session.release(through: releaser)
+
+ XCTAssertEqual(releaser.releasedWalletIds, [otherWalletId])
+ }
+
+ func testPassphraseWorkThatVerifiesEngagesTheSessionOnce() async {
+ await session.perform(walletId: walletId) {
+ await session.perform(walletId: walletId) {}
+ XCTAssertTrue(session.isWorking)
+ }
+ XCTAssertFalse(session.isWorking)
+
+ session.release(through: releaser)
+ session.release(through: releaser)
+
+ XCTAssertEqual(releaser.releasedWalletIds, [walletId])
+ }
+
+ private func waitUntil(timeout: TimeInterval = 2, _ condition: () -> Bool) async {
+ let deadline = Date().addingTimeInterval(timeout)
+ while !condition(), Date() < deadline {
+ try? await Task.sleep(nanoseconds: 10_000_000)
+ }
+ XCTAssertTrue(condition(), "Timed out waiting for condition")
+ }
+}
+
+@MainActor
+private final class RecordingSessionReleaser: HwSessionReleasing {
+ private(set) var releasedWalletIds: [String] = []
+
+ func scheduleStaleSessionCleanup(walletId: String) {
+ releasedWalletIds.append(walletId)
+ }
+}
diff --git a/BitkitTests/HwErrorPredicateTests.swift b/BitkitTests/HwErrorPredicateTests.swift
new file mode 100644
index 000000000..6e80b9bd7
--- /dev/null
+++ b/BitkitTests/HwErrorPredicateTests.swift
@@ -0,0 +1,148 @@
+@testable import Bitkit
+import BitkitCore
+import XCTest
+
+/// Truth tables for the `isJade*` predicates and the vendor-neutral `isHw*` ones built on them.
+/// `ServiceQueue` boxes core errors into an `AppError`, so every Jade case is checked raw and wrapped.
+/// `AppError` is qualified as `Bitkit.AppError` because `Errors.swift` is also compiled into this test
+/// target, so an unqualified name would resolve to the duplicate and fail the cast.
+final class HwErrorPredicateTests: XCTestCase {
+ private let sessionFailures: [JadeError] = [
+ .TransportError(errorDetails: "link lost"),
+ .DeviceDisconnected,
+ .ConnectionError(errorDetails: "refused"),
+ .Timeout,
+ .NotConnected,
+ .NotInitialized,
+ .IoError(errorDetails: "broken pipe"),
+ ]
+
+ private let otherErrors: [JadeError] = [
+ .DeviceNotFound,
+ .ProtocolError(errorDetails: "bad frame"),
+ .DeviceUninitialized,
+ .InvalidPin,
+ .NetworkMismatch(errorDetails: "testnet"),
+ .InvalidPath(errorDetails: "m/0"),
+ .InvalidPsbt(errorDetails: "bad psbt"),
+ .PsbtTooLarge(size: 20000, max: 16384),
+ .FingerprintMismatch(device: "aaaa", psbt: "bbbb"),
+ .NothingSigned,
+ .AddressMismatch(expected: "bc1qexpected", returned: "bc1qreturned"),
+ .PinServerError(errorDetails: "unreachable"),
+ .DeviceError(errorDetails: "device said no"),
+ ]
+
+ private func rawAndWrapped(_ error: JadeError) -> [Error] {
+ [error, Bitkit.AppError(error: error)]
+ }
+
+ // MARK: - Unwrapping
+
+ func testUnderlyingJadeErrorSeesThroughAppError() {
+ XCTAssertEqual(JadeError.InvalidPin.underlyingJadeError, .InvalidPin)
+ XCTAssertEqual(Bitkit.AppError(error: JadeError.InvalidPin).underlyingJadeError, .InvalidPin)
+ XCTAssertNil(TrezorError.DeviceBusy.underlyingJadeError)
+ XCTAssertNil(Bitkit.AppError(message: "boom", debugMessage: nil).underlyingJadeError)
+ XCTAssertNil(CancellationError().underlyingJadeError)
+ }
+
+ // MARK: - Jade
+
+ func testJadeUserCancellation() {
+ for error in rawAndWrapped(.UserCancelled) {
+ XCTAssertTrue(error.isJadeUserCancellation(), "\(error)")
+ }
+ for error in (sessionFailures + otherErrors + [.DeviceBusy, .DeviceLocked]).flatMap(rawAndWrapped) {
+ XCTAssertFalse(error.isJadeUserCancellation(), "\(error)")
+ }
+ XCTAssertFalse(TrezorError.UserCancelled.isJadeUserCancellation())
+ XCTAssertFalse(CancellationError().isJadeUserCancellation())
+ }
+
+ func testABusyOrLockedJadeIsBusy() {
+ for error in rawAndWrapped(.DeviceBusy) + rawAndWrapped(.DeviceLocked) {
+ XCTAssertTrue(error.isJadeDeviceBusy(), "\(error)")
+ }
+ for error in (sessionFailures + otherErrors + [.UserCancelled]).flatMap(rawAndWrapped) {
+ XCTAssertFalse(error.isJadeDeviceBusy(), "\(error)")
+ }
+ XCTAssertFalse(TrezorError.DeviceBusy.isJadeDeviceBusy())
+ }
+
+ func testOutdatedJadeFirmwareIsAFirmwareError() {
+ for error in rawAndWrapped(.UnsupportedFirmware(installed: "0.1.0", required: "1.0.34")) {
+ XCTAssertTrue(error.isJadeFirmwareError(), "\(error)")
+ }
+ for error in (sessionFailures + otherErrors + [.UserCancelled, .DeviceBusy]).flatMap(rawAndWrapped) {
+ XCTAssertFalse(error.isJadeFirmwareError(), "\(error)")
+ }
+ }
+
+ func testTransportLevelJadeFailuresAreSessionFailures() {
+ for error in sessionFailures.flatMap(rawAndWrapped) {
+ XCTAssertTrue(error.isJadeSessionFailure(), "\(error)")
+ }
+ let notSessionFailures: [JadeError] = otherErrors + [
+ .UserCancelled,
+ .DeviceBusy,
+ .DeviceLocked,
+ .UnsupportedFirmware(installed: "0.1.0", required: "1.0.34"),
+ ]
+ for error in notSessionFailures.flatMap(rawAndWrapped) {
+ XCTAssertFalse(error.isJadeSessionFailure(), "\(error)")
+ }
+ XCTAssertFalse(TrezorError.DeviceDisconnected.isJadeSessionFailure())
+ }
+
+ // MARK: - Either vendor
+
+ func testHwUserCancellationCoversBothVendors() {
+ XCTAssertTrue(JadeError.UserCancelled.isHwUserCancellation())
+ XCTAssertTrue(Bitkit.AppError(error: JadeError.UserCancelled).isHwUserCancellation())
+ XCTAssertTrue(TrezorError.UserCancelled.isHwUserCancellation())
+ XCTAssertTrue(Bitkit.AppError(error: TrezorError.PinCancelled).isHwUserCancellation())
+ XCTAssertFalse(JadeError.DeviceBusy.isHwUserCancellation())
+ XCTAssertFalse(TrezorError.Timeout.isHwUserCancellation())
+ XCTAssertFalse(CancellationError().isHwUserCancellation())
+ }
+
+ func testHwDeviceBusyCoversBothVendors() {
+ XCTAssertTrue(JadeError.DeviceLocked.isHwDeviceBusy())
+ XCTAssertTrue(Bitkit.AppError(error: JadeError.DeviceBusy).isHwDeviceBusy())
+ XCTAssertTrue(TrezorError.DeviceBusy.isHwDeviceBusy())
+ XCTAssertTrue(Bitkit.AppError(error: TrezorError.DeviceBusy).isHwDeviceBusy())
+ XCTAssertFalse(JadeError.Timeout.isHwDeviceBusy())
+ XCTAssertFalse(TrezorError.Timeout.isHwDeviceBusy())
+ }
+
+ func testHwFirmwareErrorCoversBothVendors() {
+ XCTAssertTrue(Bitkit.AppError(error: JadeError.UnsupportedFirmware(installed: "0.1.0", required: "1.0.34")).isHwFirmwareError())
+ XCTAssertTrue(
+ Bitkit.AppError(message: "Firmware error", debugMessage: "Device error (code 99): Firmware error").isHwFirmwareError()
+ )
+ XCTAssertFalse(JadeError.InvalidPin.isHwFirmwareError())
+ XCTAssertFalse(TrezorError.Timeout.isHwFirmwareError())
+ }
+
+ func testHwSessionFailureCoversBothVendors() {
+ XCTAssertTrue(JadeError.Timeout.isHwSessionFailure())
+ XCTAssertTrue(Bitkit.AppError(error: JadeError.TransportError(errorDetails: "link lost")).isHwSessionFailure())
+ XCTAssertTrue(TrezorError.DeviceDisconnected.isHwSessionFailure())
+ XCTAssertTrue(Bitkit.AppError(error: TrezorError.ProtocolError(errorDetails: "THP decryption error")).isHwSessionFailure())
+ XCTAssertFalse(JadeError.InvalidPin.isHwSessionFailure())
+ XCTAssertFalse(JadeError.AddressMismatch(expected: "a", returned: "b").isHwSessionFailure())
+ XCTAssertFalse(TrezorError.ProtocolError(errorDetails: "Invalid PSBT").isHwSessionFailure())
+ }
+
+ func testBusyVendorNamesTheBusyDevice() {
+ XCTAssertEqual(JadeError.DeviceBusy.hwBusyVendor, .blockstream)
+ XCTAssertEqual(Bitkit.AppError(error: JadeError.DeviceLocked).hwBusyVendor, .blockstream)
+ XCTAssertEqual(TrezorError.DeviceBusy.hwBusyVendor, .trezor)
+ XCTAssertEqual(Bitkit.AppError(error: TrezorError.DeviceBusy).hwBusyVendor, .trezor)
+ XCTAssertNil(JadeError.Timeout.hwBusyVendor)
+ XCTAssertNil(TrezorError.Timeout.hwBusyVendor)
+ XCTAssertNil(Bitkit.AppError(message: "sign failed", debugMessage: nil).hwBusyVendor)
+ XCTAssertNil(CancellationError().hwBusyVendor)
+ }
+}
diff --git a/BitkitTests/HwErrorPresenterTests.swift b/BitkitTests/HwErrorPresenterTests.swift
new file mode 100644
index 000000000..a165c40ac
--- /dev/null
+++ b/BitkitTests/HwErrorPresenterTests.swift
@@ -0,0 +1,81 @@
+@testable import Bitkit
+import BitkitCore
+import XCTest
+
+/// `HwErrorPresenter` gives every Jade error Jade or neutral copy and leaves the rest to the Trezor
+/// rules. `AppError` is qualified as `Bitkit.AppError` because `Errors.swift` is also compiled into this
+/// test target, so an unqualified name would resolve to the duplicate and fail the cast.
+final class HwErrorPresenterTests: XCTestCase {
+ func testMapsTypedJadeErrorsToTheirMessages() {
+ let expectations: [(JadeError, String)] = [
+ (.InvalidPin, t("hardware__jade_invalid_pin")),
+ (.DeviceUninitialized, t("hardware__jade_uninitialized")),
+ (.UnsupportedFirmware(installed: "0.1.0", required: "1.0.34"), t("hardware__jade_firmware_outdated")),
+ (.PsbtTooLarge(size: 20000, max: 16384), t("hardware__jade_psbt_too_large")),
+ (.NetworkMismatch(errorDetails: "testnet"), t("hardware__jade_network_mismatch")),
+ (.DeviceBusy, t("hardware__jade_device_busy")),
+ (.DeviceLocked, t("hardware__jade_device_busy")),
+ (.PinServerError(errorDetails: "unreachable"), t("hardware__jade_pinserver_error")),
+ (.AddressMismatch(expected: "bc1qexpected", returned: "bc1qreturned"), t("hardware__verify_address_error")),
+ ]
+
+ for (error, message) in expectations {
+ XCTAssertEqual(HwErrorPresenter.userMessage(from: error), message, "\(error)")
+ XCTAssertEqual(HwErrorPresenter.jadeMessage(from: error), message, "\(error)")
+ }
+ }
+
+ func testMapsAWrappedJadeError() {
+ XCTAssertEqual(HwErrorPresenter.userMessage(from: Bitkit.AppError(error: JadeError.InvalidPin)), t("hardware__jade_invalid_pin"))
+ XCTAssertEqual(
+ HwErrorPresenter.userMessage(from: Bitkit.AppError(error: JadeError.PinServerError(errorDetails: "x"))),
+ t("hardware__jade_pinserver_error")
+ )
+ }
+
+ func testTransportAndConnectionDetailsPassThrough() {
+ let staleBond = "Bluetooth pairing is no longer valid: forget the Jade in the iOS Bluetooth settings and pair it again."
+
+ XCTAssertEqual(HwErrorPresenter.userMessage(from: JadeError.TransportError(errorDetails: staleBond)), staleBond)
+ XCTAssertEqual(HwErrorPresenter.userMessage(from: Bitkit.AppError(error: JadeError.TransportError(errorDetails: "stale text"))), "stale text")
+ XCTAssertEqual(HwErrorPresenter.userMessage(from: JadeError.ConnectionError(errorDetails: "Jade refused")), "Jade refused")
+ }
+
+ func testBlankDetailsAndOtherJadeErrorsGiveTheConnectError() {
+ let connectError = t("hardware__connect_error")
+ let errors: [JadeError] = [
+ .TransportError(errorDetails: ""),
+ .TransportError(errorDetails: " \n"),
+ .ConnectionError(errorDetails: ""),
+ .Timeout,
+ .DeviceDisconnected,
+ .NotConnected,
+ .UserCancelled,
+ .NothingSigned,
+ .ProtocolError(errorDetails: "Device disconnected"),
+ ]
+
+ for error in errors {
+ XCTAssertEqual(HwErrorPresenter.userMessage(from: error), connectError, "\(error)")
+ XCTAssertEqual(HwErrorPresenter.userMessage(from: Bitkit.AppError(error: error)), connectError, "\(error)")
+ }
+ }
+
+ func testFallsBackToTheTrezorRulesForOtherErrors() {
+ XCTAssertEqual(HwErrorPresenter.userMessage(from: TrezorError.DeviceBusy), t("hardware__device_busy"))
+ XCTAssertEqual(HwErrorPresenter.userMessage(from: Bitkit.AppError(error: TrezorError.DeviceBusy)), t("hardware__device_busy"))
+ XCTAssertEqual(HwErrorPresenter.userMessage(from: Bitkit.AppError(message: "boom", debugMessage: nil)), "boom")
+ }
+
+ func testJadeMessageIsNilWithoutAJadeError() {
+ XCTAssertNil(HwErrorPresenter.jadeMessage(from: TrezorError.DeviceBusy))
+ XCTAssertNil(HwErrorPresenter.jadeMessage(from: Bitkit.AppError(message: "boom", debugMessage: nil)))
+ XCTAssertNil(HwErrorPresenter.jadeMessage(from: CancellationError()))
+ }
+
+ func testDeviceBusyMessageNamesTheVendor() {
+ XCTAssertEqual(HwErrorPresenter.deviceBusyMessage(for: .trezor), t("hardware__device_busy"))
+ XCTAssertEqual(HwErrorPresenter.deviceBusyMessage(for: .blockstream), t("hardware__jade_device_busy"))
+ XCTAssertNotEqual(HwErrorPresenter.deviceBusyMessage(for: .trezor), HwErrorPresenter.deviceBusyMessage(for: .blockstream))
+ }
+}
diff --git a/BitkitTests/HwFundingSignerTests.swift b/BitkitTests/HwFundingSignerTests.swift
index 8ed3139a8..84e65dc51 100644
--- a/BitkitTests/HwFundingSignerTests.swift
+++ b/BitkitTests/HwFundingSignerTests.swift
@@ -11,7 +11,7 @@ final class HwFundingSignerTests: XCTestCase {
connecting: MockHwConnecting,
feeRate: UInt64? = 2,
address: String? = "bc1qtest",
- timeouts: (reconnect: Double, compose: Double, sign: Double, broadcast: Double) = (reconnect: 5, compose: 5, sign: 5, broadcast: 5)
+ timeouts: (compose: Double, sign: Double, broadcast: Double) = (compose: 5, sign: 5, broadcast: 5)
) -> HwFundingSigner {
HwFundingSigner(
funding: funding,
@@ -289,6 +289,288 @@ final class HwFundingSignerTests: XCTestCase {
XCTAssertEqual(completedTransactionIds, [funding.broadcastTxId])
}
+ // MARK: - Leaving the sign screen
+
+ func testCoordinatorCanBeLeftWhileTheDeviceConnects() async throws {
+ for walletId in ["jade:wallet", "trezor:wallet"] {
+ let funding = MockHwFunding()
+ let connecting = MockHwConnecting()
+ let connect = AsyncGate()
+ connecting.connectGate = connect
+ let manager = HwWalletManager()
+ let coordinator = makeCoordinator(walletId: walletId, funding: funding, connecting: connecting)
+
+ let payment = Task { try await self.signAndBroadcast(coordinator, manager: manager) }
+ await waitUntil { coordinator.isConnectingDevice }
+
+ XCTAssertTrue(coordinator.isSigning, walletId)
+ XCTAssertTrue(coordinator.isConnectingDevice, walletId)
+ XCTAssertTrue(coordinator.canLeave, walletId)
+
+ connect.open()
+ _ = try await payment.value
+
+ XCTAssertFalse(coordinator.isConnectingDevice, walletId)
+ XCTAssertFalse(coordinator.isSigning, walletId)
+ XCTAssertEqual(funding.broadcastCalls, 1, walletId)
+ }
+ }
+
+ func testCoordinatorCannotBeLeftWhileTheDeviceSigns() async throws {
+ let funding = MockHwFunding()
+ let sign = AsyncGate()
+ funding.signGate = sign
+ let manager = HwWalletManager()
+ let coordinator = makeCoordinator(walletId: "jade:wallet", funding: funding, connecting: MockHwConnecting())
+
+ let payment = Task { try await self.signAndBroadcast(coordinator, manager: manager) }
+ await waitUntil { funding.signCalls == 1 }
+
+ XCTAssertTrue(coordinator.isSigning)
+ XCTAssertFalse(coordinator.isConnectingDevice)
+ XCTAssertFalse(coordinator.canLeave)
+
+ sign.open()
+ _ = try await payment.value
+ }
+
+ func testCoordinatorCannotBeLeftWhileABroadcastIsUnresolved() async throws {
+ let funding = MockHwFunding()
+ let broadcast = AsyncGate()
+ funding.broadcastGate = broadcast
+ let connecting = MockHwConnecting()
+ let manager = HwWalletManager()
+ let coordinator = makeCoordinator(walletId: "jade:wallet", funding: funding, connecting: connecting)
+
+ let payment = Task { try await self.signAndBroadcast(coordinator, manager: manager) }
+ await waitUntil { funding.broadcastCalls == 1 }
+
+ XCTAssertTrue(coordinator.isBroadcastUnresolved)
+ XCTAssertFalse(coordinator.canLeave)
+
+ coordinator.cancel()
+
+ XCTAssertTrue(coordinator.isSigning, "a broadcast that may have gone out is not cancelled")
+ XCTAssertTrue(connecting.staleDisconnects.isEmpty)
+
+ broadcast.open()
+ let result = try await payment.value
+
+ XCTAssertEqual(result.txId, funding.broadcastTxId)
+ XCTAssertFalse(coordinator.canLeave, "the outcome stays unresolved until the sheet records it")
+ coordinator.completeBroadcast()
+ XCTAssertTrue(coordinator.canLeave)
+ }
+
+ func testCancelWhileConnectingStopsBeforeSigningAndReleasesTheDevice() async throws {
+ for walletId in ["jade:wallet", "trezor:wallet"] {
+ let funding = MockHwFunding()
+ let connecting = MockHwConnecting()
+ let abandonedConnect = AsyncGate()
+ connecting.connectGate = abandonedConnect
+ let manager = HwWalletManager()
+ let coordinator = makeCoordinator(walletId: walletId, funding: funding, connecting: connecting)
+
+ let payment = Task { try await self.signAndBroadcast(coordinator, manager: manager) }
+ await waitUntil { coordinator.isConnectingDevice }
+ coordinator.cancel()
+
+ XCTAssertEqual(connecting.staleDisconnects, [walletId], "leaving releases the device")
+ XCTAssertFalse(coordinator.isSigning, walletId)
+ XCTAssertFalse(coordinator.isConnectingDevice, walletId)
+ XCTAssertTrue(coordinator.canLeave, walletId)
+ await assertThrowsAsync {
+ _ = try await payment.value
+ } _: { error in
+ XCTAssertTrue(error is CancellationError, "\(error)")
+ }
+
+ connecting.connectGate = nil
+ abandonedConnect.open()
+ await Task.yield()
+
+ XCTAssertTrue(funding.composeCalls.isEmpty, walletId)
+ XCTAssertEqual(funding.signCalls, 0, walletId)
+ XCTAssertEqual(funding.broadcastCalls, 0, walletId)
+
+ let result = try await signAndBroadcast(coordinator, manager: manager)
+
+ XCTAssertEqual(result.txId, funding.broadcastTxId, walletId)
+ XCTAssertEqual(funding.composeCalls.count, 1, walletId)
+ XCTAssertEqual(funding.broadcastCalls, 1, walletId)
+ XCTAssertEqual(connecting.staleDisconnects, [walletId], "the new attempt keeps its session")
+ }
+ }
+
+ func testACancelledAttemptDoesNotResetANewerAttempt() async throws {
+ let funding = MockHwFunding()
+ let manager = HwWalletManager()
+ let coordinator = makeCoordinator(walletId: "jade:wallet", funding: funding, connecting: MockHwConnecting())
+ let preparations = JadeCallLog()
+ let firstPreparation = AsyncGate()
+ let secondPreparation = AsyncGate()
+
+ let first = Task {
+ try await self.signAndBroadcast(coordinator, manager: manager) {
+ preparations.record("first")
+ await firstPreparation.wait()
+ }
+ }
+ await waitUntil { preparations.contains("first") }
+ coordinator.cancel()
+
+ let second = Task {
+ try await self.signAndBroadcast(coordinator, manager: manager) {
+ preparations.record("second")
+ await secondPreparation.wait()
+ }
+ }
+ await waitUntil { preparations.contains("second") }
+ firstPreparation.open()
+ await assertThrowsAsync {
+ _ = try await first.value
+ } _: { error in
+ XCTAssertTrue(error is CancellationError, "\(error)")
+ }
+
+ XCTAssertTrue(coordinator.isSigning, "the cancelled attempt must not end the newer one")
+ XCTAssertFalse(coordinator.canLeave)
+ XCTAssertEqual(funding.broadcastCalls, 0, "a cancelled attempt never broadcasts")
+
+ secondPreparation.open()
+ let result = try await second.value
+
+ XCTAssertEqual(result.txId, funding.broadcastTxId)
+ XCTAssertEqual(funding.broadcastCalls, 1)
+ XCTAssertFalse(coordinator.isSigning)
+ }
+
+ func testCancelWithNothingInFlightKeepsTheSession() async throws {
+ let funding = MockHwFunding()
+ let connecting = MockHwConnecting()
+ let manager = HwWalletManager()
+ let coordinator = makeCoordinator(walletId: "jade:wallet", funding: funding, connecting: connecting)
+
+ coordinator.cancel()
+
+ XCTAssertTrue(connecting.staleDisconnects.isEmpty)
+
+ _ = try await signAndBroadcast(coordinator, manager: manager)
+ coordinator.completeBroadcast()
+ coordinator.cancel()
+
+ XCTAssertTrue(connecting.staleDisconnects.isEmpty, "a finished payment keeps its session")
+ }
+
+ func testCoordinatorCanBeLeftWhileTheDeviceReconnectsBeforeASignRetry() async throws {
+ let walletId = "jade:wallet"
+ let funding = MockHwFunding()
+ funding.signErrors = [Bitkit.AppError(error: JadeError.DeviceDisconnected)]
+ let sign = AsyncGate()
+ funding.signGate = sign
+ let connecting = MockHwConnecting()
+ let manager = HwWalletManager()
+ let coordinator = makeCoordinator(walletId: walletId, funding: funding, connecting: connecting)
+
+ let payment = Task { try await self.signAndBroadcast(coordinator, manager: manager) }
+ await waitUntil { funding.signCalls == 1 }
+ let abandonedReconnect = AsyncGate()
+ connecting.connectGate = abandonedReconnect
+ sign.open()
+ await waitUntil { coordinator.isConnectingDevice }
+
+ XCTAssertTrue(coordinator.isSigning)
+ XCTAssertTrue(coordinator.isConnectingDevice)
+ XCTAssertTrue(coordinator.canLeave, "nothing is on the device to sign while it reconnects")
+
+ coordinator.cancel()
+
+ await assertThrowsAsync {
+ _ = try await payment.value
+ } _: { error in
+ XCTAssertTrue(error is CancellationError, "\(error)")
+ }
+ XCTAssertEqual(connecting.staleDisconnects, [walletId, walletId], "the failed sign and leaving each release the device")
+
+ abandonedReconnect.open()
+ await Task.yield()
+
+ XCTAssertEqual(funding.signCalls, 1, "the abandoned reconnect never signs again")
+ XCTAssertEqual(funding.broadcastCalls, 0)
+ }
+
+ func testConnectingIsReportedAroundEveryReconnect() async throws {
+ let funding = MockHwFunding()
+ funding.signErrors = [Bitkit.AppError(error: JadeError.DeviceDisconnected)]
+ let connecting = MockHwConnecting()
+ let signer = makeSigner(funding: funding, connecting: connecting)
+ var reports: [Bool] = []
+
+ _ = try await signer.prepareSignedPayment(
+ walletId: "jade:wallet",
+ address: "bc1qtest",
+ sats: 42000,
+ satsPerVByte: 2,
+ onConnectingDevice: { reports.append($0) }
+ )
+
+ XCTAssertEqual(connecting.ensureCalls, 2)
+ XCTAssertEqual(reports, [true, false, true, false], "the reconnect before the sign retry is reported too")
+
+ reports = []
+ connecting.connectError = MockHwFunding.TestError()
+ await assertThrowsAsync {
+ _ = try await signer.prepareSignedPayment(
+ walletId: "jade:wallet",
+ address: "bc1qtest",
+ sats: 42000,
+ satsPerVByte: 2,
+ onConnectingDevice: { reports.append($0) }
+ )
+ }
+
+ XCTAssertEqual(reports, [true, false], "a failed reconnect still ends the report")
+ }
+
+ private func makeCoordinator(
+ walletId: String,
+ funding: MockHwFunding,
+ connecting: MockHwConnecting
+ ) -> HwSendCoordinator {
+ HwSendCoordinator(
+ walletId: walletId,
+ signerFactory: { [self] _, address, satsPerVByte in
+ makeSigner(
+ funding: funding,
+ connecting: connecting,
+ feeRate: satsPerVByte,
+ address: address
+ )
+ }
+ )
+ }
+
+ private func signAndBroadcast(
+ _ coordinator: HwSendCoordinator,
+ manager: HwWalletManager,
+ beforeBroadcast: @escaping () async throws -> Void = {}
+ ) async throws -> HwFundingBroadcastResult {
+ try await coordinator.signAndBroadcast(
+ manager: manager,
+ address: "bc1qtest",
+ sats: 42000,
+ satsPerVByte: 2,
+ beforeBroadcast: beforeBroadcast
+ )
+ }
+
+ private func waitUntil(timeout: TimeInterval = 2, _ condition: () -> Bool) async {
+ let deadline = Date().addingTimeInterval(timeout)
+ while !condition(), Date() < deadline {
+ try? await Task.sleep(nanoseconds: 10_000_000)
+ }
+ }
+
// MARK: - Availability
func testAvailabilityUsesRealMaxSpendable() async throws {
@@ -377,6 +659,23 @@ final class HwFundingSignerTests: XCTestCase {
XCTAssertEqual(funding.signCalls, 0)
}
+ /// The reconnect deadline belongs to the wallet's device: a Jade may be waiting for its PIN.
+ func testReconnectUsesTheWalletsTimeout() async {
+ let funding = MockHwFunding()
+ let connecting = MockHwConnecting()
+ connecting.connectDelay = 0.4
+ connecting.reconnectTimeoutSeconds = 0.05
+ let signer = makeSigner(funding: funding, connecting: connecting)
+
+ await assertThrowsAsync {
+ _ = try await signer.prepareSignedFunding(order: .mock(), walletId: "jade:wallet", address: "bc1q...")
+ } _: { error in
+ XCTAssertEqual(error as? HwTransferError, .reconnect(isBluetooth: false))
+ }
+ XCTAssertEqual(connecting.staleDisconnects, ["jade:wallet"], "the timed-out session is cleaned up")
+ XCTAssertTrue(funding.composeCalls.isEmpty)
+ }
+
func testComposeFailureThrowsFundingError() async {
let funding = MockHwFunding()
funding.composeError = MockHwFunding.TestError()
@@ -396,7 +695,7 @@ final class HwFundingSignerTests: XCTestCase {
let funding = MockHwFunding()
funding.signDelay = 0.4
let connecting = MockHwConnecting()
- let signer = makeSigner(funding: funding, connecting: connecting, timeouts: (reconnect: 5, compose: 5, sign: 0.05, broadcast: 5))
+ let signer = makeSigner(funding: funding, connecting: connecting, timeouts: (compose: 5, sign: 0.05, broadcast: 5))
await assertThrowsAsync {
_ = try await signer.prepareSignedFunding(order: .mock(), walletId: "trezor:wallet", address: "bc1q...")
@@ -415,7 +714,7 @@ final class HwFundingSignerTests: XCTestCase {
let signer = makeSigner(
funding: funding,
connecting: connecting,
- timeouts: (reconnect: 5, compose: 5, sign: 0.05, broadcast: 5)
+ timeouts: (compose: 5, sign: 0.05, broadcast: 5)
)
let start = ContinuousClock.now
@@ -434,7 +733,7 @@ final class HwFundingSignerTests: XCTestCase {
let funding = MockHwFunding()
funding.broadcastDelay = 0.4
let connecting = MockHwConnecting()
- let signer = makeSigner(funding: funding, connecting: connecting, timeouts: (reconnect: 5, compose: 5, sign: 5, broadcast: 0.05))
+ let signer = makeSigner(funding: funding, connecting: connecting, timeouts: (compose: 5, sign: 5, broadcast: 0.05))
await assertThrowsAsync {
_ = try await signer.broadcastSignedFunding(funding.signedTx)
@@ -481,7 +780,7 @@ final class HwFundingSignerTests: XCTestCase {
let funding = MockHwFunding()
funding.composeDelay = 0.4
let connecting = MockHwConnecting()
- let signer = makeSigner(funding: funding, connecting: connecting, timeouts: (reconnect: 5, compose: 0.05, sign: 5, broadcast: 5))
+ let signer = makeSigner(funding: funding, connecting: connecting, timeouts: (compose: 0.05, sign: 5, broadcast: 5))
await assertThrowsAsync {
_ = try await signer.prepareSignedFunding(order: .mock(), walletId: "trezor:wallet", address: "bc1q...")
@@ -527,6 +826,135 @@ final class HwFundingSignerTests: XCTestCase {
XCTAssertEqual(connecting.staleDisconnects, ["trezor:wallet"])
XCTAssertEqual(connecting.ensureCalls, 2)
}
+
+ // MARK: - Vendor errors
+
+ func testBusyJadeReportsJadeVendor() async {
+ let funding = MockHwFunding()
+ let connecting = MockHwConnecting()
+ connecting.connectError = Bitkit.AppError(error: JadeError.DeviceLocked)
+ let signer = makeSigner(funding: funding, connecting: connecting)
+
+ await assertThrowsAsync {
+ _ = try await signer.prepareSignedFunding(order: .mock(), walletId: "jade:wallet", address: "bc1q...")
+ } _: { error in
+ XCTAssertEqual(error as? HwTransferError, .deviceBusy(.blockstream))
+ }
+ XCTAssertTrue(funding.composeCalls.isEmpty)
+ }
+
+ func testBusyTrezorReportsTrezorVendor() async {
+ let funding = MockHwFunding()
+ let connecting = MockHwConnecting()
+ connecting.connectError = Bitkit.AppError(error: TrezorError.DeviceBusy)
+ let signer = makeSigner(funding: funding, connecting: connecting)
+
+ await assertThrowsAsync {
+ _ = try await signer.prepareSignedFunding(order: .mock(), walletId: "trezor:wallet", address: "bc1q...")
+ } _: { error in
+ XCTAssertEqual(error as? HwTransferError, .deviceBusy(.trezor))
+ }
+ XCTAssertTrue(funding.composeCalls.isEmpty)
+ }
+
+ func testJadeWrongPinDuringReconnectShowsJadeCopy() async {
+ let funding = MockHwFunding()
+ let connecting = MockHwConnecting()
+ connecting.isBluetooth = true
+ let signer = makeSigner(funding: funding, connecting: connecting)
+
+ for (error, key) in [
+ (JadeError.InvalidPin, "hardware__jade_invalid_pin"),
+ (JadeError.PinServerError(errorDetails: "unreachable"), "hardware__jade_pinserver_error"),
+ ] {
+ connecting.connectError = Bitkit.AppError(error: error)
+ await assertThrowsAsync {
+ _ = try await signer.prepareSignedFunding(order: .mock(), walletId: "jade:wallet", address: "bc1q...")
+ } _: { thrown in
+ XCTAssertEqual(thrown as? HwTransferError, .generic(t(key)), "\(error)")
+ }
+ }
+ XCTAssertTrue(funding.composeCalls.isEmpty)
+ }
+
+ func testAJadeLinkFailureDuringReconnectStillReportsAReconnect() async {
+ let connecting = MockHwConnecting()
+ connecting.connectError = Bitkit.AppError(error: JadeError.DeviceDisconnected)
+ connecting.isBluetooth = true
+ let signer = makeSigner(funding: MockHwFunding(), connecting: connecting)
+
+ await assertThrowsAsync {
+ _ = try await signer.prepareSignedFunding(order: .mock(), walletId: "jade:wallet", address: "bc1q...")
+ } _: { error in
+ XCTAssertEqual(error as? HwTransferError, .reconnect(isBluetooth: true))
+ }
+ }
+
+ func testAJadeComposeFailureKeepsTheJadeCopy() async {
+ let funding = MockHwFunding()
+ funding.composeError = Bitkit.AppError(error: JadeError.InvalidPin)
+ let signer = makeSigner(funding: funding, connecting: MockHwConnecting())
+
+ await assertThrowsAsync {
+ _ = try await signer.prepareSignedFunding(order: .mock(), walletId: "jade:wallet", address: "bc1q...")
+ } _: { error in
+ XCTAssertEqual(error as? HwTransferError, .generic(t("hardware__jade_invalid_pin")))
+ }
+
+ funding.composeError = Bitkit.AppError(error: JadeError.DeviceBusy)
+ await assertThrowsAsync {
+ _ = try await signer.prepareSignedFunding(order: .mock(), walletId: "jade:wallet", address: "bc1q...")
+ } _: { error in
+ XCTAssertEqual(error as? HwTransferError, .deviceBusy(.blockstream))
+ }
+
+ funding.composeError = Bitkit.AppError(error: JadeError.Timeout)
+ await assertThrowsAsync {
+ _ = try await signer.prepareSignedFunding(order: .mock(), walletId: "jade:wallet", address: "bc1q...")
+ } _: { error in
+ XCTAssertEqual(error as? HwTransferError, .funding(t("hardware__connect_error")))
+ }
+ XCTAssertEqual(funding.signCalls, 0)
+ }
+
+ func testAJadeSessionFailureRetriesSigningOnce() async throws {
+ let funding = MockHwFunding()
+ funding.signErrors = [Bitkit.AppError(error: JadeError.DeviceDisconnected)]
+ let connecting = MockHwConnecting()
+ let signer = makeSigner(funding: funding, connecting: connecting)
+
+ let result = try await signer.prepareSignedFunding(order: .mock(), walletId: "jade:wallet", address: "bc1q...")
+
+ XCTAssertEqual(result, funding.signedTx)
+ XCTAssertEqual(funding.signCalls, 2)
+ XCTAssertEqual(connecting.staleDisconnects, ["jade:wallet"])
+ XCTAssertEqual(connecting.ensureCalls, 2)
+ }
+
+ func testAJadeCancellationOnDeviceIsRethrown() async {
+ let funding = MockHwFunding()
+ let connecting = MockHwConnecting()
+ connecting.connectError = JadeError.UserCancelled
+ let signer = makeSigner(funding: funding, connecting: connecting)
+
+ await assertThrowsAsync {
+ _ = try await signer.prepareSignedFunding(order: .mock(), walletId: "jade:wallet", address: "bc1q...")
+ } _: { error in
+ XCTAssertEqual(error as? JadeError, .UserCancelled, "a cancel on the Jade must not become a reconnect failure")
+ }
+ XCTAssertTrue(funding.composeCalls.isEmpty)
+
+ connecting.connectError = nil
+ funding.signError = Bitkit.AppError(error: JadeError.UserCancelled)
+ await assertThrowsAsync {
+ _ = try await signer.prepareSignedFunding(order: .mock(), walletId: "jade:wallet", address: "bc1q...")
+ } _: { error in
+ XCTAssertTrue(error.isJadeUserCancellation())
+ XCTAssertNil(error as? HwTransferError)
+ }
+ XCTAssertEqual(funding.signCalls, 1, "a cancel on the Jade is not retried")
+ XCTAssertTrue(connecting.staleDisconnects.isEmpty)
+ }
}
/// Async variant of `XCTAssertThrowsError` using a plain (non-autoclosure) operation closure, so the
diff --git a/BitkitTests/HwKnownDeviceMatchingTests.swift b/BitkitTests/HwKnownDeviceMatchingTests.swift
new file mode 100644
index 000000000..94354b780
--- /dev/null
+++ b/BitkitTests/HwKnownDeviceMatchingTests.swift
@@ -0,0 +1,385 @@
+@testable import Bitkit
+import XCTest
+
+/// Covers how a connect resolves which stored entry it refreshes and which entries it supersedes,
+/// now that one physical device can hold a standard wallet plus its passphrase (hidden) wallets.
+final class HwKnownDeviceMatchingTests: XCTestCase {
+ // MARK: - previous(in:deviceId:fetchedXpubs:)
+
+ func testRefreshesTheEntrySharingKeyMaterial() {
+ let standard = makeDevice(xpubs: ["nativeSegwit": "zStandard"])
+ let hidden = makeDevice(xpubs: ["nativeSegwit": "zHidden"], walletId: "trezor:hidden")
+
+ let previous = HwKnownDeviceMatching.previous(
+ in: [standard, hidden],
+ deviceId: "dev1",
+ fetchedXpubs: ["nativeSegwit": "zHidden", "taproot": "zHiddenTR"]
+ )
+
+ XCTAssertEqual(previous?.walletId, "trezor:hidden")
+ }
+
+ /// A passphrase wallet read for the first time overlaps nothing, so it must not adopt the
+ /// standard wallet's entry, since that would blend two seeds' xpubs into one record.
+ func testTreatsUnseenKeyMaterialAsANewIdentity() {
+ let standard = makeDevice(xpubs: ["nativeSegwit": "zStandard"])
+
+ let previous = HwKnownDeviceMatching.previous(
+ in: [standard],
+ deviceId: "dev1",
+ fetchedXpubs: ["nativeSegwit": "zHidden"]
+ )
+
+ XCTAssertNil(previous)
+ }
+
+ func testAdoptsALoneEntryStoredBeforeAnyXpubWasCaptured() {
+ let bare = makeDevice(xpubs: [:], customLabel: "My Trezor")
+
+ let previous = HwKnownDeviceMatching.previous(
+ in: [bare],
+ deviceId: "dev1",
+ fetchedXpubs: ["nativeSegwit": "zStandard"]
+ )
+
+ XCTAssertEqual(previous?.customLabel, "My Trezor")
+ }
+
+ func testIgnoresEntriesOfAnotherDevice() {
+ let other = makeDevice(id: "dev2", xpubs: ["nativeSegwit": "zStandard"])
+
+ let previous = HwKnownDeviceMatching.previous(
+ in: [other],
+ deviceId: "dev1",
+ fetchedXpubs: ["nativeSegwit": "zStandard"]
+ )
+
+ XCTAssertNil(previous)
+ }
+
+ // MARK: - named(in:previous:walletKey:)
+
+ /// The wallet reappears on a fresh transport path, so nothing matches by device id, but it is
+ /// the same key material, and the user's label belongs to the wallet, not to the path.
+ func testInheritsTheLabelOfTheSameWalletOnAnotherPath() {
+ let previouslyPaired = makeDevice(id: "old-path", xpubs: ["nativeSegwit": "zStandard"], customLabel: "Savings")
+
+ let named = HwKnownDeviceMatching.named(
+ in: [previouslyPaired],
+ previous: nil,
+ walletKey: HwKnownDevice.walletKey(for: ["nativeSegwit": "zStandard"], fallback: "dev1")
+ )
+
+ XCTAssertEqual(named?.customLabel, "Savings")
+ }
+
+ func testPrefersTheRefreshedEntryForTheLabel() {
+ let refreshed = makeDevice(xpubs: ["nativeSegwit": "zStandard"], customLabel: "Refreshed")
+ let sameKey = makeDevice(id: "old-path", xpubs: ["nativeSegwit": "zStandard"], customLabel: "Stale")
+
+ let named = HwKnownDeviceMatching.named(
+ in: [sameKey, refreshed],
+ previous: refreshed,
+ walletKey: refreshed.walletKey
+ )
+
+ XCTAssertEqual(named?.customLabel, "Refreshed")
+ }
+
+ // MARK: - merged(_:with:refreshed:)
+
+ func testKeepsTheStandardWalletWhenAPassphraseWalletIsAdded() {
+ let standard = makeDevice(xpubs: ["nativeSegwit": "zStandard"], walletId: "trezor:standard")
+ let hidden = makeDevice(xpubs: ["nativeSegwit": "zHidden"], walletId: "trezor:hidden", passphraseProtected: true)
+
+ let merged = HwKnownDeviceMatching.merged([standard], with: hidden, refreshed: nil)
+
+ XCTAssertEqual(merged.map(\.walletId), ["trezor:standard", "trezor:hidden"])
+ }
+
+ func testReplacesTheEntryHoldingTheSameIdentity() {
+ let stored = makeDevice(xpubs: ["nativeSegwit": "zStandard"], customLabel: "Old")
+ let known = makeDevice(xpubs: ["nativeSegwit": "zStandard"], customLabel: "New")
+
+ let merged = HwKnownDeviceMatching.merged([stored], with: known, refreshed: stored)
+
+ XCTAssertEqual(merged.map(\.customLabel), ["New"])
+ }
+
+ /// Reading a previously rejected address type changes the wallet key, so matching on the new
+ /// key alone would leave the entry this connect refreshed behind as a duplicate.
+ func testReplacesTheRefreshedEntryWhenReadingMoreAccountsChangesItsKey() {
+ let partial = makeDevice(xpubs: ["nativeSegwit": "zStandard"])
+ let complete = makeDevice(xpubs: ["nativeSegwit": "zStandard", "taproot": "zTaproot"])
+
+ let merged = HwKnownDeviceMatching.merged([partial], with: complete, refreshed: partial)
+
+ XCTAssertEqual(merged.count, 1)
+ XCTAssertEqual(merged[0].xpubs.count, 2)
+ }
+
+ func testSupersedesWalletsOfASeedTheDeviceNoLongerCarries() {
+ let wiped = makeDevice(xpubs: ["nativeSegwit": "zOldSeed"], trezorDeviceId: "trezor-before-wipe")
+ let known = makeDevice(xpubs: ["nativeSegwit": "zNewSeed"], trezorDeviceId: "trezor-after-wipe")
+
+ let merged = HwKnownDeviceMatching.merged([wiped], with: known, refreshed: nil)
+
+ XCTAssertEqual(merged.map(\.xpubs), [["nativeSegwit": "zNewSeed"]])
+ }
+
+ /// Two identities of one device report the same Trezor device id, so the wipe rule must not
+ /// sweep away the sibling wallet.
+ func testKeepsAnotherIdentityOfTheSameDevice() {
+ let standard = makeDevice(
+ xpubs: ["nativeSegwit": "zStandard"],
+ walletId: "trezor:standard",
+ trezorDeviceId: "trezor-id"
+ )
+ let hidden = makeDevice(
+ xpubs: ["nativeSegwit": "zHidden"],
+ walletId: "trezor:hidden",
+ passphraseProtected: true,
+ trezorDeviceId: "trezor-id"
+ )
+
+ let merged = HwKnownDeviceMatching.merged([standard], with: hidden, refreshed: nil)
+
+ XCTAssertEqual(merged.map(\.walletId), ["trezor:standard", "trezor:hidden"])
+ }
+
+ func testLeavesEntriesOfAnotherDeviceAlone() {
+ let other = makeDevice(id: "dev2", xpubs: ["nativeSegwit": "zOther"], trezorDeviceId: "other-trezor")
+ let known = makeDevice(xpubs: ["nativeSegwit": "zStandard"], trezorDeviceId: "trezor-id")
+
+ let merged = HwKnownDeviceMatching.merged([other], with: known, refreshed: nil)
+
+ XCTAssertEqual(merged.count, 2)
+ }
+
+ // MARK: - Identity helpers
+
+ func testWalletKeyIsIndependentOfAddressTypeKeys() {
+ let a = makeDevice(xpubs: ["nativeSegwit": "zA", "taproot": "zB"])
+ let b = makeDevice(xpubs: ["taproot": "zA", "nativeSegwit": "zB"])
+
+ XCTAssertEqual(a.walletKey, b.walletKey)
+ }
+
+ func testWalletKeyFallsBackToTheTransportIdWithoutXpubs() {
+ XCTAssertEqual(makeDevice(xpubs: [:]).walletKey, "dev1")
+ }
+
+ func testEntryIdSeparatesTwoIdentitiesOfOneDevice() {
+ let standard = makeDevice(xpubs: ["nativeSegwit": "zStandard"])
+ let hidden = makeDevice(xpubs: ["nativeSegwit": "zHidden"])
+
+ XCTAssertNotEqual(standard.entryId, hidden.entryId)
+ }
+
+ func testResolvedWalletIdPrefersTheStoredValue() {
+ let device = makeDevice(xpubs: ["nativeSegwit": "zStandard"], walletId: "trezor:stored")
+
+ XCTAssertEqual(device.resolvedWalletId, "trezor:stored")
+ }
+
+ // MARK: - Decoding entries stored before hidden wallets existed
+
+ func testDecodesLegacyEntriesAndDerivesTheirWalletId() throws {
+ let legacy = """
+ {
+ "id": "dev1",
+ "name": "Trezor",
+ "path": "ble://dev1",
+ "transportType": "bluetooth",
+ "lastConnectedAt": 0,
+ "xpubs": { "nativeSegwit": "zStandard" }
+ }
+ """
+
+ let decoded = try JSONDecoder().decode(HwKnownDevice.self, from: Data(legacy.utf8))
+
+ XCTAssertNil(decoded.walletId)
+ XCTAssertFalse(decoded.passphraseProtected)
+ XCTAssertNil(decoded.trezorDeviceId)
+ XCTAssertEqual(decoded.resolvedWalletId, try HwWalletId.derive(xpubs: ["nativeSegwit": "zStandard"]))
+ }
+
+ // MARK: - Vendors
+
+ func testDecodesLegacyEntriesAsTrezor() throws {
+ let decoded = try decode(legacyJson(id: "dev1", walletId: "trezor:standard"))
+
+ XCTAssertEqual(decoded.vendor, .trezor)
+ XCTAssertTrue(decoded.belongs(to: .trezor))
+ XCTAssertNil(decoded.jadeDeviceId)
+ XCTAssertNil(decoded.hardwareId)
+ }
+
+ func testInfersJadeForALegacyEntryInTheJadeNamespace() throws {
+ XCTAssertEqual(try decode(legacyJson(id: "jade:bluetooth:aabbcc", walletId: nil)).vendor, .blockstream)
+ XCTAssertEqual(try decode(legacyJson(id: "dev1", walletId: "jade:wallet")).vendor, .blockstream)
+ }
+
+ func testAJadeEntrySurvivesARoundTrip() throws {
+ let jade = makeDevice(
+ id: "jade:bluetooth:aabbcc",
+ xpubs: ["nativeSegwit": "zJade"],
+ customLabel: "Travel",
+ walletId: "jade:wallet",
+ vendor: .blockstream,
+ jadeDeviceId: "aabbcc"
+ )
+
+ let decoded = try JSONDecoder().decode(HwKnownDevice.self, from: JSONEncoder().encode(jade))
+
+ XCTAssertEqual(decoded, jade)
+ XCTAssertEqual(decoded.vendor, .blockstream)
+ XCTAssertEqual(decoded.hardwareId, "aabbcc")
+ }
+
+ /// A newer build may store a vendor this one does not know. Decoding it must not fail the whole
+ /// device list, and writing it back must keep the vendor it was stored under.
+ func testAnUnknownVendorIsWrittenBackUnchanged() throws {
+ let decoded = try decode(legacyJson(id: "passport1", walletId: "foundation:wallet", vendor: "foundation"))
+
+ XCTAssertEqual(decoded.unknownVendor, "foundation")
+ XCTAssertFalse(HwWalletVendor.allCases.contains(where: decoded.belongs(to:)))
+
+ let reencoded = try JSONEncoder().encode(decoded.refreshed(path: "ble://moved", at: Date(timeIntervalSince1970: 5)))
+ let json = try XCTUnwrap(JSONSerialization.jsonObject(with: reencoded) as? [String: Any])
+ XCTAssertEqual(json["vendor"] as? String, "foundation")
+ }
+
+ func testADeviceOfAnotherVendorNeverReplacesAnEntry() {
+ let trezor = makeDevice(id: "shared", xpubs: ["nativeSegwit": "zShared"], walletId: "trezor:wallet")
+ let jade = makeDevice(
+ id: "shared",
+ xpubs: ["nativeSegwit": "zShared"],
+ walletId: "jade:wallet",
+ vendor: .blockstream,
+ jadeDeviceId: "aabbcc"
+ )
+
+ let merged = HwKnownDeviceMatching.merged([trezor], with: jade, refreshed: nil)
+
+ XCTAssertEqual(merged.map(\.walletId), ["trezor:wallet", "jade:wallet"])
+ }
+
+ func testAJadeEntryIsSupersededByADifferentJadeDeviceId() {
+ let wiped = makeDevice(
+ id: "jade:bluetooth:aabbcc",
+ xpubs: ["nativeSegwit": "zOldSeed"],
+ vendor: .blockstream,
+ jadeDeviceId: "aabbcc"
+ )
+ let known = makeDevice(
+ id: "jade:bluetooth:aabbcc",
+ xpubs: ["nativeSegwit": "zNewSeed"],
+ vendor: .blockstream,
+ jadeDeviceId: "ddeeff"
+ )
+
+ let merged = HwKnownDeviceMatching.merged([wiped], with: known, refreshed: nil)
+
+ XCTAssertEqual(merged.map(\.xpubs), [["nativeSegwit": "zNewSeed"]])
+ }
+
+ func testAJadeEntryIsReplacedByAReReadOfTheSameHardwareWithMoreKeys() {
+ let stored = makeDevice(
+ id: "jade:bluetooth:aabbcc",
+ path: "ble:old",
+ xpubs: ["nativeSegwit": "zJade"],
+ vendor: .blockstream,
+ jadeDeviceId: "aabbcc"
+ )
+ let reread = makeDevice(
+ id: "jade:bluetooth:aabbcc",
+ path: "ble:new",
+ xpubs: ["nativeSegwit": "zJade", "taproot": "zJadeTR"],
+ vendor: .blockstream,
+ jadeDeviceId: "aabbcc"
+ )
+
+ let merged = HwKnownDeviceMatching.merged([stored], with: reread, refreshed: stored)
+
+ XCTAssertEqual(merged.map(\.path), ["ble:new"])
+ XCTAssertEqual(merged.first?.xpubs.count, 2)
+ }
+
+ func testAJadeEntryWithoutAStoredIdDerivesAJadeWalletId() throws {
+ let jade = makeDevice(xpubs: ["nativeSegwit": "zJade"], vendor: .blockstream)
+
+ let walletId = try XCTUnwrap(jade.resolvedWalletId)
+
+ XCTAssertTrue(walletId.hasPrefix("jade:"))
+ XCTAssertEqual(walletId, try HwWalletId.derive(xpubs: ["nativeSegwit": "zJade"], vendor: .blockstream))
+ }
+
+ func testRefreshingAnEntryOnlyMovesItsPathAndTime() {
+ let stored = makeDevice(
+ id: "jade:bluetooth:aabbcc",
+ path: "ble:old",
+ xpubs: ["nativeSegwit": "zJade"],
+ customLabel: "Travel",
+ walletId: "jade:wallet",
+ vendor: .blockstream,
+ jadeDeviceId: "aabbcc"
+ )
+
+ let refreshed = stored.refreshed(path: "ble:new", at: Date(timeIntervalSince1970: 50))
+
+ XCTAssertEqual(refreshed.path, "ble:new")
+ XCTAssertEqual(refreshed.lastConnectedAt, Date(timeIntervalSince1970: 50))
+ XCTAssertEqual(refreshed.entryId, stored.entryId)
+ XCTAssertEqual(refreshed.customLabel, "Travel")
+ XCTAssertEqual(refreshed.resolvedWalletId, "jade:wallet")
+ XCTAssertEqual(refreshed.vendor, .blockstream)
+ XCTAssertEqual(refreshed.hardwareId, "aabbcc")
+ }
+
+ private func makeDevice(
+ id: String = "dev1",
+ path: String? = nil,
+ xpubs: [String: String],
+ customLabel: String? = nil,
+ walletId: String? = nil,
+ passphraseProtected: Bool = false,
+ trezorDeviceId: String? = nil,
+ vendor: HwWalletVendor = .trezor,
+ jadeDeviceId: String? = nil
+ ) -> HwKnownDevice {
+ HwKnownDevice(
+ id: id,
+ name: vendor == .trezor ? "Trezor" : "Jade",
+ path: path ?? "ble://\(id)",
+ transportType: "bluetooth",
+ lastConnectedAt: Date(timeIntervalSince1970: 0),
+ xpubs: xpubs,
+ customLabel: customLabel,
+ walletId: walletId,
+ passphraseProtected: passphraseProtected,
+ trezorDeviceId: trezorDeviceId,
+ vendor: vendor,
+ jadeDeviceId: jadeDeviceId
+ )
+ }
+
+ private func legacyJson(id: String, walletId: String?, vendor: String? = nil) -> String {
+ let walletIdField = walletId.map { ", \"walletId\": \"\($0)\"" } ?? ""
+ let vendorField = vendor.map { ", \"vendor\": \"\($0)\"" } ?? ""
+ return """
+ {
+ "id": "\(id)",
+ "name": "Device",
+ "path": "ble://\(id)",
+ "transportType": "bluetooth",
+ "lastConnectedAt": 0,
+ "xpubs": { "nativeSegwit": "zStandard" }\(walletIdField)\(vendorField)
+ }
+ """
+ }
+
+ private func decode(_ json: String) throws -> HwKnownDevice {
+ try JSONDecoder().decode(HwKnownDevice.self, from: Data(json.utf8))
+ }
+}
diff --git a/BitkitTests/HwKnownDeviceStorageTests.swift b/BitkitTests/HwKnownDeviceStorageTests.swift
new file mode 100644
index 000000000..db7c7adce
--- /dev/null
+++ b/BitkitTests/HwKnownDeviceStorageTests.swift
@@ -0,0 +1,341 @@
+@testable import Bitkit
+import Combine
+import XCTest
+
+/// Covers identity-scoped reads and writes: one physical device can hold a standard wallet plus its
+/// passphrase wallets, so `id` no longer identifies a stored entry on its own. Each vendor also reads
+/// and writes only its own slice of the store.
+final class HwKnownDeviceStorageTests: XCTestCase {
+ private static let storageKey = "trezor.knownDevices"
+ private static let pendingNamesKey = "trezor.pendingWalletNames"
+ private var savedDefaults: Data?
+ private var savedPendingNames: [String: String]?
+ private var cancellables: Set = []
+
+ override func setUp() {
+ super.setUp()
+ savedDefaults = UserDefaults.standard.data(forKey: Self.storageKey)
+ savedPendingNames = UserDefaults.standard.dictionary(forKey: Self.pendingNamesKey) as? [String: String]
+ cancellables = []
+ HwKnownDeviceStorage.removeAll()
+ }
+
+ override func tearDown() {
+ cancellables = []
+ HwKnownDeviceStorage.removeAll()
+ if let savedDefaults {
+ UserDefaults.standard.set(savedDefaults, forKey: Self.storageKey)
+ }
+ if let savedPendingNames {
+ UserDefaults.standard.set(savedPendingNames, forKey: Self.pendingNamesKey)
+ }
+ super.tearDown()
+ }
+
+ func testSavingAPassphraseWalletKeepsTheStandardWalletOfTheSameDevice() {
+ HwKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zStandard"], walletId: "trezor:standard"))
+ HwKnownDeviceStorage.save(makeDevice(
+ xpubs: ["nativeSegwit": "zHidden"],
+ walletId: "trezor:hidden",
+ passphraseProtected: true
+ ))
+
+ let stored = HwKnownDeviceStorage.loadAll()
+ XCTAssertEqual(Set(stored.compactMap(\.walletId)), ["trezor:standard", "trezor:hidden"])
+ XCTAssertEqual(stored.filter(\.passphraseProtected).count, 1)
+ }
+
+ func testSavingTheSameIdentityAgainReplacesIt() {
+ HwKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zStandard"], customLabel: "Old"))
+ HwKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zStandard"], customLabel: "New"))
+
+ XCTAssertEqual(HwKnownDeviceStorage.loadAll().map(\.customLabel), ["New"])
+ }
+
+ func testRemovingOneWalletLeavesTheDevicesOtherWalletsPaired() {
+ HwKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zStandard"], walletId: "trezor:standard"))
+ HwKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zHidden"], walletId: "trezor:hidden"))
+
+ HwKnownDeviceStorage.remove(walletId: "trezor:hidden")
+
+ XCTAssertEqual(HwKnownDeviceStorage.loadAll().compactMap(\.walletId), ["trezor:standard"])
+ XCTAssertTrue(HwKnownDeviceStorage.isKnown(id: "dev1"), "the device itself stays paired")
+ }
+
+ /// Entries written before the wallet id was persisted resolve it from their xpubs.
+ func testRemovingAWalletMatchesEntriesWithoutAStoredWalletId() throws {
+ let legacy = makeDevice(xpubs: ["nativeSegwit": "zStandard"], walletId: nil)
+ HwKnownDeviceStorage.save(legacy)
+
+ try HwKnownDeviceStorage.remove(walletId: HwWalletId.derive(xpubs: legacy.xpubs))
+
+ XCTAssertTrue(HwKnownDeviceStorage.loadAll().isEmpty)
+ }
+
+ func testRemovingByDeviceIdForgetsEveryWalletItHolds() {
+ HwKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zStandard"], walletId: "trezor:standard"))
+ HwKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zHidden"], walletId: "trezor:hidden"))
+ HwKnownDeviceStorage.save(makeDevice(id: "dev2", xpubs: ["nativeSegwit": "zOther"], walletId: "trezor:other"))
+
+ HwKnownDeviceStorage.remove(id: "dev1", vendor: .trezor)
+
+ XCTAssertEqual(HwKnownDeviceStorage.loadAll().compactMap(\.walletId), ["trezor:other"])
+ }
+
+ func testLoadingByWalletIdReturnsOnlyThatIdentitysEntries() {
+ HwKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zStandard"], walletId: "trezor:standard"))
+ HwKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zHidden"], walletId: "trezor:hidden"))
+
+ let entries = HwKnownDeviceStorage.loadAll(walletId: "trezor:hidden")
+
+ XCTAssertEqual(entries.count, 1)
+ XCTAssertEqual(entries.first?.xpubs, ["nativeSegwit": "zHidden"])
+ }
+
+ func testNewFieldsSurviveAStorageRoundTrip() {
+ HwKnownDeviceStorage.save(makeDevice(
+ xpubs: ["nativeSegwit": "zHidden"],
+ walletId: "trezor:hidden",
+ passphraseProtected: true,
+ trezorDeviceId: "trezor-id"
+ ))
+
+ let stored = HwKnownDeviceStorage.loadAll().first
+ XCTAssertEqual(stored?.walletId, "trezor:hidden")
+ XCTAssertTrue(stored?.passphraseProtected == true)
+ XCTAssertEqual(stored?.trezorDeviceId, "trezor-id")
+ }
+
+ // MARK: - Hardware wallet names
+
+ func testAPendingNameAndTheDeviceListAreWrittenTogether() {
+ let device = makeDevice(xpubs: ["nativeSegwit": "zStandard"], customLabel: "Cold", walletId: "trezor:standard")
+ HwKnownDeviceStorage.save(device)
+
+ HwKnownDeviceStorage.saveAll(
+ [],
+ vendor: .trezor,
+ pendingName: PendingHwWalletName(walletId: "trezor:standard", name: "Cold")
+ )
+
+ XCTAssertTrue(HwKnownDeviceStorage.loadAll().isEmpty)
+ XCTAssertEqual(HwKnownDeviceStorage.loadPendingNames(), ["trezor:standard": "Cold"])
+ }
+
+ /// Adoption on pairing consumes a pending name by masking rather than by a second write, so a
+ /// wallet the device list already names must not report one.
+ func testAPendingNameIsMaskedOnceTheWalletIsPairedAndNamed() {
+ HwKnownDeviceStorage.setPendingName(walletId: "trezor:standard", name: "Cold")
+ HwKnownDeviceStorage.save(
+ makeDevice(xpubs: ["nativeSegwit": "zStandard"], customLabel: "Cold", walletId: "trezor:standard")
+ )
+
+ XCTAssertTrue(HwKnownDeviceStorage.loadPendingNames().isEmpty)
+ XCTAssertEqual(HwKnownDeviceStorage.backupSnapshot(), ["trezor:standard": "Cold"])
+ }
+
+ func testTheNameOfAPairedWalletWinsOverAPendingOne() {
+ HwKnownDeviceStorage.setPendingName(walletId: "trezor:standard", name: "Restored")
+ HwKnownDeviceStorage.save(
+ makeDevice(xpubs: ["nativeSegwit": "zStandard"], customLabel: "Renamed", walletId: "trezor:standard")
+ )
+
+ XCTAssertEqual(HwKnownDeviceStorage.backupSnapshot(), ["trezor:standard": "Renamed"])
+ }
+
+ func testSettingAPendingNameToNilDropsIt() {
+ HwKnownDeviceStorage.setPendingName(walletId: "trezor:standard", name: "Cold")
+ HwKnownDeviceStorage.setPendingName(walletId: "trezor:standard", name: nil)
+
+ XCTAssertTrue(HwKnownDeviceStorage.backupSnapshot().isEmpty)
+ }
+
+ func testRestoringNamesLetsALocalNameWinAndNeverClears() {
+ HwKnownDeviceStorage.setPendingName(walletId: "trezor:standard", name: "Local")
+
+ HwKnownDeviceStorage.restoreNames(["trezor:standard": "Backed up", "trezor:hidden": "Hidden"])
+ XCTAssertEqual(
+ HwKnownDeviceStorage.backupSnapshot(),
+ ["trezor:standard": "Local", "trezor:hidden": "Hidden"]
+ )
+
+ // An envelope written before the field carries no names, and must not drop what is stored.
+ HwKnownDeviceStorage.restoreNames([:])
+ XCTAssertEqual(
+ HwKnownDeviceStorage.backupSnapshot(),
+ ["trezor:standard": "Local", "trezor:hidden": "Hidden"]
+ )
+ }
+
+ func testForgettingAWalletDropsTheNameKeptForIt() {
+ HwKnownDeviceStorage.setPendingName(walletId: "trezor:hidden", name: "Hidden")
+ HwKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zHidden"], walletId: "trezor:hidden"))
+
+ HwKnownDeviceStorage.remove(walletId: "trezor:hidden")
+
+ XCTAssertTrue(HwKnownDeviceStorage.backupSnapshot().isEmpty)
+ }
+
+ func testRemoveAllClearsPendingNamesToo() {
+ HwKnownDeviceStorage.setPendingName(walletId: "trezor:standard", name: "Cold")
+
+ HwKnownDeviceStorage.removeAll()
+
+ XCTAssertTrue(HwKnownDeviceStorage.backupSnapshot().isEmpty)
+ }
+
+ /// Every connect rewrites the device list to refresh `lastConnectedAt`; only a name change may
+ /// mark the metadata backup stale.
+ func testTheNameSignalFiresOnARenameButNotOnAReconnect() {
+ let device = makeDevice(xpubs: ["nativeSegwit": "zStandard"], customLabel: "Cold", walletId: "trezor:standard")
+ HwKnownDeviceStorage.save(device)
+
+ var fires = 0
+ HwKnownDeviceStorage.namesChangedPublisher
+ .sink { fires += 1 }
+ .store(in: &cancellables)
+
+ var reconnected = device
+ reconnected.lastConnectedAt = Date(timeIntervalSince1970: 5000)
+ HwKnownDeviceStorage.saveAll([reconnected], vendor: .trezor)
+ XCTAssertEqual(fires, 0, "a reconnect must not re-upload the metadata envelope")
+
+ var renamed = reconnected
+ renamed.customLabel = "Vault"
+ HwKnownDeviceStorage.saveAll([renamed], vendor: .trezor)
+ XCTAssertEqual(fires, 1)
+ }
+
+ // MARK: - Vendor slices
+
+ func testSavingOneVendorsSliceKeepsTheOtherVendorsEntries() {
+ HwKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zTrezor"], walletId: "trezor:standard"))
+ HwKnownDeviceStorage.save(makeJade(walletId: "jade:wallet"))
+
+ HwKnownDeviceStorage.saveAll([], vendor: .trezor)
+
+ XCTAssertEqual(HwKnownDeviceStorage.loadAll().compactMap(\.walletId), ["jade:wallet"])
+ }
+
+ func testLoadingAVendorsSliceReturnsOnlyItsEntries() {
+ HwKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zTrezor"], walletId: "trezor:standard"))
+ HwKnownDeviceStorage.save(makeJade(walletId: "jade:wallet"))
+
+ XCTAssertEqual(HwKnownDeviceStorage.loadAll(vendor: .trezor).compactMap(\.walletId), ["trezor:standard"])
+ XCTAssertEqual(HwKnownDeviceStorage.loadAll(vendor: .blockstream).compactMap(\.walletId), ["jade:wallet"])
+ XCTAssertEqual(Set(HwKnownDeviceStorage.loadAll().compactMap(\.walletId)), ["trezor:standard", "jade:wallet"])
+ }
+
+ /// Both vendors share a transport-level id here only to prove the removal stays inside its slice.
+ func testRemovingByIdOnlyTouchesThatVendor() {
+ HwKnownDeviceStorage.save(makeDevice(id: "shared", xpubs: ["nativeSegwit": "zTrezor"], walletId: "trezor:standard"))
+ HwKnownDeviceStorage.save(makeJade(id: "shared", walletId: "jade:wallet"))
+
+ HwKnownDeviceStorage.remove(id: "shared", vendor: .trezor)
+
+ XCTAssertEqual(HwKnownDeviceStorage.loadAll().compactMap(\.walletId), ["jade:wallet"])
+ XCTAssertFalse(HwKnownDeviceStorage.isKnown(id: "shared", vendor: .trezor))
+ XCTAssertTrue(HwKnownDeviceStorage.isKnown(id: "shared", vendor: .blockstream))
+ XCTAssertTrue(HwKnownDeviceStorage.isKnown(id: "shared"))
+ }
+
+ func testTheBackupSnapshotCoversEveryVendor() {
+ HwKnownDeviceStorage.save(
+ makeDevice(xpubs: ["nativeSegwit": "zTrezor"], customLabel: "Cold", walletId: "trezor:standard")
+ )
+ HwKnownDeviceStorage.save(makeJade(customLabel: "Travel", walletId: "jade:wallet"))
+ HwKnownDeviceStorage.setPendingName(walletId: "jade:removed", name: "Old Jade")
+
+ XCTAssertEqual(
+ HwKnownDeviceStorage.backupSnapshot(),
+ ["trezor:standard": "Cold", "jade:wallet": "Travel", "jade:removed": "Old Jade"]
+ )
+ }
+
+ func testEntriesStoredBeforeVendorsExistedStayInTheTrezorSlice() throws {
+ try storeRawEntries([legacyEntry(id: "dev1", walletId: "trezor:legacy")])
+
+ XCTAssertEqual(HwKnownDeviceStorage.loadAll(vendor: .trezor).map(\.id), ["dev1"])
+ XCTAssertTrue(HwKnownDeviceStorage.loadAll(vendor: .blockstream).isEmpty)
+ }
+
+ /// A newer build may pair a vendor this one does not know. Rolling back must neither show that
+ /// wallet as another vendor's nor drop it on the next write.
+ func testAnEntryOfAnUnknownVendorIsKeptOutOfEverySliceAndWrittenBack() throws {
+ var foreign = legacyEntry(id: "passport1", walletId: "foundation:wallet")
+ foreign["vendor"] = "foundation"
+ try storeRawEntries([legacyEntry(id: "dev1", walletId: "trezor:standard"), foreign])
+
+ XCTAssertEqual(HwKnownDeviceStorage.loadAll().map(\.id), ["dev1"])
+ XCTAssertFalse(HwKnownDeviceStorage.isKnown(id: "passport1"))
+
+ HwKnownDeviceStorage.saveAll([], vendor: .trezor)
+ HwKnownDeviceStorage.save(makeJade(walletId: "jade:wallet"))
+ HwKnownDeviceStorage.remove(walletId: "jade:wallet")
+
+ XCTAssertTrue(HwKnownDeviceStorage.loadAll().isEmpty)
+ let stored = try XCTUnwrap(UserDefaults.standard.data(forKey: Self.storageKey))
+ let entries = try XCTUnwrap(JSONSerialization.jsonObject(with: stored) as? [[String: Any]])
+ XCTAssertEqual(entries.compactMap { $0["id"] as? String }, ["passport1"])
+ XCTAssertEqual(entries.compactMap { $0["vendor"] as? String }, ["foundation"])
+ }
+
+ private func makeDevice(
+ id: String = "dev1",
+ xpubs: [String: String],
+ customLabel: String? = nil,
+ walletId: String? = nil,
+ passphraseProtected: Bool = false,
+ trezorDeviceId: String? = nil
+ ) -> HwKnownDevice {
+ HwKnownDevice(
+ id: id,
+ name: "Trezor",
+ path: "ble://\(id)",
+ transportType: "bluetooth",
+ lastConnectedAt: Date(timeIntervalSince1970: 0),
+ xpubs: xpubs,
+ customLabel: customLabel,
+ walletId: walletId,
+ passphraseProtected: passphraseProtected,
+ trezorDeviceId: trezorDeviceId
+ )
+ }
+
+ private func makeJade(
+ id: String = "jade:bluetooth:aabbcc",
+ customLabel: String? = nil,
+ walletId: String
+ ) -> HwKnownDevice {
+ HwKnownDevice(
+ id: id,
+ name: "Jade AABBCC",
+ path: "ble:jade",
+ transportType: "bluetooth",
+ model: "Jade",
+ lastConnectedAt: Date(timeIntervalSince1970: 0),
+ xpubs: ["nativeSegwit": "zJade"],
+ customLabel: customLabel,
+ walletId: walletId,
+ vendor: .blockstream,
+ jadeDeviceId: "aabbcc"
+ )
+ }
+
+ /// An entry as a build without vendors wrote it: no `vendor` key at all.
+ private func legacyEntry(id: String, walletId: String) -> [String: Any] {
+ [
+ "id": id,
+ "name": "Device",
+ "path": "ble://\(id)",
+ "transportType": "bluetooth",
+ "lastConnectedAt": 0,
+ "xpubs": ["nativeSegwit": "z\(id)"],
+ "walletId": walletId,
+ ]
+ }
+
+ private func storeRawEntries(_ entries: [[String: Any]]) throws {
+ try UserDefaults.standard.set(JSONSerialization.data(withJSONObject: entries), forKey: Self.storageKey)
+ }
+}
diff --git a/BitkitTests/HwTransferMocks.swift b/BitkitTests/HwTransferMocks.swift
index b66268caf..31a4f5ebc 100644
--- a/BitkitTests/HwTransferMocks.swift
+++ b/BitkitTests/HwTransferMocks.swift
@@ -19,8 +19,12 @@ final class MockHwFunding: HwTransferFunding {
var signErrors: [Error] = []
var signDelay: Double = 0
var cancellationIgnoringSignDelay: Double = 0
+ /// Holds every sign until opened, so a test can act while the device is signing.
+ var signGate: AsyncGate?
var broadcastError: Error?
var broadcastDelay: Double = 0
+ /// Holds every broadcast until opened, so a test can act while its outcome is unknown.
+ var broadcastGate: AsyncGate?
var funding = HwFundingTransaction(psbt: "psbt", miningFeeSats: 141, feeRate: 1, totalSpent: 43186, satsPerVByte: 1)
var signedTx = HwFundingSignedTx(serializedTx: "rawtx", miningFeeSats: 141, feeRate: 1, totalSpent: 43186)
var broadcastTxId = "txid"
@@ -88,6 +92,9 @@ final class MockHwFunding: HwTransferFunding {
func signFunding(walletId _: String, funding _: HwFundingTransaction) async throws -> HwFundingSignedTx {
signCalls += 1
+ if let signGate {
+ await signGate.wait()
+ }
if signDelay > 0 {
try await Task.sleep(nanoseconds: UInt64(signDelay * 1_000_000_000))
}
@@ -110,6 +117,9 @@ final class MockHwFunding: HwTransferFunding {
func broadcastFunding(serializedTx: String) async throws -> String {
broadcastCalls += 1
broadcastTransactions.append(serializedTx)
+ if let broadcastGate {
+ await broadcastGate.wait()
+ }
if broadcastDelay > 0 {
try await Task.sleep(nanoseconds: UInt64(broadcastDelay * 1_000_000_000))
}
@@ -123,6 +133,10 @@ final class MockHwFunding: HwTransferFunding {
@MainActor
final class MockHwConnecting: HwTransferConnecting {
var connectError: Error?
+ var connectDelay: Double = 0
+ /// Holds every connect until opened, ignoring cancellation the way a device waiting for its PIN does.
+ var connectGate: AsyncGate?
+ var reconnectTimeoutSeconds: Double = 5
var isBluetooth = false
/// Wallets whose passphrase the device no longer holds, so signing has to ask for it again.
var walletsNeedingPassphrase: Set = []
@@ -134,11 +148,21 @@ final class MockHwConnecting: HwTransferConnecting {
func ensureConnected(walletId _: String) async throws {
ensureCalls += 1
+ if let connectGate {
+ await connectGate.wait()
+ }
+ if connectDelay > 0 {
+ try await Task.sleep(nanoseconds: UInt64(connectDelay * 1_000_000_000))
+ }
if let connectError {
throw connectError
}
}
+ func reconnectTimeout(walletId _: String) -> Double {
+ reconnectTimeoutSeconds
+ }
+
func needsPassphrase(walletId: String) -> Bool {
walletsNeedingPassphrase.contains(walletId)
}
diff --git a/BitkitTests/HwWalletIdTests.swift b/BitkitTests/HwWalletIdTests.swift
index 46bce75ed..aa8fe15ef 100644
--- a/BitkitTests/HwWalletIdTests.swift
+++ b/BitkitTests/HwWalletIdTests.swift
@@ -43,6 +43,21 @@ final class HwWalletIdTests: XCTestCase {
XCTAssertThrowsError(try HwWalletId.derive(xpubs: [:]))
}
+ func testJadeWalletIdsUseTheJadeNamespace() throws {
+ let xpubs = ["taproot": "zTR", "nativeSegwit": "zNS"]
+ let expected = "jade:" + expectedHash(ofSortedValues: xpubs)
+ XCTAssertEqual(try HwWalletId.derive(xpubs: xpubs, vendor: .blockstream), expected)
+ }
+
+ /// The same seed paired on a Trezor and on a Jade must stay two wallets.
+ func testEqualXpubsOnTwoVendorsDeriveTwoIds() throws {
+ let xpubs = ["nativeSegwit": "zNS"]
+ let trezor = try HwWalletId.derive(xpubs: xpubs, vendor: .trezor)
+ let jade = try HwWalletId.derive(xpubs: xpubs, vendor: .blockstream)
+ XCTAssertNotEqual(trezor, jade)
+ XCTAssertEqual(trezor, try HwWalletId.derive(xpubs: xpubs), "Trezor ids keep the namespace they always had")
+ }
+
private func expectedHash(ofSortedValues xpubs: [String: String]) -> String {
let joined = xpubs.values.sorted().joined(separator: "\n")
return SHA256.hash(data: Data(joined.utf8))
diff --git a/BitkitTests/HwWalletManagerFundingTests.swift b/BitkitTests/HwWalletManagerFundingTests.swift
index 047261942..a877c8468 100644
--- a/BitkitTests/HwWalletManagerFundingTests.swift
+++ b/BitkitTests/HwWalletManagerFundingTests.swift
@@ -27,9 +27,9 @@ final class HwWalletManagerFundingTests: XCTestCase {
)
}
- private func makeDevice(id: String, xpubs: [String: String]) -> TrezorKnownDevice {
+ private func makeDevice(id: String, xpubs: [String: String]) -> HwKnownDevice {
xpubsByDeviceId[id] = xpubs
- return TrezorKnownDevice(
+ return HwKnownDevice(
id: id,
name: id,
path: "ble:\(id)",
diff --git a/BitkitTests/HwWalletManagerPassphraseTests.swift b/BitkitTests/HwWalletManagerPassphraseTests.swift
index f4571f5c7..d8b5e8d78 100644
--- a/BitkitTests/HwWalletManagerPassphraseTests.swift
+++ b/BitkitTests/HwWalletManagerPassphraseTests.swift
@@ -9,11 +9,12 @@ import XCTest
final class HwWalletManagerPassphraseTests: XCTestCase {
// MARK: - Fake session
- private final class MockHwDeviceSession: HwDeviceSessioning {
- var storedDevices: [TrezorKnownDevice] = []
+ private final class MockTrezorSession: TrezorSessioning {
+ var storedDevices: [HwKnownDevice] = []
var connectedDeviceId: String?
var connectedWalletId: String?
var connectedFeatures: TrezorFeatures?
+ var isSessionActive = false
/// Wallet id the next `connectWithWalletMode` resolves to, per mode. A hidden open that is
/// not listed here resolves to `openedWalletIdOnHidden`, standing in for the different wallet
@@ -22,7 +23,7 @@ final class HwWalletManagerPassphraseTests: XCTestCase {
var openedWalletIdOnStandard: String?
/// An entry the device writes when a hidden open reads a wallet Bitkit has never seen,
/// mirroring how reading accounts persists the wallet before anything can reject it.
- var writesEntryOnHiddenOpen: TrezorKnownDevice?
+ var writesEntryOnHiddenOpen: HwKnownDevice?
var ensureConnectedError: Error?
var connectWithWalletModeError: Error?
@@ -35,6 +36,9 @@ final class HwWalletManagerPassphraseTests: XCTestCase {
private(set) var staleDisconnects: [String] = []
private(set) var forgottenWalletIds: [String] = []
private(set) var warmUpCalls: [String] = []
+ private(set) var releaseCalls = 0
+ private(set) var startAutoReconnectCalls = 0
+ private(set) var renameCalls: [(walletId: String, newName: String)] = []
func ensureConnected(deviceId: String) async throws {
ensureCalls.append(deviceId)
@@ -99,6 +103,21 @@ final class HwWalletManagerPassphraseTests: XCTestCase {
connectedWalletId = nil
}
}
+
+ func releaseSession() async {
+ releaseCalls += 1
+ isSessionActive = false
+ }
+
+ func startAutoReconnect() {
+ startAutoReconnectCalls += 1
+ }
+
+ func resetForWipe() async {}
+
+ func renameWallet(walletId: String, newName: String) {
+ renameCalls.append((walletId, newName))
+ }
}
private final class NoopWatcher: OnChainWatcherServicing, @unchecked Sendable {
@@ -107,12 +126,12 @@ final class HwWalletManagerPassphraseTests: XCTestCase {
func stopAllWatchers() {}
}
- private var session = MockHwDeviceSession()
+ private var session = MockTrezorSession()
private var deletedWalletIds: [String] = []
override func setUp() {
super.setUp()
- session = MockHwDeviceSession()
+ session = MockTrezorSession()
deletedWalletIds = []
}
@@ -587,7 +606,7 @@ final class HwWalletManagerPassphraseTests: XCTestCase {
addressProvider: @escaping HwWalletManager.AddressProvider = { _ in throw TrezorError.DeviceDisconnected }
) -> HwWalletManager {
HwWalletManager(
- session: session,
+ trezorSession: session,
watcherService: NoopWatcher(),
monitoredTypes: { ["nativeSegwit"] },
electrumUrl: { "ssl://test:1" },
@@ -611,8 +630,8 @@ final class HwWalletManagerPassphraseTests: XCTestCase {
xpubs: [String: String] = ["nativeSegwit": "zStandard"],
walletId: String,
passphraseProtected: Bool = false
- ) -> TrezorKnownDevice {
- TrezorKnownDevice(
+ ) -> HwKnownDevice {
+ HwKnownDevice(
id: id,
name: "Trezor",
path: "ble://\(id)",
diff --git a/BitkitTests/HwWalletManagerTests.swift b/BitkitTests/HwWalletManagerTests.swift
index f81c597ef..d57e89e12 100644
--- a/BitkitTests/HwWalletManagerTests.swift
+++ b/BitkitTests/HwWalletManagerTests.swift
@@ -159,9 +159,9 @@ final class HwWalletManagerTests: XCTestCase {
label: String? = nil,
model: String? = "Safe 5",
lastConnectedAt: Date = Date(timeIntervalSince1970: 1000)
- ) -> TrezorKnownDevice {
+ ) -> HwKnownDevice {
xpubsByDeviceId[id] = xpubs
- return TrezorKnownDevice(
+ return HwKnownDevice(
id: id,
name: id,
path: "ble:\(id)",
@@ -284,7 +284,7 @@ final class HwWalletManagerTests: XCTestCase {
/// Needed when one device id holds several identities, where the registry above can only
/// remember the last one written for it.
- private func watcherId(_ device: TrezorKnownDevice, _ addressType: String) -> String {
+ private func watcherId(_ device: HwKnownDevice, _ addressType: String) -> String {
let derived = (try? HwWalletId.derive(xpubs: device.xpubs)) ?? device.id
return "\(derived)|\(addressType)"
}
@@ -441,6 +441,47 @@ final class HwWalletManagerTests: XCTestCase {
XCTAssertEqual(vm.hwWalletIds.count, 1)
}
+ /// Unlike the same seed reached over two transports, the same seed on two vendors is two wallets:
+ /// each is reached and signed with through its own device.
+ func testSameSeedOnTrezorAndJadeStaysTwoWallets() async throws {
+ let xpubs = ["nativeSegwit": "zpubShared"]
+ let watcherService = MockWatcherService()
+ let trezor = makeDevice(id: "trezor1", xpubs: xpubs)
+ let jade = HwKnownDevice(
+ id: "jade:bluetooth:aabbcc",
+ name: "Jade AABBCC",
+ path: "ble:jade",
+ transportType: "bluetooth",
+ model: "Jade Plus",
+ lastConnectedAt: Date(timeIntervalSince1970: 2000),
+ xpubs: xpubs,
+ vendor: .blockstream,
+ jadeDeviceId: "aabbcc"
+ )
+ let vm = makeViewModel(watcherService: watcherService)
+
+ vm.updateDevices(knownDevices: [trezor, jade], connectedDeviceId: nil)
+
+ XCTAssertEqual(vm.wallets.count, 2)
+ let trezorWallet = try XCTUnwrap(vm.wallets.first { $0.vendor == .trezor })
+ let jadeWallet = try XCTUnwrap(vm.wallets.first { $0.vendor == .blockstream })
+ XCTAssertTrue(trezorWallet.walletId.hasPrefix("trezor:"))
+ XCTAssertTrue(jadeWallet.walletId.hasPrefix("jade:"))
+ XCTAssertEqual(jadeWallet.walletId, try HwWalletId.derive(xpubs: xpubs, vendor: .blockstream))
+ XCTAssertEqual(trezorWallet.name, "Trezor Safe 5")
+ XCTAssertEqual(jadeWallet.name, "Jade Plus")
+ XCTAssertEqual(jadeWallet.deviceIds, [jade.id])
+ XCTAssertEqual(vm.vendor(walletId: jadeWallet.walletId), .blockstream)
+ XCTAssertEqual(vm.vendor(walletId: trezorWallet.walletId), .trezor)
+ XCTAssertEqual(vm.hwWalletIds, [trezorWallet.walletId, jadeWallet.walletId])
+ await waitUntil { watcherService.startedParams.count == 2 }
+ XCTAssertEqual(
+ Set(watcherService.startedParams.map(\.walletId)),
+ [trezorWallet.walletId, jadeWallet.walletId],
+ "each wallet watches the shared account under its own id"
+ )
+ }
+
func testActivityPersistedWithDeviceWalletId() async throws {
let xpubs = ["nativeSegwit": "zpubNS"]
let device = makeDevice(id: "dev1", xpubs: xpubs)
diff --git a/BitkitTests/HwWalletManagerVendorTests.swift b/BitkitTests/HwWalletManagerVendorTests.swift
new file mode 100644
index 000000000..f67c845cf
--- /dev/null
+++ b/BitkitTests/HwWalletManagerVendorTests.swift
@@ -0,0 +1,1032 @@
+@testable import Bitkit
+import BitkitCore
+import Combine
+import XCTest
+
+/// Covers how `HwWalletManager` routes device calls by the vendor stored on a wallet's entries, and
+/// keeps a single vendor on the radio, adapting the Jade cases in bitkit-android's
+/// `HwWalletRepoTest`. Both sessions are fakes writing to one ordered log, so the tests can assert
+/// that the other vendor is released before a device is reached.
+@MainActor
+final class HwWalletManagerVendorTests: XCTestCase {
+ // MARK: - Fakes
+
+ @MainActor
+ private final class CallLog {
+ private(set) var entries: [String] = []
+
+ func record(_ entry: String) {
+ entries.append(entry)
+ }
+ }
+
+ private final class FakeTrezorSession: TrezorSessioning {
+ private let log: CallLog
+
+ var storedDevices: [HwKnownDevice] = []
+ var connectedDeviceId: String?
+ var connectedWalletId: String?
+ var connectedFeatures: TrezorFeatures?
+ var isSessionActive = false
+ var knownBluetoothIds: Set = []
+ /// Holds every release until opened, as closing a Trezor link can take seconds.
+ var releaseGate: AsyncGate?
+
+ private(set) var openCalls: [TrezorWalletMode] = []
+ private(set) var staleDisconnects: [String] = []
+ private(set) var warmUpCalls: [String] = []
+ private(set) var forgottenWalletIds: [String] = []
+ private(set) var renameCalls: [(walletId: String, newName: String)] = []
+ private(set) var releaseCalls = 0
+ private(set) var startAutoReconnectCalls = 0
+
+ init(log: CallLog) {
+ self.log = log
+ }
+
+ func ensureConnected(deviceId: String) async throws {
+ log.record("trezor.ensure:\(deviceId)")
+ }
+
+ @discardableResult
+ func connectWithWalletMode(
+ deviceId: String,
+ mode: TrezorWalletMode,
+ passphrase _: String
+ ) async throws -> TrezorFeatures {
+ log.record("trezor.open:\(deviceId)")
+ openCalls.append(mode)
+ return TrezorFeatures(
+ vendor: "trezor.io",
+ model: "Safe 5",
+ label: "Trezor",
+ deviceId: "trezor-id",
+ majorVersion: 2,
+ minorVersion: 8,
+ patchVersion: 0,
+ pinProtection: false,
+ unlocked: true,
+ passphraseProtection: true,
+ initialized: true,
+ needsBackup: false,
+ passphraseEntryCapable: false
+ )
+ }
+
+ func disconnectStaleSession(deviceId: String) async {
+ log.record("trezor.stale:\(deviceId)")
+ staleDisconnects.append(deviceId)
+ }
+
+ func releaseSession() async {
+ log.record("trezor.release")
+ releaseCalls += 1
+ await releaseGate?.wait()
+ isSessionActive = false
+ connectedDeviceId = nil
+ connectedWalletId = nil
+ }
+
+ /// Reads as active at once, as `TrezorManager` registers the reconnect before it first runs.
+ func startAutoReconnect() {
+ log.record("trezor.startAutoReconnect")
+ startAutoReconnectCalls += 1
+ isSessionActive = true
+ }
+
+ func resetForWipe() async {
+ log.record("trezor.resetForWipe")
+ isSessionActive = false
+ }
+
+ func isKnownBluetoothDevice(deviceId: String) -> Bool {
+ knownBluetoothIds.contains(deviceId)
+ }
+
+ func warmUpConnection(deviceId: String) {
+ warmUpCalls.append(deviceId)
+ }
+
+ func forgetWallet(walletId: String, pendingName _: PendingHwWalletName?) async {
+ forgottenWalletIds.append(walletId)
+ storedDevices.removeAll { $0.resolvedWalletId == walletId }
+ }
+
+ func renameWallet(walletId: String, newName: String) {
+ renameCalls.append((walletId, newName))
+ }
+ }
+
+ private final class FakeJadeSession: JadeSessioning {
+ private let log: CallLog
+
+ var storedDevices: [HwKnownDevice] = []
+ var connectedDeviceId: String?
+ var connectedWalletId: String?
+ var isSessionActive = false
+ var knownBluetoothIds: Set = []
+ var ensureError: Error?
+ var verifyErrors: [Error] = []
+ var fingerprint = "deadbeef"
+ var fingerprintError: Error?
+ var completedTransaction = CompletedTransaction(serializedTx: "rawtx", txid: "txid")
+ var blocksEnsure = false
+ var onEnsure: (() -> Void)?
+ private var ensureContinuation: CheckedContinuation?
+
+ private(set) var verifyCalls: [(addressType: AddressScriptType, derivationPath: String, expectedAddress: String)] = []
+ private(set) var signedPsbts: [String] = []
+ private(set) var staleDisconnects: [String] = []
+ private(set) var warmUpCalls: [String] = []
+ private(set) var forgottenWalletIds: [String] = []
+ private(set) var renameCalls: [(walletId: String, newName: String)] = []
+ private(set) var releaseCalls = 0
+ private(set) var startAutoReconnectCalls = 0
+
+ init(log: CallLog) {
+ self.log = log
+ }
+
+ func ensureConnected(deviceId: String) async throws {
+ log.record("jade.ensure:\(deviceId)")
+ onEnsure?()
+ if blocksEnsure {
+ await withCheckedContinuation { ensureContinuation = $0 }
+ }
+ if let ensureError {
+ throw ensureError
+ }
+ connectedDeviceId = deviceId
+ }
+
+ func finishEnsure() {
+ blocksEnsure = false
+ ensureContinuation?.resume()
+ ensureContinuation = nil
+ }
+
+ func verifyAddress(addressType: AddressScriptType, derivationPath: String, expectedAddress: String) async throws {
+ log.record("jade.verify")
+ verifyCalls.append((addressType, derivationPath, expectedAddress))
+ if !verifyErrors.isEmpty {
+ throw verifyErrors.removeFirst()
+ }
+ }
+
+ func masterFingerprint() async throws -> String {
+ log.record("jade.fingerprint")
+ if let fingerprintError {
+ throw fingerprintError
+ }
+ return fingerprint
+ }
+
+ func signPsbt(_ psbtBase64: String) async throws -> CompletedTransaction {
+ log.record("jade.sign")
+ signedPsbts.append(psbtBase64)
+ return completedTransaction
+ }
+
+ func disconnectStaleSession(deviceId: String) async {
+ log.record("jade.stale:\(deviceId)")
+ staleDisconnects.append(deviceId)
+ }
+
+ func releaseSession() async {
+ log.record("jade.release")
+ releaseCalls += 1
+ isSessionActive = false
+ connectedDeviceId = nil
+ connectedWalletId = nil
+ }
+
+ func isKnownBluetoothDevice(deviceId: String) -> Bool {
+ knownBluetoothIds.contains(deviceId)
+ }
+
+ func warmUpConnection(deviceId: String) {
+ warmUpCalls.append(deviceId)
+ }
+
+ func forgetWallet(walletId: String, pendingName _: PendingHwWalletName?) async {
+ forgottenWalletIds.append(walletId)
+ storedDevices.removeAll { $0.resolvedWalletId == walletId }
+ }
+
+ func renameWallet(walletId: String, newName: String) {
+ renameCalls.append((walletId, newName))
+ }
+
+ func startAutoReconnect() {
+ log.record("jade.startAutoReconnect")
+ startAutoReconnectCalls += 1
+ }
+
+ func onAppBackgrounded() {
+ log.record("jade.backgrounded")
+ }
+
+ func onAppBecameActive() {
+ log.record("jade.active")
+ }
+
+ func resetForWipe() async {
+ log.record("jade.resetForWipe")
+ }
+ }
+
+ private final class NoopWatcher: OnChainWatcherServicing, @unchecked Sendable {
+ func startWatcher(params _: WatcherParams, listener _: EventListener) async throws {}
+ func stopWatcher(watcherId _: String) throws {}
+ func stopAllWatchers() {}
+ }
+
+ // MARK: - Setup
+
+ private static let storageKey = "trezor.knownDevices"
+ private static let pendingNamesKey = "trezor.pendingWalletNames"
+
+ private let trezorDeviceId = "dev1"
+ private let trezorWalletId = "trezor:wallet"
+ private let jadeDeviceId = "jade:bluetooth:246F288F6B64"
+ private let jadeWalletId = "jade:wallet"
+
+ private var log = CallLog()
+ private var trezor: FakeTrezorSession!
+ private var jade: FakeJadeSession!
+ private var jadeBluetoothPoweredOn: PassthroughSubject!
+ private var savedDefaults: Data?
+ private var savedPendingNames: [String: String]?
+
+ override func setUp() {
+ super.setUp()
+ log = CallLog()
+ trezor = FakeTrezorSession(log: log)
+ jade = FakeJadeSession(log: log)
+ jadeBluetoothPoweredOn = PassthroughSubject()
+ savedDefaults = UserDefaults.standard.data(forKey: Self.storageKey)
+ savedPendingNames = UserDefaults.standard.dictionary(forKey: Self.pendingNamesKey) as? [String: String]
+ HwKnownDeviceStorage.removeAll()
+ }
+
+ override func tearDown() {
+ HwKnownDeviceStorage.removeAll()
+ if let savedDefaults {
+ UserDefaults.standard.set(savedDefaults, forKey: Self.storageKey)
+ }
+ if let savedPendingNames {
+ UserDefaults.standard.set(savedPendingNames, forKey: Self.pendingNamesKey)
+ }
+ trezor = nil
+ jade = nil
+ jadeBluetoothPoweredOn = nil
+ super.tearDown()
+ }
+
+ // MARK: - Wallet identity
+
+ func testSameSeedOnTrezorAndJadeStaysTwoWallets() {
+ let xpubs = ["nativeSegwit": "zSharedSeed"]
+ let manager = makeManager()
+
+ manager.updateDevices(
+ knownDevices: [
+ makeTrezorEntry(xpubs: xpubs, walletId: nil),
+ makeJadeEntry(xpubs: xpubs, walletId: nil),
+ ],
+ connectedDeviceId: nil
+ )
+
+ XCTAssertEqual(manager.wallets.count, 2)
+ XCTAssertEqual(Set(manager.wallets.map(\.vendor)), [.trezor, .blockstream])
+ XCTAssertTrue(manager.wallets.first { $0.vendor == .trezor }?.walletId.hasPrefix("trezor:") == true)
+ XCTAssertTrue(manager.wallets.first { $0.vendor == .blockstream }?.walletId.hasPrefix("jade:") == true)
+ }
+
+ func testReconnectTimeoutFollowsTheVendor() {
+ trezor.storedDevices = [makeTrezorEntry()]
+ jade.storedDevices = [makeJadeEntry()]
+ let manager = makeManager()
+
+ XCTAssertEqual(manager.reconnectTimeout(walletId: trezorWalletId), 30)
+ XCTAssertEqual(manager.reconnectTimeout(walletId: jadeWalletId), 300)
+ XCTAssertEqual(manager.reconnectTimeout(walletId: "trezor:unknown"), 30)
+ }
+
+ // MARK: - One vendor at a time
+
+ func testEnsuringAJadeWalletReleasesTheTrezorFirst() async throws {
+ trezor.storedDevices = [makeTrezorEntry()]
+ jade.storedDevices = [makeJadeEntry()]
+ trezor.isSessionActive = true
+ let manager = makeManager()
+
+ try await manager.ensureConnected(walletId: jadeWalletId)
+
+ XCTAssertEqual(log.entries, ["trezor.release", "jade.ensure:\(jadeDeviceId)"])
+ }
+
+ func testEnsuringATrezorWalletReleasesTheJadeFirst() async throws {
+ trezor.storedDevices = [makeTrezorEntry()]
+ trezor.connectedWalletId = trezorWalletId
+ jade.storedDevices = [makeJadeEntry()]
+ jade.isSessionActive = true
+ let manager = makeManager()
+
+ try await manager.ensureConnected(walletId: trezorWalletId)
+
+ XCTAssertEqual(log.entries, ["jade.release", "trezor.ensure:\(trezorDeviceId)"])
+ }
+
+ func testEnsuringLeavesAnIdleOtherVendorAlone() async throws {
+ trezor.storedDevices = [makeTrezorEntry()]
+ trezor.connectedWalletId = trezorWalletId
+ jade.storedDevices = [makeJadeEntry()]
+ let manager = makeManager()
+
+ try await manager.ensureConnected(walletId: trezorWalletId)
+ try await manager.ensureConnected(walletId: jadeWalletId)
+
+ XCTAssertEqual(log.entries, ["trezor.ensure:\(trezorDeviceId)", "jade.ensure:\(jadeDeviceId)"])
+ }
+
+ /// A Jade holds a single wallet, so a session that resolved to another one is the wrong device.
+ func testEnsureRejectsAJadeHoldingAnotherWallet() async {
+ jade.storedDevices = [makeJadeEntry()]
+ jade.connectedWalletId = "jade:other"
+ let manager = makeManager()
+
+ do {
+ try await manager.ensureConnected(walletId: jadeWalletId)
+ XCTFail("expected the other wallet's session to be refused")
+ } catch {
+ XCTAssertEqual(log.entries, ["jade.ensure:\(jadeDeviceId)"])
+ }
+ }
+
+ /// A session whose accounts could not be read proves nothing either way; signing still checks
+ /// which device it is.
+ func testEnsureAcceptsAJadeThatResolvedNoWallet() async throws {
+ jade.storedDevices = [makeJadeEntry()]
+ jade.connectedWalletId = nil
+ let manager = makeManager()
+
+ try await manager.ensureConnected(walletId: jadeWalletId)
+
+ XCTAssertEqual(jade.connectedDeviceId, jadeDeviceId)
+ }
+
+ func testPairingThroughWithVendorSessionReleasesTheOtherVendor() async throws {
+ trezor.isSessionActive = true
+ let manager = makeManager()
+
+ let result = try await manager.withVendorSession(.blockstream) {
+ log.record("pair")
+ return 7
+ }
+
+ XCTAssertEqual(result, 7)
+ XCTAssertEqual(log.entries, ["trezor.release", "pair"])
+ }
+
+ func testSessionOperationsRunOneAtATimeInArrivalOrder() async throws {
+ trezor.storedDevices = [makeTrezorEntry()]
+ trezor.connectedWalletId = trezorWalletId
+ jade.storedDevices = [makeJadeEntry()]
+ jade.blocksEnsure = true
+ let jadeEnsureStarted = expectation(description: "jade ensure started")
+ jade.onEnsure = { jadeEnsureStarted.fulfill() }
+ let manager = makeManager()
+
+ let jadeOperation = Task { @MainActor in
+ try await manager.ensureConnected(walletId: self.jadeWalletId)
+ }
+ await fulfillment(of: [jadeEnsureStarted], timeout: 1)
+ let trezorOperation = Task { @MainActor in
+ try await manager.ensureConnected(walletId: self.trezorWalletId)
+ }
+ for _ in 0 ..< 5 {
+ await Task.yield()
+ }
+
+ XCTAssertEqual(log.entries, ["jade.ensure:\(jadeDeviceId)"], "the Trezor waits for the Jade operation")
+
+ jade.isSessionActive = true
+ jade.finishEnsure()
+ try await jadeOperation.value
+ try await trezorOperation.value
+
+ XCTAssertEqual(log.entries, ["jade.ensure:\(jadeDeviceId)", "jade.release", "trezor.ensure:\(trezorDeviceId)"])
+ }
+
+ func testACancelledOperationWaitingForTheLockNeverReachesTheDevice() async throws {
+ trezor.storedDevices = [makeTrezorEntry()]
+ trezor.connectedWalletId = trezorWalletId
+ jade.storedDevices = [makeJadeEntry()]
+ jade.blocksEnsure = true
+ let jadeEnsureStarted = expectation(description: "jade ensure started")
+ jade.onEnsure = { jadeEnsureStarted.fulfill() }
+ let manager = makeManager()
+
+ let jadeOperation = Task { @MainActor in
+ try await manager.ensureConnected(walletId: self.jadeWalletId)
+ }
+ await fulfillment(of: [jadeEnsureStarted], timeout: 1)
+ let trezorOperation = Task { @MainActor in
+ try await manager.ensureConnected(walletId: self.trezorWalletId)
+ }
+ await Task.yield()
+ trezorOperation.cancel()
+ jade.finishEnsure()
+ try await jadeOperation.value
+
+ do {
+ try await trezorOperation.value
+ XCTFail("expected the queued operation to be cancelled")
+ } catch {
+ XCTAssertTrue(error is CancellationError)
+ }
+ XCTAssertEqual(log.entries, ["jade.ensure:\(jadeDeviceId)"])
+ }
+
+ func testACancelWhileTheTrezorIsReleasedStopsThePairingBeforeItRuns() async {
+ trezor.isSessionActive = true
+ let release = AsyncGate()
+ trezor.releaseGate = release
+ let manager = makeManager()
+
+ let pairing = Task { @MainActor in
+ try await manager.withVendorSession(.blockstream) {
+ self.log.record("pair")
+ }
+ }
+ await waitUntil { self.trezor.releaseCalls == 1 }
+ pairing.cancel()
+ release.open()
+
+ do {
+ try await pairing.value
+ XCTFail("expected the cancelled pairing to stop")
+ } catch {
+ XCTAssertTrue(error is CancellationError, "\(error)")
+ }
+ XCTAssertEqual(log.entries, ["trezor.release"])
+ }
+
+ func testACancelWhileTheTrezorIsReleasedNeverReachesTheJade() async {
+ trezor.storedDevices = [makeTrezorEntry()]
+ jade.storedDevices = [makeJadeEntry()]
+ trezor.isSessionActive = true
+ let release = AsyncGate()
+ trezor.releaseGate = release
+ let manager = makeManager()
+
+ let ensure = Task { @MainActor in
+ try await manager.ensureConnected(walletId: self.jadeWalletId)
+ }
+ await waitUntil { self.trezor.releaseCalls == 1 }
+ ensure.cancel()
+ release.open()
+
+ do {
+ try await ensure.value
+ XCTFail("expected the cancelled connect to stop")
+ } catch {
+ XCTAssertTrue(error is CancellationError, "\(error)")
+ }
+ XCTAssertEqual(log.entries, ["trezor.release"])
+ }
+
+ // MARK: - Foreground reconnect
+
+ func testForegroundReconnectTargetsTheConnectedJade() async {
+ HwKnownDeviceStorage.saveAll([makeTrezorEntry(lastConnectedAt: 50)], vendor: .trezor)
+ HwKnownDeviceStorage.saveAll([makeJadeEntry(lastConnectedAt: 0)], vendor: .blockstream)
+ jade.connectedDeviceId = jadeDeviceId
+ let manager = makeManager()
+
+ await manager.reconnectOnForeground()
+ await settle()
+
+ XCTAssertEqual(jade.startAutoReconnectCalls, 1)
+ XCTAssertEqual(trezor.startAutoReconnectCalls, 0)
+ }
+
+ func testForegroundReconnectPicksTheMostRecentlyUsedVendor() async {
+ HwKnownDeviceStorage.saveAll([makeTrezorEntry(lastConnectedAt: 0)], vendor: .trezor)
+ HwKnownDeviceStorage.saveAll([makeJadeEntry(lastConnectedAt: 20)], vendor: .blockstream)
+ trezor.storedDevices = [makeTrezorEntry(lastConnectedAt: 0)]
+ jade.storedDevices = [makeJadeEntry(lastConnectedAt: 20)]
+ let manager = makeManager()
+
+ await manager.reconnectOnForeground()
+ await settle()
+
+ XCTAssertEqual(jade.startAutoReconnectCalls, 1)
+ XCTAssertEqual(trezor.startAutoReconnectCalls, 0)
+ }
+
+ /// On a cold launch the foreground reconnect runs before the vendor managers load their entries,
+ /// so the choice has to come from what is saved.
+ func testForegroundReconnectReadsSavedJadeBeforeTheManagersLoadIt() async {
+ HwKnownDeviceStorage.saveAll([makeJadeEntry()], vendor: .blockstream)
+ let manager = makeManager()
+
+ await manager.reconnectOnForeground()
+ await settle()
+
+ XCTAssertEqual(jade.startAutoReconnectCalls, 1)
+ XCTAssertEqual(trezor.startAutoReconnectCalls, 0)
+ }
+
+ func testForegroundReconnectDefaultsToTrezor() async {
+ let manager = makeManager()
+
+ await manager.reconnectOnForeground()
+
+ XCTAssertEqual(trezor.startAutoReconnectCalls, 1)
+ XCTAssertEqual(jade.startAutoReconnectCalls, 0)
+ }
+
+ func testForegroundReconnectOfATrezorReleasesAPendingJadeFirst() async {
+ trezor.connectedDeviceId = trezorDeviceId
+ jade.isSessionActive = true
+ let manager = makeManager()
+
+ await manager.reconnectOnForeground()
+
+ XCTAssertEqual(log.entries, ["jade.release", "trezor.startAutoReconnect"])
+ }
+
+ /// The Trezor reconnect outlives the lock, so it must already read as active when the next
+ /// operation takes it, or a Jade would dial alongside it.
+ func testAJadeOperationAfterAForegroundTrezorReconnectReleasesItFirst() async throws {
+ jade.storedDevices = [makeJadeEntry()]
+ let manager = makeManager()
+
+ await manager.reconnectOnForeground()
+ try await manager.ensureConnected(walletId: jadeWalletId)
+
+ XCTAssertEqual(log.entries, ["trezor.startAutoReconnect", "trezor.release", "jade.ensure:\(jadeDeviceId)"])
+ }
+
+ // MARK: - Bluetooth restored
+
+ func testBluetoothRestoredStartsASilentJadeReconnect() {
+ jade.storedDevices = [makeJadeEntry()]
+ let manager = makeManager()
+
+ manager.onJadeBluetoothRestored()
+
+ XCTAssertEqual(jade.startAutoReconnectCalls, 1)
+ }
+
+ func testBluetoothRestoredLeavesAnActiveTrezorAlone() {
+ jade.storedDevices = [makeJadeEntry()]
+ trezor.isSessionActive = true
+ let manager = makeManager()
+
+ manager.onJadeBluetoothRestored()
+
+ XCTAssertEqual(jade.startAutoReconnectCalls, 0)
+ XCTAssertEqual(trezor.releaseCalls, 0)
+ }
+
+ func testBluetoothRestoredWaitsForTheForegroundAndAPairedJade() {
+ let manager = makeManager()
+
+ manager.onJadeBluetoothRestored()
+ XCTAssertEqual(jade.startAutoReconnectCalls, 0, "nothing is paired")
+
+ jade.storedDevices = [makeJadeEntry()]
+ manager.onAppBackgrounded()
+ manager.onJadeBluetoothRestored()
+ XCTAssertEqual(jade.startAutoReconnectCalls, 0, "the app is in the background")
+
+ manager.onAppBecameActive()
+ manager.onJadeBluetoothRestored()
+ XCTAssertEqual(jade.startAutoReconnectCalls, 1)
+ }
+
+ func testTransportReportingBluetoothOnStartsASilentJadeReconnect() async {
+ jade.storedDevices = [makeJadeEntry()]
+ let manager = makeManager()
+
+ jadeBluetoothPoweredOn.send(())
+ await waitUntil { self.jade.startAutoReconnectCalls == 1 }
+
+ XCTAssertEqual(jade.startAutoReconnectCalls, 1)
+ XCTAssertEqual(trezor.startAutoReconnectCalls, 0)
+ withExtendedLifetime(manager) {}
+ }
+
+ /// Reports reach the manager on the main queue in the order sent, so a single reconnect after the
+ /// second report means the first, sent while in the background, was dropped.
+ func testTransportReportingBluetoothOnInTheBackgroundIsIgnored() async {
+ jade.storedDevices = [makeJadeEntry()]
+ let manager = makeManager()
+
+ manager.onAppBackgrounded()
+ jadeBluetoothPoweredOn.send(())
+ await settle()
+ manager.onAppBecameActive()
+ jadeBluetoothPoweredOn.send(())
+ await waitUntil { self.jade.startAutoReconnectCalls > 0 }
+ await settle()
+
+ XCTAssertEqual(jade.startAutoReconnectCalls, 1)
+ withExtendedLifetime(manager) {}
+ }
+
+ // MARK: - Passphrase
+
+ func testPassphraseIsRejectedForJade() async {
+ trezor.storedDevices = [makeTrezorEntry()]
+ trezor.isSessionActive = true
+ jade.storedDevices = [makeJadeEntry()]
+ let manager = makeManager()
+
+ await assertThrows(HwPassphraseError.protectionDisabled) {
+ _ = try await manager.connectWithPassphrase(deviceId: self.jadeDeviceId, passphrase: "correct horse")
+ }
+ await assertThrows(HwPassphraseError.protectionDisabled) {
+ try await manager.reconnectWithPassphrase(walletId: self.jadeWalletId, passphrase: "correct horse")
+ }
+
+ XCTAssertFalse(manager.needsPassphrase(walletId: jadeWalletId))
+ XCTAssertTrue(trezor.openCalls.isEmpty)
+ XCTAssertEqual(trezor.releaseCalls, 0, "a refused request leaves the Trezor session alone")
+ }
+
+ // MARK: - Receive address verification
+
+ func testVerifiesAJadeAddressOnTheDevice() async throws {
+ jade.storedDevices = [makeJadeEntry()]
+ var trezorAddressCalls = 0
+ let manager = makeManager(addressProvider: { _ in
+ trezorAddressCalls += 1
+ throw TrezorError.DeviceDisconnected
+ })
+ let receiveAddress = makeReceiveAddress()
+
+ try await manager.verifyReceiveAddress(walletId: jadeWalletId, receiveAddress: receiveAddress)
+
+ XCTAssertEqual(trezorAddressCalls, 0, "the Trezor address call is never made for a Jade")
+ XCTAssertEqual(log.entries, ["jade.ensure:\(jadeDeviceId)", "jade.verify"])
+ XCTAssertEqual(jade.verifyCalls.first?.addressType, .nativeSegwit)
+ XCTAssertEqual(jade.verifyCalls.first?.derivationPath, receiveAddress.path)
+ XCTAssertEqual(jade.verifyCalls.first?.expectedAddress, receiveAddress.address)
+ }
+
+ func testReportsAJadeAddressMismatch() async {
+ jade.storedDevices = [makeJadeEntry()]
+ jade.verifyErrors = [Bitkit.AppError(error: JadeError.AddressMismatch(expected: "bcrt1qreceive", returned: "bcrt1qother"))]
+ let manager = makeManager()
+
+ do {
+ try await manager.verifyReceiveAddress(walletId: jadeWalletId, receiveAddress: makeReceiveAddress())
+ XCTFail("expected the mismatch to be reported")
+ } catch {
+ XCTAssertEqual(error.localizedDescription, t("hardware__verify_address_error"))
+ }
+ XCTAssertTrue(jade.staleDisconnects.isEmpty, "a mismatch is not a broken session")
+ }
+
+ func testRetriesJadeVerificationAfterAStaleSession() async throws {
+ jade.storedDevices = [makeJadeEntry()]
+ jade.verifyErrors = [JadeError.Timeout]
+ let manager = makeManager()
+
+ try await manager.verifyReceiveAddress(walletId: jadeWalletId, receiveAddress: makeReceiveAddress())
+
+ XCTAssertEqual(log.entries, [
+ "jade.ensure:\(jadeDeviceId)",
+ "jade.verify",
+ "jade.stale:\(jadeDeviceId)",
+ "jade.ensure:\(jadeDeviceId)",
+ "jade.verify",
+ ])
+ }
+
+ func testDisconnectsAfterJadeVerificationRetryFails() async {
+ jade.storedDevices = [makeJadeEntry()]
+ jade.verifyErrors = [JadeError.Timeout, Bitkit.AppError(error: JadeError.DeviceDisconnected)]
+ let manager = makeManager()
+
+ do {
+ try await manager.verifyReceiveAddress(walletId: jadeWalletId, receiveAddress: makeReceiveAddress())
+ XCTFail("expected verification to fail")
+ } catch {
+ XCTAssertTrue(error.isJadeSessionFailure())
+ }
+
+ XCTAssertEqual(jade.verifyCalls.count, 2)
+ XCTAssertEqual(jade.staleDisconnects, [jadeDeviceId, jadeDeviceId])
+ }
+
+ func testAJadeVerificationDeclinedOnTheDeviceIsNotRetried() async {
+ jade.storedDevices = [makeJadeEntry()]
+ jade.verifyErrors = [JadeError.UserCancelled]
+ let manager = makeManager()
+
+ do {
+ try await manager.verifyReceiveAddress(walletId: jadeWalletId, receiveAddress: makeReceiveAddress())
+ XCTFail("expected the cancellation to be rethrown")
+ } catch {
+ XCTAssertTrue(error.isJadeUserCancellation())
+ }
+
+ XCTAssertEqual(jade.verifyCalls.count, 1)
+ XCTAssertTrue(jade.staleDisconnects.isEmpty)
+ }
+
+ // MARK: - Funding
+
+ /// Without the Jade's key origins in the PSBT the device finds nothing of its own to sign.
+ func testComposingForJadeUsesItsMasterFingerprint() async throws {
+ let entry = makeJadeEntry()
+ jade.storedDevices = [entry]
+ var composedParams: ComposeParams?
+ let manager = makeManager(composeProvider: { [log] params in
+ log.record("compose")
+ composedParams = params
+ return [.success(psbt: "psbt", fee: 141, feeRate: 2, totalSpent: 1141)]
+ })
+ manager.updateDevices(knownDevices: [entry], connectedDeviceId: nil)
+
+ let funding = try await manager.composeFundingTransaction(
+ walletId: jadeWalletId,
+ address: "bcrt1qdestination",
+ sats: 1000,
+ satsPerVByte: 2
+ )
+
+ XCTAssertEqual(funding.psbt, "psbt")
+ XCTAssertEqual(composedParams?.wallet.fingerprint, "deadbeef")
+ XCTAssertEqual(composedParams?.wallet.extendedKey, "zJade")
+ XCTAssertEqual(log.entries, ["jade.ensure:\(jadeDeviceId)", "jade.fingerprint", "compose"])
+ }
+
+ /// Core keeps a link that failed mid-request marked connected, so the next attempt would reuse it.
+ func testAJadeLinkFailureReadingTheFingerprintReleasesTheStaleSession() async {
+ jade.storedDevices = [makeJadeEntry()]
+ jade.fingerprintError = JadeError.DeviceDisconnected
+ let manager = makeManager()
+
+ do {
+ _ = try await manager.composeFundingTransaction(
+ walletId: jadeWalletId,
+ address: "bcrt1qdestination",
+ sats: 1000,
+ satsPerVByte: 2
+ )
+ XCTFail("expected the link failure to be rethrown")
+ } catch {
+ XCTAssertEqual(error.underlyingJadeError, .DeviceDisconnected)
+ }
+
+ XCTAssertEqual(jade.staleDisconnects, [jadeDeviceId])
+ XCTAssertEqual(log.entries, ["jade.ensure:\(jadeDeviceId)", "jade.fingerprint", "jade.stale:\(jadeDeviceId)"])
+ }
+
+ func testSignFundingRefusesAJadeSessionOfAnotherWallet() async {
+ jade.storedDevices = [makeJadeEntry()]
+ jade.connectedDeviceId = jadeDeviceId
+ jade.connectedWalletId = "jade:other"
+ let manager = makeManager()
+
+ do {
+ _ = try await manager.signFunding(walletId: jadeWalletId, funding: makeFunding())
+ XCTFail("expected the other wallet's session to be refused")
+ } catch {
+ XCTAssertTrue(jade.signedPsbts.isEmpty)
+ }
+ }
+
+ func testSignFundingRefusesAnUnresolvedJadeSessionOfAnotherDevice() async {
+ jade.storedDevices = [makeJadeEntry()]
+ jade.connectedDeviceId = "jade:bluetooth:AAAAAAAAAAAA"
+ jade.connectedWalletId = nil
+ let manager = makeManager()
+
+ do {
+ _ = try await manager.signFunding(walletId: jadeWalletId, funding: makeFunding())
+ XCTFail("expected another device's session to be refused")
+ } catch {
+ XCTAssertTrue(jade.signedPsbts.isEmpty)
+ }
+ }
+
+ func testSignFundingSignsOnTheJadeHoldingTheWallet() async throws {
+ jade.storedDevices = [makeJadeEntry()]
+ jade.connectedDeviceId = jadeDeviceId
+ jade.connectedWalletId = jadeWalletId
+ let manager = makeManager()
+
+ let signed = try await manager.signFunding(walletId: jadeWalletId, funding: makeFunding())
+
+ XCTAssertEqual(signed.serializedTx, "rawtx")
+ XCTAssertEqual(signed.miningFeeSats, 141)
+ XCTAssertEqual(signed.totalSpent, 43186)
+ XCTAssertEqual(jade.signedPsbts, ["psbt"])
+ }
+
+ func testSignFundingAcceptsAnUnresolvedSessionOfTheWalletsJade() async throws {
+ jade.storedDevices = [makeJadeEntry()]
+ jade.connectedDeviceId = jadeDeviceId
+ jade.connectedWalletId = nil
+ let manager = makeManager()
+
+ _ = try await manager.signFunding(walletId: jadeWalletId, funding: makeFunding())
+
+ XCTAssertEqual(jade.signedPsbts, ["psbt"])
+ }
+
+ // MARK: - Routed maintenance
+
+ func testRenameAndRemoveRouteToTheWalletsVendor() async throws {
+ trezor.storedDevices = [makeTrezorEntry()]
+ jade.storedDevices = [makeJadeEntry()]
+ let manager = makeManager()
+
+ manager.renameWallet(walletId: jadeWalletId, newName: "Cold")
+ manager.renameWallet(walletId: trezorWalletId, newName: "Hot")
+ try await manager.removeWallet(walletId: jadeWalletId, keepBackupData: false)
+ await manager.drainPendingPersists()
+
+ XCTAssertEqual(jade.renameCalls.map(\.walletId), [jadeWalletId])
+ XCTAssertEqual(jade.renameCalls.map(\.newName), ["Cold"])
+ XCTAssertEqual(trezor.renameCalls.map(\.walletId), [trezorWalletId])
+ XCTAssertEqual(jade.forgottenWalletIds, [jadeWalletId])
+ XCTAssertTrue(trezor.forgottenWalletIds.isEmpty)
+ }
+
+ func testWarmUpSkipsWhileTheOtherVendorIsActive() {
+ jade.storedDevices = [makeJadeEntry()]
+ trezor.isSessionActive = true
+ let manager = makeManager()
+
+ manager.warmUpConnection(walletId: jadeWalletId)
+ XCTAssertTrue(jade.warmUpCalls.isEmpty)
+ XCTAssertEqual(trezor.releaseCalls, 0, "a warm-up never takes the radio from the other vendor")
+
+ trezor.isSessionActive = false
+ manager.warmUpConnection(walletId: jadeWalletId)
+ XCTAssertEqual(jade.warmUpCalls, [jadeDeviceId])
+ XCTAssertTrue(trezor.warmUpCalls.isEmpty)
+ }
+
+ func testStaleSessionCleanupRoutesToTheWalletsVendor() async {
+ trezor.storedDevices = [makeTrezorEntry()]
+ jade.storedDevices = [makeJadeEntry()]
+ let manager = makeManager()
+
+ await manager.disconnectStaleSession(walletId: jadeWalletId)
+ await manager.disconnectStaleSession(walletId: trezorWalletId)
+
+ XCTAssertEqual(log.entries, ["jade.stale:\(jadeDeviceId)", "trezor.stale:\(trezorDeviceId)"])
+ }
+
+ /// The wallet can be forgotten before a scheduled cleanup runs, which would leave nothing to read
+ /// its vendor from.
+ func testScheduledCleanupReachesTheVendorItWasScheduledFor() async {
+ jade.storedDevices = [makeJadeEntry()]
+ let manager = makeManager()
+
+ manager.scheduleStaleSessionCleanup(walletId: jadeWalletId)
+ jade.storedDevices = []
+ await waitUntil { !self.jade.staleDisconnects.isEmpty }
+
+ XCTAssertEqual(jade.staleDisconnects, [jadeDeviceId])
+ XCTAssertTrue(trezor.staleDisconnects.isEmpty)
+ }
+
+ func testKnownBluetoothDeviceIsAskedOfTheWalletsVendor() {
+ trezor.storedDevices = [makeTrezorEntry()]
+ jade.storedDevices = [makeJadeEntry()]
+ jade.knownBluetoothIds = [jadeDeviceId]
+ let manager = makeManager()
+
+ XCTAssertTrue(manager.isKnownBluetoothDevice(walletId: jadeWalletId))
+ XCTAssertFalse(manager.isKnownBluetoothDevice(walletId: trezorWalletId))
+
+ trezor.knownBluetoothIds = [trezorDeviceId]
+ XCTAssertTrue(manager.isKnownBluetoothDevice(walletId: trezorWalletId))
+ }
+
+ // MARK: - App lifecycle
+
+ func testLifecycleHooksReachTheJade() async {
+ let manager = makeManager()
+
+ manager.onAppBackgrounded()
+ manager.onAppBecameActive()
+
+ XCTAssertEqual(log.entries, ["jade.backgrounded", "jade.active"])
+ }
+
+ /// A Trezor reconnect still running would otherwise save its device back after the wipe.
+ func testWipeResetsBothVendors() async {
+ trezor.isSessionActive = true
+ let manager = makeManager()
+
+ await manager.resetForWipe()
+
+ XCTAssertEqual(log.entries, ["trezor.resetForWipe", "jade.resetForWipe"])
+ }
+
+ // MARK: - Helpers
+
+ private func makeManager(
+ addressProvider: @escaping HwWalletManager.AddressProvider = { _ in throw TrezorError.DeviceDisconnected },
+ composeProvider: @escaping HwWalletManager.ComposeProvider = { _ in [] }
+ ) -> HwWalletManager {
+ HwWalletManager(
+ trezorSession: trezor,
+ jadeSession: jade,
+ jadeBluetoothPoweredOn: jadeBluetoothPoweredOn.eraseToAnyPublisher(),
+ watcherService: NoopWatcher(),
+ monitoredTypes: { ["nativeSegwit"] },
+ electrumUrl: { "ssl://test:1" },
+ network: { .regtest },
+ addressProvider: addressProvider,
+ composeProvider: composeProvider,
+ persistSnapshot: { _ in },
+ deleteActivities: { _ in }
+ )
+ }
+
+ private func makeTrezorEntry(
+ xpubs: [String: String] = ["nativeSegwit": "zTrezor"],
+ walletId: String? = "trezor:wallet",
+ lastConnectedAt: TimeInterval = 1000
+ ) -> HwKnownDevice {
+ HwKnownDevice(
+ id: trezorDeviceId,
+ name: "Trezor",
+ path: "ble:\(trezorDeviceId)",
+ transportType: "bluetooth",
+ model: "Safe 5",
+ lastConnectedAt: Date(timeIntervalSince1970: lastConnectedAt),
+ xpubs: xpubs,
+ walletId: walletId
+ )
+ }
+
+ private func makeJadeEntry(
+ xpubs: [String: String] = ["nativeSegwit": "zJade"],
+ walletId: String? = "jade:wallet",
+ lastConnectedAt: TimeInterval = 1000
+ ) -> HwKnownDevice {
+ HwKnownDevice(
+ id: jadeDeviceId,
+ name: "Jade 8F6B64",
+ path: "ble:jade",
+ transportType: "bluetooth",
+ model: "Jade",
+ lastConnectedAt: Date(timeIntervalSince1970: lastConnectedAt),
+ xpubs: xpubs,
+ walletId: walletId,
+ vendor: .blockstream,
+ jadeDeviceId: "246F288F6B64"
+ )
+ }
+
+ private func makeReceiveAddress() -> HwReceiveAddress {
+ HwReceiveAddress(address: "bcrt1qreceive", path: "m/84'/1'/0'/0/7", addressType: .nativeSegwit)
+ }
+
+ private func makeFunding() -> HwFundingTransaction {
+ HwFundingTransaction(psbt: "psbt", miningFeeSats: 141, feeRate: 1, totalSpent: 43186, satsPerVByte: 1)
+ }
+
+ /// Lets a launched reconnect run, so a test can assert that one was not launched.
+ private func settle() async {
+ for _ in 0 ..< 10 {
+ await Task.yield()
+ }
+ }
+
+ private func waitUntil(timeout: TimeInterval = 2, _ condition: () -> Bool) async {
+ let deadline = Date().addingTimeInterval(timeout)
+ while !condition(), Date() < deadline {
+ try? await Task.sleep(nanoseconds: 10_000_000)
+ }
+ }
+
+ private func assertThrows(
+ _ expected: HwPassphraseError,
+ file: StaticString = #filePath,
+ line: UInt = #line,
+ _ operation: () async throws -> Void
+ ) async {
+ do {
+ try await operation()
+ XCTFail("expected \(expected)", file: file, line: line)
+ } catch let error as HwPassphraseError {
+ XCTAssertEqual(error, expected, file: file, line: line)
+ } catch {
+ XCTFail("expected \(expected), got \(error)", file: file, line: line)
+ }
+ }
+}
diff --git a/BitkitTests/HwWalletNameTests.swift b/BitkitTests/HwWalletNameTests.swift
index 5b2f28fc3..3cbc47a8e 100644
--- a/BitkitTests/HwWalletNameTests.swift
+++ b/BitkitTests/HwWalletNameTests.swift
@@ -46,4 +46,48 @@ final class HwWalletNameTests: XCTestCase {
"Trezor Safe 5"
)
}
+
+ // MARK: - Jade
+
+ func testJadeUsesItsModel() {
+ XCTAssertEqual(resolveHwWalletName(label: nil, model: "Jade Plus", vendor: .blockstream), "Jade Plus")
+ }
+
+ func testJadeFallsBackToJadeWithoutModel() {
+ XCTAssertEqual(resolveHwWalletName(label: nil, model: nil, vendor: .blockstream), "Jade")
+ }
+
+ func testJadeBlankModelFallsBackToJade() {
+ XCTAssertEqual(resolveHwWalletName(label: nil, model: " ", vendor: .blockstream), "Jade")
+ }
+
+ func testJadeIgnoresTheDeviceLabel() {
+ XCTAssertEqual(resolveHwWalletName(label: "Jade 8F6B64", model: "Jade", vendor: .blockstream), "Jade")
+ }
+
+ func testJadeCustomLabelWins() {
+ XCTAssertEqual(
+ resolveHwWalletName(label: nil, model: "Jade", customLabel: "Travel", vendor: .blockstream),
+ "Travel"
+ )
+ }
+
+ func testJadeModelIsNotPrefixedWithTrezor() {
+ XCTAssertEqual(resolveHwWalletName(label: nil, model: "Jade", vendor: .blockstream), "Jade")
+ }
+
+ func testAJadeEntryIsNamedAsAJade() {
+ let entry = HwKnownDevice(
+ id: "jade:bluetooth:aabbcc",
+ name: "Jade AABBCC",
+ path: "ble:jade",
+ transportType: "bluetooth",
+ label: "Jade AABBCC",
+ model: "Jade Plus",
+ lastConnectedAt: Date(timeIntervalSince1970: 0),
+ vendor: .blockstream
+ )
+
+ XCTAssertEqual(entry.displayName, "Jade Plus")
+ }
}
diff --git a/BitkitTests/HwWalletVendorPresentationTests.swift b/BitkitTests/HwWalletVendorPresentationTests.swift
new file mode 100644
index 000000000..80f1c4edc
--- /dev/null
+++ b/BitkitTests/HwWalletVendorPresentationTests.swift
@@ -0,0 +1,79 @@
+@testable import Bitkit
+import UIKit
+import XCTest
+
+/// Each vendor's illustrations resolve in the app bundle and its copy comes from its own keys, so a
+/// Jade never shows Trezor art or wording.
+final class HwWalletVendorPresentationTests: XCTestCase {
+ func testTheDeviceIllustrationsResolveInTheAppBundle() {
+ for name in ["jade-placeholder", "trezor-device", "trezor-card"] {
+ XCTAssertNotNil(UIImage(named: name), name)
+ }
+ for vendor in HwWalletVendor.allCases {
+ XCTAssertNotNil(UIImage(named: vendor.deviceImageName), "\(vendor)")
+ XCTAssertNotNil(UIImage(named: vendor.signImageName), "\(vendor)")
+ }
+ }
+
+ func testTheJadePlaceholderKeepsItsVectorSize() throws {
+ let image = try XCTUnwrap(UIImage(named: "jade-placeholder"))
+
+ XCTAssertEqual(image.size, CGSize(width: 256, height: 256))
+ }
+
+ func testTrezorKeepsItsArtAndCopy() {
+ let vendor = HwWalletVendor.trezor
+
+ XCTAssertEqual(vendor.deviceImageName, "trezor-device")
+ XCTAssertEqual(vendor.signImageName, "trezor-card")
+ XCTAssertEqual(vendor.modelName, t("hardware__device_model_trezor"))
+ XCTAssertEqual(vendor.foundHeader, t("hardware__found_header"))
+ XCTAssertEqual(vendor.pairedHeader, t("hardware__paired_header"))
+ XCTAssertEqual(vendor.sendSignButtonTitle, t("hardware__send_open_connect"))
+ XCTAssertEqual(vendor.transferSignButtonTitle, t("lightning__transfer_hw__open_connect"))
+ XCTAssertTrue(vendor.supportsPassphraseWallets)
+ }
+
+ func testJadeHasItsOwnArtAndCopy() {
+ let vendor = HwWalletVendor.blockstream
+
+ XCTAssertEqual(vendor.deviceImageName, "jade-placeholder")
+ XCTAssertEqual(vendor.signImageName, "jade-placeholder")
+ XCTAssertEqual(vendor.modelName, t("hardware__device_model_jade"))
+ XCTAssertEqual(vendor.modelName, "Jade")
+ XCTAssertEqual(vendor.foundHeader, t("hardware__found_header_jade"))
+ XCTAssertEqual(vendor.pairedHeader, t("hardware__paired_header_jade"))
+ XCTAssertEqual(vendor.sendSignButtonTitle, t("hardware__send_open_connect_jade"))
+ XCTAssertEqual(vendor.transferSignButtonTitle, t("hardware__send_open_connect_jade"))
+ XCTAssertFalse(vendor.supportsPassphraseWallets)
+ }
+
+ func testNoJadeCopyMentionsTrezor() {
+ let vendor = HwWalletVendor.blockstream
+ let copy = [vendor.modelName, vendor.foundHeader, vendor.pairedHeader, vendor.sendSignButtonTitle, vendor.transferSignButtonTitle]
+
+ for text in copy {
+ XCTAssertFalse(text.contains("Trezor"), text)
+ XCTAssertTrue(text.contains("Jade"), text)
+ }
+ }
+
+ /// The shared e2e helper taps `Tab-trezor`, so a Trezor wallet's receive tab keeps its name.
+ func testTheHardwareReceiveTabIsNamedAfterTheWalletVendor() {
+ let trezorTab = TabItem(ReceiveQr.ReceiveTab.hardware, label: HwWalletVendor.trezor.modelName)
+ let jadeTab = TabItem(ReceiveQr.ReceiveTab.hardware, label: HwWalletVendor.blockstream.modelName)
+
+ XCTAssertEqual(trezorTab.title, "Trezor")
+ XCTAssertEqual(trezorTab.resolvedAccessibilityIdentifier, "Tab-trezor")
+ XCTAssertEqual(jadeTab.title, "Jade")
+ XCTAssertEqual(jadeTab.resolvedAccessibilityIdentifier, "Tab-jade")
+ }
+
+ func testTheHardwareReceiveTabWithoutAWalletIsNamedHardware() {
+ let tab = TabItem(ReceiveQr.ReceiveTab.hardware)
+
+ XCTAssertEqual(tab.title, t("hardware__receive_tab_hardware"))
+ XCTAssertEqual(tab.title, "Hardware")
+ XCTAssertEqual(tab.resolvedAccessibilityIdentifier, "Tab-hardware")
+ }
+}
diff --git a/BitkitTests/JadeBLELinkStateTests.swift b/BitkitTests/JadeBLELinkStateTests.swift
new file mode 100644
index 000000000..fb51d2656
--- /dev/null
+++ b/BitkitTests/JadeBLELinkStateTests.swift
@@ -0,0 +1,262 @@
+@testable import Bitkit
+import XCTest
+
+final class JadeBLELinkStateTests: XCTestCase {
+ private final class Outcomes: @unchecked Sendable {
+ private let lock = NSLock()
+ private var results: [String: Result?] = [:]
+ private var reads: [JadeBLELinkState.ReadOutcome] = []
+
+ func record(_ name: String, _ result: Result?) {
+ lock.withLock { results[name] = result }
+ }
+
+ func recordRead(_ outcome: JadeBLELinkState.ReadOutcome) {
+ lock.withLock { reads.append(outcome) }
+ }
+
+ func error(of name: String) -> JadeBLEError? {
+ lock.withLock {
+ guard case let .failure(error)?? = results[name] else { return nil }
+ return error as? JadeBLEError
+ }
+ }
+
+ var readOutcomes: [JadeBLELinkState.ReadOutcome] {
+ lock.withLock { reads }
+ }
+ }
+
+ private func makeConnectedLink() -> JadeBLELinkState {
+ let state = JadeBLELinkState(generation: 1)
+ _ = state.begin(.connect)
+ XCTAssertTrue(state.markConnected())
+ return state
+ }
+
+ private func failure(of waiter: BLEOneShot, timeout: TimeInterval = 0) -> JadeBLEError? {
+ guard case let .failure(error)? = waiter.wait(timeout: timeout) else { return nil }
+ return error as? JadeBLEError
+ }
+
+ private func succeeded(_ waiter: BLEOneShot, timeout: TimeInterval = 0) -> Bool {
+ guard case .success? = waiter.wait(timeout: timeout) else { return false }
+ return true
+ }
+
+ // MARK: - Closing
+
+ func testClosingReleasesEveryPendingWaiter() {
+ let state = JadeBLELinkState(generation: 1)
+ let waiters: [(String, BLEOneShot)] = [
+ ("connect", state.begin(.connect)),
+ ("subscribe", state.begin(.subscribe)),
+ ("write", state.begin(.write)),
+ ]
+ let outcomes = Outcomes()
+ let returned = expectation(description: "every blocked thread returned")
+ returned.expectedFulfillmentCount = waiters.count + 1
+
+ for (name, waiter) in waiters {
+ DispatchQueue.global().async {
+ outcomes.record(name, waiter.wait(timeout: 10))
+ returned.fulfill()
+ }
+ }
+ DispatchQueue.global().async {
+ outcomes.recordRead(state.read(timeout: 10))
+ returned.fulfill()
+ }
+ Thread.sleep(forTimeInterval: 0.1)
+
+ XCTAssertTrue(state.beginClosing())
+ wait(for: [returned], timeout: 1)
+
+ for (name, _) in waiters {
+ XCTAssertEqual(outcomes.error(of: name), .closed, name)
+ }
+ XCTAssertEqual(outcomes.readOutcomes, [.down])
+ }
+
+ func testCloseIsIdempotent() {
+ let state = makeConnectedLink()
+
+ XCTAssertTrue(state.beginClosing())
+ XCTAssertFalse(state.beginClosing())
+ XCTAssertTrue(state.isClosing)
+ }
+
+ func testSecondCloserWaitsForTheFirstToFinish() {
+ let state = makeConnectedLink()
+ XCTAssertTrue(state.beginClosing())
+
+ XCTAssertFalse(state.waitUntilClosed(timeout: 0.05))
+ state.finishClosing()
+ XCTAssertTrue(state.waitUntilClosed(timeout: 0))
+ }
+
+ func testClosingKeepsTheDisconnectWaiter() {
+ let state = makeConnectedLink()
+ let disconnected = state.begin(.disconnect)
+
+ XCTAssertTrue(state.beginClosing())
+ XCTAssertNil(disconnected.wait(timeout: 0))
+
+ XCTAssertFalse(state.markDown(reason: JadeBLEError.disconnected))
+ XCTAssertTrue(succeeded(disconnected))
+ }
+
+ func testClosingLinkIsNeverReady() throws {
+ let state = makeConnectedLink()
+ try state.markReady(chunkSize: 182)
+ XCTAssertTrue(state.isReady)
+ XCTAssertTrue(state.isUsable)
+ XCTAssertEqual(state.chunkSize, 182)
+
+ XCTAssertTrue(state.beginClosing())
+
+ XCTAssertFalse(state.isReady)
+ XCTAssertFalse(state.isUsable)
+ XCTAssertFalse(state.reuseIfUsable())
+ XCTAssertThrowsError(try state.markReady(chunkSize: 182)) { XCTAssertEqual($0 as? JadeBLEError, .closed) }
+ XCTAssertEqual(failure(of: state.begin(.write)), .closed)
+ }
+
+ func testReadyLinkCanBeReused() throws {
+ let state = makeConnectedLink()
+ try state.markReady(chunkSize: 20)
+ state.enqueue(Data([0x01]))
+
+ XCTAssertTrue(state.reuseIfUsable())
+ XCTAssertEqual(state.read(timeout: 0.01), .empty)
+ }
+
+ // MARK: - Reads
+
+ func testReadReturnsEmptyAfterTimeout() {
+ let state = makeConnectedLink()
+ let start = Date()
+
+ XCTAssertEqual(state.read(timeout: 0.05), .empty)
+ XCTAssertGreaterThanOrEqual(Date().timeIntervalSince(start), 0.04)
+ }
+
+ func testNotificationsReturnedInOrderJoined() {
+ let state = makeConnectedLink()
+ state.enqueue(Data([0x01, 0x02]))
+ state.enqueue(Data([0x03]))
+ state.enqueue(Data([0x04, 0x05]))
+
+ XCTAssertEqual(state.read(timeout: 0.05), .data(Data([0x01, 0x02, 0x03, 0x04, 0x05])))
+ XCTAssertEqual(state.read(timeout: 0.01), .empty)
+ }
+
+ func testEmptyNotificationsAndNotificationsAfterClosingAreIgnored() {
+ let state = makeConnectedLink()
+ state.enqueue(Data())
+ XCTAssertEqual(state.read(timeout: 0.01), .empty)
+
+ XCTAssertTrue(state.beginClosing())
+ state.enqueue(Data([0x01]))
+ XCTAssertEqual(state.read(timeout: 0.01), .down)
+ }
+
+ func testReadReportsDownAfterLinkDrops() {
+ let state = makeConnectedLink()
+ state.enqueue(Data([0x01]))
+
+ XCTAssertTrue(state.markDown(reason: JadeBLEError.disconnected))
+
+ XCTAssertEqual(state.read(timeout: 0.05), .down)
+ XCTAssertFalse(state.isLinkUp)
+ }
+
+ // MARK: - Drops
+
+ func testDropDuringSetupFailsWaitersWithTheReason() {
+ let state = makeConnectedLink()
+ let subscribed = state.begin(.subscribe)
+ let disconnected = state.begin(.disconnect)
+
+ XCTAssertTrue(state.markDown(reason: JadeBLEError.staleBond))
+
+ XCTAssertEqual(failure(of: subscribed), .staleBond)
+ XCTAssertTrue(succeeded(disconnected))
+ XCTAssertEqual(failure(of: state.begin(.write)), .staleBond)
+ XCTAssertTrue(succeeded(state.begin(.disconnect)))
+ XCTAssertThrowsError(try state.markReady(chunkSize: 20)) { XCTAssertEqual($0 as? JadeBLEError, .staleBond) }
+ }
+
+ func testDropWhileClosingIsNotExternal() {
+ let state = makeConnectedLink()
+ XCTAssertTrue(state.beginClosing())
+
+ XCTAssertFalse(state.markDown(reason: JadeBLEError.disconnected))
+ }
+
+ // MARK: - Waiters
+
+ func testTimedOutWaiterIgnoresLateResolution() {
+ let state = makeConnectedLink()
+ let written = state.begin(.write)
+ XCTAssertNil(written.wait(timeout: 0.01))
+
+ state.abandon(.write, written)
+
+ XCTAssertFalse(state.resolve(.write, error: nil))
+ XCTAssertFalse(written.isResolved)
+ }
+
+ func testAbandoningAnOlderWaiterKeepsTheNewerOne() {
+ let state = makeConnectedLink()
+ let older = state.begin(.subscribe)
+ let newer = state.begin(.subscribe)
+ XCTAssertEqual(failure(of: older), .closed)
+
+ state.abandon(.subscribe, older)
+
+ XCTAssertTrue(state.resolve(.subscribe, error: nil))
+ XCTAssertTrue(succeeded(newer))
+ }
+
+ func testResolutionCarriesTheError() {
+ let state = makeConnectedLink()
+ let written = state.begin(.write)
+
+ XCTAssertTrue(state.resolve(.write, error: JadeBLEError.writeFailed("busy")))
+
+ XCTAssertEqual(failure(of: written), .writeFailed("busy"))
+ }
+
+ func testConnectNobodyWaitsForIsNotMarkedUp() {
+ let state = JadeBLELinkState(generation: 1)
+
+ XCTAssertFalse(state.markConnected())
+ XCTAssertFalse(state.isLinkUp)
+
+ let connected = state.begin(.connect)
+ XCTAssertTrue(state.beginClosing())
+ XCTAssertEqual(failure(of: connected), .closed)
+ XCTAssertFalse(state.markConnected())
+ XCTAssertFalse(state.isLinkUp)
+ }
+
+ func testFirstResolutionWins() {
+ let waiter = BLEOneShot()
+
+ waiter.resolve(.failure(JadeBLEError.writeTimeout))
+ waiter.resolve(.success(()))
+
+ XCTAssertEqual(failure(of: waiter), .writeTimeout)
+ XCTAssertEqual(failure(of: waiter), .writeTimeout)
+ }
+
+ func testWritesAreCounted() {
+ let state = makeConnectedLink()
+
+ state.recordWrite()
+ state.recordWrite()
+
+ XCTAssertEqual(state.writesCompleted, 2)
+ }
+}
diff --git a/BitkitTests/JadeDeviceIdentityTests.swift b/BitkitTests/JadeDeviceIdentityTests.swift
new file mode 100644
index 000000000..61bbafc0e
--- /dev/null
+++ b/BitkitTests/JadeDeviceIdentityTests.swift
@@ -0,0 +1,117 @@
+@testable import Bitkit
+import BitkitCore
+import LDKNode
+import XCTest
+
+final class JadeDeviceIdentityTests: XCTestCase {
+ // MARK: - Device id and model
+
+ func testTheDeviceIdCarriesTheEfuseMacInTheJadeNamespace() {
+ XCTAssertEqual(JadeDeviceIdentity.deviceId(efuseMac: "246F288F6B64"), "jade:bluetooth:246F288F6B64")
+ }
+
+ func testThereIsNoDeviceIdWithoutAnEfuseMac() {
+ XCTAssertNil(JadeDeviceIdentity.deviceId(efuseMac: nil))
+ XCTAssertNil(JadeDeviceIdentity.deviceId(efuseMac: ""))
+ XCTAssertNil(JadeDeviceIdentity.deviceId(efuseMac: " "))
+ }
+
+ func testAV2BoardIsAJadePlus() {
+ XCTAssertEqual(JadeDeviceIdentity.model(boardType: "JADE_V2"), "Jade Plus")
+ XCTAssertEqual(JadeDeviceIdentity.model(boardType: "jade_v2"), "Jade Plus")
+ }
+
+ func testEveryOtherBoardIsTheOriginalJade() {
+ XCTAssertEqual(JadeDeviceIdentity.model(boardType: "JADE_V1_1"), "Jade")
+ XCTAssertEqual(JadeDeviceIdentity.model(boardType: nil), "Jade")
+ }
+
+ // MARK: - Advertised name
+
+ func testAJadeIsRecognisedByTheSuffixOfItsAdvertisedName() {
+ XCTAssertTrue(JadeDeviceIdentity.advertises("Jade 8F6B64", jadeDeviceId: "246F288F6B64"))
+ XCTAssertTrue(JadeDeviceIdentity.advertises("jade 8f6b64", jadeDeviceId: "246F288F6B64"))
+ }
+
+ func testAnotherNameOrNoNameIsNotTheJade() {
+ XCTAssertFalse(JadeDeviceIdentity.advertises("Jade AAAAAA", jadeDeviceId: "246F288F6B64"))
+ XCTAssertFalse(JadeDeviceIdentity.advertises(nil, jadeDeviceId: "246F288F6B64"))
+ }
+
+ func testAnIdTooShortOrBlankNeverMatches() {
+ XCTAssertFalse(JadeDeviceIdentity.advertises("Jade 8F6B", jadeDeviceId: "8F6B"))
+ XCTAssertFalse(JadeDeviceIdentity.advertises("Jade", jadeDeviceId: nil))
+ XCTAssertFalse(JadeDeviceIdentity.advertises("Jade ", jadeDeviceId: " "))
+ }
+
+ func testAPairedEntryIsTheSameJadeUnderItsPathOrItsName() {
+ let entry = HwKnownDevice(
+ id: "jade:bluetooth:246F288F6B64",
+ name: "Jade 8F6B64",
+ path: "ble:old",
+ transportType: "bluetooth",
+ lastConnectedAt: Date(),
+ vendor: .blockstream,
+ jadeDeviceId: "246F288F6B64"
+ )
+
+ XCTAssertTrue(entry.isSameJade(as: JadeDeviceInfo(path: "ble:old", transport: .bluetooth, name: nil, serialNumber: nil)))
+ XCTAssertTrue(entry.isSameJade(as: JadeDeviceInfo(path: "ble:new", transport: .bluetooth, name: "Jade 8F6B64", serialNumber: nil)))
+ XCTAssertFalse(entry.isSameJade(as: JadeDeviceInfo(path: "ble:new", transport: .bluetooth, name: "Jade AAAAAA", serialNumber: nil)))
+ XCTAssertTrue(entry.matches(deviceId: "ble:old"))
+ XCTAssertTrue(entry.matches(deviceId: "jade:bluetooth:246F288F6B64"))
+ XCTAssertFalse(entry.matches(deviceId: "ble:new"))
+ }
+
+ // MARK: - Session state
+
+ func testOnlyAReadyOrTemporaryJadeIsUnlocked() {
+ XCTAssertTrue(JadeState.ready.isUnlocked)
+ XCTAssertTrue(JadeState.temp.isUnlocked)
+ for state in [JadeState.locked, .uninit, .unsaved, .unknown] {
+ XCTAssertFalse(state.isUnlocked, "\(state)")
+ }
+ }
+
+ func testAConnectedJadeReportsItsLockAndModel() {
+ let session = ConnectedJadeDevice(
+ id: "jade:bluetooth:246F288F6B64",
+ path: "ble:path",
+ versionInfo: JadeFixtures.version(.locked),
+ walletId: nil
+ )
+
+ XCTAssertTrue(session.isLocked)
+ XCTAssertEqual(session.model, "Jade")
+ XCTAssertTrue(session.matches("ble:path"))
+ XCTAssertTrue(session.matches("jade:bluetooth:246F288F6B64"))
+ XCTAssertFalse(session.matches("ble:other"))
+ }
+
+ // MARK: - Network and address variants
+
+ func testNetworksMapToTheirJadeNetwork() throws {
+ XCTAssertEqual(try LDKNode.Network.bitcoin.toJadeNetwork(), .mainnet)
+ XCTAssertEqual(try LDKNode.Network.testnet.toJadeNetwork(), .testnet)
+ XCTAssertEqual(try LDKNode.Network.regtest.toJadeNetwork(), .regtest)
+ }
+
+ func testSignetIsNotSupportedByJade() {
+ XCTAssertThrowsError(try LDKNode.Network.signet.toJadeNetwork()) { error in
+ XCTAssertEqual((error as? Bitkit.AppError)?.message, "Signet is not supported by Jade")
+ }
+ }
+
+ func testEveryAddressTypeRoundTripsThroughItsJadeVariant() {
+ for addressType in AddressScriptType.allAddressTypes {
+ XCTAssertEqual(AddressScriptType(jadeVariant: addressType.jadeVariant), addressType, "\(addressType)")
+ }
+ }
+
+ func testAddressVariantsMatchCore() {
+ XCTAssertEqual(AddressScriptType.nativeSegwit.jadeVariant, .wpkh)
+ for addressType in AddressScriptType.allAddressTypes {
+ XCTAssertEqual(addressType.jadeVariant, jadeAccountTypeToVariant(accountType: addressType.accountType), "\(addressType)")
+ }
+ }
+}
diff --git a/BitkitTests/JadeManagerTests.swift b/BitkitTests/JadeManagerTests.swift
new file mode 100644
index 000000000..f76b8e5e9
--- /dev/null
+++ b/BitkitTests/JadeManagerTests.swift
@@ -0,0 +1,1009 @@
+@testable import Bitkit
+import BitkitCore
+import XCTest
+
+/// Ports bitkit-android's `JadeRepoTest` to `JadeManager` (cases 1-8, 11-19 and 22-27; the USB-only
+/// cases 9, 10 and 20 have no iOS counterpart, and case 21 becomes the background expiration case),
+/// then covers what iOS adds: teardown ordering against core, the connect epoch, the in-progress
+/// flags and the background budget. Core and the transport are fakes writing to one ordered log.
+@MainActor
+final class JadeManagerTests: XCTestCase {
+ private nonisolated static let fastTiming = JadeManager.Timing(
+ backgroundRelease: 0.05,
+ expirationMargin: 0.01,
+ reconnectBackoff: 0.02,
+ reconnectAttempts: 4,
+ connectPollInterval: 0.01,
+ connectMaxWait: 2
+ )
+
+ private var log: JadeCallLog!
+ private var service: FakeJadeService!
+ private var transport: FakeJadeTransportControl!
+ private var store: InMemoryJadeKnownDeviceStore!
+ private var backgroundTasks: FakeBackgroundTasks!
+
+ override func setUp() {
+ super.setUp()
+ log = JadeCallLog()
+ service = FakeJadeService(log: log)
+ transport = FakeJadeTransportControl(log: log)
+ store = InMemoryJadeKnownDeviceStore()
+ backgroundTasks = FakeBackgroundTasks()
+ }
+
+ override func tearDown() {
+ backgroundTasks = nil
+ store = nil
+ transport = nil
+ service = nil
+ log = nil
+ super.tearDown()
+ }
+
+ // MARK: - Scan and pair (Android cases 1-6)
+
+ func testScanListsTheLastDevicesWhileASessionIsOpen() async throws {
+ service.stubs.isConnected = true
+ service.stubs.listed = [JadeFixtures.device()]
+ let sut = makeManager()
+
+ let devices = try await sut.scan()
+
+ XCTAssertEqual(devices, [JadeFixtures.device()])
+ XCTAssertFalse(log.contains("service.scan"))
+ XCTAssertEqual(sut.nearbyDevices, [JadeFixtures.device()])
+ }
+
+ func testPairingUnlocksALockedJadeReadsItsAccountsAndStoresTheEntry() async throws {
+ service.stubs.scanned = [JadeFixtures.device()]
+ service.stubs.connectResult = .success(JadeFixtures.version(.locked))
+ let sut = makeManager()
+ _ = try await sut.scan()
+
+ let connected = try await sut.connect(path: JadeFixtures.blePath)
+
+ XCTAssertEqual(service.calls.unlockNetworks, [.regtest])
+ XCTAssertEqual(service.calls.exportTypes, [JadeManager.allAccountTypes])
+ XCTAssertEqual(store.saves.count, 1)
+ let stored = try XCTUnwrap(store.saves.last?.first)
+ XCTAssertEqual(store.saves.last?.count, 1)
+ XCTAssertEqual(stored.id, JadeFixtures.deviceId)
+ XCTAssertEqual(stored.vendor, .blockstream)
+ XCTAssertEqual(stored.jadeDeviceId, JadeFixtures.efuseMac)
+ XCTAssertEqual(stored.path, JadeFixtures.blePath)
+ XCTAssertEqual(stored.name, JadeFixtures.advertisedName)
+ XCTAssertEqual(stored.transportType, "bluetooth")
+ XCTAssertEqual(stored.xpubs["nativeSegwit"], JadeFixtures.xpub)
+ XCTAssertEqual(stored.model, "Jade")
+ XCTAssertTrue(stored.walletId?.hasPrefix("jade:") == true, "walletId=\(stored.walletId ?? "nil")")
+ XCTAssertEqual(connected.id, stored.id)
+ XCTAssertEqual(connected.walletId, stored.walletId)
+ XCTAssertFalse(connected.isLocked)
+ XCTAssertEqual(sut.connected, connected)
+ XCTAssertEqual(sut.knownDevices, [stored])
+ XCTAssertTrue(sut.nearbyDevices.isEmpty)
+ XCTAssertFalse(sut.isConnecting)
+ XCTAssertFalse(sut.isUnlocking)
+ }
+
+ func testPairingRefusesAJadeThatHasNoWalletYet() async throws {
+ service.stubs.scanned = [JadeFixtures.device()]
+ service.stubs.connectResult = .success(JadeFixtures.version(.uninit))
+ let sut = makeManager()
+ _ = try await sut.scan()
+
+ do {
+ try await sut.connect(path: JadeFixtures.blePath)
+ XCTFail("an uninitialised Jade must be refused")
+ } catch {
+ XCTAssertEqual(error.underlyingJadeError, .DeviceUninitialized)
+ }
+
+ XCTAssertTrue(log.contains("transport.disconnect:\(JadeFixtures.blePath)"))
+ XCTAssertTrue(log.contains("service.disconnect"))
+ XCTAssertTrue(service.calls.unlockNetworks.isEmpty)
+ XCTAssertNil(sut.connected)
+ XCTAssertTrue(store.saves.isEmpty)
+ }
+
+ func testAFailedAccountExportClosesTheLinkAndTheCoreSession() async throws {
+ service.stubs.scanned = [JadeFixtures.device()]
+ service.stubs.exportHandler = { _ in throw JadeError.IoError(errorDetails: "export failed") }
+ let sut = makeManager()
+ _ = try await sut.scan()
+
+ do {
+ try await sut.connect(path: JadeFixtures.blePath)
+ XCTFail("the connect must fail with the export")
+ } catch {}
+
+ XCTAssertTrue(log.contains("transport.disconnect:\(JadeFixtures.blePath)"))
+ XCTAssertTrue(log.contains("service.disconnect"))
+ XCTAssertNil(sut.connected)
+ XCTAssertTrue(store.saves.isEmpty)
+ }
+
+ func testTheAccountsAreReadAgainWithoutTaprootOnOldFirmware() async throws {
+ service.stubs.scanned = [JadeFixtures.device()]
+ service.stubs.exportHandler = { types in
+ if types.contains(.taproot) {
+ throw Bitkit.AppError(error: JadeError.UnsupportedFirmware(installed: "1.0.30", required: "1.0.34"))
+ }
+ return JadeFixtures.accountExport()
+ }
+ let sut = makeManager()
+ _ = try await sut.scan()
+
+ try await sut.connect(path: JadeFixtures.blePath)
+
+ XCTAssertEqual(service.calls.exportTypes, [JadeManager.allAccountTypes, [.legacy, .wrappedSegwit, .nativeSegwit]])
+ XCTAssertNotNil(sut.connected)
+ }
+
+ func testAJadeReachedUnderANewPathRefreshesItsStoredEntry() async throws {
+ store.devices = [JadeFixtures.knownEntry(path: JadeFixtures.stalePath)]
+ service.stubs.scanned = [JadeFixtures.device(path: JadeFixtures.readvertisedPath)]
+ let sut = makeManager()
+ _ = try await sut.scan()
+
+ try await sut.connect(path: JadeFixtures.readvertisedPath)
+
+ let saved = try XCTUnwrap(store.saves.last)
+ XCTAssertEqual(saved.count, 1)
+ XCTAssertEqual(saved.first?.id, JadeFixtures.deviceId)
+ XCTAssertEqual(saved.first?.path, JadeFixtures.readvertisedPath)
+ XCTAssertEqual(saved.first?.walletId, JadeFixtures.walletId)
+ }
+
+ // MARK: - Known device reconnects (Android cases 7, 8 and 11-18)
+
+ func testReconnectingAKnownJadeRejectsADifferentDevice() async throws {
+ store.devices = [JadeFixtures.knownEntry()]
+ service.stubs.scanned = [JadeFixtures.device()]
+ service.stubs.connectResult = .success(JadeFixtures.version(.ready, efuseMac: "other"))
+ let sut = makeManager()
+
+ do {
+ try await sut.connectKnownDevice(deviceId: JadeFixtures.deviceId)
+ XCTFail("a different Jade must be rejected")
+ } catch {}
+
+ XCTAssertGreaterThanOrEqual(log.count("service.disconnect"), 1)
+ XCTAssertTrue(service.calls.exportTypes.isEmpty)
+ XCTAssertNil(sut.connected)
+ }
+
+ func testReconnectingAKnownJadeRejectsADeviceReportingNoEfuseMac() async throws {
+ store.devices = [JadeFixtures.knownEntry()]
+ service.stubs.scanned = [JadeFixtures.device()]
+ service.stubs.connectResult = .success(JadeFixtures.version(.ready, efuseMac: nil))
+ let sut = makeManager()
+
+ do {
+ try await sut.connectKnownDevice(deviceId: JadeFixtures.deviceId)
+ XCTFail("a Jade that cannot prove its identity must be rejected")
+ } catch {}
+
+ XCTAssertGreaterThanOrEqual(log.count("service.disconnect"), 1)
+ XCTAssertTrue(service.calls.exportTypes.isEmpty)
+ XCTAssertNil(sut.connected)
+ }
+
+ func testAWrongJadeIsRejectedBeforeThePinPrompt() async throws {
+ store.devices = [JadeFixtures.knownEntry()]
+ service.stubs.scanned = [JadeFixtures.device()]
+ service.stubs.connectResult = .success(JadeFixtures.version(.locked, efuseMac: "other"))
+ let sut = makeManager()
+
+ do {
+ try await sut.connectKnownDevice(deviceId: JadeFixtures.deviceId)
+ XCTFail("a different Jade must be rejected")
+ } catch {}
+
+ XCTAssertTrue(service.calls.unlockNetworks.isEmpty)
+ XCTAssertFalse(sut.isUnlocking)
+ }
+
+ func testCancellingAPendingConnectionClosesTheLinkThenCancelsThenDisconnects() async {
+ store.devices = [JadeFixtures.knownEntry()]
+ let sut = makeManager()
+
+ await sut.cancelPendingConnection(deviceId: JadeFixtures.deviceId)
+
+ XCTAssertTrue(log.contains("transport.disconnect:\(JadeFixtures.blePath)"))
+ XCTAssertEqual(log.entries.filter { $0 == "service.cancel" || $0 == "service.disconnect" }, ["service.cancel", "service.disconnect"])
+ XCTAssertNil(sut.connected)
+ }
+
+ /// Android closes the link before core. Here both start together, which keeps that guarantee: the
+ /// link is released without waiting for core, so a request stuck on the link lets go of core.
+ func testClosingAStaleSessionReleasesTheLinkWithoutWaitingForCore() async throws {
+ let sut = try await connectedManager()
+ let coreGate = service.gate(.disconnect)
+
+ let teardown = Task { await sut.disconnectStaleSession(deviceId: JadeFixtures.deviceId) }
+ let linkReleased = await waitUntil { self.log.contains("transport.disconnect.done:\(JadeFixtures.blePath)") }
+
+ XCTAssertTrue(linkReleased, "the link closes while core is still busy")
+ XCTAssertFalse(log.contains("service.disconnect.done"))
+ XCTAssertNil(sut.connected)
+ coreGate.open()
+ await teardown.value
+ XCTAssertTrue(log.contains("service.disconnect.done"))
+ }
+
+ func testATeardownReachesCoreWithoutWaitingForTheLinkToClose() async throws {
+ let sut = try await connectedManager()
+ let linkGate = transport.gateDisconnects()
+
+ let teardown = Task { await sut.disconnectStaleSession(deviceId: JadeFixtures.deviceId) }
+ let coreReached = await waitUntil { self.log.contains("service.disconnect.done") }
+
+ XCTAssertTrue(coreReached, "core hears of the teardown while the link is still closing")
+ XCTAssertFalse(log.contains("transport.disconnect.done:\(JadeFixtures.blePath)"))
+ linkGate.open()
+ await teardown.value
+ }
+
+ func testAKnownJadeIsRecognisedByNameAfterItsPathChanged() async throws {
+ let readvertised = JadeFixtures.device(path: JadeFixtures.readvertisedPath)
+ store.devices = [JadeFixtures.knownEntry(path: JadeFixtures.stalePath)]
+ service.stubs.scanned = [readvertised]
+ let sut = makeManager()
+
+ let connected = try await sut.connectKnownDevice(deviceId: JadeFixtures.deviceId)
+
+ XCTAssertEqual(connected.path, JadeFixtures.readvertisedPath)
+ XCTAssertEqual(service.calls.connectPaths, [JadeFixtures.readvertisedPath])
+ let found = try await sut.scan()
+ XCTAssertEqual(found, [readvertised])
+ XCTAssertTrue(sut.nearbyDevices.isEmpty, "a paired Jade is not offered as new")
+ }
+
+ func testPairingDialsThePathAReadvertisedJadeIsScannedUnder() async throws {
+ store.devices = [JadeFixtures.knownEntry(path: JadeFixtures.stalePath)]
+ service.stubs.scanned = [JadeFixtures.device(path: JadeFixtures.readvertisedPath)]
+ let sut = makeManager()
+
+ let connected = try await sut.connect(path: JadeFixtures.stalePath)
+
+ XCTAssertEqual(connected.path, JadeFixtures.readvertisedPath)
+ XCTAssertEqual(service.calls.connectPaths, [JadeFixtures.readvertisedPath])
+ }
+
+ func testPairingKeepsTheRequestedPathWhenNoScannedJadeAdvertisesAsIt() async throws {
+ store.devices = [JadeFixtures.knownEntry(path: JadeFixtures.stalePath)]
+ service.stubs.scanned = [JadeFixtures.device(path: JadeFixtures.readvertisedPath, name: "Jade AAAAAA")]
+ let sut = makeManager()
+
+ try await sut.connect(path: JadeFixtures.stalePath)
+
+ XCTAssertEqual(service.calls.connectPaths, [JadeFixtures.stalePath])
+ }
+
+ func testARebootedJadeIsStillKnownUnderItsNewPath() {
+ store.devices = [JadeFixtures.knownEntry(path: JadeFixtures.stalePath)]
+ let sut = makeManager()
+
+ XCTAssertTrue(sut.hasKnownDevice(deviceId: JadeFixtures.readvertisedPath, advertisedName: JadeFixtures.advertisedName))
+ XCTAssertFalse(sut.hasKnownDevice(deviceId: JadeFixtures.readvertisedPath))
+ XCTAssertFalse(sut.hasKnownDevice(deviceId: JadeFixtures.readvertisedPath, advertisedName: "Jade AAAAAA"))
+ }
+
+ func testSilentAutoReconnectNeverAsksForThePin() async throws {
+ store.devices = [JadeFixtures.knownEntry()]
+ service.stubs.scanned = [JadeFixtures.device()]
+ service.stubs.connectResult = .success(JadeFixtures.version(.locked))
+ let sut = makeManager()
+
+ let connected = try await sut.autoReconnect()
+
+ XCTAssertTrue(connected.isLocked)
+ XCTAssertEqual(connected.walletId, JadeFixtures.walletId)
+ XCTAssertTrue(service.calls.unlockNetworks.isEmpty)
+ XCTAssertTrue(service.calls.exportTypes.isEmpty)
+ XCTAssertFalse(sut.isAutoReconnecting)
+ XCTAssertFalse(sut.isConnecting)
+ }
+
+ func testEnsureConnectedUnlocksALockedSessionWithoutReconnecting() async throws {
+ store.devices = [JadeFixtures.knownEntry()]
+ service.stubs.scanned = [JadeFixtures.device()]
+ service.stubs.connectResult = .success(JadeFixtures.version(.locked))
+ let sut = makeManager()
+ try await sut.autoReconnect()
+ service.stubs.isConnected = true
+
+ try await sut.ensureConnected(deviceId: JadeFixtures.deviceId)
+
+ XCTAssertEqual(sut.connected?.isLocked, false)
+ XCTAssertEqual(service.calls.unlockNetworks, [.regtest])
+ XCTAssertEqual(service.calls.connectPaths.count, 1, "the live session is reused")
+ }
+
+ // MARK: - Background (Android cases 19 and 22, case 21 replaced)
+
+ func testABluetoothLinkIsReleasedAfterTheAppStaysInTheBackground() async throws {
+ let sut = try await connectedManager(timing: JadeManager.Timing(backgroundRelease: 0.1, expirationMargin: 0.01))
+
+ sut.onAppBackgrounded()
+ XCTAssertEqual(backgroundTasks.begun.count, 1)
+ sut.onAppBecameActive()
+ XCTAssertEqual(backgroundTasks.ended, backgroundTasks.begun)
+ try await Task.sleep(for: .seconds(0.3))
+ XCTAssertEqual(log.count("service.disconnect"), 0)
+ XCTAssertNotNil(sut.connected)
+
+ sut.onAppBackgrounded()
+ let released = await waitUntil { self.backgroundTasks.ended.count == 2 }
+
+ XCTAssertTrue(released)
+ XCTAssertEqual(backgroundTasks.ended, backgroundTasks.begun)
+ XCTAssertEqual(log.count("service.disconnect"), 1)
+ XCTAssertTrue(log.contains("transport.disconnect:\(JadeFixtures.blePath)"))
+ XCTAssertNil(sut.connected)
+ }
+
+ func testRunningOutOfBackgroundTimeReleasesTheLinkImmediately() async throws {
+ let sut = try await connectedManager(timing: JadeManager.Timing(backgroundRelease: 60))
+ sut.onAppBackgrounded()
+
+ backgroundTasks.expire()
+
+ XCTAssertEqual(log.count("transport.releaseAll"), 1)
+ XCTAssertNil(sut.connected)
+ XCTAssertFalse(sut.isSessionActive)
+ XCTAssertEqual(backgroundTasks.ended, backgroundTasks.begun)
+ let coreClosed = await waitUntil { self.log.contains("service.disconnect.done") }
+ XCTAssertTrue(coreClosed)
+ }
+
+ func testWithoutBackgroundTimeTheLinkIsReleasedAtOnce() async throws {
+ let sut = try await connectedManager(timing: JadeManager.Timing(backgroundRelease: 60))
+ backgroundTasks.grantsTasks = false
+
+ sut.onAppBackgrounded()
+ let released = await waitUntil { self.log.contains("service.disconnect.done") }
+
+ XCTAssertTrue(released)
+ XCTAssertNil(sut.connected)
+ XCTAssertTrue(backgroundTasks.ended.isEmpty, "an invalid task is never ended")
+ }
+
+ func testAutoReconnectRunsAgainAfterABackgroundRelease() async throws {
+ let sut = try await connectedManager(timing: Self.fastTiming)
+ sut.onAppBackgrounded()
+ let released = await waitUntil { self.backgroundTasks.ended.count == 1 }
+ XCTAssertTrue(released)
+ XCTAssertNil(sut.connected)
+
+ sut.onAppBecameActive()
+ sut.startAutoReconnect()
+ let reconnected = await waitUntil { sut.connected != nil }
+
+ XCTAssertTrue(reconnected)
+ XCTAssertEqual(service.calls.connectPaths.count, 2)
+ XCTAssertEqual(sut.connected?.id, JadeFixtures.deviceId)
+ }
+
+ func testAPendingReconnectIsCancelledOnBackground() async throws {
+ store.devices = [JadeFixtures.knownEntry()]
+ service.stubs.scanned = [JadeFixtures.device()]
+ let sut = makeManager(timing: JadeManager.Timing(backgroundRelease: 0.05, expirationMargin: 0, reconnectBackoff: 0.1))
+ sut.startAutoReconnect()
+ XCTAssertTrue(sut.isSessionActive)
+
+ sut.onAppBackgrounded()
+
+ XCTAssertFalse(sut.isSessionActive)
+ XCTAssertEqual(backgroundTasks.begun.count, 1, "a pending reconnect may already have dialled, so a release is still scheduled")
+ let released = await waitUntil { self.backgroundTasks.ended.count == 1 }
+ XCTAssertTrue(released)
+ try await Task.sleep(for: .seconds(0.3))
+ XCTAssertTrue(service.calls.connectPaths.isEmpty)
+ XCTAssertFalse(log.contains("service.scan"))
+ }
+
+ func testForegroundReconnectReadsTheSavedJadeBeforeTheEntriesAreLoaded() async {
+ store.devices = [JadeFixtures.knownEntry()]
+ service.stubs.scanned = [JadeFixtures.device()]
+ service.stubs.connectResult = .success(JadeFixtures.version(.locked))
+ let sut = makeManager(timing: Self.fastTiming)
+ XCTAssertTrue(sut.knownDevices.isEmpty)
+
+ sut.startAutoReconnect()
+ XCTAssertTrue(sut.isSessionActive)
+ let reconnected = await waitUntil { sut.connected != nil }
+
+ XCTAssertTrue(reconnected)
+ XCTAssertEqual(sut.connected?.id, JadeFixtures.deviceId)
+ XCTAssertTrue(service.calls.unlockNetworks.isEmpty)
+ }
+
+ // MARK: - External disconnects (Android cases 23 and 24)
+
+ func testAnExternalDisconnectClearsTheSessionAndTellsCore() async throws {
+ let sut = try await connectedManager()
+
+ transport.externalDisconnectSubject.send(JadeFixtures.blePath)
+ let notified = await waitUntil { self.service.calls.notifiedPaths == [JadeFixtures.blePath] }
+
+ XCTAssertTrue(notified)
+ XCTAssertNil(sut.connected)
+ }
+
+ /// Core matches a pending disconnect notice against the connected path, so a notice still in
+ /// flight when a reconnect completes would tear the new session down instead of the old one.
+ func testAReconnectWaitsForTheDisconnectNoticeToReachCore() async throws {
+ let sut = try await connectedManager()
+ let notice = service.gate(.notifyDisconnected)
+ transport.externalDisconnectSubject.send(JadeFixtures.blePath)
+ let notifying = await waitUntil { self.log.contains("service.notifyDisconnected:\(JadeFixtures.blePath)") }
+ XCTAssertTrue(notifying)
+
+ let reconnect = Task { try await sut.connectKnownDevice(deviceId: JadeFixtures.deviceId) }
+ let scanned = await waitUntil { self.log.count("service.scan") == 2 }
+ XCTAssertTrue(scanned)
+ try await Task.sleep(for: .seconds(0.1))
+ XCTAssertEqual(service.calls.connectPaths.count, 1)
+
+ notice.open()
+ _ = try await reconnect.value
+
+ XCTAssertEqual(service.calls.connectPaths.count, 2)
+ let noticeDone = try XCTUnwrap(log.entries.firstIndex(of: "service.notifyDisconnected.done"))
+ let reconnectStart = try XCTUnwrap(log.entries.lastIndex(of: "service.connect:\(JadeFixtures.blePath)"))
+ XCTAssertLessThan(noticeDone, reconnectStart)
+ }
+
+ func testAnExternalDisconnectOfAnotherPathIsIgnored() async throws {
+ let sut = try await connectedManager()
+
+ sut.handleExternalDisconnect(path: JadeFixtures.stalePath)
+
+ XCTAssertNotNil(sut.connected)
+ try await Task.sleep(for: .seconds(0.05))
+ XCTAssertTrue(service.calls.notifiedPaths.isEmpty)
+ }
+
+ func testAnExternalDisconnectWhileConnectingTellsCore() async throws {
+ store.devices = [JadeFixtures.knownEntry()]
+ service.stubs.scanned = [JadeFixtures.device()]
+ service.stubs.connectResults = [.failure(JadeError.DeviceDisconnected)]
+ let connectGate = service.gate(.connect)
+ let sut = makeManager()
+ let attempt = Task { try await sut.connectKnownDevice(deviceId: JadeFixtures.deviceId) }
+ let dialling = await waitUntil { self.service.calls.connectPaths.count == 1 }
+ XCTAssertTrue(dialling)
+
+ sut.handleExternalDisconnect(path: JadeFixtures.blePath)
+ let notified = await waitUntil { self.service.calls.notifiedPaths == [JadeFixtures.blePath] }
+
+ XCTAssertTrue(notified)
+ connectGate.open()
+ _ = try? await attempt.value
+ XCTAssertNil(sut.connected)
+ }
+
+ // MARK: - Maintenance and device operations (Android cases 25-27)
+
+ func testForgettingTheConnectedJadeClosesItsSessionAndDropsTheEntry() async throws {
+ let sut = try await connectedManager()
+
+ await sut.forgetWallet(walletId: JadeFixtures.walletId, pendingName: nil)
+
+ XCTAssertEqual(log.count("service.disconnect"), 1)
+ XCTAssertEqual(store.saves.last, [])
+ XCTAssertNil(sut.connected)
+ XCTAssertTrue(sut.knownDevices.isEmpty)
+ }
+
+ func testSignPsbtCompletesTheSignedPsbtIntoATransaction() async throws {
+ let sut = makeManager()
+
+ let completed = try await sut.signPsbt("psbt")
+
+ XCTAssertEqual(completed, CompletedTransaction(serializedTx: "rawtx", txid: "txid"))
+ XCTAssertEqual(service.calls.signings, ["psbt"])
+ XCTAssertEqual(service.calls.finalizations.map(\.original), ["psbt"])
+ XCTAssertEqual(service.calls.finalizations.map(\.signed), ["signed"])
+ }
+
+ func testVerifyAddressAsksTheDeviceForTheNativeSegwitVariant() async throws {
+ let sut = makeManager()
+
+ try await sut.verifyAddress(addressType: .nativeSegwit, derivationPath: "m/84'/1'/0'/0/0", expectedAddress: "bcrt1q")
+
+ XCTAssertEqual(
+ service.calls.verifications,
+ [.init(network: .regtest, variant: .wpkh, derivationPath: "m/84'/1'/0'/0/0", expectedAddress: "bcrt1q")]
+ )
+ }
+
+ func testAJadeThatLockedSinceConnectingIsUnlockedAndVerifiesAgain() async throws {
+ let sut = try await connectedManager()
+ service.stubs.isConnected = true
+ service.stubs.verifyErrors = [JadeError.DeviceLocked]
+
+ try await sut.verifyAddress(addressType: .nativeSegwit, derivationPath: "m/84'/1'/0'/0/0", expectedAddress: "bcrt1q")
+
+ XCTAssertEqual(service.calls.verifications.count, 2)
+ XCTAssertEqual(service.calls.unlockNetworks, [.regtest])
+ XCTAssertEqual(service.calls.connectPaths.count, 1)
+ XCTAssertEqual(sut.connected?.isLocked, false)
+ }
+
+ func testAJadeThatLockedSinceConnectingIsUnlockedAndSignsAgain() async throws {
+ let sut = try await connectedManager()
+ service.stubs.isConnected = true
+ service.stubs.signErrors = [Bitkit.AppError(error: JadeError.DeviceLocked)]
+
+ let completed = try await sut.signPsbt("psbt")
+
+ XCTAssertEqual(completed.serializedTx, "rawtx")
+ XCTAssertEqual(service.calls.signings, ["psbt", "psbt"])
+ XCTAssertEqual(service.calls.finalizations.count, 1)
+ XCTAssertEqual(service.calls.unlockNetworks, [.regtest])
+ }
+
+ func testRenamingAJadeWalletLabelsItsEntries() async throws {
+ let sut = try await connectedManager()
+
+ sut.renameWallet(walletId: JadeFixtures.walletId, newName: " Cold storage ")
+
+ XCTAssertEqual(store.devices.first?.customLabel, "Cold storage")
+ XCTAssertEqual(sut.knownDevices.first?.customLabel, "Cold storage")
+ }
+
+ func testPairedPathsArePushedToTheTransport() {
+ store.devices = [JadeFixtures.knownEntry()]
+ let sut = makeManager()
+
+ sut.loadKnownDevices()
+
+ XCTAssertEqual(transport.pairedPathUpdates.last, [JadeFixtures.blePath])
+ }
+
+ // MARK: - Teardown ordering and the connect epoch
+
+ /// Core's disconnect cancels any connect that started before it arrives, so a release still on
+ /// its way must be waited out before the next connect dials.
+ func testALateReleaseDoesNotCancelTheNextConnect() async throws {
+ let sut = try await connectedManager()
+ let coreGate = service.gate(.disconnect)
+ let release = Task { await sut.releaseSession() }
+ let releasing = await waitUntil { self.log.contains("service.disconnect") }
+ XCTAssertTrue(releasing)
+ XCTAssertNil(sut.connected)
+ XCTAssertFalse(sut.isSessionActive)
+
+ let reconnect = Task { try await sut.connectKnownDevice(deviceId: JadeFixtures.deviceId) }
+ let scanned = await waitUntil { self.log.count("service.scan") == 2 }
+ XCTAssertTrue(scanned)
+ try await Task.sleep(for: .seconds(0.1))
+ XCTAssertEqual(service.calls.connectPaths.count, 1, "the connect waits for the release to reach core")
+
+ coreGate.open()
+ await release.value
+ let session = try await reconnect.value
+
+ XCTAssertEqual(sut.connected, session)
+ let releaseDone = try XCTUnwrap(log.entries.firstIndex(of: "service.disconnect.done"))
+ let reconnectStart = try XCTUnwrap(log.entries.lastIndex(of: "service.connect:\(JadeFixtures.blePath)"))
+ XCTAssertLessThan(releaseDone, reconnectStart)
+ }
+
+ func testEveryTeardownClearsTheSessionBeforeReachingTheDevice() async throws {
+ let teardowns: [(name: String, run: (JadeManager) async -> Void)] = [
+ ("disconnect", { await $0.disconnect() }),
+ ("disconnectStaleSession", { await $0.disconnectStaleSession(deviceId: JadeFixtures.deviceId) }),
+ ("cancelPendingConnection", { await $0.cancelPendingConnection(deviceId: JadeFixtures.deviceId) }),
+ ("releaseSession", { await $0.releaseSession() }),
+ ("resetForWipe", { await $0.resetForWipe() }),
+ ]
+ store.devices = [JadeFixtures.knownEntry()]
+ service.stubs.scanned = [JadeFixtures.device()]
+ let sut = makeManager()
+
+ for teardown in teardowns {
+ try await sut.connectKnownDevice(deviceId: JadeFixtures.deviceId)
+ XCTAssertNotNil(sut.connected, teardown.name)
+ let disconnectsBefore = log.count("service.disconnect")
+ let coreGate = service.gate(.disconnect)
+ let linkGate = transport.gateDisconnects()
+
+ let running = Task { await teardown.run(sut) }
+ let reachedCore = await waitUntil { self.log.count("service.disconnect") > disconnectsBefore }
+
+ XCTAssertTrue(reachedCore, teardown.name)
+ XCTAssertNil(sut.connected, teardown.name)
+ XCTAssertFalse(sut.isSessionActive, teardown.name)
+ coreGate.open()
+ linkGate.open()
+ await running.value
+ }
+ }
+
+ func testACancelledAttemptUnwindingLateLeavesTheNewerAttemptAlone() async throws {
+ store.devices = [JadeFixtures.knownEntry()]
+ service.stubs.scanned = [JadeFixtures.device()]
+ service.stubs.connectResults = [
+ .failure(JadeError.UserCancelled),
+ .success(JadeFixtures.version(.locked)),
+ ]
+ let connectGate = service.gate(.connect)
+ let unlockGate = service.gate(.unlock)
+ let sut = makeManager()
+
+ let cancelled = Task { try await sut.connectKnownDevice(deviceId: JadeFixtures.deviceId) }
+ let firstDialled = await waitUntil { self.service.calls.connectPaths.count == 1 }
+ XCTAssertTrue(firstDialled)
+ await sut.cancelPendingConnection(deviceId: JadeFixtures.deviceId)
+ let newer = Task { try await sut.connectKnownDevice(deviceId: JadeFixtures.deviceId) }
+ let secondDialled = await waitUntil { self.service.calls.connectPaths.count == 2 }
+ XCTAssertTrue(secondDialled)
+ let teardownsBefore = (log.count("service.disconnect"), log.count("transport.disconnect:\(JadeFixtures.blePath)"))
+
+ connectGate.open()
+ do {
+ _ = try await cancelled.value
+ XCTFail("the cancelled attempt must fail")
+ } catch {}
+
+ XCTAssertEqual(log.count("service.disconnect"), teardownsBefore.0, "the cancelled attempt closed nothing")
+ XCTAssertEqual(log.count("transport.disconnect:\(JadeFixtures.blePath)"), teardownsBefore.1)
+ XCTAssertTrue(sut.isConnecting, "the newer attempt keeps its flag")
+ let unlocking = await waitUntil { sut.isUnlocking }
+ XCTAssertTrue(unlocking)
+
+ unlockGate.open()
+ let session = try await newer.value
+ XCTAssertEqual(sut.connected, session)
+ XCTAssertFalse(sut.isConnecting)
+ XCTAssertFalse(sut.isUnlocking)
+ }
+
+ func testNoFlagSticksAfterCancellingAConnect() async throws {
+ store.devices = [JadeFixtures.knownEntry()]
+ service.stubs.scanned = [JadeFixtures.device()]
+ service.stubs.connectResults = [.failure(JadeError.UserCancelled)]
+ let connectGate = service.gate(.connect)
+ let sut = makeManager()
+ let attempt = Task { try await sut.connectKnownDevice(deviceId: JadeFixtures.deviceId) }
+ let dialling = await waitUntil { self.service.calls.connectPaths.count == 1 }
+ XCTAssertTrue(dialling)
+ XCTAssertTrue(sut.isConnecting)
+
+ await sut.cancelPendingConnection(deviceId: JadeFixtures.deviceId)
+
+ XCTAssertFalse(sut.isConnecting)
+ XCTAssertFalse(sut.isSessionActive)
+ connectGate.open()
+ _ = try? await attempt.value
+ XCTAssertFalse(sut.isConnecting)
+ try await sut.connectKnownDevice(deviceId: JadeFixtures.deviceId)
+ XCTAssertNotNil(sut.connected, "a new connect is not refused as already running")
+ }
+
+ func testNoFlagSticksAfterReleasingDuringThePinPrompt() async throws {
+ store.devices = [JadeFixtures.knownEntry()]
+ service.stubs.scanned = [JadeFixtures.device()]
+ service.stubs.connectResult = .success(JadeFixtures.version(.locked))
+ let unlockGate = service.gate(.unlock)
+ let sut = makeManager()
+ let ensure = Task { try await sut.ensureConnected(deviceId: JadeFixtures.deviceId) }
+ let unlocking = await waitUntil { sut.isUnlocking }
+ XCTAssertTrue(unlocking)
+
+ await sut.releaseSession()
+
+ XCTAssertFalse(sut.isUnlocking)
+ XCTAssertFalse(sut.isConnecting)
+ XCTAssertFalse(sut.isSessionActive)
+ service.stubs.unlockError = JadeError.UserCancelled
+ unlockGate.open()
+ _ = try? await ensure.value
+ XCTAssertFalse(sut.isUnlocking)
+ XCTAssertFalse(sut.isConnecting)
+ XCTAssertNil(sut.connected)
+ }
+
+ func testNoFlagSticksAfterABackgroundRelease() async throws {
+ store.devices = [JadeFixtures.knownEntry()]
+ service.stubs.scanned = [JadeFixtures.device()]
+ let connectGate = service.gate(.connect)
+ let sut = makeManager(timing: JadeManager.Timing(backgroundRelease: 0.01, expirationMargin: 0, reconnectBackoff: 0.01))
+ sut.startAutoReconnect()
+ let dialling = await waitUntil { self.service.calls.connectPaths.count == 1 }
+ XCTAssertTrue(dialling)
+ XCTAssertTrue(sut.isAutoReconnecting)
+ XCTAssertTrue(sut.isConnecting)
+
+ sut.onAppBackgrounded()
+ let released = await waitUntil { self.backgroundTasks.ended.count == 1 }
+
+ XCTAssertTrue(released)
+ XCTAssertTrue(log.contains("service.cancel"))
+ XCTAssertFalse(sut.isAutoReconnecting)
+ XCTAssertFalse(sut.isConnecting)
+ XCTAssertFalse(sut.isSessionActive)
+ connectGate.open()
+ try await Task.sleep(for: .seconds(0.2))
+ XCTAssertNil(sut.connected, "the released attempt does not revive the session")
+ XCTAssertFalse(sut.isAutoReconnecting)
+ XCTAssertFalse(sut.isConnecting)
+ XCTAssertEqual(service.calls.connectPaths.count, 1, "the released reconnect does not retry")
+ }
+
+ // MARK: - Reconnect loop and session upkeep
+
+ func testAConnectKnownDeviceIsRefusedWhileAnotherConnectIsRunning() async throws {
+ store.devices = [JadeFixtures.knownEntry()]
+ service.stubs.scanned = [JadeFixtures.device()]
+ let connectGate = service.gate(.connect)
+ let sut = makeManager()
+ let first = Task { try await sut.connectKnownDevice(deviceId: JadeFixtures.deviceId) }
+ let dialling = await waitUntil { self.service.calls.connectPaths.count == 1 }
+ XCTAssertTrue(dialling)
+
+ do {
+ try await sut.connectKnownDevice(deviceId: JadeFixtures.deviceId)
+ XCTFail("a second connect must be refused")
+ } catch {
+ XCTAssertEqual((error as? Bitkit.AppError)?.message, "Connection already in progress")
+ }
+
+ connectGate.open()
+ _ = try await first.value
+ XCTAssertEqual(service.calls.connectPaths.count, 1)
+ }
+
+ func testIsUnlockingOnlyWhileTheJadeWaitsForItsPin() async throws {
+ service.stubs.scanned = [JadeFixtures.device()]
+ service.stubs.connectResult = .success(JadeFixtures.version(.locked))
+ let unlockGate = service.gate(.unlock)
+ let sut = makeManager()
+ _ = try await sut.scan()
+ XCTAssertFalse(sut.isUnlocking)
+
+ let pairing = Task { try await sut.connect(path: JadeFixtures.blePath) }
+ let unlocking = await waitUntil { sut.isUnlocking }
+ XCTAssertTrue(unlocking)
+ XCTAssertTrue(sut.isConnecting)
+
+ unlockGate.open()
+ _ = try await pairing.value
+ XCTAssertFalse(sut.isUnlocking)
+ XCTAssertFalse(sut.isConnecting)
+ }
+
+ func testReleasingTheSessionCancelsAPendingReconnect() async throws {
+ store.devices = [JadeFixtures.knownEntry()]
+ service.stubs.scanned = [JadeFixtures.device()]
+ let sut = makeManager(timing: JadeManager.Timing(reconnectBackoff: 0.1))
+ sut.startAutoReconnect()
+ XCTAssertTrue(sut.isSessionActive)
+
+ await sut.releaseSession()
+
+ XCTAssertFalse(sut.isSessionActive)
+ try await Task.sleep(for: .seconds(0.3))
+ XCTAssertTrue(service.calls.connectPaths.isEmpty)
+ }
+
+ func testEnsureConnectedDropsAPendingReconnectInsteadOfWaitingItOut() async throws {
+ store.devices = [JadeFixtures.knownEntry()]
+ service.stubs.scanned = [JadeFixtures.device()]
+ let sut = makeManager(timing: JadeManager.Timing(reconnectBackoff: 60))
+ sut.startAutoReconnect()
+ let started = Date()
+
+ try await sut.ensureConnected(deviceId: JadeFixtures.deviceId)
+
+ XCTAssertLessThan(Date().timeIntervalSince(started), 5)
+ XCTAssertNotNil(sut.connected)
+ XCTAssertEqual(service.calls.connectPaths.count, 1)
+ }
+
+ func testPairingCancelsABackgroundReconnectThatIsDialling() async throws {
+ store.devices = [JadeFixtures.knownEntry()]
+ service.stubs.scanned = [JadeFixtures.device()]
+ service.stubs.connectResults = [.failure(JadeError.UserCancelled)]
+ let connectGate = service.gate(.connect)
+ let sut = makeManager(timing: JadeManager.Timing(reconnectBackoff: 0.01))
+ sut.startAutoReconnect()
+ let dialling = await waitUntil { self.service.calls.connectPaths.count == 1 }
+ XCTAssertTrue(dialling)
+
+ let pairing = Task { try await sut.connect(path: JadeFixtures.blePath) }
+ let cancelled = await waitUntil { self.log.contains("service.cancel") }
+ XCTAssertTrue(cancelled)
+ connectGate.open()
+ let session = try await pairing.value
+
+ XCTAssertEqual(sut.connected, session)
+ XCTAssertEqual(service.calls.connectPaths.count, 2)
+ let cancelIndex = try XCTUnwrap(log.entries.firstIndex(of: "service.cancel"))
+ let pairingStart = try XCTUnwrap(log.entries.lastIndex(of: "service.connect:\(JadeFixtures.blePath)"))
+ XCTAssertLessThan(cancelIndex, pairingStart)
+ }
+
+ func testACancelledPairingNeverDials() async {
+ service.stubs.scanned = [JadeFixtures.device()]
+ let sut = makeManager()
+
+ let pairing = Task { try await sut.connect(path: JadeFixtures.blePath) }
+ pairing.cancel()
+
+ do {
+ _ = try await pairing.value
+ XCTFail("a cancelled pairing must not connect")
+ } catch {
+ XCTAssertTrue(error is CancellationError, "\(error)")
+ }
+ XCTAssertTrue(service.calls.connectPaths.isEmpty)
+ XCTAssertFalse(sut.isConnecting)
+ }
+
+ /// Stopping the reconnect in flight can take seconds, and leaving the pairing screen meanwhile
+ /// must not dial once it is stopped.
+ func testAPairingCancelledWhileStoppingAReconnectNeverDials() async {
+ store.devices = [JadeFixtures.knownEntry()]
+ service.stubs.scanned = [JadeFixtures.device()]
+ service.stubs.connectResults = [.failure(JadeError.UserCancelled)]
+ let connectGate = service.gate(.connect)
+ let cancelGate = service.gate(.cancel)
+ let sut = makeManager(timing: JadeManager.Timing(reconnectBackoff: 0.01))
+ sut.startAutoReconnect()
+ let dialling = await waitUntil { self.service.calls.connectPaths.count == 1 }
+ XCTAssertTrue(dialling)
+
+ let pairing = Task { try await sut.connect(path: JadeFixtures.blePath) }
+ let stopping = await waitUntil { self.log.contains("service.cancel") }
+ XCTAssertTrue(stopping)
+ pairing.cancel()
+ cancelGate.open()
+ connectGate.open()
+
+ do {
+ _ = try await pairing.value
+ XCTFail("a cancelled pairing must not connect")
+ } catch {
+ XCTAssertTrue(error is CancellationError, "\(error)")
+ }
+ XCTAssertEqual(service.calls.connectPaths.count, 1, "only the stopped reconnect dialled")
+ XCTAssertFalse(sut.isConnecting)
+ }
+
+ func testAFailedReconnectAttemptIsRetried() async {
+ store.devices = [JadeFixtures.knownEntry()]
+ service.stubs.scanned = [JadeFixtures.device()]
+ service.stubs.connectResults = [.failure(JadeError.Timeout), .success(JadeFixtures.version(.locked))]
+ let sut = makeManager(timing: Self.fastTiming)
+
+ sut.startAutoReconnect()
+ let reconnected = await waitUntil { sut.connected != nil }
+
+ XCTAssertTrue(reconnected)
+ XCTAssertEqual(service.calls.connectPaths.count, 2)
+ XCTAssertEqual(sut.connected?.id, JadeFixtures.deviceId)
+ XCTAssertTrue(service.calls.unlockNetworks.isEmpty)
+ }
+
+ /// A busy Jade is waiting on the user, so dialling it again would only interrupt them.
+ func testTheReconnectLoopStopsWhenTheJadeIsBusy() async {
+ store.devices = [JadeFixtures.knownEntry()]
+ service.stubs.scanned = [JadeFixtures.device()]
+ service.stubs.connectResult = .failure(JadeError.DeviceBusy)
+ let sut = makeManager(timing: Self.fastTiming)
+
+ sut.startAutoReconnect()
+ let stopped = await waitUntil { !sut.isSessionActive }
+
+ XCTAssertTrue(stopped)
+ XCTAssertEqual(service.calls.connectPaths.count, 1)
+ XCTAssertNil(sut.connected)
+ }
+
+ /// A loop cancelled by a release unwinds after the next loop has started, and must not clear it.
+ func testACancelledReconnectLoopLeavesTheNewerLoopRunning() async {
+ store.devices = [JadeFixtures.knownEntry()]
+ service.stubs.scanned = [JadeFixtures.device()]
+ service.stubs.connectResult = .success(JadeFixtures.version(.locked))
+ let sut = makeManager(timing: JadeManager.Timing(reconnectBackoff: 0.2))
+
+ sut.startAutoReconnect()
+ await sut.releaseSession()
+ sut.startAutoReconnect()
+ var stayedActive = true
+ let dialled = await waitUntil {
+ stayedActive = stayedActive && sut.isSessionActive
+ return !self.service.calls.connectPaths.isEmpty
+ }
+
+ XCTAssertTrue(dialled)
+ XCTAssertTrue(stayedActive, "the session stays active until the newer loop dials")
+ let reconnected = await waitUntil { sut.connected != nil }
+ XCTAssertTrue(reconnected)
+ XCTAssertEqual(service.calls.connectPaths.count, 1)
+ }
+
+ func testResetForWipeDropsTheSessionAndItsBackgroundWork() async throws {
+ let sut = try await connectedManager(timing: JadeManager.Timing(backgroundRelease: 60))
+ sut.onAppBackgrounded()
+ XCTAssertEqual(backgroundTasks.begun.count, 1)
+
+ await sut.resetForWipe()
+
+ XCTAssertNil(sut.connected)
+ XCTAssertTrue(sut.knownDevices.isEmpty)
+ XCTAssertFalse(sut.isSessionActive)
+ XCTAssertTrue(log.contains("transport.closeAll"))
+ XCTAssertEqual(log.count("service.disconnect"), 1)
+ XCTAssertEqual(backgroundTasks.ended, backgroundTasks.begun)
+ _ = try await sut.scan()
+ XCTAssertEqual(log.count("service.initialize"), 2, "a wiped manager sets itself up again")
+ }
+
+ // MARK: - App lifecycle without a paired Jade
+
+ /// Starting the Jade Bluetooth central is what shows the iOS Bluetooth prompt, so the calls the app
+ /// makes at launch, on scene changes and on a wipe must leave it alone until a Jade is paired.
+ func testAppLifecycleWithoutAPairedJadeNeverStartsBluetooth() async {
+ let bluetooth = JadeBLEManager()
+ let sut = JadeManager(
+ service: service,
+ transport: JadeTransport(driver: bluetooth, isTrezorBridgeEnabled: { false }),
+ store: store,
+ backgroundTasks: backgroundTasks,
+ timing: JadeManagerTests.fastTiming,
+ network: { .regtest }
+ )
+
+ sut.loadKnownDevices()
+ sut.onAppBecameActive()
+ sut.startAutoReconnect()
+ sut.onAppBackgrounded()
+ sut.onAppBecameActive()
+ await sut.resetForWipe()
+
+ XCTAssertFalse(bluetooth.hasCentral)
+ XCTAssertFalse(sut.isConnectInProgress)
+ XCTAssertTrue(backgroundTasks.begun.isEmpty)
+ }
+
+ // MARK: - Helpers
+
+ private func makeManager(timing: JadeManager.Timing = JadeManagerTests.fastTiming) -> JadeManager {
+ JadeManager(
+ service: service,
+ transport: transport,
+ store: store,
+ backgroundTasks: backgroundTasks,
+ timing: timing,
+ now: { Date(timeIntervalSince1970: 1000) },
+ network: { .regtest }
+ )
+ }
+
+ /// A manager holding an unlocked session of the paired Jade.
+ private func connectedManager(timing: JadeManager.Timing = JadeManagerTests.fastTiming) async throws -> JadeManager {
+ store.devices = [JadeFixtures.knownEntry()]
+ service.stubs.scanned = [JadeFixtures.device()]
+ let sut = makeManager(timing: timing)
+ try await sut.connectKnownDevice(deviceId: JadeFixtures.deviceId)
+ return sut
+ }
+
+ private func waitUntil(timeout: TimeInterval = 2, _ condition: () -> Bool) async -> Bool {
+ let deadline = Date().addingTimeInterval(timeout)
+ while !condition() {
+ if Date() >= deadline {
+ return false
+ }
+ try? await Task.sleep(nanoseconds: 5_000_000)
+ }
+ return true
+ }
+}
diff --git a/BitkitTests/JadeMocks.swift b/BitkitTests/JadeMocks.swift
new file mode 100644
index 000000000..f15165e81
--- /dev/null
+++ b/BitkitTests/JadeMocks.swift
@@ -0,0 +1,537 @@
+@testable import Bitkit
+import BitkitCore
+import Combine
+import Foundation
+import UIKit
+
+/// A `JadeBLEDriving` that answers from stubs and records every call, for testing `JadeTransport`
+/// without CoreBluetooth. Calls can arrive on any thread, so both are guarded by a lock.
+final class FakeBLEDriver: JadeBLEDriving, @unchecked Sendable {
+ struct Stubs {
+ var discoveries: [JadeBLEDiscovery] = []
+ var openError: JadeBLEError?
+ var writeError: JadeBLEError?
+ var readResult: Result = .success(Data())
+ var chunkSizes: [String: UInt32] = [:]
+ }
+
+ struct Calls {
+ var scanDurations: [TimeInterval] = []
+ var openedPaths: [String] = []
+ var closedPaths: [String] = []
+ var closedOnMainThread: [Bool] = []
+ var writes: [(path: String, data: Data)] = []
+ var readTimeouts: [TimeInterval] = []
+ var closeAllCount = 0
+ var releaseAllCount = 0
+ var pairedPaths: Set?
+ }
+
+ let externalDisconnectSubject = PassthroughSubject()
+ let poweredOnSubject = PassthroughSubject()
+
+ private let lock = NSLock()
+ private var storedStubs = Stubs()
+ private var recordedCalls = Calls()
+
+ var stubs: Stubs {
+ get { lock.withLock { storedStubs } }
+ set { lock.withLock { storedStubs = newValue } }
+ }
+
+ var calls: Calls {
+ lock.withLock { recordedCalls }
+ }
+
+ var externalDisconnects: AnyPublisher {
+ externalDisconnectSubject.eraseToAnyPublisher()
+ }
+
+ var bluetoothPoweredOn: AnyPublisher {
+ poweredOnSubject.eraseToAnyPublisher()
+ }
+
+ func scan(duration: TimeInterval) -> [JadeBLEDiscovery] {
+ lock.withLock {
+ recordedCalls.scanDurations.append(duration)
+ return storedStubs.discoveries
+ }
+ }
+
+ func open(path: String) throws {
+ let error: JadeBLEError? = lock.withLock {
+ recordedCalls.openedPaths.append(path)
+ return storedStubs.openError
+ }
+ if let error {
+ throw error
+ }
+ }
+
+ func close(path: String) {
+ let isMainThread = Thread.isMainThread
+ lock.withLock {
+ recordedCalls.closedPaths.append(path)
+ recordedCalls.closedOnMainThread.append(isMainThread)
+ }
+ }
+
+ func write(path: String, data: Data) throws {
+ let error: JadeBLEError? = lock.withLock {
+ recordedCalls.writes.append((path, data))
+ return storedStubs.writeError
+ }
+ if let error {
+ throw error
+ }
+ }
+
+ func read(path _: String, timeout: TimeInterval) throws -> Data {
+ let result: Result = lock.withLock {
+ recordedCalls.readTimeouts.append(timeout)
+ return storedStubs.readResult
+ }
+ return try result.get()
+ }
+
+ func chunkSize(path: String) -> UInt32 {
+ lock.withLock { storedStubs.chunkSizes[path] ?? JadeBLEManager.defaultChunkSize }
+ }
+
+ func closeAll() {
+ lock.withLock { recordedCalls.closeAllCount += 1 }
+ }
+
+ func releaseAllImmediately() {
+ lock.withLock { recordedCalls.releaseAllCount += 1 }
+ }
+
+ func setPairedPaths(_ paths: Set) {
+ lock.withLock { recordedCalls.pairedPaths = paths }
+ }
+}
+
+// MARK: - Session manager fakes
+
+/// One ordered record of the calls the Jade fakes receive, shared by the service and the transport so
+/// a test can assert the order across both. Calls arrive on any thread, so it is guarded by a lock.
+final class JadeCallLog: @unchecked Sendable {
+ private let lock = NSLock()
+ private var recorded: [String] = []
+
+ var entries: [String] {
+ lock.withLock { recorded }
+ }
+
+ func record(_ entry: String) {
+ lock.withLock { recorded.append(entry) }
+ }
+
+ func count(_ entry: String) -> Int {
+ entries.filter { $0 == entry }.count
+ }
+
+ func contains(_ entry: String) -> Bool {
+ entries.contains(entry)
+ }
+}
+
+/// Holds the calls that wait on it until a test opens it; once open it stays open.
+final class AsyncGate: @unchecked Sendable {
+ private let lock = NSLock()
+ private var isOpen = false
+ private var waiters: [CheckedContinuation] = []
+
+ func wait() async {
+ await withCheckedContinuation { (continuation: CheckedContinuation) in
+ let resumeNow = lock.withLock {
+ if isOpen {
+ return true
+ }
+ waiters.append(continuation)
+ return false
+ }
+ if resumeNow {
+ continuation.resume()
+ }
+ }
+ }
+
+ func open() {
+ let pending = lock.withLock {
+ isOpen = true
+ defer { waiters.removeAll() }
+ return waiters
+ }
+ pending.forEach { $0.resume() }
+ }
+}
+
+enum JadeFixtures {
+ static let efuseMac = "246F288F6B64"
+ static let deviceId = "jade:bluetooth:\(efuseMac)"
+ static let advertisedName = "Jade 8F6B64"
+ static let walletId = "jade:wallet"
+ static let blePath = "ble:6B7A9B16-C81C-4A4B-9B11-000000000001"
+ static let stalePath = "ble:6B7A9B16-C81C-4A4B-9B11-000000000002"
+ static let readvertisedPath = "ble:56C4BFB3-9E75-4A4B-9B11-000000000003"
+ static let xpub = "zpubNS"
+
+ static func version(_ state: JadeState, efuseMac: String? = efuseMac) -> JadeVersionInfo {
+ JadeVersionInfo(
+ jadeVersion: "1.0.41",
+ jadeState: state,
+ jadeNetworks: "ALL",
+ jadeHasPin: true,
+ boardType: "JADE_V1_1",
+ jadeConfig: nil,
+ jadeFeatures: nil,
+ idfVersion: nil,
+ chipFeatures: nil,
+ efuseMac: efuseMac,
+ batteryStatus: nil,
+ jadeOtaMaxChunk: nil
+ )
+ }
+
+ static func accountExport() -> JadeAccountExport {
+ JadeAccountExport(
+ masterFingerprint: "deadbeef",
+ accountIndex: 0,
+ accounts: [JadeAccount(variant: .wpkh, xpub: xpub, derivationPath: "m/84'/1'/0'")]
+ )
+ }
+
+ static func device(path: String = blePath, name: String? = advertisedName) -> JadeDeviceInfo {
+ JadeDeviceInfo(path: path, transport: .bluetooth, name: name, serialNumber: nil)
+ }
+
+ static func knownEntry(path: String = blePath, lastConnectedAt: Date = Date(timeIntervalSince1970: 0)) -> HwKnownDevice {
+ HwKnownDevice(
+ id: deviceId,
+ name: advertisedName,
+ path: path,
+ transportType: "bluetooth",
+ model: "Jade",
+ lastConnectedAt: lastConnectedAt,
+ xpubs: ["nativeSegwit": xpub],
+ walletId: walletId,
+ vendor: .blockstream,
+ jadeDeviceId: efuseMac
+ )
+ }
+}
+
+/// A `JadeServicing` that answers from stubs and writes every call to a shared `JadeCallLog`. A call
+/// with a gate waits for the gate to open, so a test can hold core mid-request.
+final class FakeJadeService: JadeServicing, @unchecked Sendable {
+ enum GatedCall: Hashable {
+ case connect
+ case unlock
+ case disconnect
+ case cancel
+ case notifyDisconnected
+ }
+
+ struct Stubs {
+ var isConnected = false
+ var scanned: [JadeDeviceInfo] = []
+ var scanError: Error?
+ var listed: [JadeDeviceInfo] = []
+ /// Answers to successive connects; once used up, `connectResult` answers every later one.
+ var connectResults: [Result] = []
+ var connectResult: Result = .success(JadeFixtures.version(.ready))
+ var unlockError: Error?
+ var refreshedVersion = JadeFixtures.version(.ready)
+ var exportHandler: ([AccountType]) throws -> JadeAccountExport = { _ in JadeFixtures.accountExport() }
+ var fingerprint = "deadbeef"
+ /// Errors thrown by successive verifications; once used up, a verification succeeds.
+ var verifyErrors: [Error] = []
+ var signErrors: [Error] = []
+ var signedPsbt = "signed"
+ var completed = CompletedTransaction(serializedTx: "rawtx", txid: "txid")
+ }
+
+ struct Verification: Equatable {
+ let network: JadeNetwork
+ let variant: JadeAddressVariant
+ let derivationPath: String
+ let expectedAddress: String
+ }
+
+ struct Calls {
+ var connectPaths: [String] = []
+ var unlockNetworks: [JadeNetwork] = []
+ var exportTypes: [[AccountType]] = []
+ var verifications: [Verification] = []
+ var signings: [String] = []
+ var finalizations: [(original: String, signed: String)] = []
+ var notifiedPaths: [String] = []
+ }
+
+ let log: JadeCallLog
+
+ private let lock = NSLock()
+ private var storedStubs = Stubs()
+ private var recordedCalls = Calls()
+ private var gates: [GatedCall: AsyncGate] = [:]
+
+ init(log: JadeCallLog) {
+ self.log = log
+ }
+
+ var stubs: Stubs {
+ get { lock.withLock { storedStubs } }
+ set { lock.withLock { storedStubs = newValue } }
+ }
+
+ var calls: Calls {
+ lock.withLock { recordedCalls }
+ }
+
+ /// Makes every later `call` wait until the returned gate is opened.
+ @discardableResult
+ func gate(_ call: GatedCall) -> AsyncGate {
+ lock.withLock {
+ let gate = AsyncGate()
+ gates[call] = gate
+ return gate
+ }
+ }
+
+ func initialize() async throws {
+ log.record("service.initialize")
+ }
+
+ func scan(timeoutMs _: UInt32) async throws -> [JadeDeviceInfo] {
+ log.record("service.scan")
+ let stubs = stubs
+ if let scanError = stubs.scanError {
+ throw scanError
+ }
+ return stubs.scanned
+ }
+
+ func listDevices() async -> [JadeDeviceInfo] {
+ log.record("service.list")
+ return stubs.listed
+ }
+
+ func connect(path: String) async throws -> JadeVersionInfo {
+ log.record("service.connect:\(path)")
+ let result = lock.withLock {
+ recordedCalls.connectPaths.append(path)
+ return storedStubs.connectResults.isEmpty ? storedStubs.connectResult : storedStubs.connectResults.removeFirst()
+ }
+ await waitAtGate(.connect)
+ return try result.get()
+ }
+
+ func disconnect() async throws {
+ log.record("service.disconnect")
+ await waitAtGate(.disconnect)
+ log.record("service.disconnect.done")
+ }
+
+ func cancel() async throws {
+ log.record("service.cancel")
+ await waitAtGate(.cancel)
+ }
+
+ func notifyDisconnected(path: String) async {
+ log.record("service.notifyDisconnected:\(path)")
+ lock.withLock { recordedCalls.notifiedPaths.append(path) }
+ await waitAtGate(.notifyDisconnected)
+ log.record("service.notifyDisconnected.done")
+ }
+
+ func isConnected() -> Bool {
+ stubs.isConnected
+ }
+
+ func refreshVersionInfo() async throws -> JadeVersionInfo {
+ log.record("service.refresh")
+ return stubs.refreshedVersion
+ }
+
+ func unlock(network: JadeNetwork) async throws {
+ log.record("service.unlock")
+ lock.withLock { recordedCalls.unlockNetworks.append(network) }
+ await waitAtGate(.unlock)
+ if let unlockError = stubs.unlockError {
+ throw unlockError
+ }
+ }
+
+ func getMasterFingerprint(network _: JadeNetwork) async throws -> String {
+ log.record("service.fingerprint")
+ return stubs.fingerprint
+ }
+
+ func getAccountExport(network _: JadeNetwork, accountTypes: [AccountType], accountIndex _: UInt32) async throws -> JadeAccountExport {
+ log.record("service.export")
+ lock.withLock { recordedCalls.exportTypes.append(accountTypes) }
+ return try stubs.exportHandler(accountTypes)
+ }
+
+ func verifyAddress(network: JadeNetwork, variant: JadeAddressVariant, derivationPath: String, expectedAddress: String) async throws {
+ log.record("service.verify")
+ let error: Error? = lock.withLock {
+ recordedCalls.verifications.append(
+ Verification(network: network, variant: variant, derivationPath: derivationPath, expectedAddress: expectedAddress)
+ )
+ return storedStubs.verifyErrors.isEmpty ? nil : storedStubs.verifyErrors.removeFirst()
+ }
+ if let error {
+ throw error
+ }
+ }
+
+ func signPsbt(network _: JadeNetwork, psbtBase64: String) async throws -> String {
+ log.record("service.sign")
+ let error: Error? = lock.withLock {
+ recordedCalls.signings.append(psbtBase64)
+ return storedStubs.signErrors.isEmpty ? nil : storedStubs.signErrors.removeFirst()
+ }
+ if let error {
+ throw error
+ }
+ return stubs.signedPsbt
+ }
+
+ func finalizePsbt(originalPsbt: String, signedPsbt: String) async throws -> CompletedTransaction {
+ log.record("service.finalize")
+ lock.withLock { recordedCalls.finalizations.append((originalPsbt, signedPsbt)) }
+ return stubs.completed
+ }
+
+ private func waitAtGate(_ call: GatedCall) async {
+ let gate = lock.withLock { gates[call] }
+ await gate?.wait()
+ }
+}
+
+/// A `JadeTransportControlling` that records every call to the shared log, with a gate for holding a
+/// link close open.
+final class FakeJadeTransportControl: JadeTransportControlling, @unchecked Sendable {
+ let log: JadeCallLog
+ let externalDisconnectSubject = PassthroughSubject()
+ let poweredOnSubject = PassthroughSubject()
+
+ private let lock = NSLock()
+ private var disconnectGate: AsyncGate?
+ private var recordedPairedPaths: [Set] = []
+
+ init(log: JadeCallLog) {
+ self.log = log
+ }
+
+ var externalDisconnects: AnyPublisher {
+ externalDisconnectSubject.eraseToAnyPublisher()
+ }
+
+ var bluetoothPoweredOn: AnyPublisher {
+ poweredOnSubject.eraseToAnyPublisher()
+ }
+
+ /// Every set of paired paths pushed, oldest first.
+ var pairedPathUpdates: [Set] {
+ lock.withLock { recordedPairedPaths }
+ }
+
+ /// Makes every later link close wait until the returned gate is opened.
+ @discardableResult
+ func gateDisconnects() -> AsyncGate {
+ lock.withLock {
+ let gate = AsyncGate()
+ disconnectGate = gate
+ return gate
+ }
+ }
+
+ func disconnectDevice(path: String) async {
+ log.record("transport.disconnect:\(path)")
+ let gate = lock.withLock { disconnectGate }
+ await gate?.wait()
+ log.record("transport.disconnect.done:\(path)")
+ }
+
+ func closeAllConnections() async {
+ log.record("transport.closeAll")
+ }
+
+ func releaseAllImmediately() {
+ log.record("transport.releaseAll")
+ }
+
+ func setPairedPaths(_ paths: Set) {
+ lock.withLock { recordedPairedPaths.append(paths) }
+ }
+}
+
+/// The Jade slice of the paired-device store, in memory.
+final class InMemoryJadeKnownDeviceStore: JadeKnownDeviceStoring {
+ var devices: [HwKnownDevice]
+ var pendingNames: [String: String] = [:]
+ private(set) var saves: [[HwKnownDevice]] = []
+ private(set) var pendingNameUpdates: [PendingHwWalletName?] = []
+
+ init(devices: [HwKnownDevice] = []) {
+ self.devices = devices
+ }
+
+ func loadAll() -> [HwKnownDevice] {
+ devices.sorted { $0.lastConnectedAt > $1.lastConnectedAt }
+ }
+
+ func saveAll(_ devices: [HwKnownDevice], pendingName: PendingHwWalletName?) {
+ if let pendingName {
+ setPendingName(walletId: pendingName.walletId, name: pendingName.name)
+ }
+ pendingNameUpdates.append(pendingName)
+ saves.append(devices)
+ self.devices = devices
+ }
+
+ func loadPendingNames() -> [String: String] {
+ let named = Set(devices.filter { $0.customLabel?.isEmpty == false }.compactMap(\.resolvedWalletId))
+ return pendingNames.filter { !named.contains($0.key) }
+ }
+
+ func setPendingName(walletId: String, name: String?) {
+ pendingNames[walletId] = name.flatMap { $0.isEmpty ? nil : $0 }
+ }
+}
+
+/// A `BackgroundTaskScheduling` that hands out identifiers and keeps the expiration handler, so a test
+/// can run out the background time on demand.
+@MainActor
+final class FakeBackgroundTasks: BackgroundTaskScheduling {
+ var backgroundTimeRemaining: TimeInterval = 100
+ var grantsTasks = true
+ private(set) var begun: [UIBackgroundTaskIdentifier] = []
+ private(set) var ended: [UIBackgroundTaskIdentifier] = []
+ private var expirationHandlers: [UIBackgroundTaskIdentifier: @MainActor () -> Void] = [:]
+ private var nextIdentifier = 1
+
+ func beginBackgroundTask(named _: String, expiration: @escaping @MainActor () -> Void) -> UIBackgroundTaskIdentifier {
+ guard grantsTasks else { return .invalid }
+ let identifier = UIBackgroundTaskIdentifier(rawValue: nextIdentifier)
+ nextIdentifier += 1
+ begun.append(identifier)
+ expirationHandlers[identifier] = expiration
+ return identifier
+ }
+
+ func endBackgroundTask(_ identifier: UIBackgroundTaskIdentifier) {
+ ended.append(identifier)
+ expirationHandlers[identifier] = nil
+ }
+
+ /// Runs the expiration handler of every task still running, as the system does when time is up.
+ func expire() {
+ for handler in expirationHandlers.values {
+ handler()
+ }
+ }
+}
diff --git a/BitkitTests/JadeServiceTests.swift b/BitkitTests/JadeServiceTests.swift
new file mode 100644
index 000000000..674dc482f
--- /dev/null
+++ b/BitkitTests/JadeServiceTests.swift
@@ -0,0 +1,18 @@
+@testable import Bitkit
+import BitkitCore
+import XCTest
+
+final class JadeServiceTests: XCTestCase {
+ /// `JadeService.finalizePsbt` shares its name with the core function it wraps. Called unqualified,
+ /// it would call itself until the stack overflows instead of reaching core.
+ func testFinalizePsbtReachesTheCoreFunctionInsteadOfRecursing() async {
+ let sut = JadeService(transport: JadeTransport(driver: FakeBLEDriver()))
+
+ do {
+ _ = try await sut.finalizePsbt(originalPsbt: "not a psbt", signedPsbt: "not a psbt")
+ XCTFail("core must reject an invalid psbt")
+ } catch {
+ XCTAssertTrue((error as? Bitkit.AppError)?.underlyingError is PsbtCompletionError, "error=\(error)")
+ }
+ }
+}
diff --git a/BitkitTests/JadeTransportTests.swift b/BitkitTests/JadeTransportTests.swift
new file mode 100644
index 000000000..b78c6f152
--- /dev/null
+++ b/BitkitTests/JadeTransportTests.swift
@@ -0,0 +1,318 @@
+@testable import Bitkit
+import BitkitCore
+import Combine
+import CoreBluetooth
+import XCTest
+
+final class JadeTransportTests: XCTestCase {
+ private let path = "ble:2F8C7A10-4B3E-4D1A-9E6C-5A7B8C9D0E1F"
+ private let staleBondText = "Bluetooth pairing is no longer valid: forget the Jade in the iOS Bluetooth settings and pair it again."
+
+ private var driver: FakeBLEDriver!
+ private var cancellables: Set = []
+
+ override func setUp() {
+ super.setUp()
+ driver = FakeBLEDriver()
+ }
+
+ override func tearDown() {
+ cancellables.removeAll()
+ driver = nil
+ super.tearDown()
+ }
+
+ private func makeTransport(isTrezorBridgeEnabled: Bool = false) -> JadeTransport {
+ JadeTransport(driver: driver, isTrezorBridgeEnabled: { isTrezorBridgeEnabled })
+ }
+
+ // MARK: - Chunk size
+
+ func testChunkSizeClampsMaximumWriteLength() {
+ XCTAssertEqual(JadeBLEManager.chunkSize(maximumWriteLength: -1), 1)
+ XCTAssertEqual(JadeBLEManager.chunkSize(maximumWriteLength: 0), 1)
+ XCTAssertEqual(JadeBLEManager.chunkSize(maximumWriteLength: 20), 20)
+ XCTAssertEqual(JadeBLEManager.chunkSize(maximumWriteLength: 244), 244)
+ XCTAssertEqual(JadeBLEManager.chunkSize(maximumWriteLength: 509), 509)
+ XCTAssertEqual(JadeBLEManager.chunkSize(maximumWriteLength: 512), 509)
+ }
+
+ func testChunkSizeDefaultsTo20WhenNotOpen() {
+ let transport = JadeTransport(driver: JadeBLEManager(), isTrezorBridgeEnabled: { false })
+
+ XCTAssertEqual(transport.getChunkSize(path: path), 20)
+ }
+
+ func testChunkSizeComesFromTheDriver() {
+ driver.stubs.chunkSizes = [path: 182]
+
+ XCTAssertEqual(makeTransport().getChunkSize(path: path), 182)
+ }
+
+ // MARK: - Unopened paths
+
+ func testOperationsOnUnopenedDeviceReportNotConnected() {
+ let transport = JadeTransport(driver: JadeBLEManager(), isTrezorBridgeEnabled: { false })
+
+ let read = transport.readChunk(path: path, timeoutMs: 50)
+ XCTAssertFalse(read.success)
+ XCTAssertTrue(read.data.isEmpty)
+ XCTAssertEqual(read.errorCode, .notConnected)
+ XCTAssertEqual(read.error, "Jade is not connected.")
+
+ let write = transport.writeChunk(path: path, data: Data([0x01]))
+ XCTAssertFalse(write.success)
+ XCTAssertEqual(write.errorCode, .notConnected)
+
+ XCTAssertTrue(transport.closeDevice(path: path).success)
+ }
+
+ func testOpeningAnInvalidPathFailsWithoutStartingBluetooth() {
+ let manager = JadeBLEManager()
+ let transport = JadeTransport(driver: manager, isTrezorBridgeEnabled: { false })
+
+ let result = transport.openDevice(path: "usb:jade")
+
+ XCTAssertFalse(result.success)
+ XCTAssertEqual(result.error, "Invalid Jade Bluetooth path: usb:jade")
+ XCTAssertNil(result.errorCode)
+ XCTAssertFalse(manager.hasCentral)
+ }
+
+ func testCentralIsOnlyCreatedByScanOrOpen() {
+ let manager = JadeBLEManager()
+ let transport = JadeTransport(driver: manager, isTrezorBridgeEnabled: { false })
+
+ manager.setPairedPaths([path])
+ manager.releaseAllImmediately()
+ manager.closeAll()
+ _ = manager.chunkSize(path: path)
+ manager.externalDisconnects.sink { _ in }.store(in: &cancellables)
+ manager.bluetoothPoweredOn.sink { _ in }.store(in: &cancellables)
+ _ = transport.readChunk(path: path, timeoutMs: 10)
+ _ = transport.writeChunk(path: path, data: Data([0x01]))
+ _ = transport.closeDevice(path: path)
+ transport.releaseAllImmediately()
+ transport.setPairedPaths([])
+
+ XCTAssertFalse(manager.hasCentral)
+ }
+
+ // MARK: - Reads
+
+ func testReadTimeoutIsEmptySuccess() {
+ driver.stubs.readResult = .success(Data())
+
+ let result = makeTransport().readChunk(path: path, timeoutMs: 250)
+
+ XCTAssertEqual(result, JadeTransportReadResult(success: true, data: Data(), error: "", errorCode: nil))
+ XCTAssertEqual(driver.calls.readTimeouts, [0.25])
+ }
+
+ func testReadReturnsArrivedBytes() {
+ driver.stubs.readResult = .success(Data([0xA1, 0xB2, 0xC3]))
+
+ let result = makeTransport().readChunk(path: path, timeoutMs: 100)
+
+ XCTAssertTrue(result.success)
+ XCTAssertEqual(result.data, Data([0xA1, 0xB2, 0xC3]))
+ XCTAssertNil(result.errorCode)
+ }
+
+ func testReadAfterLinkDropReportsDisconnected() {
+ driver.stubs.readResult = .failure(.disconnected)
+
+ let result = makeTransport().readChunk(path: path, timeoutMs: 250)
+
+ XCTAssertFalse(result.success)
+ XCTAssertTrue(result.data.isEmpty)
+ XCTAssertEqual(result.errorCode, .disconnected)
+ XCTAssertEqual(result.error, "Your Jade disconnected.")
+ }
+
+ // MARK: - Writes
+
+ func testWriteSendsTheChunkToTheDriver() {
+ let result = makeTransport().writeChunk(path: path, data: Data([0x01, 0x02]))
+
+ XCTAssertEqual(result, JadeTransportResult(success: true, error: "", errorCode: nil))
+ XCTAssertEqual(driver.calls.writes.map(\.path), [path])
+ XCTAssertEqual(driver.calls.writes.map(\.data), [Data([0x01, 0x02])])
+ }
+
+ func testWriteTimeoutReportsTimeoutCode() {
+ driver.stubs.writeError = .writeTimeout
+
+ let result = makeTransport().writeChunk(path: path, data: Data([0x01]))
+
+ XCTAssertFalse(result.success)
+ XCTAssertEqual(result.errorCode, .timeout)
+ XCTAssertEqual(result.error, "Timed out sending data to your Jade.")
+ }
+
+ func testStaleBondHasNoErrorCodeAndVerbatimText() {
+ driver.stubs.writeError = .staleBond
+
+ let result = makeTransport().writeChunk(path: path, data: Data([0x01]))
+
+ XCTAssertFalse(result.success)
+ XCTAssertNil(result.errorCode)
+ XCTAssertEqual(result.error, staleBondText)
+ XCTAssertNil(JadeBLEError.staleBond.transportErrorCode)
+ XCTAssertEqual(JadeBLEError.staleBond.localizedDescription, staleBondText)
+ }
+
+ func testErrorCodesFollowTheFailure() {
+ XCTAssertEqual(JadeTransport.errorCode(for: JadeBLEError.notOpen), .notConnected)
+ XCTAssertEqual(JadeTransport.errorCode(for: JadeBLEError.deviceNotFound), .notConnected)
+ XCTAssertEqual(JadeTransport.errorCode(for: JadeBLEError.connectTimeout), .timeout)
+ XCTAssertEqual(JadeTransport.errorCode(for: JadeBLEError.closed), .disconnected)
+ XCTAssertNil(JadeTransport.errorCode(for: JadeBLEError.pairingNotConfirmed))
+ XCTAssertNil(JadeTransport.errorCode(for: JadeBLEError.writeFailed("busy")))
+ XCTAssertNil(JadeTransport.errorCode(for: CancellationError()))
+ }
+
+ // MARK: - Opening and closing
+
+ func testOpenReportsTheFailureText() {
+ driver.stubs.openError = .pairingNotConfirmed
+
+ let result = makeTransport().openDevice(path: path)
+
+ XCTAssertFalse(result.success)
+ XCTAssertEqual(result.error, "Bluetooth pairing with your Jade was not confirmed. Try again and accept the pairing request.")
+ XCTAssertEqual(driver.calls.openedPaths, [path])
+ }
+
+ func testOpenSucceedsWhenTheDriverOpens() {
+ XCTAssertEqual(makeTransport().openDevice(path: path), JadeTransportResult(success: true, error: "", errorCode: nil))
+ }
+
+ func testCloseAlwaysSucceeds() {
+ let transport = makeTransport()
+
+ XCTAssertTrue(transport.closeDevice(path: path).success)
+ XCTAssertTrue(transport.closeDevice(path: path).success)
+ XCTAssertEqual(driver.calls.closedPaths, [path, path])
+ }
+
+ @MainActor
+ func testDisconnectDeviceClosesOffTheMainThread() async {
+ let transport = makeTransport()
+
+ await transport.disconnectDevice(path: path)
+ await transport.closeAllConnections()
+
+ XCTAssertEqual(driver.calls.closedPaths, [path])
+ XCTAssertEqual(driver.calls.closedOnMainThread, [false])
+ XCTAssertEqual(driver.calls.closeAllCount, 1)
+ }
+
+ func testControlCallsReachTheDriver() {
+ let transport = makeTransport()
+
+ transport.setPairedPaths([path])
+ transport.releaseAllImmediately()
+
+ XCTAssertEqual(driver.calls.pairedPaths, [path])
+ XCTAssertEqual(driver.calls.releaseAllCount, 1)
+ }
+
+ func testPublishersForwardDriverEvents() {
+ let transport = makeTransport()
+ var disconnectedPaths: [String] = []
+ var poweredOnCount = 0
+ transport.externalDisconnects.sink { disconnectedPaths.append($0) }.store(in: &cancellables)
+ transport.bluetoothPoweredOn.sink { poweredOnCount += 1 }.store(in: &cancellables)
+
+ driver.externalDisconnectSubject.send(path)
+ driver.poweredOnSubject.send(())
+
+ XCTAssertEqual(disconnectedPaths, [path])
+ XCTAssertEqual(poweredOnCount, 1)
+ }
+
+ // MARK: - Scanning
+
+ func testScanDurationIsClamped() {
+ XCTAssertEqual(JadeTransport.scanDuration(timeoutMs: 0), 0.5)
+ XCTAssertEqual(JadeTransport.scanDuration(timeoutMs: 499), 0.5)
+ XCTAssertEqual(JadeTransport.scanDuration(timeoutMs: 3000), 3)
+ XCTAssertEqual(JadeTransport.scanDuration(timeoutMs: 15000), 15)
+ XCTAssertEqual(JadeTransport.scanDuration(timeoutMs: 60000), 15)
+ }
+
+ func testScanMapsDiscoveriesToBluetoothDevices() {
+ driver.stubs.discoveries = [
+ JadeBLEDiscovery(path: path, name: "Jade 8F6B64"),
+ JadeBLEDiscovery(path: "ble:9A1B2C3D-4E5F-4061-8293-A4B5C6D7E8F9", name: "Jade"),
+ ]
+
+ let devices = makeTransport().scanDevices(timeoutMs: 3000)
+
+ XCTAssertEqual(devices, [
+ JadeNativeDevice(path: path, transport: .bluetooth, name: "Jade 8F6B64", serialNumber: nil),
+ JadeNativeDevice(path: "ble:9A1B2C3D-4E5F-4061-8293-A4B5C6D7E8F9", transport: .bluetooth, name: "Jade", serialNumber: nil),
+ ])
+ XCTAssertEqual(driver.calls.scanDurations, [3])
+ }
+
+ func testScanIsSkippedWhileTrezorBridgeIsEnabled() {
+ driver.stubs.discoveries = [JadeBLEDiscovery(path: path, name: "Jade 8F6B64")]
+
+ let devices = makeTransport(isTrezorBridgeEnabled: true).scanDevices(timeoutMs: 3000)
+
+ XCTAssertTrue(devices.isEmpty)
+ XCTAssertTrue(driver.calls.scanDurations.isEmpty)
+ }
+
+ func testOnlyJadeNamesPassTheScanFilter() {
+ XCTAssertTrue(JadeBLEManager.isJadeName(nil))
+ XCTAssertTrue(JadeBLEManager.isJadeName("Jade"))
+ XCTAssertTrue(JadeBLEManager.isJadeName("Jade 8F6B64"))
+ XCTAssertTrue(JadeBLEManager.isJadeName("jade plus"))
+ XCTAssertFalse(JadeBLEManager.isJadeName("Nordic UART"))
+ XCTAssertFalse(JadeBLEManager.isJadeName("My Jade"))
+ XCTAssertFalse(JadeBLEManager.isJadeName(""))
+ }
+
+ // MARK: - Error mapping
+
+ func testCoreBluetoothPairingErrorsMapToPairingAdvice() {
+ let peerRemoved = NSError(domain: CBErrorDomain, code: CBError.Code.peerRemovedPairingInformation.rawValue)
+ let encryptionTimedOut = NSError(domain: CBErrorDomain, code: CBError.Code.encryptionTimedOut.rawValue)
+ let insufficientEncryption = NSError(domain: CBATTErrorDomain, code: CBATTError.Code.insufficientEncryption.rawValue)
+ let insufficientAuthentication = NSError(domain: CBATTErrorDomain, code: CBATTError.Code.insufficientAuthentication.rawValue)
+ let connectionTimeout = NSError(domain: CBErrorDomain, code: CBError.Code.connectionTimeout.rawValue)
+
+ XCTAssertEqual(JadeBLEManager.pairingError(for: peerRemoved, isPaired: false), .staleBond)
+ XCTAssertEqual(JadeBLEManager.pairingError(for: encryptionTimedOut, isPaired: true), .pairingNotConfirmed)
+ XCTAssertEqual(JadeBLEManager.pairingError(for: insufficientEncryption, isPaired: true), .staleBond)
+ XCTAssertEqual(JadeBLEManager.pairingError(for: insufficientEncryption, isPaired: false), .pairingNotConfirmed)
+ XCTAssertEqual(JadeBLEManager.pairingError(for: insufficientAuthentication, isPaired: true), .staleBond)
+ XCTAssertEqual(JadeBLEManager.pairingError(for: insufficientAuthentication, isPaired: false), .pairingNotConfirmed)
+ XCTAssertNil(JadeBLEManager.pairingError(for: connectionTimeout, isPaired: true))
+ XCTAssertNil(JadeBLEManager.pairingError(for: nil, isPaired: true))
+ }
+
+ func testFailureDetailsReadAsOneSentence() {
+ XCTAssertEqual(
+ JadeBLEError.writeFailed("The operation was cancelled.").localizedDescription,
+ "Sending data to your Jade failed (The operation was cancelled)."
+ )
+ XCTAssertEqual(JadeBLEError.connectFailed(" ").localizedDescription, "Could not connect to your Jade over Bluetooth (unknown error).")
+ }
+
+ // MARK: - Paths
+
+ func testDevicePathRoundTrip() throws {
+ let identifier = try XCTUnwrap(UUID(uuidString: "2F8C7A10-4B3E-4D1A-9E6C-5A7B8C9D0E1F"))
+
+ XCTAssertEqual(HwDevicePath.ble(identifier), path)
+ XCTAssertTrue(HwDevicePath.isBle(path))
+ XCTAssertEqual(HwDevicePath.bleIdentifier(path), identifier)
+ XCTAssertFalse(HwDevicePath.isBle("usb:jade"))
+ XCTAssertNil(HwDevicePath.bleIdentifier("usb:jade"))
+ XCTAssertNil(HwDevicePath.bleIdentifier("ble:not-a-uuid"))
+ }
+}
diff --git a/BitkitTests/TransferViewModelHwTests.swift b/BitkitTests/TransferViewModelHwTests.swift
index edf24f5b6..31a5e025c 100644
--- a/BitkitTests/TransferViewModelHwTests.swift
+++ b/BitkitTests/TransferViewModelHwTests.swift
@@ -11,7 +11,7 @@ final class TransferViewModelHwTests: XCTestCase {
funding: MockHwFunding,
connecting: MockHwConnecting,
feeRate: UInt64? = 2,
- timeouts: (reconnect: Double, compose: Double, sign: Double, broadcast: Double) = (reconnect: 5, compose: 5, sign: 5, broadcast: 5)
+ timeouts: (compose: Double, sign: Double, broadcast: Double) = (compose: 5, sign: 5, broadcast: 5)
) -> TransferViewModel {
TransferViewModel(
hwFunding: funding,
@@ -478,7 +478,47 @@ final class TransferViewModelHwTests: XCTestCase {
vm.onTransferToSpendingHwConfirm(order: .mock(), walletId: "trezor:wallet")
await awaitSigningComplete(vm)
- XCTAssertEqual(vm.hwTransferError, .deviceBusy)
+ XCTAssertEqual(vm.hwTransferError, .deviceBusy(.trezor))
+ }
+
+ func testJadeBusyShowsTheJadeCopy() async {
+ let funding = MockHwFunding()
+ funding.signError = Bitkit.AppError(error: JadeError.DeviceLocked)
+ let connecting = MockHwConnecting()
+ let vm = makeViewModel(funding: funding, connecting: connecting)
+
+ vm.onTransferToSpendingHwConfirm(order: .mock(), walletId: "jade:wallet")
+ await awaitSigningComplete(vm)
+
+ XCTAssertEqual(vm.hwTransferError, .deviceBusy(.blockstream))
+ XCTAssertEqual(HwErrorPresenter.deviceBusyMessage(for: .blockstream), t("hardware__jade_device_busy"))
+ XCTAssertTrue(connecting.staleDisconnects.isEmpty, "a busy device keeps its session")
+ }
+
+ func testJadeCancellationOnDeviceIsSilent() async {
+ let funding = MockHwFunding()
+ funding.signError = Bitkit.AppError(error: JadeError.UserCancelled)
+ let connecting = MockHwConnecting()
+ let vm = makeViewModel(funding: funding, connecting: connecting)
+
+ vm.onTransferToSpendingHwConfirm(order: .mock(), walletId: "jade:wallet")
+ await awaitSigningComplete(vm)
+
+ XCTAssertNil(vm.hwTransferError, "a cancel on the Jade must not surface a toast")
+ XCTAssertFalse(vm.hwSpending.isSigning)
+ XCTAssertEqual(vm.hwSignedEvent, 0, "a cancelled transfer must not advance the flow")
+ XCTAssertTrue(connecting.staleDisconnects.isEmpty, "a device cancel must not tear down the session")
+ }
+
+ func testJadeSigningFailureShowsTheJadeCopy() async {
+ let funding = MockHwFunding()
+ funding.signError = Bitkit.AppError(error: JadeError.PsbtTooLarge(size: 20000, max: 16384))
+ let vm = makeViewModel(funding: funding, connecting: MockHwConnecting())
+
+ vm.onTransferToSpendingHwConfirm(order: .mock(), walletId: "jade:wallet")
+ await awaitSigningComplete(vm)
+
+ XCTAssertEqual(vm.hwTransferError, .generic(t("hardware__jade_psbt_too_large")))
}
func testFirmwareErrorMapsToFirmwareReconnectError() async {
diff --git a/BitkitTests/TrezorKnownDeviceMatchingTests.swift b/BitkitTests/TrezorKnownDeviceMatchingTests.swift
deleted file mode 100644
index 94a7da740..000000000
--- a/BitkitTests/TrezorKnownDeviceMatchingTests.swift
+++ /dev/null
@@ -1,228 +0,0 @@
-@testable import Bitkit
-import XCTest
-
-/// Covers how a connect resolves which stored entry it refreshes and which entries it supersedes,
-/// now that one physical device can hold a standard wallet plus its passphrase (hidden) wallets.
-final class TrezorKnownDeviceMatchingTests: XCTestCase {
- // MARK: - previous(in:deviceId:fetchedXpubs:)
-
- func testRefreshesTheEntrySharingKeyMaterial() {
- let standard = makeDevice(xpubs: ["nativeSegwit": "zStandard"])
- let hidden = makeDevice(xpubs: ["nativeSegwit": "zHidden"], walletId: "trezor:hidden")
-
- let previous = TrezorKnownDeviceMatching.previous(
- in: [standard, hidden],
- deviceId: "dev1",
- fetchedXpubs: ["nativeSegwit": "zHidden", "taproot": "zHiddenTR"]
- )
-
- XCTAssertEqual(previous?.walletId, "trezor:hidden")
- }
-
- /// A passphrase wallet read for the first time overlaps nothing, so it must not adopt the
- /// standard wallet's entry — that would blend two seeds' xpubs into one record.
- func testTreatsUnseenKeyMaterialAsANewIdentity() {
- let standard = makeDevice(xpubs: ["nativeSegwit": "zStandard"])
-
- let previous = TrezorKnownDeviceMatching.previous(
- in: [standard],
- deviceId: "dev1",
- fetchedXpubs: ["nativeSegwit": "zHidden"]
- )
-
- XCTAssertNil(previous)
- }
-
- func testAdoptsALoneEntryStoredBeforeAnyXpubWasCaptured() {
- let bare = makeDevice(xpubs: [:], customLabel: "My Trezor")
-
- let previous = TrezorKnownDeviceMatching.previous(
- in: [bare],
- deviceId: "dev1",
- fetchedXpubs: ["nativeSegwit": "zStandard"]
- )
-
- XCTAssertEqual(previous?.customLabel, "My Trezor")
- }
-
- func testIgnoresEntriesOfAnotherDevice() {
- let other = makeDevice(id: "dev2", xpubs: ["nativeSegwit": "zStandard"])
-
- let previous = TrezorKnownDeviceMatching.previous(
- in: [other],
- deviceId: "dev1",
- fetchedXpubs: ["nativeSegwit": "zStandard"]
- )
-
- XCTAssertNil(previous)
- }
-
- // MARK: - named(in:previous:walletKey:)
-
- /// The wallet reappears on a fresh transport path, so nothing matches by device id — but it is
- /// the same key material, and the user's label belongs to the wallet, not to the path.
- func testInheritsTheLabelOfTheSameWalletOnAnotherPath() {
- let previouslyPaired = makeDevice(id: "old-path", xpubs: ["nativeSegwit": "zStandard"], customLabel: "Savings")
-
- let named = TrezorKnownDeviceMatching.named(
- in: [previouslyPaired],
- previous: nil,
- walletKey: TrezorKnownDevice.walletKey(for: ["nativeSegwit": "zStandard"], fallback: "dev1")
- )
-
- XCTAssertEqual(named?.customLabel, "Savings")
- }
-
- func testPrefersTheRefreshedEntryForTheLabel() {
- let refreshed = makeDevice(xpubs: ["nativeSegwit": "zStandard"], customLabel: "Refreshed")
- let sameKey = makeDevice(id: "old-path", xpubs: ["nativeSegwit": "zStandard"], customLabel: "Stale")
-
- let named = TrezorKnownDeviceMatching.named(
- in: [sameKey, refreshed],
- previous: refreshed,
- walletKey: refreshed.walletKey
- )
-
- XCTAssertEqual(named?.customLabel, "Refreshed")
- }
-
- // MARK: - merged(_:with:refreshed:)
-
- func testKeepsTheStandardWalletWhenAPassphraseWalletIsAdded() {
- let standard = makeDevice(xpubs: ["nativeSegwit": "zStandard"], walletId: "trezor:standard")
- let hidden = makeDevice(xpubs: ["nativeSegwit": "zHidden"], walletId: "trezor:hidden", passphraseProtected: true)
-
- let merged = TrezorKnownDeviceMatching.merged([standard], with: hidden, refreshed: nil)
-
- XCTAssertEqual(merged.map(\.walletId), ["trezor:standard", "trezor:hidden"])
- }
-
- func testReplacesTheEntryHoldingTheSameIdentity() {
- let stored = makeDevice(xpubs: ["nativeSegwit": "zStandard"], customLabel: "Old")
- let known = makeDevice(xpubs: ["nativeSegwit": "zStandard"], customLabel: "New")
-
- let merged = TrezorKnownDeviceMatching.merged([stored], with: known, refreshed: stored)
-
- XCTAssertEqual(merged.map(\.customLabel), ["New"])
- }
-
- /// Reading a previously rejected address type changes the wallet key, so matching on the new
- /// key alone would leave the entry this connect refreshed behind as a duplicate.
- func testReplacesTheRefreshedEntryWhenReadingMoreAccountsChangesItsKey() {
- let partial = makeDevice(xpubs: ["nativeSegwit": "zStandard"])
- let complete = makeDevice(xpubs: ["nativeSegwit": "zStandard", "taproot": "zTaproot"])
-
- let merged = TrezorKnownDeviceMatching.merged([partial], with: complete, refreshed: partial)
-
- XCTAssertEqual(merged.count, 1)
- XCTAssertEqual(merged[0].xpubs.count, 2)
- }
-
- func testSupersedesWalletsOfASeedTheDeviceNoLongerCarries() {
- let wiped = makeDevice(xpubs: ["nativeSegwit": "zOldSeed"], trezorDeviceId: "trezor-before-wipe")
- let known = makeDevice(xpubs: ["nativeSegwit": "zNewSeed"], trezorDeviceId: "trezor-after-wipe")
-
- let merged = TrezorKnownDeviceMatching.merged([wiped], with: known, refreshed: nil)
-
- XCTAssertEqual(merged.map(\.xpubs), [["nativeSegwit": "zNewSeed"]])
- }
-
- /// Two identities of one device report the same Trezor device id, so the wipe rule must not
- /// sweep away the sibling wallet.
- func testKeepsAnotherIdentityOfTheSameDevice() {
- let standard = makeDevice(
- xpubs: ["nativeSegwit": "zStandard"],
- walletId: "trezor:standard",
- trezorDeviceId: "trezor-id"
- )
- let hidden = makeDevice(
- xpubs: ["nativeSegwit": "zHidden"],
- walletId: "trezor:hidden",
- passphraseProtected: true,
- trezorDeviceId: "trezor-id"
- )
-
- let merged = TrezorKnownDeviceMatching.merged([standard], with: hidden, refreshed: nil)
-
- XCTAssertEqual(merged.map(\.walletId), ["trezor:standard", "trezor:hidden"])
- }
-
- func testLeavesEntriesOfAnotherDeviceAlone() {
- let other = makeDevice(id: "dev2", xpubs: ["nativeSegwit": "zOther"], trezorDeviceId: "other-trezor")
- let known = makeDevice(xpubs: ["nativeSegwit": "zStandard"], trezorDeviceId: "trezor-id")
-
- let merged = TrezorKnownDeviceMatching.merged([other], with: known, refreshed: nil)
-
- XCTAssertEqual(merged.count, 2)
- }
-
- // MARK: - Identity helpers
-
- func testWalletKeyIsIndependentOfAddressTypeKeys() {
- let a = makeDevice(xpubs: ["nativeSegwit": "zA", "taproot": "zB"])
- let b = makeDevice(xpubs: ["taproot": "zA", "nativeSegwit": "zB"])
-
- XCTAssertEqual(a.walletKey, b.walletKey)
- }
-
- func testWalletKeyFallsBackToTheTransportIdWithoutXpubs() {
- XCTAssertEqual(makeDevice(xpubs: [:]).walletKey, "dev1")
- }
-
- func testEntryIdSeparatesTwoIdentitiesOfOneDevice() {
- let standard = makeDevice(xpubs: ["nativeSegwit": "zStandard"])
- let hidden = makeDevice(xpubs: ["nativeSegwit": "zHidden"])
-
- XCTAssertNotEqual(standard.entryId, hidden.entryId)
- }
-
- func testResolvedWalletIdPrefersTheStoredValue() {
- let device = makeDevice(xpubs: ["nativeSegwit": "zStandard"], walletId: "trezor:stored")
-
- XCTAssertEqual(device.resolvedWalletId, "trezor:stored")
- }
-
- // MARK: - Decoding entries stored before hidden wallets existed
-
- func testDecodesLegacyEntriesAndDerivesTheirWalletId() throws {
- let legacy = """
- {
- "id": "dev1",
- "name": "Trezor",
- "path": "ble://dev1",
- "transportType": "bluetooth",
- "lastConnectedAt": 0,
- "xpubs": { "nativeSegwit": "zStandard" }
- }
- """
-
- let decoded = try JSONDecoder().decode(TrezorKnownDevice.self, from: Data(legacy.utf8))
-
- XCTAssertNil(decoded.walletId)
- XCTAssertFalse(decoded.passphraseProtected)
- XCTAssertNil(decoded.trezorDeviceId)
- XCTAssertEqual(decoded.resolvedWalletId, try HwWalletId.derive(xpubs: ["nativeSegwit": "zStandard"]))
- }
-
- private func makeDevice(
- id: String = "dev1",
- xpubs: [String: String],
- customLabel: String? = nil,
- walletId: String? = nil,
- passphraseProtected: Bool = false,
- trezorDeviceId: String? = nil
- ) -> TrezorKnownDevice {
- TrezorKnownDevice(
- id: id,
- name: "Trezor",
- path: "ble://\(id)",
- transportType: "bluetooth",
- lastConnectedAt: Date(timeIntervalSince1970: 0),
- xpubs: xpubs,
- customLabel: customLabel,
- walletId: walletId,
- passphraseProtected: passphraseProtected,
- trezorDeviceId: trezorDeviceId
- )
- }
-}
diff --git a/BitkitTests/TrezorKnownDeviceStorageTests.swift b/BitkitTests/TrezorKnownDeviceStorageTests.swift
deleted file mode 100644
index 9e0f1eb00..000000000
--- a/BitkitTests/TrezorKnownDeviceStorageTests.swift
+++ /dev/null
@@ -1,225 +0,0 @@
-@testable import Bitkit
-import Combine
-import XCTest
-
-/// Covers identity-scoped reads and writes: one physical device can hold a standard wallet plus its
-/// passphrase wallets, so `id` no longer identifies a stored entry on its own.
-final class TrezorKnownDeviceStorageTests: XCTestCase {
- private static let storageKey = "trezor.knownDevices"
- private static let pendingNamesKey = "trezor.pendingWalletNames"
- private var savedDefaults: Data?
- private var savedPendingNames: [String: String]?
- private var cancellables: Set = []
-
- override func setUp() {
- super.setUp()
- savedDefaults = UserDefaults.standard.data(forKey: Self.storageKey)
- savedPendingNames = UserDefaults.standard.dictionary(forKey: Self.pendingNamesKey) as? [String: String]
- cancellables = []
- TrezorKnownDeviceStorage.removeAll()
- }
-
- override func tearDown() {
- cancellables = []
- TrezorKnownDeviceStorage.removeAll()
- if let savedDefaults {
- UserDefaults.standard.set(savedDefaults, forKey: Self.storageKey)
- }
- if let savedPendingNames {
- UserDefaults.standard.set(savedPendingNames, forKey: Self.pendingNamesKey)
- }
- super.tearDown()
- }
-
- func testSavingAPassphraseWalletKeepsTheStandardWalletOfTheSameDevice() {
- TrezorKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zStandard"], walletId: "trezor:standard"))
- TrezorKnownDeviceStorage.save(makeDevice(
- xpubs: ["nativeSegwit": "zHidden"],
- walletId: "trezor:hidden",
- passphraseProtected: true
- ))
-
- let stored = TrezorKnownDeviceStorage.loadAll()
- XCTAssertEqual(Set(stored.compactMap(\.walletId)), ["trezor:standard", "trezor:hidden"])
- XCTAssertEqual(stored.filter(\.passphraseProtected).count, 1)
- }
-
- func testSavingTheSameIdentityAgainReplacesIt() {
- TrezorKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zStandard"], customLabel: "Old"))
- TrezorKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zStandard"], customLabel: "New"))
-
- XCTAssertEqual(TrezorKnownDeviceStorage.loadAll().map(\.customLabel), ["New"])
- }
-
- func testRemovingOneWalletLeavesTheDevicesOtherWalletsPaired() {
- TrezorKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zStandard"], walletId: "trezor:standard"))
- TrezorKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zHidden"], walletId: "trezor:hidden"))
-
- TrezorKnownDeviceStorage.remove(walletId: "trezor:hidden")
-
- XCTAssertEqual(TrezorKnownDeviceStorage.loadAll().compactMap(\.walletId), ["trezor:standard"])
- XCTAssertTrue(TrezorKnownDeviceStorage.isKnown(id: "dev1"), "the device itself stays paired")
- }
-
- /// Entries written before the wallet id was persisted resolve it from their xpubs.
- func testRemovingAWalletMatchesEntriesWithoutAStoredWalletId() throws {
- let legacy = makeDevice(xpubs: ["nativeSegwit": "zStandard"], walletId: nil)
- TrezorKnownDeviceStorage.save(legacy)
-
- try TrezorKnownDeviceStorage.remove(walletId: HwWalletId.derive(xpubs: legacy.xpubs))
-
- XCTAssertTrue(TrezorKnownDeviceStorage.loadAll().isEmpty)
- }
-
- func testRemovingByDeviceIdForgetsEveryWalletItHolds() {
- TrezorKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zStandard"], walletId: "trezor:standard"))
- TrezorKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zHidden"], walletId: "trezor:hidden"))
- TrezorKnownDeviceStorage.save(makeDevice(id: "dev2", xpubs: ["nativeSegwit": "zOther"], walletId: "trezor:other"))
-
- TrezorKnownDeviceStorage.remove(id: "dev1")
-
- XCTAssertEqual(TrezorKnownDeviceStorage.loadAll().compactMap(\.walletId), ["trezor:other"])
- }
-
- func testLoadingByWalletIdReturnsOnlyThatIdentitysEntries() {
- TrezorKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zStandard"], walletId: "trezor:standard"))
- TrezorKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zHidden"], walletId: "trezor:hidden"))
-
- let entries = TrezorKnownDeviceStorage.loadAll(walletId: "trezor:hidden")
-
- XCTAssertEqual(entries.count, 1)
- XCTAssertEqual(entries.first?.xpubs, ["nativeSegwit": "zHidden"])
- }
-
- func testNewFieldsSurviveAStorageRoundTrip() {
- TrezorKnownDeviceStorage.save(makeDevice(
- xpubs: ["nativeSegwit": "zHidden"],
- walletId: "trezor:hidden",
- passphraseProtected: true,
- trezorDeviceId: "trezor-id"
- ))
-
- let stored = TrezorKnownDeviceStorage.loadAll().first
- XCTAssertEqual(stored?.walletId, "trezor:hidden")
- XCTAssertTrue(stored?.passphraseProtected == true)
- XCTAssertEqual(stored?.trezorDeviceId, "trezor-id")
- }
-
- // MARK: - Hardware wallet names
-
- func testAPendingNameAndTheDeviceListAreWrittenTogether() {
- let device = makeDevice(xpubs: ["nativeSegwit": "zStandard"], customLabel: "Cold", walletId: "trezor:standard")
- TrezorKnownDeviceStorage.save(device)
-
- TrezorKnownDeviceStorage.saveAll([], pendingName: PendingHwWalletName(walletId: "trezor:standard", name: "Cold"))
-
- XCTAssertTrue(TrezorKnownDeviceStorage.loadAll().isEmpty)
- XCTAssertEqual(TrezorKnownDeviceStorage.loadPendingNames(), ["trezor:standard": "Cold"])
- }
-
- /// Adoption on pairing consumes a pending name by masking rather than by a second write, so a
- /// wallet the device list already names must not report one.
- func testAPendingNameIsMaskedOnceTheWalletIsPairedAndNamed() {
- TrezorKnownDeviceStorage.setPendingName(walletId: "trezor:standard", name: "Cold")
- TrezorKnownDeviceStorage.save(
- makeDevice(xpubs: ["nativeSegwit": "zStandard"], customLabel: "Cold", walletId: "trezor:standard")
- )
-
- XCTAssertTrue(TrezorKnownDeviceStorage.loadPendingNames().isEmpty)
- XCTAssertEqual(TrezorKnownDeviceStorage.backupSnapshot(), ["trezor:standard": "Cold"])
- }
-
- func testTheNameOfAPairedWalletWinsOverAPendingOne() {
- TrezorKnownDeviceStorage.setPendingName(walletId: "trezor:standard", name: "Restored")
- TrezorKnownDeviceStorage.save(
- makeDevice(xpubs: ["nativeSegwit": "zStandard"], customLabel: "Renamed", walletId: "trezor:standard")
- )
-
- XCTAssertEqual(TrezorKnownDeviceStorage.backupSnapshot(), ["trezor:standard": "Renamed"])
- }
-
- func testSettingAPendingNameToNilDropsIt() {
- TrezorKnownDeviceStorage.setPendingName(walletId: "trezor:standard", name: "Cold")
- TrezorKnownDeviceStorage.setPendingName(walletId: "trezor:standard", name: nil)
-
- XCTAssertTrue(TrezorKnownDeviceStorage.backupSnapshot().isEmpty)
- }
-
- func testRestoringNamesLetsALocalNameWinAndNeverClears() {
- TrezorKnownDeviceStorage.setPendingName(walletId: "trezor:standard", name: "Local")
-
- TrezorKnownDeviceStorage.restoreNames(["trezor:standard": "Backed up", "trezor:hidden": "Hidden"])
- XCTAssertEqual(
- TrezorKnownDeviceStorage.backupSnapshot(),
- ["trezor:standard": "Local", "trezor:hidden": "Hidden"]
- )
-
- // An envelope written before the field carries no names, and must not drop what is stored.
- TrezorKnownDeviceStorage.restoreNames([:])
- XCTAssertEqual(
- TrezorKnownDeviceStorage.backupSnapshot(),
- ["trezor:standard": "Local", "trezor:hidden": "Hidden"]
- )
- }
-
- func testForgettingAWalletDropsTheNameKeptForIt() {
- TrezorKnownDeviceStorage.setPendingName(walletId: "trezor:hidden", name: "Hidden")
- TrezorKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zHidden"], walletId: "trezor:hidden"))
-
- TrezorKnownDeviceStorage.remove(walletId: "trezor:hidden")
-
- XCTAssertTrue(TrezorKnownDeviceStorage.backupSnapshot().isEmpty)
- }
-
- func testRemoveAllClearsPendingNamesToo() {
- TrezorKnownDeviceStorage.setPendingName(walletId: "trezor:standard", name: "Cold")
-
- TrezorKnownDeviceStorage.removeAll()
-
- XCTAssertTrue(TrezorKnownDeviceStorage.backupSnapshot().isEmpty)
- }
-
- /// Every connect rewrites the device list to refresh `lastConnectedAt`; only a name change may
- /// mark the metadata backup stale.
- func testTheNameSignalFiresOnARenameButNotOnAReconnect() {
- let device = makeDevice(xpubs: ["nativeSegwit": "zStandard"], customLabel: "Cold", walletId: "trezor:standard")
- TrezorKnownDeviceStorage.save(device)
-
- var fires = 0
- TrezorKnownDeviceStorage.namesChangedPublisher
- .sink { fires += 1 }
- .store(in: &cancellables)
-
- var reconnected = device
- reconnected.lastConnectedAt = Date(timeIntervalSince1970: 5000)
- TrezorKnownDeviceStorage.saveAll([reconnected])
- XCTAssertEqual(fires, 0, "a reconnect must not re-upload the metadata envelope")
-
- var renamed = reconnected
- renamed.customLabel = "Vault"
- TrezorKnownDeviceStorage.saveAll([renamed])
- XCTAssertEqual(fires, 1)
- }
-
- private func makeDevice(
- id: String = "dev1",
- xpubs: [String: String],
- customLabel: String? = nil,
- walletId: String? = nil,
- passphraseProtected: Bool = false,
- trezorDeviceId: String? = nil
- ) -> TrezorKnownDevice {
- TrezorKnownDevice(
- id: id,
- name: "Trezor",
- path: "ble://\(id)",
- transportType: "bluetooth",
- lastConnectedAt: Date(timeIntervalSince1970: 0),
- xpubs: xpubs,
- customLabel: customLabel,
- walletId: walletId,
- passphraseProtected: passphraseProtected,
- trezorDeviceId: trezorDeviceId
- )
- }
-}
diff --git a/BitkitTests/TrezorManagerSessionTests.swift b/BitkitTests/TrezorManagerSessionTests.swift
new file mode 100644
index 000000000..941809f78
--- /dev/null
+++ b/BitkitTests/TrezorManagerSessionTests.swift
@@ -0,0 +1,48 @@
+@testable import Bitkit
+import XCTest
+
+/// How a Trezor session makes way for another vendor: a foreground reconnect reads as active from the
+/// moment it is started, and releasing the session or wiping the wallet cancels it before it runs.
+@MainActor
+final class TrezorManagerSessionTests: XCTestCase {
+ func testAForegroundReconnectReadsAsActiveBeforeItFirstRuns() {
+ let manager = TrezorManager()
+
+ manager.startAutoReconnect()
+
+ XCTAssertTrue(manager.isSessionActive)
+ }
+
+ func testReleasingCancelsAPendingForegroundReconnect() async {
+ let manager = TrezorManager()
+ manager.startAutoReconnect()
+
+ await manager.releaseSession()
+
+ XCTAssertFalse(manager.isSessionActive)
+ }
+
+ func testWipeCancelsAPendingForegroundReconnectAndDropsTheLoadedDevices() async {
+ let manager = TrezorManager()
+ manager.knownDevices = [makeTrezor()]
+ manager.startAutoReconnect()
+
+ await manager.resetForWipe()
+
+ XCTAssertFalse(manager.isSessionActive)
+ XCTAssertTrue(manager.knownDevices.isEmpty)
+ }
+
+ private func makeTrezor() -> HwKnownDevice {
+ HwKnownDevice(
+ id: "trezor-dev",
+ name: "Trezor",
+ path: "ble:trezor",
+ transportType: "bluetooth",
+ model: "Safe 7",
+ lastConnectedAt: Date(timeIntervalSince1970: 0),
+ xpubs: ["nativeSegwit": "zTrezor"],
+ walletId: "trezor:standard"
+ )
+ }
+}
diff --git a/BitkitTests/TrezorManagerVendorIsolationTests.swift b/BitkitTests/TrezorManagerVendorIsolationTests.swift
new file mode 100644
index 000000000..f9e54849c
--- /dev/null
+++ b/BitkitTests/TrezorManagerVendorIsolationTests.swift
@@ -0,0 +1,140 @@
+@testable import Bitkit
+import XCTest
+
+/// Trezor reads and writes only its own slice of the paired-device store, so nothing it loads, renames
+/// or forgets can reach a paired Jade, not even one holding the same seed.
+@MainActor
+final class TrezorManagerVendorIsolationTests: XCTestCase {
+ private static let storageKey = "trezor.knownDevices"
+ private static let pendingNamesKey = "trezor.pendingWalletNames"
+ private static let sharedSeed = ["nativeSegwit": "zShared"]
+
+ private var savedDefaults: Data?
+ private var savedPendingNames: [String: String]?
+
+ override func setUp() {
+ super.setUp()
+ savedDefaults = UserDefaults.standard.data(forKey: Self.storageKey)
+ savedPendingNames = UserDefaults.standard.dictionary(forKey: Self.pendingNamesKey) as? [String: String]
+ HwKnownDeviceStorage.removeAll()
+ }
+
+ override func tearDown() {
+ HwKnownDeviceStorage.removeAll()
+ if let savedDefaults {
+ UserDefaults.standard.set(savedDefaults, forKey: Self.storageKey)
+ }
+ if let savedPendingNames {
+ UserDefaults.standard.set(savedPendingNames, forKey: Self.pendingNamesKey)
+ }
+ super.tearDown()
+ }
+
+ func testLoadingKnownDevicesSeesOnlyTrezorEntries() {
+ HwKnownDeviceStorage.save(makeTrezor(walletId: "trezor:standard"))
+ HwKnownDeviceStorage.save(makeJade())
+ let manager = TrezorManager()
+
+ manager.loadKnownDevices()
+
+ XCTAssertEqual(manager.knownDevices.map(\.walletId), ["trezor:standard"])
+ XCTAssertEqual(manager.storedDevices.map(\.vendor), [.trezor])
+ }
+
+ func testRenamingATrezorWalletLeavesTheJadeUntouched() {
+ HwKnownDeviceStorage.save(makeTrezor(walletId: "trezor:standard"))
+ let jade = makeJade()
+ HwKnownDeviceStorage.save(jade)
+
+ TrezorManager().renameWallet(walletId: "trezor:standard", newName: "Vault")
+
+ XCTAssertEqual(HwKnownDeviceStorage.loadAll(vendor: .trezor).map(\.customLabel), ["Vault"])
+ XCTAssertEqual(HwKnownDeviceStorage.loadAll(vendor: .blockstream), [jade])
+ }
+
+ /// Renaming a device also renames every entry holding its xpubs, which must stop at the Trezor
+ /// slice: a Jade restored from the same seed is a different wallet.
+ func testRenamingATrezorDeviceLeavesAJadeWithTheSameSeedUntouched() {
+ HwKnownDeviceStorage.save(makeTrezor(walletId: "trezor:standard"))
+ let jade = makeJade()
+ HwKnownDeviceStorage.save(jade)
+
+ TrezorManager().renameDevice(id: "trezor-dev", newName: "Vault")
+
+ XCTAssertEqual(HwKnownDeviceStorage.loadAll(vendor: .trezor).map(\.customLabel), ["Vault"])
+ XCTAssertEqual(HwKnownDeviceStorage.loadAll(vendor: .blockstream), [jade])
+ }
+
+ func testRenamingAJadeWalletThroughTrezorChangesNothing() {
+ let jade = makeJade()
+ HwKnownDeviceStorage.save(jade)
+
+ TrezorManager().renameWallet(walletId: "jade:wallet", newName: "Vault")
+
+ XCTAssertEqual(HwKnownDeviceStorage.loadAll(vendor: .blockstream), [jade])
+ }
+
+ /// The hidden wallet's sibling stays paired, so no transport credential is cleared.
+ func testForgettingATrezorWalletKeepsTheJade() async {
+ HwKnownDeviceStorage.save(makeTrezor(walletId: "trezor:standard"))
+ HwKnownDeviceStorage.save(makeTrezor(xpubs: ["nativeSegwit": "zHidden"], walletId: "trezor:hidden"))
+ let jade = makeJade()
+ HwKnownDeviceStorage.save(jade)
+ let manager = TrezorManager()
+ manager.loadKnownDevices()
+
+ await manager.forgetWallet(walletId: "trezor:hidden")
+
+ XCTAssertEqual(HwKnownDeviceStorage.loadAll(vendor: .trezor).compactMap(\.walletId), ["trezor:standard"])
+ XCTAssertEqual(HwKnownDeviceStorage.loadAll(vendor: .blockstream), [jade])
+ }
+
+ func testForgettingAJadeWalletThroughTrezorChangesNothing() async {
+ let jade = makeJade()
+ HwKnownDeviceStorage.save(jade)
+
+ await TrezorManager().forgetWallet(walletId: "jade:wallet")
+
+ XCTAssertEqual(HwKnownDeviceStorage.loadAll(vendor: .blockstream), [jade])
+ }
+
+ func testForgettingADeviceIdOnlyAJadeHoldsChangesNothing() async {
+ let jade = makeJade()
+ HwKnownDeviceStorage.save(jade)
+ let manager = TrezorManager()
+ manager.loadKnownDevices()
+
+ await manager.forgetDevice(id: jade.id)
+
+ XCTAssertEqual(HwKnownDeviceStorage.loadAll(vendor: .blockstream), [jade])
+ }
+
+ private func makeTrezor(xpubs: [String: String] = ["nativeSegwit": "zShared"], walletId: String) -> HwKnownDevice {
+ HwKnownDevice(
+ id: "trezor-dev",
+ name: "Trezor",
+ path: "ble:trezor",
+ transportType: "bluetooth",
+ model: "Safe 7",
+ lastConnectedAt: Date(timeIntervalSince1970: 0),
+ xpubs: xpubs,
+ walletId: walletId
+ )
+ }
+
+ private func makeJade() -> HwKnownDevice {
+ HwKnownDevice(
+ id: "jade:bluetooth:aabbcc",
+ name: "Jade AABBCC",
+ path: "ble:jade",
+ transportType: "bluetooth",
+ model: "Jade",
+ lastConnectedAt: Date(timeIntervalSince1970: 10),
+ xpubs: Self.sharedSeed,
+ customLabel: "Travel",
+ walletId: "jade:wallet",
+ vendor: .blockstream,
+ jadeDeviceId: "aabbcc"
+ )
+ }
+}
diff --git a/changelog.d/next/765.added.md b/changelog.d/next/765.added.md
new file mode 100644
index 000000000..edf8e5a96
--- /dev/null
+++ b/changelog.d/next/765.added.md
@@ -0,0 +1 @@
+Added support for pairing Blockstream Jade and Jade Plus hardware wallets over Bluetooth, including watch-only balances, address verification and on-device transaction signing.
diff --git a/journeys/README.md b/journeys/README.md
index 67db5dee3..824d5885e 100644
--- a/journeys/README.md
+++ b/journeys/README.md
@@ -126,6 +126,7 @@ Known naming differences:
| Send max | `SendAmountMax` | *(no button — tap `AvailableAmount`)* |
| External amount available | — | `ExternalAmountAvailable` |
| Payment Request details screen | `PaymentRequestDetailsScreen` | `PaymentRequestDetailScreen` |
+| Hardware wallet receive tab | `Tab-hardware` (after bitkit-android#1231) | `Tab-trezor` for a Trezor wallet, `Tab-jade` for a Jade |
`SubscriptionRow-` matches Android exactly. `PaymentRequestRow` keeps that prefix
but appends the billing period (`-one-time` for a one-off), because every recurring payment of one
diff --git a/journeys/hardware-wallet/README.md b/journeys/hardware-wallet/README.md
index 8f1363d58..b70bd3d23 100644
--- a/journeys/hardware-wallet/README.md
+++ b/journeys/hardware-wallet/README.md
@@ -132,3 +132,15 @@ Walked as far as a simulator allows, against a wallet with two Trezor identities
Tag button `ActivityTag` (labelled "Tag"), tag field `TagInput`, submit `ActivityTagsSubmit`,
detail chip list `ActivityTags` (only rendered once a tag exists), All Activity tag filter
`TagsPrompt` (not `ActivityTags` — that one is detail-screen only), explorer `ActivityTxDetails`.
+
+## Blockstream Jade
+
+There is no Jade emulator in `bitkit-docker`, and iOS reaches a Jade over Bluetooth only, which the
+simulator does not have. The Jade flows (Connect Hardware, receive-address verification and on-device
+signing) are therefore covered by unit tests (`JadeManagerTests.swift`, `JadeTransportTests.swift`,
+`JadeBLELinkStateTests.swift`, `HwWalletManagerVendorTests.swift`, `HwConnectViewModelTests.swift`,
+`HwFundingSignerTests.swift`, `HwEngagedSessionTests.swift`) and by manual runs against a physical Jade
+paired with a physical iPhone. The journeys in this folder stay Trezor-only.
+
+While a Jade waits for its PIN, the Found step shows `HwFoundUnlockHint`. The receive tab of a hardware
+wallet is labelled with its vendor: a Trezor wallet keeps `Tab-trezor` and a Jade reads `Tab-jade`.