From 0e36d504213e6c1a6fc9456073fa8a6dd2d936c3 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Fri, 18 Sep 2026 10:37:35 -0400 Subject: [PATCH 01/18] chore: bump bitkit-core to 0.5.17 --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 191c32332..535843b4a 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -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" } }, { From 980910743612ab96d34678e502ca31f161fc34f8 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Fri, 18 Sep 2026 10:46:08 -0400 Subject: [PATCH 02/18] refactor: give the paired device store vendor neutral names --- Bitkit/BitkitApp.swift | 2 +- .../Components/Trezor/TrezorDeviceRow.swift | 2 +- .../Extensions/TrezorDevice+DisplayName.swift | 2 +- Bitkit/Managers/HwDeviceSessioning.swift | 8 +- Bitkit/Managers/HwWalletManager.swift | 20 +-- Bitkit/Managers/TrezorManager.swift | 46 +++---- Bitkit/Services/BackupService.swift | 6 +- ...hing.swift => HwKnownDeviceMatching.swift} | 26 ++-- ...orage.swift => HwKnownDeviceStorage.swift} | 32 ++--- .../Trezor/HwConnectViewModel.swift | 10 +- .../Views/Trezor/TrezorDeviceListView.swift | 2 +- BitkitTests/HwConnectViewModelTests.swift | 4 +- ...swift => HwKnownDeviceMatchingTests.swift} | 38 +++--- ....swift => HwKnownDeviceStorageTests.swift} | 118 +++++++++--------- BitkitTests/HwWalletManagerFundingTests.swift | 4 +- .../HwWalletManagerPassphraseTests.swift | 14 +-- BitkitTests/HwWalletManagerTests.swift | 6 +- 17 files changed, 170 insertions(+), 170 deletions(-) rename Bitkit/Services/{Trezor/TrezorKnownDeviceMatching.swift => HwKnownDeviceMatching.swift} (85%) rename Bitkit/Services/{Trezor/TrezorKnownDeviceStorage.swift => HwKnownDeviceStorage.swift} (91%) rename BitkitTests/{TrezorKnownDeviceMatchingTests.swift => HwKnownDeviceMatchingTests.swift} (84%) rename BitkitTests/{TrezorKnownDeviceStorageTests.swift => HwKnownDeviceStorageTests.swift} (53%) diff --git a/Bitkit/BitkitApp.swift b/Bitkit/BitkitApp.swift index 00127fddc..7dc43ffc2 100644 --- a/Bitkit/BitkitApp.swift +++ b/Bitkit/BitkitApp.swift @@ -178,7 +178,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/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/TrezorDevice+DisplayName.swift b/Bitkit/Extensions/TrezorDevice+DisplayName.swift index b1f5a6bed..4098d598b 100644 --- a/Bitkit/Extensions/TrezorDevice+DisplayName.swift +++ b/Bitkit/Extensions/TrezorDevice+DisplayName.swift @@ -14,7 +14,7 @@ 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) } diff --git a/Bitkit/Managers/HwDeviceSessioning.swift b/Bitkit/Managers/HwDeviceSessioning.swift index 4b063e4fe..4b6137aad 100644 --- a/Bitkit/Managers/HwDeviceSessioning.swift +++ b/Bitkit/Managers/HwDeviceSessioning.swift @@ -7,10 +7,10 @@ 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 } @@ -36,8 +36,8 @@ protocol HwDeviceSessioning: AnyObject, Sendable { func forgetWallet(walletId: String, pendingName: PendingHwWalletName?) async } -extension TrezorManager: HwDeviceSessioning { - var storedDevices: [TrezorKnownDevice] { +extension TrezorManager: TrezorSessioning { + var storedDevices: [HwKnownDevice] { knownDevices } diff --git a/Bitkit/Managers/HwWalletManager.swift b/Bitkit/Managers/HwWalletManager.swift index f2419e415..3cd658069 100644 --- a/Bitkit/Managers/HwWalletManager.swift +++ b/Bitkit/Managers/HwWalletManager.swift @@ -13,7 +13,7 @@ import Foundation /// 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 +/// injected `TrezorSessioning` 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. /// @Observable @@ -73,7 +73,7 @@ final class HwWalletManager { /// 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? + private weak var session: TrezorSessioning? /// 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 +81,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] = [:] @@ -123,7 +123,7 @@ final class HwWalletManager { private var staleSessionCleanupTasks: [String: Task] = [:] init( - session: HwDeviceSessioning? = nil, + session: TrezorSessioning? = nil, watcherService: OnChainWatcherServicing = OnChainHwService.shared, monitoredTypes: (() -> Set)? = nil, electrumUrl: (() -> String)? = nil, @@ -185,7 +185,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 ) { @@ -291,7 +291,7 @@ final class HwWalletManager { /// 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] { + private func entries(for walletId: String) -> [HwKnownDevice] { (session?.storedDevices ?? knownDevices).filter { $0.resolvedWalletId == walletId } } @@ -572,7 +572,7 @@ 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 } @@ -906,7 +906,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 { @@ -1220,13 +1220,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/TrezorManager.swift b/Bitkit/Managers/TrezorManager.swift index d50953584..89aafef6b 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 } } @@ -585,7 +585,7 @@ final class TrezorManager { // MARK: - Known Devices func loadKnownDevices() { - knownDevices = TrezorKnownDeviceStorage.loadAll() + knownDevices = HwKnownDeviceStorage.loadAll() } /// Display name for the currently connected device, applying any Bitkit-side custom rename (from @@ -606,7 +606,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() guard let target = devices.first(where: { $0.id == id }) else { return } applyCustomLabel(newName, to: devices) { device in @@ -618,7 +618,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() guard devices.contains(where: { $0.resolvedWalletId == walletId }) else { return } applyCustomLabel(newName, to: devices) { $0.resolvedWalletId == walletId } @@ -627,13 +627,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 +643,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) loadKnownDevices() } @@ -660,12 +660,12 @@ final class TrezorManager { @discardableResult func saveCurrentDeviceAsKnown() async -> Bool { guard let device = connectedDevice else { return false } - let stored = TrezorKnownDeviceStorage.loadAll() + let stored = HwKnownDeviceStorage.loadAll() 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 +684,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, @@ -708,7 +708,7 @@ final class TrezorManager { passphraseProtected: passphraseProtection(previous: previous), trezorDeviceId: deviceFeatures?.deviceId ?? previous?.trezorDeviceId ) - TrezorKnownDeviceStorage.saveAll(TrezorKnownDeviceMatching.merged(stored, with: known, refreshed: previous)) + HwKnownDeviceStorage.saveAll(HwKnownDeviceMatching.merged(stored, with: known, refreshed: previous)) loadKnownDevices() connectedWalletId = known.resolvedWalletId trezorLog("Saved known device: \(known.name) with \(mergedXpubs.count) xpubs") @@ -716,10 +716,10 @@ 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 @@ -733,7 +733,7 @@ final class TrezorManager { /// 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 +809,7 @@ final class TrezorManager { if let device = known { await clearCredentials(path: device.path) } - TrezorKnownDeviceStorage.remove(id: id) + HwKnownDeviceStorage.remove(id: id) loadKnownDevices() trezorLog("Forgot device: \(id)") @@ -823,7 +823,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() let forgotten = stored.filter { $0.resolvedWalletId == walletId } guard !forgotten.isEmpty else { trezorLog("Nothing to forget for hardware wallet '\(walletId)'", level: "warn") @@ -835,7 +835,7 @@ final class TrezorManager { await clearCredentials(path: entry.path) } - TrezorKnownDeviceStorage.saveAll(remaining, pendingName: pendingName) + HwKnownDeviceStorage.saveAll(remaining, pendingName: pendingName) loadKnownDevices() trezorLog("Forgot hardware wallet: \(walletId)") @@ -1024,7 +1024,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/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 85% rename from Bitkit/Services/Trezor/TrezorKnownDeviceMatching.swift rename to Bitkit/Services/HwKnownDeviceMatching.swift index f2c0e88d4..502206442 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] } @@ -49,9 +49,9 @@ enum TrezorKnownDeviceMatching { /// 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. 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 } if entry.walletKey == known.walletKey { diff --git a/Bitkit/Services/Trezor/TrezorKnownDeviceStorage.swift b/Bitkit/Services/HwKnownDeviceStorage.swift similarity index 91% rename from Bitkit/Services/Trezor/TrezorKnownDeviceStorage.swift rename to Bitkit/Services/HwKnownDeviceStorage.swift index 3b0850552..005f07d0d 100644 --- a/Bitkit/Services/Trezor/TrezorKnownDeviceStorage.swift +++ b/Bitkit/Services/HwKnownDeviceStorage.swift @@ -2,9 +2,9 @@ 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 — +/// 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 { +struct HwKnownDevice: Codable, Identifiable { let id: String let name: String let path: String @@ -22,7 +22,7 @@ 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 @@ -75,12 +75,12 @@ struct TrezorKnownDevice: Codable, Identifiable { } } -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 { @@ -110,7 +110,7 @@ struct PendingHwWalletName: Equatable { /// Persists known Trezor device metadata in UserDefaults /// THP credentials remain in Keychain via TrezorCredentialStorage -enum TrezorKnownDeviceStorage { +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. @@ -121,16 +121,16 @@ enum TrezorKnownDeviceStorage { private static let namesChangedSubject = PassthroughSubject() /// Load all known devices, sorted by most recently connected - static func loadAll() -> [TrezorKnownDevice] { + static func loadAll() -> [HwKnownDevice] { guard let data = UserDefaults.standard.data(forKey: key) else { return [] } - let devices = (try? JSONDecoder().decode([TrezorKnownDevice].self, from: data)) ?? [] + let devices = (try? JSONDecoder().decode([HwKnownDevice].self, from: data)) ?? [] return devices.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) { + static func save(_ device: HwKnownDevice) { var devices = loadAll() devices.removeAll { $0.id == device.id && $0.walletKey == device.walletKey } devices.insert(device, at: 0) @@ -144,7 +144,7 @@ enum TrezorKnownDeviceStorage { /// 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], pendingName: PendingHwWalletName? = nil) { let previousNames = backupSnapshot() if let pendingName { writePendingName(pendingName) @@ -154,7 +154,7 @@ enum TrezorKnownDeviceStorage { } /// Entries tracking one wallet identity. - static func loadAll(walletId: String) -> [TrezorKnownDevice] { + static func loadAll(walletId: String) -> [HwKnownDevice] { loadAll().filter { $0.resolvedWalletId == walletId } } @@ -192,7 +192,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 +207,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 +216,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 +229,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,7 +241,7 @@ enum TrezorKnownDeviceStorage { notifyIfNamesChanged(from: previousNames) } - private static func writeDevices(_ devices: [TrezorKnownDevice]) { + private static func writeDevices(_ devices: [HwKnownDevice]) { guard let data = try? JSONEncoder().encode(devices) else { return } UserDefaults.standard.set(data, forKey: key) } diff --git a/Bitkit/ViewModels/Trezor/HwConnectViewModel.swift b/Bitkit/ViewModels/Trezor/HwConnectViewModel.swift index 079c75d21..5016d39a7 100644 --- a/Bitkit/ViewModels/Trezor/HwConnectViewModel.swift +++ b/Bitkit/ViewModels/Trezor/HwConnectViewModel.swift @@ -376,7 +376,7 @@ struct TrezorHwConnectService: HwConnectServicing { // 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) } + let (paired, unpaired) = trezorManager.devices.partitioned { HwKnownDeviceStorage.isKnown(id: $0.id) } return unpaired + paired } @@ -395,7 +395,7 @@ struct TrezorHwConnectService: HwConnectServicing { walletId: walletId, name: Self.pairedName( walletId: walletId, - storedEntries: TrezorKnownDeviceStorage.loadAll(), + storedEntries: HwKnownDeviceStorage.loadAll(), deviceDefaultName: deviceDefaultName ), deviceDefaultName: deviceDefaultName @@ -411,14 +411,14 @@ struct TrezorHwConnectService: HwConnectServicing { /// to the device's own. Finishing the step then persists that fallback over it. static func pairedName( walletId: String?, - storedEntries: [TrezorKnownDevice], + 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: [TrezorKnownDevice]) -> String? { + static func storedName(walletId: String?, storedEntries: [HwKnownDevice]) -> String? { guard let walletId, let label = storedEntries.first(where: { $0.resolvedWalletId == walletId })?.customLabel, !label.isEmpty @@ -433,7 +433,7 @@ struct TrezorHwConnectService: HwConnectServicing { } func storedName(forWallet walletId: String) -> String? { - Self.storedName(walletId: walletId, storedEntries: TrezorKnownDeviceStorage.loadAll()) + Self.storedName(walletId: walletId, storedEntries: HwKnownDeviceStorage.loadAll()) } func setWalletLabel(walletId: String, label: String) { 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/BitkitTests/HwConnectViewModelTests.swift b/BitkitTests/HwConnectViewModelTests.swift index 767550cd7..3f9daf924 100644 --- a/BitkitTests/HwConnectViewModelTests.swift +++ b/BitkitTests/HwConnectViewModelTests.swift @@ -459,8 +459,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", diff --git a/BitkitTests/TrezorKnownDeviceMatchingTests.swift b/BitkitTests/HwKnownDeviceMatchingTests.swift similarity index 84% rename from BitkitTests/TrezorKnownDeviceMatchingTests.swift rename to BitkitTests/HwKnownDeviceMatchingTests.swift index 94a7da740..75c743415 100644 --- a/BitkitTests/TrezorKnownDeviceMatchingTests.swift +++ b/BitkitTests/HwKnownDeviceMatchingTests.swift @@ -3,14 +3,14 @@ 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 { +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 = TrezorKnownDeviceMatching.previous( + let previous = HwKnownDeviceMatching.previous( in: [standard, hidden], deviceId: "dev1", fetchedXpubs: ["nativeSegwit": "zHidden", "taproot": "zHiddenTR"] @@ -20,11 +20,11 @@ final class TrezorKnownDeviceMatchingTests: XCTestCase { } /// 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. + /// standard wallet's entry, since that would blend two seeds' xpubs into one record. func testTreatsUnseenKeyMaterialAsANewIdentity() { let standard = makeDevice(xpubs: ["nativeSegwit": "zStandard"]) - let previous = TrezorKnownDeviceMatching.previous( + let previous = HwKnownDeviceMatching.previous( in: [standard], deviceId: "dev1", fetchedXpubs: ["nativeSegwit": "zHidden"] @@ -36,7 +36,7 @@ final class TrezorKnownDeviceMatchingTests: XCTestCase { func testAdoptsALoneEntryStoredBeforeAnyXpubWasCaptured() { let bare = makeDevice(xpubs: [:], customLabel: "My Trezor") - let previous = TrezorKnownDeviceMatching.previous( + let previous = HwKnownDeviceMatching.previous( in: [bare], deviceId: "dev1", fetchedXpubs: ["nativeSegwit": "zStandard"] @@ -48,7 +48,7 @@ final class TrezorKnownDeviceMatchingTests: XCTestCase { func testIgnoresEntriesOfAnotherDevice() { let other = makeDevice(id: "dev2", xpubs: ["nativeSegwit": "zStandard"]) - let previous = TrezorKnownDeviceMatching.previous( + let previous = HwKnownDeviceMatching.previous( in: [other], deviceId: "dev1", fetchedXpubs: ["nativeSegwit": "zStandard"] @@ -59,15 +59,15 @@ final class TrezorKnownDeviceMatchingTests: XCTestCase { // MARK: - named(in:previous:walletKey:) - /// The wallet reappears on a fresh transport path, so nothing matches by device id — but it is + /// 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( + let named = HwKnownDeviceMatching.named( in: [previouslyPaired], previous: nil, - walletKey: TrezorKnownDevice.walletKey(for: ["nativeSegwit": "zStandard"], fallback: "dev1") + walletKey: HwKnownDevice.walletKey(for: ["nativeSegwit": "zStandard"], fallback: "dev1") ) XCTAssertEqual(named?.customLabel, "Savings") @@ -77,7 +77,7 @@ final class TrezorKnownDeviceMatchingTests: XCTestCase { let refreshed = makeDevice(xpubs: ["nativeSegwit": "zStandard"], customLabel: "Refreshed") let sameKey = makeDevice(id: "old-path", xpubs: ["nativeSegwit": "zStandard"], customLabel: "Stale") - let named = TrezorKnownDeviceMatching.named( + let named = HwKnownDeviceMatching.named( in: [sameKey, refreshed], previous: refreshed, walletKey: refreshed.walletKey @@ -92,7 +92,7 @@ final class TrezorKnownDeviceMatchingTests: XCTestCase { 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) + let merged = HwKnownDeviceMatching.merged([standard], with: hidden, refreshed: nil) XCTAssertEqual(merged.map(\.walletId), ["trezor:standard", "trezor:hidden"]) } @@ -101,7 +101,7 @@ final class TrezorKnownDeviceMatchingTests: XCTestCase { 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) + let merged = HwKnownDeviceMatching.merged([stored], with: known, refreshed: stored) XCTAssertEqual(merged.map(\.customLabel), ["New"]) } @@ -112,7 +112,7 @@ final class TrezorKnownDeviceMatchingTests: XCTestCase { let partial = makeDevice(xpubs: ["nativeSegwit": "zStandard"]) let complete = makeDevice(xpubs: ["nativeSegwit": "zStandard", "taproot": "zTaproot"]) - let merged = TrezorKnownDeviceMatching.merged([partial], with: complete, refreshed: partial) + let merged = HwKnownDeviceMatching.merged([partial], with: complete, refreshed: partial) XCTAssertEqual(merged.count, 1) XCTAssertEqual(merged[0].xpubs.count, 2) @@ -122,7 +122,7 @@ final class TrezorKnownDeviceMatchingTests: XCTestCase { 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) + let merged = HwKnownDeviceMatching.merged([wiped], with: known, refreshed: nil) XCTAssertEqual(merged.map(\.xpubs), [["nativeSegwit": "zNewSeed"]]) } @@ -142,7 +142,7 @@ final class TrezorKnownDeviceMatchingTests: XCTestCase { trezorDeviceId: "trezor-id" ) - let merged = TrezorKnownDeviceMatching.merged([standard], with: hidden, refreshed: nil) + let merged = HwKnownDeviceMatching.merged([standard], with: hidden, refreshed: nil) XCTAssertEqual(merged.map(\.walletId), ["trezor:standard", "trezor:hidden"]) } @@ -151,7 +151,7 @@ final class TrezorKnownDeviceMatchingTests: XCTestCase { 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) + let merged = HwKnownDeviceMatching.merged([other], with: known, refreshed: nil) XCTAssertEqual(merged.count, 2) } @@ -196,7 +196,7 @@ final class TrezorKnownDeviceMatchingTests: XCTestCase { } """ - let decoded = try JSONDecoder().decode(TrezorKnownDevice.self, from: Data(legacy.utf8)) + let decoded = try JSONDecoder().decode(HwKnownDevice.self, from: Data(legacy.utf8)) XCTAssertNil(decoded.walletId) XCTAssertFalse(decoded.passphraseProtected) @@ -211,8 +211,8 @@ final class TrezorKnownDeviceMatchingTests: XCTestCase { walletId: String? = nil, passphraseProtected: Bool = false, trezorDeviceId: String? = nil - ) -> TrezorKnownDevice { - TrezorKnownDevice( + ) -> HwKnownDevice { + HwKnownDevice( id: id, name: "Trezor", path: "ble://\(id)", diff --git a/BitkitTests/TrezorKnownDeviceStorageTests.swift b/BitkitTests/HwKnownDeviceStorageTests.swift similarity index 53% rename from BitkitTests/TrezorKnownDeviceStorageTests.swift rename to BitkitTests/HwKnownDeviceStorageTests.swift index 9e0f1eb00..132e18cf3 100644 --- a/BitkitTests/TrezorKnownDeviceStorageTests.swift +++ b/BitkitTests/HwKnownDeviceStorageTests.swift @@ -4,7 +4,7 @@ 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 { +final class HwKnownDeviceStorageTests: XCTestCase { private static let storageKey = "trezor.knownDevices" private static let pendingNamesKey = "trezor.pendingWalletNames" private var savedDefaults: Data? @@ -16,12 +16,12 @@ final class TrezorKnownDeviceStorageTests: XCTestCase { savedDefaults = UserDefaults.standard.data(forKey: Self.storageKey) savedPendingNames = UserDefaults.standard.dictionary(forKey: Self.pendingNamesKey) as? [String: String] cancellables = [] - TrezorKnownDeviceStorage.removeAll() + HwKnownDeviceStorage.removeAll() } override func tearDown() { cancellables = [] - TrezorKnownDeviceStorage.removeAll() + HwKnownDeviceStorage.removeAll() if let savedDefaults { UserDefaults.standard.set(savedDefaults, forKey: Self.storageKey) } @@ -32,74 +32,74 @@ final class TrezorKnownDeviceStorageTests: XCTestCase { } func testSavingAPassphraseWalletKeepsTheStandardWalletOfTheSameDevice() { - TrezorKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zStandard"], walletId: "trezor:standard")) - TrezorKnownDeviceStorage.save(makeDevice( + HwKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zStandard"], walletId: "trezor:standard")) + HwKnownDeviceStorage.save(makeDevice( xpubs: ["nativeSegwit": "zHidden"], walletId: "trezor:hidden", passphraseProtected: true )) - let stored = TrezorKnownDeviceStorage.loadAll() + let stored = HwKnownDeviceStorage.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")) + HwKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zStandard"], customLabel: "Old")) + HwKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zStandard"], customLabel: "New")) - XCTAssertEqual(TrezorKnownDeviceStorage.loadAll().map(\.customLabel), ["New"]) + XCTAssertEqual(HwKnownDeviceStorage.loadAll().map(\.customLabel), ["New"]) } func testRemovingOneWalletLeavesTheDevicesOtherWalletsPaired() { - TrezorKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zStandard"], walletId: "trezor:standard")) - TrezorKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zHidden"], walletId: "trezor:hidden")) + HwKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zStandard"], walletId: "trezor:standard")) + HwKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zHidden"], walletId: "trezor:hidden")) - TrezorKnownDeviceStorage.remove(walletId: "trezor:hidden") + HwKnownDeviceStorage.remove(walletId: "trezor:hidden") - XCTAssertEqual(TrezorKnownDeviceStorage.loadAll().compactMap(\.walletId), ["trezor:standard"]) - XCTAssertTrue(TrezorKnownDeviceStorage.isKnown(id: "dev1"), "the device itself stays paired") + 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) - TrezorKnownDeviceStorage.save(legacy) + HwKnownDeviceStorage.save(legacy) - try TrezorKnownDeviceStorage.remove(walletId: HwWalletId.derive(xpubs: legacy.xpubs)) + try HwKnownDeviceStorage.remove(walletId: HwWalletId.derive(xpubs: legacy.xpubs)) - XCTAssertTrue(TrezorKnownDeviceStorage.loadAll().isEmpty) + XCTAssertTrue(HwKnownDeviceStorage.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")) + 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")) - TrezorKnownDeviceStorage.remove(id: "dev1") + HwKnownDeviceStorage.remove(id: "dev1") - XCTAssertEqual(TrezorKnownDeviceStorage.loadAll().compactMap(\.walletId), ["trezor:other"]) + XCTAssertEqual(HwKnownDeviceStorage.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")) + HwKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zStandard"], walletId: "trezor:standard")) + HwKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zHidden"], walletId: "trezor:hidden")) - let entries = TrezorKnownDeviceStorage.loadAll(walletId: "trezor:hidden") + let entries = HwKnownDeviceStorage.loadAll(walletId: "trezor:hidden") XCTAssertEqual(entries.count, 1) XCTAssertEqual(entries.first?.xpubs, ["nativeSegwit": "zHidden"]) } func testNewFieldsSurviveAStorageRoundTrip() { - TrezorKnownDeviceStorage.save(makeDevice( + HwKnownDeviceStorage.save(makeDevice( xpubs: ["nativeSegwit": "zHidden"], walletId: "trezor:hidden", passphraseProtected: true, trezorDeviceId: "trezor-id" )) - let stored = TrezorKnownDeviceStorage.loadAll().first + let stored = HwKnownDeviceStorage.loadAll().first XCTAssertEqual(stored?.walletId, "trezor:hidden") XCTAssertTrue(stored?.passphraseProtected == true) XCTAssertEqual(stored?.trezorDeviceId, "trezor-id") @@ -109,95 +109,95 @@ final class TrezorKnownDeviceStorageTests: XCTestCase { func testAPendingNameAndTheDeviceListAreWrittenTogether() { let device = makeDevice(xpubs: ["nativeSegwit": "zStandard"], customLabel: "Cold", walletId: "trezor:standard") - TrezorKnownDeviceStorage.save(device) + HwKnownDeviceStorage.save(device) - TrezorKnownDeviceStorage.saveAll([], pendingName: PendingHwWalletName(walletId: "trezor:standard", name: "Cold")) + HwKnownDeviceStorage.saveAll([], pendingName: PendingHwWalletName(walletId: "trezor:standard", name: "Cold")) - XCTAssertTrue(TrezorKnownDeviceStorage.loadAll().isEmpty) - XCTAssertEqual(TrezorKnownDeviceStorage.loadPendingNames(), ["trezor:standard": "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() { - TrezorKnownDeviceStorage.setPendingName(walletId: "trezor:standard", name: "Cold") - TrezorKnownDeviceStorage.save( + HwKnownDeviceStorage.setPendingName(walletId: "trezor:standard", name: "Cold") + HwKnownDeviceStorage.save( makeDevice(xpubs: ["nativeSegwit": "zStandard"], customLabel: "Cold", walletId: "trezor:standard") ) - XCTAssertTrue(TrezorKnownDeviceStorage.loadPendingNames().isEmpty) - XCTAssertEqual(TrezorKnownDeviceStorage.backupSnapshot(), ["trezor:standard": "Cold"]) + XCTAssertTrue(HwKnownDeviceStorage.loadPendingNames().isEmpty) + XCTAssertEqual(HwKnownDeviceStorage.backupSnapshot(), ["trezor:standard": "Cold"]) } func testTheNameOfAPairedWalletWinsOverAPendingOne() { - TrezorKnownDeviceStorage.setPendingName(walletId: "trezor:standard", name: "Restored") - TrezorKnownDeviceStorage.save( + HwKnownDeviceStorage.setPendingName(walletId: "trezor:standard", name: "Restored") + HwKnownDeviceStorage.save( makeDevice(xpubs: ["nativeSegwit": "zStandard"], customLabel: "Renamed", walletId: "trezor:standard") ) - XCTAssertEqual(TrezorKnownDeviceStorage.backupSnapshot(), ["trezor:standard": "Renamed"]) + XCTAssertEqual(HwKnownDeviceStorage.backupSnapshot(), ["trezor:standard": "Renamed"]) } func testSettingAPendingNameToNilDropsIt() { - TrezorKnownDeviceStorage.setPendingName(walletId: "trezor:standard", name: "Cold") - TrezorKnownDeviceStorage.setPendingName(walletId: "trezor:standard", name: nil) + HwKnownDeviceStorage.setPendingName(walletId: "trezor:standard", name: "Cold") + HwKnownDeviceStorage.setPendingName(walletId: "trezor:standard", name: nil) - XCTAssertTrue(TrezorKnownDeviceStorage.backupSnapshot().isEmpty) + XCTAssertTrue(HwKnownDeviceStorage.backupSnapshot().isEmpty) } func testRestoringNamesLetsALocalNameWinAndNeverClears() { - TrezorKnownDeviceStorage.setPendingName(walletId: "trezor:standard", name: "Local") + HwKnownDeviceStorage.setPendingName(walletId: "trezor:standard", name: "Local") - TrezorKnownDeviceStorage.restoreNames(["trezor:standard": "Backed up", "trezor:hidden": "Hidden"]) + HwKnownDeviceStorage.restoreNames(["trezor:standard": "Backed up", "trezor:hidden": "Hidden"]) XCTAssertEqual( - TrezorKnownDeviceStorage.backupSnapshot(), + HwKnownDeviceStorage.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([:]) + HwKnownDeviceStorage.restoreNames([:]) XCTAssertEqual( - TrezorKnownDeviceStorage.backupSnapshot(), + HwKnownDeviceStorage.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")) + HwKnownDeviceStorage.setPendingName(walletId: "trezor:hidden", name: "Hidden") + HwKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zHidden"], walletId: "trezor:hidden")) - TrezorKnownDeviceStorage.remove(walletId: "trezor:hidden") + HwKnownDeviceStorage.remove(walletId: "trezor:hidden") - XCTAssertTrue(TrezorKnownDeviceStorage.backupSnapshot().isEmpty) + XCTAssertTrue(HwKnownDeviceStorage.backupSnapshot().isEmpty) } func testRemoveAllClearsPendingNamesToo() { - TrezorKnownDeviceStorage.setPendingName(walletId: "trezor:standard", name: "Cold") + HwKnownDeviceStorage.setPendingName(walletId: "trezor:standard", name: "Cold") - TrezorKnownDeviceStorage.removeAll() + HwKnownDeviceStorage.removeAll() - XCTAssertTrue(TrezorKnownDeviceStorage.backupSnapshot().isEmpty) + 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") - TrezorKnownDeviceStorage.save(device) + HwKnownDeviceStorage.save(device) var fires = 0 - TrezorKnownDeviceStorage.namesChangedPublisher + HwKnownDeviceStorage.namesChangedPublisher .sink { fires += 1 } .store(in: &cancellables) var reconnected = device reconnected.lastConnectedAt = Date(timeIntervalSince1970: 5000) - TrezorKnownDeviceStorage.saveAll([reconnected]) + HwKnownDeviceStorage.saveAll([reconnected]) XCTAssertEqual(fires, 0, "a reconnect must not re-upload the metadata envelope") var renamed = reconnected renamed.customLabel = "Vault" - TrezorKnownDeviceStorage.saveAll([renamed]) + HwKnownDeviceStorage.saveAll([renamed]) XCTAssertEqual(fires, 1) } @@ -208,8 +208,8 @@ final class TrezorKnownDeviceStorageTests: XCTestCase { walletId: String? = nil, passphraseProtected: Bool = false, trezorDeviceId: String? = nil - ) -> TrezorKnownDevice { - TrezorKnownDevice( + ) -> HwKnownDevice { + HwKnownDevice( id: id, name: "Trezor", path: "ble://\(id)", 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..b7705362e 100644 --- a/BitkitTests/HwWalletManagerPassphraseTests.swift +++ b/BitkitTests/HwWalletManagerPassphraseTests.swift @@ -9,8 +9,8 @@ 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? @@ -22,7 +22,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? @@ -107,12 +107,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 = [] } @@ -611,8 +611,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..c3e174466 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)" } From e37021ca971aab717a0026542893274798988320 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Fri, 18 Sep 2026 10:57:05 -0400 Subject: [PATCH 03/18] feat: scope paired hardware devices by vendor --- .../Extensions/TrezorDevice+DisplayName.swift | 20 +- Bitkit/Managers/HwWalletManager.swift | 40 ++-- Bitkit/Managers/TrezorManager.swift | 23 +-- Bitkit/Models/HwWallet.swift | 6 +- Bitkit/Models/HwWalletId.swift | 7 +- Bitkit/Models/HwWalletVendor.swift | 17 ++ Bitkit/Services/HwKnownDeviceMatching.swift | 9 +- Bitkit/Services/HwKnownDeviceStorage.swift | 178 ++++++++++++++---- .../Trezor/HwConnectViewModel.swift | 2 +- BitkitTests/HwKnownDeviceMatchingTests.swift | 165 +++++++++++++++- BitkitTests/HwKnownDeviceStorageTests.swift | 126 ++++++++++++- BitkitTests/HwWalletIdTests.swift | 15 ++ BitkitTests/HwWalletManagerTests.swift | 41 ++++ BitkitTests/HwWalletNameTests.swift | 44 +++++ .../TrezorManagerVendorIsolationTests.swift | 140 ++++++++++++++ 15 files changed, 754 insertions(+), 79 deletions(-) create mode 100644 Bitkit/Models/HwWalletVendor.swift create mode 100644 BitkitTests/TrezorManagerVendorIsolationTests.swift diff --git a/Bitkit/Extensions/TrezorDevice+DisplayName.swift b/Bitkit/Extensions/TrezorDevice+DisplayName.swift index 4098d598b..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 } @@ -16,7 +26,7 @@ func resolveHwWalletName(label: String?, model: String?, customLabel: String? = 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/Managers/HwWalletManager.swift b/Bitkit/Managers/HwWalletManager.swift index 3cd658069..f62a64a3d 100644 --- a/Bitkit/Managers/HwWalletManager.swift +++ b/Bitkit/Managers/HwWalletManager.swift @@ -98,7 +98,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] = [:] @@ -223,7 +223,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 @@ -295,6 +295,12 @@ final class HwWalletManager { (session?.storedDevices ?? knownDevices).filter { $0.resolvedWalletId == walletId } } + /// 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 + } + /// 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) @@ -576,21 +582,26 @@ final class HwWalletManager { 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 +611,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 +906,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 ) } diff --git a/Bitkit/Managers/TrezorManager.swift b/Bitkit/Managers/TrezorManager.swift index 89aafef6b..855674272 100644 --- a/Bitkit/Managers/TrezorManager.swift +++ b/Bitkit/Managers/TrezorManager.swift @@ -585,7 +585,7 @@ final class TrezorManager { // MARK: - Known Devices func loadKnownDevices() { - knownDevices = HwKnownDeviceStorage.loadAll() + knownDevices = HwKnownDeviceStorage.loadAll(vendor: .trezor) } /// Display name for the currently connected device, applying any Bitkit-side custom rename (from @@ -606,7 +606,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 = HwKnownDeviceStorage.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 +618,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 = HwKnownDeviceStorage.loadAll() + let devices = HwKnownDeviceStorage.loadAll(vendor: .trezor) guard devices.contains(where: { $0.resolvedWalletId == walletId }) else { return } applyCustomLabel(newName, to: devices) { $0.resolvedWalletId == walletId } @@ -645,7 +645,7 @@ final class TrezorManager { for walletId in Set(devices.filter(isTarget).compactMap(\.resolvedWalletId)) { HwKnownDeviceStorage.setPendingName(walletId: walletId, name: nil) } - HwKnownDeviceStorage.saveAll(updated) + HwKnownDeviceStorage.saveAll(updated, vendor: .trezor) loadKnownDevices() } @@ -660,7 +660,7 @@ final class TrezorManager { @discardableResult func saveCurrentDeviceAsKnown() async -> Bool { guard let device = connectedDevice else { return false } - let stored = HwKnownDeviceStorage.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 @@ -706,9 +706,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 ) - HwKnownDeviceStorage.saveAll(HwKnownDeviceMatching.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") @@ -726,7 +727,7 @@ final class TrezorManager { { 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 @@ -809,7 +810,7 @@ final class TrezorManager { if let device = known { await clearCredentials(path: device.path) } - HwKnownDeviceStorage.remove(id: id) + HwKnownDeviceStorage.remove(id: id, vendor: .trezor) loadKnownDevices() trezorLog("Forgot device: \(id)") @@ -823,7 +824,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 = HwKnownDeviceStorage.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 +836,7 @@ final class TrezorManager { await clearCredentials(path: entry.path) } - HwKnownDeviceStorage.saveAll(remaining, pendingName: pendingName) + HwKnownDeviceStorage.saveAll(remaining, vendor: .trezor, pendingName: pendingName) loadKnownDevices() trezorLog("Forgot hardware wallet: \(walletId)") 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/Services/HwKnownDeviceMatching.swift b/Bitkit/Services/HwKnownDeviceMatching.swift index 502206442..de6ba51b4 100644 --- a/Bitkit/Services/HwKnownDeviceMatching.swift +++ b/Bitkit/Services/HwKnownDeviceMatching.swift @@ -47,20 +47,21 @@ enum HwKnownDeviceMatching { /// 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: 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/HwKnownDeviceStorage.swift b/Bitkit/Services/HwKnownDeviceStorage.swift index 005f07d0d..793b12603 100644 --- a/Bitkit/Services/HwKnownDeviceStorage.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 HwKnownDevice: 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 @@ -29,6 +29,15 @@ struct HwKnownDevice: Codable, Identifiable { /// 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 HwKnownDevice: 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 HwKnownDevice: 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,6 +102,36 @@ struct HwKnownDevice: 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 } } @@ -87,19 +147,55 @@ extension HwKnownDevice { 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,48 +204,56 @@ struct PendingHwWalletName: Equatable { let name: String? } -/// Persists known Trezor device metadata in UserDefaults -/// THP credentials remain in Keychain via TrezorCredentialStorage +/// 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() -> [HwKnownDevice] { - guard let data = UserDefaults.standard.data(forKey: key) else { return [] } - let devices = (try? JSONDecoder().decode([HwKnownDevice].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. + /// 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() + 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: [HwKnownDevice], 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) } @@ -158,10 +262,11 @@ enum HwKnownDeviceStorage { 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 HwKnownDeviceStorage { ) } - /// 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 HwKnownDeviceStorage { 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 @@ -241,8 +346,17 @@ enum HwKnownDeviceStorage { notifyIfNamesChanged(from: previousNames) } + 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]) { - guard let data = try? JSONEncoder().encode(devices) else { return } + 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/ViewModels/Trezor/HwConnectViewModel.swift b/Bitkit/ViewModels/Trezor/HwConnectViewModel.swift index 5016d39a7..1f7915837 100644 --- a/Bitkit/ViewModels/Trezor/HwConnectViewModel.swift +++ b/Bitkit/ViewModels/Trezor/HwConnectViewModel.swift @@ -376,7 +376,7 @@ struct TrezorHwConnectService: HwConnectServicing { // 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 { HwKnownDeviceStorage.isKnown(id: $0.id) } + let (paired, unpaired) = trezorManager.devices.partitioned { HwKnownDeviceStorage.isKnown(id: $0.id, vendor: .trezor) } return unpaired + paired } diff --git a/BitkitTests/HwKnownDeviceMatchingTests.swift b/BitkitTests/HwKnownDeviceMatchingTests.swift index 75c743415..94354b780 100644 --- a/BitkitTests/HwKnownDeviceMatchingTests.swift +++ b/BitkitTests/HwKnownDeviceMatchingTests.swift @@ -204,25 +204,182 @@ final class HwKnownDeviceMatchingTests: XCTestCase { 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 + trezorDeviceId: String? = nil, + vendor: HwWalletVendor = .trezor, + jadeDeviceId: String? = nil ) -> HwKnownDevice { HwKnownDevice( id: id, - name: "Trezor", - path: "ble://\(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 + 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 index 132e18cf3..db7c7adce 100644 --- a/BitkitTests/HwKnownDeviceStorageTests.swift +++ b/BitkitTests/HwKnownDeviceStorageTests.swift @@ -3,7 +3,8 @@ 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. +/// 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" @@ -76,7 +77,7 @@ final class HwKnownDeviceStorageTests: XCTestCase { HwKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zHidden"], walletId: "trezor:hidden")) HwKnownDeviceStorage.save(makeDevice(id: "dev2", xpubs: ["nativeSegwit": "zOther"], walletId: "trezor:other")) - HwKnownDeviceStorage.remove(id: "dev1") + HwKnownDeviceStorage.remove(id: "dev1", vendor: .trezor) XCTAssertEqual(HwKnownDeviceStorage.loadAll().compactMap(\.walletId), ["trezor:other"]) } @@ -111,7 +112,11 @@ final class HwKnownDeviceStorageTests: XCTestCase { let device = makeDevice(xpubs: ["nativeSegwit": "zStandard"], customLabel: "Cold", walletId: "trezor:standard") HwKnownDeviceStorage.save(device) - HwKnownDeviceStorage.saveAll([], pendingName: PendingHwWalletName(walletId: "trezor:standard", name: "Cold")) + HwKnownDeviceStorage.saveAll( + [], + vendor: .trezor, + pendingName: PendingHwWalletName(walletId: "trezor:standard", name: "Cold") + ) XCTAssertTrue(HwKnownDeviceStorage.loadAll().isEmpty) XCTAssertEqual(HwKnownDeviceStorage.loadPendingNames(), ["trezor:standard": "Cold"]) @@ -192,15 +197,89 @@ final class HwKnownDeviceStorageTests: XCTestCase { var reconnected = device reconnected.lastConnectedAt = Date(timeIntervalSince1970: 5000) - HwKnownDeviceStorage.saveAll([reconnected]) + 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]) + 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], @@ -222,4 +301,41 @@ final class HwKnownDeviceStorageTests: XCTestCase { 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/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/HwWalletManagerTests.swift b/BitkitTests/HwWalletManagerTests.swift index c3e174466..d57e89e12 100644 --- a/BitkitTests/HwWalletManagerTests.swift +++ b/BitkitTests/HwWalletManagerTests.swift @@ -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/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/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" + ) + } +} From ef67ea4370d123b42628467abdeefc961151f2b3 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Fri, 18 Sep 2026 11:06:19 -0400 Subject: [PATCH 04/18] feat: classify jade errors next to trezor ones --- Bitkit/Extensions/HwError+Cancellation.swift | 31 ++++ .../Extensions/JadeError+Cancellation.swift | 50 ++++++ .../Localization/en.lproj/Localizable.strings | 7 + Bitkit/Utilities/HwErrorPresenter.swift | 51 ++++++ Bitkit/ViewModels/AppViewModel.swift | 8 +- Bitkit/ViewModels/HwFundingSigner.swift | 28 +++- Bitkit/ViewModels/TransferViewModel.swift | 19 +-- Bitkit/Views/Wallets/Receive/ReceiveQr.swift | 19 ++- .../Views/Wallets/Send/HwSendSignView.swift | 8 +- BitkitTests/HwErrorPredicateTests.swift | 148 ++++++++++++++++++ BitkitTests/HwErrorPresenterTests.swift | 81 ++++++++++ BitkitTests/HwFundingSignerTests.swift | 129 +++++++++++++++ BitkitTests/TransferViewModelHwTests.swift | 42 ++++- 13 files changed, 591 insertions(+), 30 deletions(-) create mode 100644 Bitkit/Extensions/HwError+Cancellation.swift create mode 100644 Bitkit/Extensions/JadeError+Cancellation.swift create mode 100644 Bitkit/Utilities/HwErrorPresenter.swift create mode 100644 BitkitTests/HwErrorPredicateTests.swift create mode 100644 BitkitTests/HwErrorPresenterTests.swift 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/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/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 9c968fc6d..b6c4f66d0 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -56,6 +56,13 @@ "hardware__found_title" = "Found Device"; "hardware__found_header" = "Found\nTrezor"; "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_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_text" = "Bitkit found funds on your device and added these to your balance."; 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..ccef64380 100644 --- a/Bitkit/ViewModels/HwFundingSigner.swift +++ b/Bitkit/ViewModels/HwFundingSigner.swift @@ -136,7 +136,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 +145,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,6 +178,17 @@ 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) @@ -188,7 +204,7 @@ 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) @@ -199,7 +215,7 @@ struct HwFundingSigner { disconnectAfterTimeout(walletId: walletId) throw HwTransferError.signingTimeout } catch { - if error.isTrezorSessionFailure() { + if error.isHwSessionFailure() { await connecting.disconnectStaleSession(walletId: walletId) } throw error @@ -462,7 +478,7 @@ 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 } diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index c18497cf0..926ac5259 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. @@ -674,7 +675,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 +777,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 +802,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 +817,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/Views/Wallets/Receive/ReceiveQr.swift b/Bitkit/Views/Wallets/Receive/ReceiveQr.swift index 0417ca1c5..20244341a 100644 --- a/Bitkit/Views/Wallets/Receive/ReceiveQr.swift +++ b/Bitkit/Views/Wallets/Receive/ReceiveQr.swift @@ -568,9 +568,7 @@ struct ReceiveQr: View { } catch HwPassphraseError.required { isPassphraseRequired = true } catch { - if !error.isTrezorUserCancellation() { - app.toast(error) - } + showHardwareVerifyError(error) } } @@ -600,9 +598,7 @@ struct ReceiveQr: View { } catch HwPassphraseError.mismatch { app.toast(HwTransferError.passphraseMismatch) } catch { - if !error.isTrezorUserCancellation() { - app.toast(error) - } + showHardwareVerifyError(error) } } } @@ -613,6 +609,17 @@ struct ReceiveQr: View { isVerifyingPassphrase = false } + 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 /// - Parameter bip21: The original BIP21 URI string /// - Returns: BIP21 URI with lightning parameter removed diff --git a/Bitkit/Views/Wallets/Send/HwSendSignView.swift b/Bitkit/Views/Wallets/Send/HwSendSignView.swift index 47d13c8e2..2bd3c0a13 100644 --- a/Bitkit/Views/Wallets/Send/HwSendSignView.swift +++ b/Bitkit/Views/Wallets/Send/HwSendSignView.swift @@ -174,12 +174,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/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..3735a2edd 100644 --- a/BitkitTests/HwFundingSignerTests.swift +++ b/BitkitTests/HwFundingSignerTests.swift @@ -527,6 +527,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/TransferViewModelHwTests.swift b/BitkitTests/TransferViewModelHwTests.swift index edf24f5b6..25cf71a82 100644 --- a/BitkitTests/TransferViewModelHwTests.swift +++ b/BitkitTests/TransferViewModelHwTests.swift @@ -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 { From 0841cdab62186e2d068d12ecfc09ca25d27ff10f Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Fri, 18 Sep 2026 11:23:34 -0400 Subject: [PATCH 05/18] feat: route hardware wallet sessions by vendor --- Bitkit/AppScene.swift | 6 +- Bitkit/Managers/HwDeviceSessioning.swift | 56 ++ Bitkit/Managers/HwWalletManager.swift | 397 +++++++- Bitkit/Managers/TrezorManager.swift | 16 + Bitkit/ViewModels/HwFundingSigner.swift | 6 +- Bitkit/ViewModels/TransferViewModel.swift | 8 +- .../Trezor/HwConnectViewModel.swift | 2 +- .../Sheets/RenameHardwareWalletSheet.swift | 8 +- BitkitTests/HwFundingSignerTests.swift | 27 +- BitkitTests/HwTransferMocks.swift | 9 + .../HwWalletManagerPassphraseTests.swift | 19 +- BitkitTests/HwWalletManagerVendorTests.swift | 894 ++++++++++++++++++ BitkitTests/TransferViewModelHwTests.swift | 2 +- 13 files changed, 1393 insertions(+), 57 deletions(-) create mode 100644 BitkitTests/HwWalletManagerVendorTests.swift diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 86e18d2b6..ac106e5f3 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -258,7 +258,7 @@ 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 hwWalletManager = HwWalletManager(trezorSession: trezorManager) _transfer = StateObject(wrappedValue: TransferViewModel( transferService: transferService, @@ -331,7 +331,7 @@ struct AppScene: View { .onChange(of: trezorManager.devicesRevision) { _, _ in pushHardwareDevices() } .onChange(of: isPinVerified) { _, verified in if verified { - Task { await trezorManager.autoReconnect() } + Task { await hwWalletManager.reconnectOnForeground() } } } .onReceive(settings.settingsPublisher) { _ in hwWalletManager.reconcileForSettingsChange() } @@ -943,7 +943,7 @@ struct AppScene: View { if newPhase == .active { // 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 { diff --git a/Bitkit/Managers/HwDeviceSessioning.swift b/Bitkit/Managers/HwDeviceSessioning.swift index 4b6137aad..0ef2cf907 100644 --- a/Bitkit/Managers/HwDeviceSessioning.swift +++ b/Bitkit/Managers/HwDeviceSessioning.swift @@ -15,6 +15,9 @@ protocol TrezorSessioning: AnyObject, Sendable { /// 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,11 @@ protocol TrezorSessioning: 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 + /// Silently reconnects a known device, as on returning to the foreground. + func autoReconnect() async func isKnownBluetoothDevice(deviceId: String) -> Bool func warmUpConnection(deviceId: String) /// Forgets every stored entry of `walletId`, keeping transport credentials while another @@ -34,6 +42,7 @@ protocol TrezorSessioning: 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: TrezorSessioning { @@ -49,3 +58,50 @@ extension TrezorManager: TrezorSessioning { 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 before 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 f62a64a3d..4f3d82fc8 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 `TrezorSessioning` 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: TrezorSessioning? + /// 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. @@ -121,9 +133,13 @@ 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 init( - session: TrezorSessioning? = nil, + trezorSession: TrezorSessioning? = nil, + jadeSession: JadeSessioning? = nil, watcherService: OnChainWatcherServicing = OnChainHwService.shared, monitoredTypes: (() -> Set)? = nil, electrumUrl: (() -> String)? = nil, @@ -140,12 +156,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 +174,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 @@ -210,7 +231,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 @@ -278,13 +299,28 @@ 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 @@ -292,7 +328,14 @@ final class HwWalletManager { /// 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) -> [HwKnownDevice] { - (session?.storedDevices ?? knownDevices).filter { $0.resolvedWalletId == walletId } + 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, @@ -301,10 +344,19 @@ final class HwWalletManager { 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 @@ -327,7 +379,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 @@ -339,14 +391,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) @@ -384,10 +467,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 { @@ -405,11 +498,29 @@ 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) + 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 { @@ -418,7 +529,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 @@ -426,10 +537,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 } } @@ -438,13 +551,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) { @@ -455,7 +578,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 @@ -463,7 +594,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) @@ -503,6 +642,119 @@ 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) + 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: + // Launched rather than awaited so a scan for a Trezor that is not around does not hold + // the lock; `TrezorManager` runs its own connection work one at a time. + Task { [weak trezorSession] in await trezorSession?.autoReconnect() } + 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 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 @@ -1009,6 +1261,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 @@ -1036,6 +1291,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( @@ -1070,7 +1365,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 @@ -1091,7 +1386,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, @@ -1099,7 +1395,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, @@ -1110,6 +1406,18 @@ 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) + return try await jadeSession.masterFingerprint() + } + /// Offline coin-selection for the exact funding amount; returns the mining fee only. func estimateOfflineFundingMiningFee( walletId: String, @@ -1151,7 +1459,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( @@ -1178,12 +1486,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) @@ -1197,6 +1508,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( diff --git a/Bitkit/Managers/TrezorManager.swift b/Bitkit/Managers/TrezorManager.swift index 855674272..5d4aa1073 100644 --- a/Bitkit/Managers/TrezorManager.swift +++ b/Bitkit/Managers/TrezorManager.swift @@ -429,6 +429,22 @@ final class TrezorManager { connectedDevice != nil } + var isSessionActive: Bool { + connectedDevice != nil || isAutoReconnecting || isConnectionOperationActive + } + + func releaseSession() async { + 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() + } + }.value + } + // MARK: - UI Callbacks func submitPin(_ pin: String) { diff --git a/Bitkit/ViewModels/HwFundingSigner.swift b/Bitkit/ViewModels/HwFundingSigner.swift index ccef64380..610d31b54 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. @@ -127,7 +127,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 { @@ -579,7 +579,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) ) } diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 926ac5259..3c4d0e168 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -97,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 @@ -190,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 @@ -241,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() diff --git a/Bitkit/ViewModels/Trezor/HwConnectViewModel.swift b/Bitkit/ViewModels/Trezor/HwConnectViewModel.swift index 1f7915837..8d38e29b0 100644 --- a/Bitkit/ViewModels/Trezor/HwConnectViewModel.swift +++ b/Bitkit/ViewModels/Trezor/HwConnectViewModel.swift @@ -437,7 +437,7 @@ struct TrezorHwConnectService: HwConnectServicing { } func setWalletLabel(walletId: String, label: String) { - trezorManager.renameWallet(walletId: walletId, newName: label) + hwWalletManager.renameWallet(walletId: walletId, newName: label) } func cancelPairingCode() { 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/BitkitTests/HwFundingSignerTests.swift b/BitkitTests/HwFundingSignerTests.swift index 3735a2edd..a6d86dd24 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, @@ -377,6 +377,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 +413,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 +432,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 +451,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 +498,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...") diff --git a/BitkitTests/HwTransferMocks.swift b/BitkitTests/HwTransferMocks.swift index b66268caf..c581574eb 100644 --- a/BitkitTests/HwTransferMocks.swift +++ b/BitkitTests/HwTransferMocks.swift @@ -123,6 +123,8 @@ final class MockHwFunding: HwTransferFunding { @MainActor final class MockHwConnecting: HwTransferConnecting { var connectError: Error? + var connectDelay: Double = 0 + 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 +136,18 @@ final class MockHwConnecting: HwTransferConnecting { func ensureConnected(walletId _: String) async throws { ensureCalls += 1 + 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/HwWalletManagerPassphraseTests.swift b/BitkitTests/HwWalletManagerPassphraseTests.swift index b7705362e..6904132df 100644 --- a/BitkitTests/HwWalletManagerPassphraseTests.swift +++ b/BitkitTests/HwWalletManagerPassphraseTests.swift @@ -14,6 +14,7 @@ final class HwWalletManagerPassphraseTests: XCTestCase { 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 @@ -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 autoReconnectCalls = 0 + private(set) var renameCalls: [(walletId: String, newName: String)] = [] func ensureConnected(deviceId: String) async throws { ensureCalls.append(deviceId) @@ -99,6 +103,19 @@ final class HwWalletManagerPassphraseTests: XCTestCase { connectedWalletId = nil } } + + func releaseSession() async { + releaseCalls += 1 + isSessionActive = false + } + + func autoReconnect() async { + autoReconnectCalls += 1 + } + + func renameWallet(walletId: String, newName: String) { + renameCalls.append((walletId, newName)) + } } private final class NoopWatcher: OnChainWatcherServicing, @unchecked Sendable { @@ -587,7 +604,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" }, diff --git a/BitkitTests/HwWalletManagerVendorTests.swift b/BitkitTests/HwWalletManagerVendorTests.swift new file mode 100644 index 000000000..4f321ac79 --- /dev/null +++ b/BitkitTests/HwWalletManagerVendorTests.swift @@ -0,0 +1,894 @@ +@testable import Bitkit +import BitkitCore +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 = [] + + 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 autoReconnectCalls = 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 + isSessionActive = false + connectedDeviceId = nil + connectedWalletId = nil + } + + func autoReconnect() async { + log.record("trezor.autoReconnect") + autoReconnectCalls += 1 + } + + 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 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") + 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 savedDefaults: Data? + private var savedPendingNames: [String: String]? + + override func setUp() { + super.setUp() + log = CallLog() + trezor = FakeTrezorSession(log: log) + jade = FakeJadeSession(log: log) + 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 + 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)"]) + } + + // 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.autoReconnectCalls, 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.autoReconnectCalls, 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.autoReconnectCalls, 0) + } + + func testForegroundReconnectDefaultsToTrezor() async { + let manager = makeManager() + + await manager.reconnectOnForeground() + await waitUntil { self.trezor.autoReconnectCalls == 1 } + + XCTAssertEqual(trezor.autoReconnectCalls, 1) + XCTAssertEqual(jade.startAutoReconnectCalls, 0) + } + + func testForegroundReconnectOfATrezorReleasesAPendingJadeFirst() async { + trezor.connectedDeviceId = trezorDeviceId + jade.isSessionActive = true + let manager = makeManager() + + await manager.reconnectOnForeground() + await waitUntil { self.trezor.autoReconnectCalls == 1 } + + XCTAssertEqual(log.entries, ["jade.release", "trezor.autoReconnect"]) + } + + // 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) + } + + // 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"]) + } + + 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() + await manager.resetForWipe() + + XCTAssertEqual(log.entries, ["jade.backgrounded", "jade.active", "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, + 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/TransferViewModelHwTests.swift b/BitkitTests/TransferViewModelHwTests.swift index 25cf71a82..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, From 79533a2793761f912f50b478c19677d4e92f46a5 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Fri, 18 Sep 2026 11:41:52 -0400 Subject: [PATCH 06/18] feat: add the jade bluetooth transport --- Bitkit/Services/Jade/JadeBLEError.swift | 86 ++ Bitkit/Services/Jade/JadeBLELinkState.swift | 249 ++++++ Bitkit/Services/Jade/JadeBLEManager.swift | 735 ++++++++++++++++++ Bitkit/Services/Jade/JadeTransport.swift | 144 ++++ Bitkit/Services/Trezor/TrezorBLEManager.swift | 49 -- Bitkit/Utilities/BlockingQueue.swift | 56 ++ Bitkit/Utilities/HwDevicePath.swift | 20 + BitkitTests/BlockingQueueTests.swift | 66 ++ BitkitTests/JadeBLELinkStateTests.swift | 262 +++++++ BitkitTests/JadeMocks.swift | 110 +++ BitkitTests/JadeTransportTests.swift | 318 ++++++++ 11 files changed, 2046 insertions(+), 49 deletions(-) create mode 100644 Bitkit/Services/Jade/JadeBLEError.swift create mode 100644 Bitkit/Services/Jade/JadeBLELinkState.swift create mode 100644 Bitkit/Services/Jade/JadeBLEManager.swift create mode 100644 Bitkit/Services/Jade/JadeTransport.swift create mode 100644 Bitkit/Utilities/BlockingQueue.swift create mode 100644 Bitkit/Utilities/HwDevicePath.swift create mode 100644 BitkitTests/BlockingQueueTests.swift create mode 100644 BitkitTests/JadeBLELinkStateTests.swift create mode 100644 BitkitTests/JadeMocks.swift create mode 100644 BitkitTests/JadeTransportTests.swift 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/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/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/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/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/JadeMocks.swift b/BitkitTests/JadeMocks.swift new file mode 100644 index 000000000..11c8a15e3 --- /dev/null +++ b/BitkitTests/JadeMocks.swift @@ -0,0 +1,110 @@ +@testable import Bitkit +import Combine +import Foundation + +/// 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 } + } +} 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")) + } +} From 7c140121da6ed564b907e7c4e258bc63db90d6f9 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Fri, 18 Sep 2026 12:13:01 -0400 Subject: [PATCH 07/18] feat: add the jade session manager --- Bitkit/Extensions/LDKNode+Jade.swift | 36 + Bitkit/Managers/HwDeviceSessioning.swift | 4 +- Bitkit/Managers/JadeManager.swift | 902 ++++++++++++++++++ Bitkit/Models/JadeDevice.swift | 81 ++ .../Services/Jade/JadeKnownDeviceStore.swift | 31 + Bitkit/Services/Jade/JadeService.swift | 157 +++ .../Utilities/BackgroundTaskScheduling.swift | 30 + BitkitTests/JadeDeviceIdentityTests.swift | 117 +++ BitkitTests/JadeManagerTests.swift | 882 +++++++++++++++++ BitkitTests/JadeMocks.swift | 427 +++++++++ BitkitTests/JadeServiceTests.swift | 18 + 11 files changed, 2683 insertions(+), 2 deletions(-) create mode 100644 Bitkit/Extensions/LDKNode+Jade.swift create mode 100644 Bitkit/Managers/JadeManager.swift create mode 100644 Bitkit/Models/JadeDevice.swift create mode 100644 Bitkit/Services/Jade/JadeKnownDeviceStore.swift create mode 100644 Bitkit/Services/Jade/JadeService.swift create mode 100644 Bitkit/Utilities/BackgroundTaskScheduling.swift create mode 100644 BitkitTests/JadeDeviceIdentityTests.swift create mode 100644 BitkitTests/JadeManagerTests.swift create mode 100644 BitkitTests/JadeServiceTests.swift 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/Managers/HwDeviceSessioning.swift b/Bitkit/Managers/HwDeviceSessioning.swift index 0ef2cf907..53ef757e0 100644 --- a/Bitkit/Managers/HwDeviceSessioning.swift +++ b/Bitkit/Managers/HwDeviceSessioning.swift @@ -84,8 +84,8 @@ protocol JadeSessioning: AnyObject, Sendable { 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 before the core session, so a device call waiting on the link lets go of core. - /// A no-op while another Jade is connected. + /// 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. diff --git a/Bitkit/Managers/JadeManager.swift b/Bitkit/Managers/JadeManager.swift new file mode 100644 index 000000000..7d72c5ebc --- /dev/null +++ b/Bitkit/Managers/JadeManager.swift @@ -0,0 +1,902 @@ +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() + 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/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/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/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/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..fdbf7f884 --- /dev/null +++ b/BitkitTests/JadeManagerTests.swift @@ -0,0 +1,882 @@ +@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 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: - 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 index 11c8a15e3..f15165e81 100644 --- a/BitkitTests/JadeMocks.swift +++ b/BitkitTests/JadeMocks.swift @@ -1,6 +1,8 @@ @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. @@ -108,3 +110,428 @@ final class FakeBLEDriver: JadeBLEDriving, @unchecked Sendable { 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)") + } + } +} From 4704827b139b6e5c64eb4f166b5a83f42f993f4e Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Fri, 18 Sep 2026 12:24:21 -0400 Subject: [PATCH 08/18] feat: wire jade into the app lifecycle --- Bitkit.xcodeproj/project.pbxproj | 4 +-- Bitkit/AppScene.swift | 35 +++++++++++++++----- Bitkit/BitkitApp.swift | 2 ++ Bitkit/Info.plist | 4 +-- Bitkit/Managers/HwWalletManager.swift | 8 +++++ Bitkit/Utilities/AppReset.swift | 7 ++++ BitkitTests/HwWalletManagerVendorTests.swift | 35 ++++++++++++++++++++ BitkitTests/JadeManagerTests.swift | 27 +++++++++++++++ 8 files changed, 110 insertions(+), 12 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 535843b4a..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; diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index ac106e5f3..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(trezorSession: 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,9 +330,10 @@ 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 hwWalletManager.reconnectOnForeground() } @@ -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,9 +946,13 @@ 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 hwWalletManager.reconnectOnForeground() } @@ -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/BitkitApp.swift b/Bitkit/BitkitApp.swift index 7dc43ffc2..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() } } 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/HwWalletManager.swift b/Bitkit/Managers/HwWalletManager.swift index 4f3d82fc8..041a68bff 100644 --- a/Bitkit/Managers/HwWalletManager.swift +++ b/Bitkit/Managers/HwWalletManager.swift @@ -136,10 +136,12 @@ final class HwWalletManager { @ObservationIgnored private var isSessionOperationActive = false @ObservationIgnored private var sessionOperationWaiters: [CheckedContinuation] = [] @ObservationIgnored private var isAppActive = true + @ObservationIgnored private var jadeBluetoothPoweredOnSubscription: AnyCancellable? init( trezorSession: TrezorSessioning? = nil, jadeSession: JadeSessioning? = nil, + jadeBluetoothPoweredOn: AnyPublisher = JadeTransport.shared.bluetoothPoweredOn, watcherService: OnChainWatcherServicing = OnChainHwService.shared, monitoredTypes: (() -> Set)? = nil, electrumUrl: (() -> String)? = nil, @@ -195,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 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/BitkitTests/HwWalletManagerVendorTests.swift b/BitkitTests/HwWalletManagerVendorTests.swift index 4f321ac79..2565e7f7c 100644 --- a/BitkitTests/HwWalletManagerVendorTests.swift +++ b/BitkitTests/HwWalletManagerVendorTests.swift @@ -1,5 +1,6 @@ @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 @@ -239,6 +240,7 @@ final class HwWalletManagerVendorTests: XCTestCase { 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]? @@ -247,6 +249,7 @@ final class HwWalletManagerVendorTests: XCTestCase { 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() @@ -262,6 +265,7 @@ final class HwWalletManagerVendorTests: XCTestCase { } trezor = nil jade = nil + jadeBluetoothPoweredOn = nil super.tearDown() } @@ -532,6 +536,36 @@ final class HwWalletManagerVendorTests: XCTestCase { 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.autoReconnectCalls, 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 { @@ -807,6 +841,7 @@ final class HwWalletManagerVendorTests: XCTestCase { HwWalletManager( trezorSession: trezor, jadeSession: jade, + jadeBluetoothPoweredOn: jadeBluetoothPoweredOn.eraseToAnyPublisher(), watcherService: NoopWatcher(), monitoredTypes: { ["nativeSegwit"] }, electrumUrl: { "ssl://test:1" }, diff --git a/BitkitTests/JadeManagerTests.swift b/BitkitTests/JadeManagerTests.swift index fdbf7f884..841758ef6 100644 --- a/BitkitTests/JadeManagerTests.swift +++ b/BitkitTests/JadeManagerTests.swift @@ -846,6 +846,33 @@ final class JadeManagerTests: XCTestCase { 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 { From 771f4658c7e0ba38b055b87e47eca98daca5a6d0 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Fri, 18 Sep 2026 12:39:40 -0400 Subject: [PATCH 09/18] feat: pair a jade from connect hardware --- .../jade-placeholder.imageset/Contents.json | 15 + .../jade-placeholder.svg | 10 + Bitkit/Extensions/HwWalletVendor+UI.swift | 63 +++ .../Localization/en.lproj/Localizable.strings | 9 +- .../ViewModels/Trezor/HwConnectService.swift | 195 +++++++++ .../Trezor/HwConnectViewModel.swift | 241 +++++------ .../Sheets/HardwareConnect/HwFoundView.swift | 32 +- .../Sheets/HardwareConnect/HwPairedView.swift | 29 +- .../Views/Sheets/HardwareConnectSheet.swift | 15 +- .../Views/Wallets/HardwareWalletScreen.swift | 12 +- .../Views/Wallets/Send/SendAmountView.swift | 2 +- .../Wallets/Send/SendConfirmationView.swift | 2 +- BitkitTests/HwConnectViewModelTests.swift | 387 +++++++++++++++++- .../HwWalletVendorPresentationTests.swift | 60 +++ 14 files changed, 920 insertions(+), 152 deletions(-) create mode 100644 Bitkit/Assets.xcassets/Illustrations/jade-placeholder.imageset/Contents.json create mode 100644 Bitkit/Assets.xcassets/Illustrations/jade-placeholder.imageset/jade-placeholder.svg create mode 100644 Bitkit/Extensions/HwWalletVendor+UI.swift create mode 100644 Bitkit/ViewModels/Trezor/HwConnectService.swift create mode 100644 BitkitTests/HwWalletVendorPresentationTests.swift 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/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/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index b6c4f66d0..54dfba1f8 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -43,20 +43,23 @@ "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."; @@ -65,6 +68,7 @@ "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"; @@ -92,6 +96,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/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 8d38e29b0..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 { HwKnownDeviceStorage.isKnown(id: $0.id, vendor: .trezor) } - 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: HwKnownDeviceStorage.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: [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() - } -} - -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/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/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/BitkitTests/HwConnectViewModelTests.swift b/BitkitTests/HwConnectViewModelTests.swift index 3f9daf924..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" @@ -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/HwWalletVendorPresentationTests.swift b/BitkitTests/HwWalletVendorPresentationTests.swift new file mode 100644 index 000000000..56d824136 --- /dev/null +++ b/BitkitTests/HwWalletVendorPresentationTests.swift @@ -0,0 +1,60 @@ +@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) + } + } +} From f3928e150ff4adf2c7aa7b78efb5f86295cccc86 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Fri, 18 Sep 2026 12:51:36 -0400 Subject: [PATCH 10/18] feat: sign with a jade from send and transfer --- Bitkit/ViewModels/HwFundingSigner.swift | 71 ++++- .../Transfer/Hardware/SpendingHwSign.swift | 23 +- .../Views/Wallets/Send/HwSendSignView.swift | 10 +- Bitkit/Views/Wallets/Send/SendSheet.swift | 2 +- BitkitTests/HwFundingSignerTests.swift | 245 ++++++++++++++++++ BitkitTests/HwTransferMocks.swift | 15 ++ 6 files changed, 343 insertions(+), 23 deletions(-) diff --git a/Bitkit/ViewModels/HwFundingSigner.swift b/Bitkit/ViewModels/HwFundingSigner.swift index 610d31b54..15f55d707 100644 --- a/Bitkit/ViewModels/HwFundingSigner.swift +++ b/Bitkit/ViewModels/HwFundingSigner.swift @@ -83,14 +83,21 @@ struct HwFundingSigner { } /// Reconnects, composes and signs a normal on-chain payment without broadcasting it. + /// `onConnectingDevice` brackets the initial reconnect (true before it, false once it returns or + /// throws), the one phase a caller may abandon because nothing has reached 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) @@ -332,6 +339,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 @@ -339,6 +347,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 @@ -351,6 +363,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 @@ -387,6 +406,7 @@ final class HwSendCoordinator { isFundingSourceLoading = walletId != nil && showsLoading isPreviewLoading = false isSigning = false + isConnectingDevice = false isBroadcastUnresolved = false isPassphraseRequired = false isVerifyingPassphrase = false @@ -482,11 +502,19 @@ final class HwSendCoordinator { 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 @@ -496,13 +524,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 } @@ -522,9 +559,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 } @@ -562,11 +603,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( @@ -594,4 +644,9 @@ final class HwSendCoordinator { let signedTx: HwFundingSignedTx var isPreparedForBroadcast = false } + + private struct OperationSession { + let walletId: String + let connecting: HwTransferConnecting + } } 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/Wallets/Send/HwSendSignView.swift b/Bitkit/Views/Wallets/Send/HwSendSignView.swift index 2bd3c0a13..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 }, 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/HwFundingSignerTests.swift b/BitkitTests/HwFundingSignerTests.swift index a6d86dd24..3b111d6a0 100644 --- a/BitkitTests/HwFundingSignerTests.swift +++ b/BitkitTests/HwFundingSignerTests.swift @@ -289,6 +289,251 @@ 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 testConnectingIsReportedAroundTheFirstReconnectOnly() 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], "the reconnect before the sign retry is not reported") + + 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 { diff --git a/BitkitTests/HwTransferMocks.swift b/BitkitTests/HwTransferMocks.swift index c581574eb..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)) } @@ -124,6 +134,8 @@ final class MockHwFunding: HwTransferFunding { 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. @@ -136,6 +148,9 @@ 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)) } From 57beeeacbe639eae32ddb6d25e09a1438dbb512b Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Fri, 18 Sep 2026 13:00:48 -0400 Subject: [PATCH 11/18] feat: label the hardware receive tab by vendor --- Bitkit/Components/SegmentedControl.swift | 17 ++- Bitkit/Components/TabBar/TabBar.swift | 2 +- .../Localization/en.lproj/Localizable.strings | 1 + Bitkit/Utilities/HwEngagedSession.swift | 50 +++++++ .../Views/Wallets/Receive/ReceiveEdit.swift | 2 +- Bitkit/Views/Wallets/Receive/ReceiveQr.swift | 72 +++++++--- BitkitTests/HwEngagedSessionTests.swift | 128 ++++++++++++++++++ .../HwWalletVendorPresentationTests.swift | 19 +++ 8 files changed, 266 insertions(+), 25 deletions(-) create mode 100644 Bitkit/Utilities/HwEngagedSession.swift create mode 100644 BitkitTests/HwEngagedSessionTests.swift 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/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 54dfba1f8..6b00f4679 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -86,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."; 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/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 20244341a..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,7 +577,9 @@ 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 { @@ -589,10 +606,12 @@ 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 { @@ -607,6 +626,19 @@ 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) { 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/HwWalletVendorPresentationTests.swift b/BitkitTests/HwWalletVendorPresentationTests.swift index 56d824136..80f1c4edc 100644 --- a/BitkitTests/HwWalletVendorPresentationTests.swift +++ b/BitkitTests/HwWalletVendorPresentationTests.swift @@ -57,4 +57,23 @@ final class HwWalletVendorPresentationTests: XCTestCase { 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") + } } From 56abc4054534ce42e57b16072d8ef70aea99ad57 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Fri, 18 Sep 2026 13:01:40 -0400 Subject: [PATCH 12/18] docs: note jade coverage in hardware wallet journeys --- journeys/README.md | 1 + journeys/hardware-wallet/README.md | 12 ++++++++++++ 2 files changed, 13 insertions(+) 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`. From 527ace74bd9114f7ad3ece3662edd9973c14f971 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Fri, 18 Sep 2026 13:01:40 -0400 Subject: [PATCH 13/18] chore: add changelog fragment --- changelog.d/next/765.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/next/765.added.md 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. From bc6edccbd0f91d3dc7433e07eabe8d4f65376d5b Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Fri, 18 Sep 2026 13:39:18 -0400 Subject: [PATCH 14/18] fix: stop cancelled jade connects and release stalled links --- Bitkit/Managers/HwWalletManager.swift | 14 ++- Bitkit/Managers/JadeManager.swift | 2 + Bitkit/Managers/TrezorManager.swift | 3 + Bitkit/ViewModels/HwFundingSigner.swift | 19 +++- BitkitTests/HwFundingSignerTests.swift | 41 +++++++- BitkitTests/HwWalletManagerVendorTests.swift | 77 ++++++++++++++ BitkitTests/JadeManagerTests.swift | 100 +++++++++++++++++++ 7 files changed, 248 insertions(+), 8 deletions(-) diff --git a/Bitkit/Managers/HwWalletManager.swift b/Bitkit/Managers/HwWalletManager.swift index 041a68bff..0d88dba24 100644 --- a/Bitkit/Managers/HwWalletManager.swift +++ b/Bitkit/Managers/HwWalletManager.swift @@ -515,6 +515,8 @@ final class HwWalletManager { 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( @@ -657,6 +659,8 @@ final class HwWalletManager { 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() } } @@ -1423,7 +1427,15 @@ final class HwWalletManager { } // Without the key origins this carries, the Jade finds nothing of its own to sign. try await ensureConnected(walletId: walletId) - return try await jadeSession.masterFingerprint() + 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. diff --git a/Bitkit/Managers/JadeManager.swift b/Bitkit/Managers/JadeManager.swift index 7d72c5ebc..c1ff47c7e 100644 --- a/Bitkit/Managers/JadeManager.swift +++ b/Bitkit/Managers/JadeManager.swift @@ -158,6 +158,8 @@ final class JadeManager { 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 diff --git a/Bitkit/Managers/TrezorManager.swift b/Bitkit/Managers/TrezorManager.swift index 5d4aa1073..bb41b96cf 100644 --- a/Bitkit/Managers/TrezorManager.swift +++ b/Bitkit/Managers/TrezorManager.swift @@ -441,6 +441,9 @@ final class TrezorManager { 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 } diff --git a/Bitkit/ViewModels/HwFundingSigner.swift b/Bitkit/ViewModels/HwFundingSigner.swift index 15f55d707..2eb615118 100644 --- a/Bitkit/ViewModels/HwFundingSigner.swift +++ b/Bitkit/ViewModels/HwFundingSigner.swift @@ -83,8 +83,9 @@ struct HwFundingSigner { } /// Reconnects, composes and signs a normal on-chain payment without broadcasting it. - /// `onConnectingDevice` brackets the initial reconnect (true before it, false once it returns or - /// throws), the one phase a caller may abandon because nothing has reached the device to sign yet. + /// `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, @@ -100,7 +101,7 @@ struct HwFundingSigner { } 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. @@ -202,7 +203,11 @@ struct HwFundingSigner { } } - 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 { @@ -214,7 +219,11 @@ struct HwFundingSigner { 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) diff --git a/BitkitTests/HwFundingSignerTests.swift b/BitkitTests/HwFundingSignerTests.swift index 3b111d6a0..84e65dc51 100644 --- a/BitkitTests/HwFundingSignerTests.swift +++ b/BitkitTests/HwFundingSignerTests.swift @@ -462,7 +462,44 @@ final class HwFundingSignerTests: XCTestCase { XCTAssertTrue(connecting.staleDisconnects.isEmpty, "a finished payment keeps its session") } - func testConnectingIsReportedAroundTheFirstReconnectOnly() async throws { + 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() @@ -478,7 +515,7 @@ final class HwFundingSignerTests: XCTestCase { ) XCTAssertEqual(connecting.ensureCalls, 2) - XCTAssertEqual(reports, [true, false], "the reconnect before the sign retry is not reported") + XCTAssertEqual(reports, [true, false, true, false], "the reconnect before the sign retry is reported too") reports = [] connecting.connectError = MockHwFunding.TestError() diff --git a/BitkitTests/HwWalletManagerVendorTests.swift b/BitkitTests/HwWalletManagerVendorTests.swift index 2565e7f7c..bf2d93874 100644 --- a/BitkitTests/HwWalletManagerVendorTests.swift +++ b/BitkitTests/HwWalletManagerVendorTests.swift @@ -29,6 +29,8 @@ final class HwWalletManagerVendorTests: XCTestCase { 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] = [] @@ -79,6 +81,7 @@ final class HwWalletManagerVendorTests: XCTestCase { func releaseSession() async { log.record("trezor.release") releaseCalls += 1 + await releaseGate?.wait() isSessionActive = false connectedDeviceId = nil connectedWalletId = nil @@ -118,6 +121,7 @@ final class HwWalletManagerVendorTests: XCTestCase { 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)? @@ -164,6 +168,9 @@ final class HwWalletManagerVendorTests: XCTestCase { func masterFingerprint() async throws -> String { log.record("jade.fingerprint") + if let fingerprintError { + throw fingerprintError + } return fingerprint } @@ -435,6 +442,54 @@ final class HwWalletManagerVendorTests: XCTestCase { 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 { @@ -695,6 +750,28 @@ final class HwWalletManagerVendorTests: XCTestCase { 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 diff --git a/BitkitTests/JadeManagerTests.swift b/BitkitTests/JadeManagerTests.swift index 841758ef6..f76b8e5e9 100644 --- a/BitkitTests/JadeManagerTests.swift +++ b/BitkitTests/JadeManagerTests.swift @@ -829,6 +829,106 @@ final class JadeManagerTests: XCTestCase { 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() From 01b3be8c0f08bac3951794b086aec4abdab8a5c1 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Fri, 18 Sep 2026 14:10:21 -0400 Subject: [PATCH 15/18] fix: keep trezor reconnects under the session lock and reset them on wipe The foreground Trezor reconnect was launched in a task that only marked the session active once it ran, so a Jade operation taking the lock in between could dial alongside it. The reconnect now registers before the lock is released and is cancelled when the session is released. A wipe only tore down the Jade, so a Trezor reconnect finishing after the wipe could save its device back. The Trezor session is now released and its loaded devices dropped before the wipe clears storage. --- Bitkit/Managers/HwDeviceSessioning.swift | 7 ++- Bitkit/Managers/HwWalletManager.swift | 8 +-- Bitkit/Managers/TrezorManager.swift | 32 +++++++++++- .../HwWalletManagerPassphraseTests.swift | 8 +-- BitkitTests/HwWalletManagerVendorTests.swift | 52 ++++++++++++++----- BitkitTests/TrezorManagerSessionTests.swift | 48 +++++++++++++++++ 6 files changed, 133 insertions(+), 22 deletions(-) create mode 100644 BitkitTests/TrezorManagerSessionTests.swift diff --git a/Bitkit/Managers/HwDeviceSessioning.swift b/Bitkit/Managers/HwDeviceSessioning.swift index 53ef757e0..3c148c15a 100644 --- a/Bitkit/Managers/HwDeviceSessioning.swift +++ b/Bitkit/Managers/HwDeviceSessioning.swift @@ -31,8 +31,11 @@ protocol TrezorSessioning: AnyObject, Sendable { /// 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 - /// Silently reconnects a known device, as on returning to the foreground. - func autoReconnect() 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 diff --git a/Bitkit/Managers/HwWalletManager.swift b/Bitkit/Managers/HwWalletManager.swift index 0d88dba24..1fa4d16d3 100644 --- a/Bitkit/Managers/HwWalletManager.swift +++ b/Bitkit/Managers/HwWalletManager.swift @@ -673,9 +673,10 @@ final class HwWalletManager { await disconnectOtherVendor(target) switch target { case .trezor: - // Launched rather than awaited so a scan for a Trezor that is not around does not hold - // the lock; `TrezorManager` runs its own connection work one at a time. - Task { [weak trezorSession] in await trezorSession?.autoReconnect() } + // 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() } @@ -701,6 +702,7 @@ final class HwWalletManager { } func resetForWipe() async { + await trezorSession?.resetForWipe() await jadeSession?.resetForWipe() } diff --git a/Bitkit/Managers/TrezorManager.swift b/Bitkit/Managers/TrezorManager.swift index bb41b96cf..6def3f1f9 100644 --- a/Bitkit/Managers/TrezorManager.swift +++ b/Bitkit/Managers/TrezorManager.swift @@ -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). @@ -430,10 +435,11 @@ final class TrezorManager { } var isSessionActive: Bool { - connectedDevice != nil || isAutoReconnecting || isConnectionOperationActive + 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. @@ -448,6 +454,14 @@ final class TrezorManager { }.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) { @@ -878,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 { diff --git a/BitkitTests/HwWalletManagerPassphraseTests.swift b/BitkitTests/HwWalletManagerPassphraseTests.swift index 6904132df..d8b5e8d78 100644 --- a/BitkitTests/HwWalletManagerPassphraseTests.swift +++ b/BitkitTests/HwWalletManagerPassphraseTests.swift @@ -37,7 +37,7 @@ final class HwWalletManagerPassphraseTests: XCTestCase { private(set) var forgottenWalletIds: [String] = [] private(set) var warmUpCalls: [String] = [] private(set) var releaseCalls = 0 - private(set) var autoReconnectCalls = 0 + private(set) var startAutoReconnectCalls = 0 private(set) var renameCalls: [(walletId: String, newName: String)] = [] func ensureConnected(deviceId: String) async throws { @@ -109,10 +109,12 @@ final class HwWalletManagerPassphraseTests: XCTestCase { isSessionActive = false } - func autoReconnect() async { - autoReconnectCalls += 1 + func startAutoReconnect() { + startAutoReconnectCalls += 1 } + func resetForWipe() async {} + func renameWallet(walletId: String, newName: String) { renameCalls.append((walletId, newName)) } diff --git a/BitkitTests/HwWalletManagerVendorTests.swift b/BitkitTests/HwWalletManagerVendorTests.swift index bf2d93874..f67c845cf 100644 --- a/BitkitTests/HwWalletManagerVendorTests.swift +++ b/BitkitTests/HwWalletManagerVendorTests.swift @@ -38,7 +38,7 @@ final class HwWalletManagerVendorTests: XCTestCase { private(set) var forgottenWalletIds: [String] = [] private(set) var renameCalls: [(walletId: String, newName: String)] = [] private(set) var releaseCalls = 0 - private(set) var autoReconnectCalls = 0 + private(set) var startAutoReconnectCalls = 0 init(log: CallLog) { self.log = log @@ -87,9 +87,16 @@ final class HwWalletManagerVendorTests: XCTestCase { connectedWalletId = nil } - func autoReconnect() async { - log.record("trezor.autoReconnect") - autoReconnectCalls += 1 + /// 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 { @@ -502,7 +509,7 @@ final class HwWalletManagerVendorTests: XCTestCase { await settle() XCTAssertEqual(jade.startAutoReconnectCalls, 1) - XCTAssertEqual(trezor.autoReconnectCalls, 0) + XCTAssertEqual(trezor.startAutoReconnectCalls, 0) } func testForegroundReconnectPicksTheMostRecentlyUsedVendor() async { @@ -516,7 +523,7 @@ final class HwWalletManagerVendorTests: XCTestCase { await settle() XCTAssertEqual(jade.startAutoReconnectCalls, 1) - XCTAssertEqual(trezor.autoReconnectCalls, 0) + XCTAssertEqual(trezor.startAutoReconnectCalls, 0) } /// On a cold launch the foreground reconnect runs before the vendor managers load their entries, @@ -529,16 +536,15 @@ final class HwWalletManagerVendorTests: XCTestCase { await settle() XCTAssertEqual(jade.startAutoReconnectCalls, 1) - XCTAssertEqual(trezor.autoReconnectCalls, 0) + XCTAssertEqual(trezor.startAutoReconnectCalls, 0) } func testForegroundReconnectDefaultsToTrezor() async { let manager = makeManager() await manager.reconnectOnForeground() - await waitUntil { self.trezor.autoReconnectCalls == 1 } - XCTAssertEqual(trezor.autoReconnectCalls, 1) + XCTAssertEqual(trezor.startAutoReconnectCalls, 1) XCTAssertEqual(jade.startAutoReconnectCalls, 0) } @@ -548,9 +554,20 @@ final class HwWalletManagerVendorTests: XCTestCase { let manager = makeManager() await manager.reconnectOnForeground() - await waitUntil { self.trezor.autoReconnectCalls == 1 } - XCTAssertEqual(log.entries, ["jade.release", "trezor.autoReconnect"]) + 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 @@ -599,7 +616,7 @@ final class HwWalletManagerVendorTests: XCTestCase { await waitUntil { self.jade.startAutoReconnectCalls == 1 } XCTAssertEqual(jade.startAutoReconnectCalls, 1) - XCTAssertEqual(trezor.autoReconnectCalls, 0) + XCTAssertEqual(trezor.startAutoReconnectCalls, 0) withExtendedLifetime(manager) {} } @@ -904,9 +921,18 @@ final class HwWalletManagerVendorTests: XCTestCase { 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, ["jade.backgrounded", "jade.active", "jade.resetForWipe"]) + XCTAssertEqual(log.entries, ["trezor.resetForWipe", "jade.resetForWipe"]) } // MARK: - Helpers 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" + ) + } +} From b3879ac968c75ba07bd75bc0dec2b4952f337393 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Thu, 24 Sep 2026 08:33:18 -0400 Subject: [PATCH 16/18] fix: let the transfer sign screen be left while the jade waits for its pin The Send sheet already allowed leaving while the device was being reached. The transfer sign screen kept Back and the drawer disabled for the whole reconnect, which for a locked Jade can last up to five minutes. Track the connecting phase and gate navigation on it, so leaving cancels the connect and releases the link. --- Bitkit/ViewModels/HwFundingSigner.swift | 7 ++- Bitkit/ViewModels/TransferViewModel.swift | 12 +++++ .../Transfer/Hardware/SpendingHwSign.swift | 2 +- BitkitTests/TransferViewModelHwTests.swift | 52 +++++++++++++++++++ 4 files changed, 70 insertions(+), 3 deletions(-) diff --git a/Bitkit/ViewModels/HwFundingSigner.swift b/Bitkit/ViewModels/HwFundingSigner.swift index 2eb615118..39be5b22e 100644 --- a/Bitkit/ViewModels/HwFundingSigner.swift +++ b/Bitkit/ViewModels/HwFundingSigner.swift @@ -66,11 +66,13 @@ struct HwFundingSigner { } /// Reconnects, composes and signs the funding transaction without broadcasting it. + /// `onConnectingDevice` brackets every reconnect, as in `prepareSignedPayment`. func prepareSignedFunding( order: IBtOrder, walletId: String, address: String, - onComposed: (HwFundingTransaction) -> Void = { _ in } + onComposed: (HwFundingTransaction) -> Void = { _ in }, + onConnectingDevice: (Bool) -> Void = { _ in } ) async throws -> HwFundingSignedTx { let satsPerVByte = await resolvedSatsPerVByte() return try await prepareSignedPayment( @@ -78,7 +80,8 @@ struct HwFundingSigner { address: address, sats: order.feeSat, satsPerVByte: satsPerVByte, - onComposed: onComposed + onComposed: onComposed, + onConnectingDevice: onConnectingDevice ) } diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 2dfe19133..3f839870d 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -26,6 +26,8 @@ struct TransferValues { struct HwSpendingState: Equatable { var isLoading = false var isSigning = false + /// Reaching the device before anything is on it to sign, which may be abandoned. + var isConnectingDevice = false var hasPendingBroadcast = false var isPassphraseRequired = false var isVerifyingPassphrase = false @@ -317,6 +319,12 @@ class TransferViewModel: ObservableObject { uiState.isConfirming || hwSpending.isSigning || hwSpending.isCreatingOrder } + /// Whether the hardware 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 it cannot. + var canLeaveHwSign: Bool { + !isSpendingBusy || hwSpending.isConnectingDevice + } + func onEstimateReady(clientBalance: UInt64, lspBalance: UInt64, feeSat: UInt64, isAdvanced: Bool = false) { guard !isSpendingBusy else { return } clearPendingHwFundingBroadcast() @@ -711,6 +719,7 @@ class TransferViewModel: ObservableObject { guard let self else { return } defer { self.hwSpending.isSigning = false + self.hwSpending.isConnectingDevice = false self.hwSignTask = nil } @@ -726,6 +735,8 @@ class TransferViewModel: ObservableObject { address: address ) { [weak self] funding in self?.hwSpending.miningFeeSats = funding.miningFeeSats + } onConnectingDevice: { [weak self] isConnecting in + self?.hwSpending.isConnectingDevice = isConnecting } pendingHwFundingBroadcast = PendingHwFundingBroadcast( orderId: order.id, @@ -831,6 +842,7 @@ class TransferViewModel: ObservableObject { hwSignTask?.cancel() hwSignTask = nil hwSpending.isSigning = false + hwSpending.isConnectingDevice = false activeHwTransferWalletId = nil if let walletId, let hwConnecting { Task { diff --git a/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift b/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift index fa18dfeba..7bf19db23 100644 --- a/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift +++ b/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift @@ -24,7 +24,7 @@ struct SpendingHwSign: View { private func content() -> some View { VStack(alignment: .leading, spacing: 0) { NavigationBar(title: t("lightning__transfer__nav_title")) - .disabled(transfer.isSpendingBusy) + .disabled(!transfer.canLeaveHwSign) .padding(.bottom, 16) ZStack(alignment: .top) { diff --git a/BitkitTests/TransferViewModelHwTests.swift b/BitkitTests/TransferViewModelHwTests.swift index 27e4e3657..a1682ec4d 100644 --- a/BitkitTests/TransferViewModelHwTests.swift +++ b/BitkitTests/TransferViewModelHwTests.swift @@ -511,6 +511,58 @@ final class TransferViewModelHwTests: XCTestCase { XCTAssertEqual(connecting.staleDisconnects, ["trezor:wallet"], "cancelling during sign must tear down the stale session") } + func testTheSignScreenCanBeLeftOnlyWhileTheDeviceIsBeingReached() async { + let funding = MockHwFunding() + funding.signDelay = 1.0 + let connecting = MockHwConnecting() + let gate = AsyncGate() + connecting.connectGate = gate + let vm = makeViewModel(funding: funding, connecting: connecting) + XCTAssertTrue(vm.canLeaveHwSign) + + vm.onTransferToSpendingHwConfirm(order: .mock(), walletId: "jade:wallet") + while !vm.hwSpending.isConnectingDevice { + await Task.yield() + } + XCTAssertTrue(vm.isSpendingBusy) + XCTAssertTrue(vm.canLeaveHwSign, "a device waiting for its PIN must not trap the user") + + gate.open() + while funding.signCalls == 0 { + await Task.yield() + } + XCTAssertFalse(vm.hwSpending.isConnectingDevice) + XCTAssertFalse(vm.canLeaveHwSign, "the device has been asked to sign") + + vm.cancelHwSigning() + await awaitSigningComplete(vm) + XCTAssertTrue(vm.canLeaveHwSign) + } + + func testLeavingWhileTheDeviceIsBeingReachedReleasesIt() async { + let funding = MockHwFunding() + let connecting = MockHwConnecting() + let gate = AsyncGate() + connecting.connectGate = gate + let vm = makeViewModel(funding: funding, connecting: connecting) + + vm.onTransferToSpendingHwConfirm(order: .mock(), walletId: "jade:wallet") + while !vm.hwSpending.isConnectingDevice { + await Task.yield() + } + vm.cancelHwSigning() + for _ in 0 ..< 50 where connecting.staleDisconnects.isEmpty { + try? await Task.sleep(nanoseconds: 10_000_000) + } + gate.open() + await awaitSigningComplete(vm) + + XCTAssertFalse(vm.hwSpending.isConnectingDevice) + XCTAssertEqual(connecting.staleDisconnects, ["jade:wallet"]) + XCTAssertEqual(funding.signCalls, 0, "an abandoned connect never reaches signing") + XCTAssertEqual(vm.hwSignedEvent, 0) + } + func testDeviceBusyMapsToDeviceBusyError() async { let funding = MockHwFunding() let connecting = MockHwConnecting() From 5c3c9420a273c3a3d0b19ea5f3cbf43700019e1d Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Thu, 24 Sep 2026 08:33:23 -0400 Subject: [PATCH 17/18] fix: refuse a jade reconnect that finds an unpaired wallet A Jade restored with another seed keeps its efuse MAC, so a reconnect stored the new wallet with no pairing step and left the old one looking usable. A reconnect now fails closed when the exported keys match no wallet paired on that device; adding the new wallet stays an explicit pairing. --- Bitkit/Managers/JadeManager.swift | 12 ++++++++ BitkitTests/JadeManagerTests.swift | 45 ++++++++++++++++++++++++++++++ BitkitTests/JadeMocks.swift | 2 +- 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/Bitkit/Managers/JadeManager.swift b/Bitkit/Managers/JadeManager.swift index c1ff47c7e..15865dbc9 100644 --- a/Bitkit/Managers/JadeManager.swift +++ b/Bitkit/Managers/JadeManager.swift @@ -334,6 +334,9 @@ final class JadeManager { if version.jadeState.isUnlocked { let xpubs = try await exportAccounts() try requireCurrent(epoch) + if let expected { + try rejectUnpairedWallet(expected: expected, fetchedXpubs: xpubs) + } return addOrUpdateKnownDevice(device, version: version, fetchedXpubs: xpubs) } let entryId = JadeDeviceIdentity.deviceId(efuseMac: version.efuseMac) ?? device.path @@ -356,6 +359,15 @@ final class JadeManager { } } + /// Refuses a reconnect that finds a wallet this Jade was never paired with, as after a restore + /// with another seed. Its efuse MAC never changes, so storing it would add a wallet with no + /// pairing step and leave the old one looking usable; adding that wallet stays an explicit pairing. + private func rejectUnpairedWallet(expected: HwKnownDevice, fetchedXpubs: [String: String]) throws { + let paired = HwKnownDeviceMatching.previous(in: allKnownDevices(), deviceId: expected.id, fetchedXpubs: fetchedXpubs) + guard paired == nil else { return } + throw AppError(message: "Reconnect Hardware Device", debugMessage: "The Jade holds a wallet it was not paired with") + } + private func unlockConnected() async throws -> JadeVersionInfo { let token = beginAttempt(.unlocking) defer { endAttempt(.unlocking, token: token) } diff --git a/BitkitTests/JadeManagerTests.swift b/BitkitTests/JadeManagerTests.swift index f76b8e5e9..709388d8b 100644 --- a/BitkitTests/JadeManagerTests.swift +++ b/BitkitTests/JadeManagerTests.swift @@ -205,6 +205,51 @@ final class JadeManagerTests: XCTestCase { XCTAssertFalse(sut.isUnlocking) } + func testReconnectingAJadeRestoredWithAnotherSeedAddsNoWallet() async throws { + store.devices = [JadeFixtures.knownEntry()] + service.stubs.scanned = [JadeFixtures.device()] + service.stubs.exportHandler = { _ in JadeFixtures.accountExport(xpub: "zpubOtherSeed") } + let sut = makeManager() + + do { + try await sut.connectKnownDevice(deviceId: JadeFixtures.deviceId) + XCTFail("a wallet the Jade was never paired with must be rejected") + } catch {} + + XCTAssertTrue(store.saves.isEmpty) + XCTAssertEqual(store.devices.map(\.walletId), [JadeFixtures.walletId]) + XCTAssertGreaterThanOrEqual(log.count("service.disconnect"), 1) + XCTAssertNil(sut.connected) + } + + func testReconnectingAJadeHoldingAnotherPairedWalletLandsOnThatWallet() async throws { + var otherWallet = JadeFixtures.knownEntry() + otherWallet.xpubs = ["nativeSegwit": "zpubOtherSeed"] + otherWallet.walletId = "jade:other-wallet" + store.devices = [JadeFixtures.knownEntry(), otherWallet] + service.stubs.scanned = [JadeFixtures.device()] + service.stubs.exportHandler = { _ in JadeFixtures.accountExport(xpub: "zpubOtherSeed") } + let sut = makeManager() + + let connected = try await sut.connectKnownDevice(deviceId: JadeFixtures.deviceId) + + XCTAssertEqual(connected.walletId, "jade:other-wallet") + XCTAssertEqual(Set(store.devices.map(\.walletId)), [JadeFixtures.walletId, "jade:other-wallet"]) + } + + func testPairingAJadeRestoredWithAnotherSeedAddsItsWallet() async throws { + store.devices = [JadeFixtures.knownEntry()] + service.stubs.scanned = [JadeFixtures.device()] + service.stubs.exportHandler = { _ in JadeFixtures.accountExport(xpub: "zpubOtherSeed") } + let sut = makeManager() + _ = try await sut.scan() + + let connected = try await sut.connect(path: JadeFixtures.blePath) + + XCTAssertNotEqual(connected.walletId, JadeFixtures.walletId) + XCTAssertEqual(store.devices.count, 2) + } + func testCancellingAPendingConnectionClosesTheLinkThenCancelsThenDisconnects() async { store.devices = [JadeFixtures.knownEntry()] let sut = makeManager() diff --git a/BitkitTests/JadeMocks.swift b/BitkitTests/JadeMocks.swift index f15165e81..64356414a 100644 --- a/BitkitTests/JadeMocks.swift +++ b/BitkitTests/JadeMocks.swift @@ -194,7 +194,7 @@ enum JadeFixtures { ) } - static func accountExport() -> JadeAccountExport { + static func accountExport(xpub: String = xpub) -> JadeAccountExport { JadeAccountExport( masterFingerprint: "deadbeef", accountIndex: 0, From fed70273c56601b3a7574107d43085ff783e46ef Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Thu, 24 Sep 2026 13:13:49 -0400 Subject: [PATCH 18/18] fix: restore the jade bluetooth usage description in the build settings The master merge took the old build setting, which says Bitkit does not use Bluetooth. With GENERATE_INFOPLIST_FILE on, that setting is what ships, so set it to the same sentence as Info.plist for Debug and Release. --- Bitkit.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 327f85c10..06d2c0506 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -945,7 +945,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; @@ -994,7 +994,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;