diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 191c32332..3751dc3e5 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -949,7 +949,7 @@ INFOPLIST_FILE = Bitkit/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Bitkit; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; - INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Bluetooth access is included as part of a library used for wallet operations. Bitkit does not actively use Bluetooth."; + INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Bitkit uses Bluetooth to connect to your hardware wallet for signing transactions."; INFOPLIST_KEY_NSCameraUsageDescription = "Bitkit needs access to the camera to scan QR codes"; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; @@ -998,7 +998,7 @@ INFOPLIST_FILE = Bitkit/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Bitkit; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; - INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Bluetooth access is included as part of a library used for wallet operations. Bitkit does not actively use Bluetooth."; + INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Bitkit uses Bluetooth to connect to your hardware wallet for signing transactions."; INFOPLIST_KEY_NSCameraUsageDescription = "Bitkit needs access to the camera to scan QR codes"; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; @@ -1214,7 +1214,7 @@ repositoryURL = "https://github.com/synonymdev/bitkit-core"; requirement = { kind = exactVersion; - version = 0.5.14; + version = 0.5.17; }; }; 96E20CD22CB6D91A00C24149 /* XCRemoteSwiftPackageReference "CodeScanner" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index ce1844188..12f7a3b73 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/bitkit-core", "state" : { - "revision" : "890502f241e12305325cdd0f5e46b2752b9aedf4", - "version" : "0.5.14" + "revision" : "9724efdf13fa141819b52d6337de60a4228831a6", + "version" : "0.5.17" } }, { diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 86e18d2b6..5ec83106a 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -207,6 +207,7 @@ struct AppScene: View { @State private var keyboardManager = KeyboardManager() @State private var trezorManager: TrezorManager @State private var trezorViewModel: TrezorViewModel + @State private var jadeManager: JadeManager @State private var hwWalletManager: HwWalletManager @State private var calculatorInputManager = CalculatorInputManager() @State private var paykitPaymentRequestManager = PaykitPaymentRequestManager() @@ -258,7 +259,8 @@ struct AppScene: View { // Created ahead of `transfer` so the hardware-wallet transfer flow can reach the funding // (compose/sign/broadcast) and device-session (reconnect) capabilities. let trezorManager = TrezorManager() - let hwWalletManager = HwWalletManager(session: trezorManager) + let jadeManager = JadeManager() + let hwWalletManager = HwWalletManager(trezorSession: trezorManager, jadeSession: jadeManager) _transfer = StateObject(wrappedValue: TransferViewModel( transferService: transferService, @@ -283,6 +285,8 @@ struct AppScene: View { let trezorViewModel = TrezorViewModel(connection: trezorManager) _trezorManager = State(initialValue: trezorManager) _trezorViewModel = State(initialValue: trezorViewModel) + // Held here because `HwWalletManager` keeps its vendor sessions weakly. + _jadeManager = State(initialValue: jadeManager) _hwWalletManager = State(initialValue: hwWalletManager) CoreService.shared.activity.setPrivatePaykitContactResolvers( @@ -326,12 +330,13 @@ struct AppScene: View { .onChange(of: scenePhase, initial: true) { _, newValue in handleScenePhaseChange(newValue) } .onChange(of: network.isConnected) { _, isConnected in handleNetworkChange(isConnected) } .onOpenURL { url in app.retainDeepLink(url) } - // Bridge Trezor device state into the watch-only manager without coupling the two: - // TrezorManager bumps devicesRevision on any device/connection change. + // Bridge the vendor managers' device state into the watch-only manager without coupling them: + // each bumps devicesRevision on any device or connection change. .onChange(of: trezorManager.devicesRevision) { _, _ in pushHardwareDevices() } + .onChange(of: jadeManager.devicesRevision) { _, _ in pushHardwareDevices() } .onChange(of: isPinVerified) { _, verified in if verified { - Task { await trezorManager.autoReconnect() } + Task { await hwWalletManager.reconnectOnForeground() } } } .onReceive(settings.settingsPublisher) { _ in hwWalletManager.reconcileForSettingsChange() } @@ -379,6 +384,7 @@ struct AppScene: View { .environment(keyboardManager) .environment(trezorManager) .environment(trezorViewModel) + .environment(jadeManager) .environment(hwWalletManager) .environment(calculatorInputManager) .environment(paykitPaymentRequestManager) @@ -789,6 +795,7 @@ struct AppScene: View { @Sendable private func setupTask() async { + AppReset.hardwareWallets = hwWalletManager do { // Handle orphaned keychain before anything else handleOrphanedKeychain() @@ -800,6 +807,7 @@ struct AppScene: View { // watchers start at launch (no-op until a device is paired). loadKnownDevices() also // bumps devicesRevision, but push explicitly so the initial state is delivered. trezorManager.loadKnownDevices() + jadeManager.loadKnownDevices() pushHardwareDevices() // Setup TimedSheetManager with all timed sheets @@ -938,12 +946,16 @@ struct AppScene: View { // If PIN is enabled, lock the app when the app goes to the background isPinVerified = false } + hwWalletManager.onAppBackgrounded() } + // `.inactive` is left alone: the iOS Bluetooth pairing alert puts the app there mid-connect. if newPhase == .active { + // Called even behind the PIN screen, so a background release still pending is called off. + hwWalletManager.onAppBecameActive() // Reconnect a known hardware device so its connection indicator turns green again; if isPinVerified || !settings.pinEnabled { - Task { await trezorManager.autoReconnect() } + Task { await hwWalletManager.reconnectOnForeground() } } if wallet.walletExists == true { Task { @@ -1344,13 +1356,20 @@ struct AppScene: View { center.removeDeliveredNotifications(withIdentifiers: deliveredNotifications.map(\.request.identifier)) } - /// Feed the current Trezor device snapshot into the watch-only manager. This is the only link - /// between the two managers, kept in the composition root so neither type references the other. + /// Feed both vendors' device snapshots into the watch-only manager. This is the only link between + /// the vendor managers and it, kept in the composition root so none of them references another. private func pushHardwareDevices() { + let connected: (deviceId: String, walletId: String?)? = if let trezorDevice = trezorManager.connectedDevice { + (trezorDevice.id, trezorManager.connectedWalletId) + } else if let jadeDevice = jadeManager.connected { + (jadeDevice.id, jadeDevice.walletId) + } else { + nil + } hwWalletManager.updateDevices( - knownDevices: trezorManager.knownDevices, - connectedDeviceId: trezorManager.connectedDevice?.id, - connectedWalletId: trezorManager.connectedWalletId + knownDevices: (trezorManager.knownDevices + jadeManager.knownDevices).sorted { $0.lastConnectedAt > $1.lastConnectedAt }, + connectedDeviceId: connected?.deviceId, + connectedWalletId: connected?.walletId ) } diff --git a/Bitkit/Assets.xcassets/Illustrations/jade-placeholder.imageset/Contents.json b/Bitkit/Assets.xcassets/Illustrations/jade-placeholder.imageset/Contents.json new file mode 100644 index 000000000..7ca008ca5 --- /dev/null +++ b/Bitkit/Assets.xcassets/Illustrations/jade-placeholder.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "jade-placeholder.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Bitkit/Assets.xcassets/Illustrations/jade-placeholder.imageset/jade-placeholder.svg b/Bitkit/Assets.xcassets/Illustrations/jade-placeholder.imageset/jade-placeholder.svg new file mode 100644 index 000000000..284ba95b1 --- /dev/null +++ b/Bitkit/Assets.xcassets/Illustrations/jade-placeholder.imageset/jade-placeholder.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Bitkit/BitkitApp.swift b/Bitkit/BitkitApp.swift index 00127fddc..b3f1881a4 100644 --- a/Bitkit/BitkitApp.swift +++ b/Bitkit/BitkitApp.swift @@ -117,6 +117,8 @@ class AppDelegate: NSObject, UIApplicationDelegate { func applicationWillTerminate(_ application: UIApplication) { try? StateLocker.unlock(.lightning) + // A Jade left holding a link when the process ends can drop its Bluetooth bond. + JadeTransport.shared.releaseAllImmediately() } } @@ -178,7 +180,7 @@ struct BitkitApp: App { init() { UIWindow.appearance().overrideUserInterfaceStyle = .dark if Env.shouldResetTrezorEmulatorState { - TrezorKnownDeviceStorage.removeAll() + HwKnownDeviceStorage.removeAll() TrezorCredentialStorage.deleteAll() } _ = ToastWindowManager.shared diff --git a/Bitkit/Components/SegmentedControl.swift b/Bitkit/Components/SegmentedControl.swift index 462f91ac3..20ef16a23 100644 --- a/Bitkit/Components/SegmentedControl.swift +++ b/Bitkit/Components/SegmentedControl.swift @@ -2,16 +2,27 @@ import SwiftUI struct TabItem { let tab: T + /// Shown in place of the tab's own description, for a tab whose name depends on what it shows. + let label: String? let activeColor: Color? let badge: Int? let accessibilityIdentifier: String? - init(_ tab: T, activeColor: Color? = nil, badge: Int? = nil, accessibilityIdentifier: String? = nil) { + init(_ tab: T, label: String? = nil, activeColor: Color? = nil, badge: Int? = nil, accessibilityIdentifier: String? = nil) { self.tab = tab + self.label = label self.activeColor = activeColor self.badge = badge self.accessibilityIdentifier = accessibilityIdentifier } + + var title: String { + label ?? tab.description + } + + var resolvedAccessibilityIdentifier: String { + accessibilityIdentifier ?? "Tab-\(title.lowercased())" + } } struct SegmentedControl: View { @@ -51,7 +62,7 @@ struct SegmentedControl: View { badge(for: tabItem) .hidden() CaptionBText( - tabItem.tab.description, + tabItem.title, textColor: selectedTab == tabItem.tab ? .white : inactiveColor ?? .secondary ) badge(for: tabItem) @@ -75,7 +86,7 @@ struct SegmentedControl: View { .contentShape(Rectangle()) } .buttonStyle(PlainButtonStyle()) - .accessibilityIdentifier(tabItem.accessibilityIdentifier ?? "Tab-\(tabItem.tab.description.lowercased())") + .accessibilityIdentifier(tabItem.resolvedAccessibilityIdentifier) } } .frame(maxWidth: .infinity) diff --git a/Bitkit/Components/TabBar/TabBar.swift b/Bitkit/Components/TabBar/TabBar.swift index 6c5d2b703..e5779010e 100644 --- a/Bitkit/Components/TabBar/TabBar.swift +++ b/Bitkit/Components/TabBar/TabBar.swift @@ -65,7 +65,7 @@ struct TabBar: View { sheets.showSheet( .receive, data: ReceiveConfig( - view: .qr(cjitInvoice: nil, tab: .trezor), + view: .qr(cjitInvoice: nil, tab: .hardware), hardwareWalletId: walletId ) ) diff --git a/Bitkit/Components/Trezor/TrezorDeviceRow.swift b/Bitkit/Components/Trezor/TrezorDeviceRow.swift index 04adeda98..959da1866 100644 --- a/Bitkit/Components/Trezor/TrezorDeviceRow.swift +++ b/Bitkit/Components/Trezor/TrezorDeviceRow.swift @@ -81,7 +81,7 @@ struct TrezorDeviceRow: View { /// Row displaying a previously connected (known) Trezor device struct KnownDeviceRow: View { - let device: TrezorKnownDevice + let device: HwKnownDevice let isConnecting: Bool let onConnect: () -> Void let onForget: () -> Void diff --git a/Bitkit/Extensions/HwError+Cancellation.swift b/Bitkit/Extensions/HwError+Cancellation.swift new file mode 100644 index 000000000..28512be6f --- /dev/null +++ b/Bitkit/Extensions/HwError+Cancellation.swift @@ -0,0 +1,31 @@ +/// Vendor-neutral views over the Trezor and Jade error predicates, for code shared by every vendor. +/// A `TrezorError` is never a `JadeError`, so for a Trezor error each one answers as its `isTrezor*` +/// counterpart does. +extension Error { + func isHwUserCancellation() -> Bool { + isTrezorUserCancellation() || isJadeUserCancellation() + } + + func isHwDeviceBusy() -> Bool { + isTrezorDeviceBusy() || isJadeDeviceBusy() + } + + func isHwFirmwareError() -> Bool { + isTrezorFirmwareError() || isJadeFirmwareError() + } + + func isHwSessionFailure() -> Bool { + isTrezorSessionFailure() || isJadeSessionFailure() + } + + /// The vendor of the busy or locked device this error reports, so its busy copy can name it. + var hwBusyVendor: HwWalletVendor? { + if isJadeDeviceBusy() { + return .blockstream + } + if isTrezorDeviceBusy() { + return .trezor + } + return nil + } +} diff --git a/Bitkit/Extensions/HwWalletVendor+UI.swift b/Bitkit/Extensions/HwWalletVendor+UI.swift new file mode 100644 index 000000000..def5ba30b --- /dev/null +++ b/Bitkit/Extensions/HwWalletVendor+UI.swift @@ -0,0 +1,63 @@ +import Foundation + +/// How each vendor's devices are shown and named across the hardware wallet screens. +extension HwWalletVendor { + /// The upright device illustration; the Jade one is a placeholder until design supplies the asset. + var deviceImageName: String { + switch self { + case .trezor: "trezor-device" + case .blockstream: "jade-placeholder" + } + } + + /// The illustration shown while the device signs a transaction. + var signImageName: String { + switch self { + case .trezor: "trezor-card" + case .blockstream: "jade-placeholder" + } + } + + var modelName: String { + switch self { + case .trezor: t("hardware__device_model_trezor") + case .blockstream: t("hardware__device_model_jade") + } + } + + var foundHeader: String { + switch self { + case .trezor: t("hardware__found_header") + case .blockstream: t("hardware__found_header_jade") + } + } + + var pairedHeader: String { + switch self { + case .trezor: t("hardware__paired_header") + case .blockstream: t("hardware__paired_header_jade") + } + } + + var sendSignButtonTitle: String { + switch self { + case .trezor: t("hardware__send_open_connect") + case .blockstream: t("hardware__send_open_connect_jade") + } + } + + var transferSignButtonTitle: String { + switch self { + case .trezor: t("lightning__transfer_hw__open_connect") + case .blockstream: t("hardware__send_open_connect_jade") + } + } + + /// Passphrase (hidden) wallets are a Trezor feature; a Jade holds one wallet per device. + var supportsPassphraseWallets: Bool { + switch self { + case .trezor: true + case .blockstream: false + } + } +} diff --git a/Bitkit/Extensions/JadeError+Cancellation.swift b/Bitkit/Extensions/JadeError+Cancellation.swift new file mode 100644 index 000000000..0f4a36678 --- /dev/null +++ b/Bitkit/Extensions/JadeError+Cancellation.swift @@ -0,0 +1,50 @@ +import BitkitCore + +extension Error { + /// The `JadeError` this error is or wraps. `ServiceQueue` boxes core errors into an `AppError` + /// before they reach the caller, so the preserved underlying error is unwrapped as well. + var underlyingJadeError: JadeError? { + if let jadeError = self as? JadeError { + return jadeError + } + if let appError = self as? AppError, let underlyingError = appError.underlyingError { + return underlyingError.underlyingJadeError + } + return nil + } + + /// Whether the user declined the request on the Jade. Callers treat it as a silent no-op so the + /// user can retry on the same screen. + func isJadeUserCancellation() -> Bool { + guard case .UserCancelled? = underlyingJadeError else { return false } + return true + } + + /// Whether the Jade cannot serve the request until the user acts on it: busy with another prompt, + /// or locked. + func isJadeDeviceBusy() -> Bool { + guard let jadeError = underlyingJadeError else { return false } + switch jadeError { + case .DeviceBusy, .DeviceLocked: + return true + default: + return false + } + } + + func isJadeFirmwareError() -> Bool { + guard case .UnsupportedFirmware? = underlyingJadeError else { return false } + return true + } + + /// Whether the current Jade channel can no longer be used and must be re-established. + func isJadeSessionFailure() -> Bool { + guard let jadeError = underlyingJadeError else { return false } + switch jadeError { + case .TransportError, .DeviceDisconnected, .ConnectionError, .Timeout, .NotConnected, .NotInitialized, .IoError: + return true + default: + return false + } + } +} diff --git a/Bitkit/Extensions/LDKNode+Jade.swift b/Bitkit/Extensions/LDKNode+Jade.swift new file mode 100644 index 000000000..5bdcf13e6 --- /dev/null +++ b/Bitkit/Extensions/LDKNode+Jade.swift @@ -0,0 +1,36 @@ +import BitkitCore +import LDKNode + +extension LDKNode.Network { + /// The network a Jade is asked to use. Jade has no signet; its regtest is named "localtest" on + /// the wire, which bitkit-core maps. + func toJadeNetwork() throws -> JadeNetwork { + switch self { + case .bitcoin: .mainnet + case .testnet: .testnet + case .regtest: .regtest + case .signet: throw AppError(message: "Signet is not supported by Jade", debugMessage: nil) + } + } +} + +extension LDKNode.AddressType { + /// The Jade script variant of this address type, as used to verify an address on the device. + var jadeVariant: JadeAddressVariant { + switch self { + case .legacy: .pkh + case .nestedSegwit: .shWpkh + case .nativeSegwit: .wpkh + case .taproot: .tr + } + } + + init(jadeVariant: JadeAddressVariant) { + switch jadeVariant { + case .pkh: self = .legacy + case .shWpkh: self = .nestedSegwit + case .wpkh: self = .nativeSegwit + case .tr: self = .taproot + } + } +} diff --git a/Bitkit/Extensions/TrezorDevice+DisplayName.swift b/Bitkit/Extensions/TrezorDevice+DisplayName.swift index b1f5a6bed..d08247c61 100644 --- a/Bitkit/Extensions/TrezorDevice+DisplayName.swift +++ b/Bitkit/Extensions/TrezorDevice+DisplayName.swift @@ -1,12 +1,22 @@ import BitkitCore -/// Canonical Trezor display name: the Bitkit-side custom name when set, otherwise the device's own -/// label when it differs from the factory model, otherwise the vendor-prefixed model, falling back -/// to "Trezor". -func resolveHwWalletName(label: String?, model: String?, customLabel: String? = nil) -> String { +/// Canonical hardware wallet display name: the Bitkit-side custom name when set. For a Trezor, +/// otherwise the device's own label when it differs from the factory model, otherwise the +/// vendor-prefixed model, falling back to "Trezor". A Jade has no label of its own and its model +/// already carries its name ("Jade", "Jade Plus"), so it shows the model, falling back to "Jade". +func resolveHwWalletName( + label: String?, + model: String?, + customLabel: String? = nil, + vendor: HwWalletVendor = .trezor +) -> String { if let customLabel, !customLabel.isEmpty { return customLabel } + if vendor == .blockstream { + let trimmedModel = model?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmedModel.isEmpty ? "Jade" : trimmedModel + } if let label, !label.isEmpty, label != model { return label } @@ -14,9 +24,9 @@ func resolveHwWalletName(label: String?, model: String?, customLabel: String? = return model.hasPrefix("Trezor") ? model : "Trezor \(model)" } -extension TrezorKnownDevice { +extension HwKnownDevice { var displayName: String { - resolveHwWalletName(label: label, model: model, customLabel: customLabel) + resolveHwWalletName(label: label, model: model, customLabel: customLabel, vendor: vendor) } } diff --git a/Bitkit/Info.plist b/Bitkit/Info.plist index 5e1652411..7c4646b3a 100644 --- a/Bitkit/Info.plist +++ b/Bitkit/Info.plist @@ -47,9 +47,9 @@ NSBluetoothAlwaysUsageDescription - Bitkit uses Bluetooth to connect to your Trezor hardware wallet for signing transactions. + Bitkit uses Bluetooth to connect to your hardware wallet for signing transactions. NSBluetoothPeripheralUsageDescription - Bitkit uses Bluetooth to connect to your Trezor hardware wallet for signing transactions. + Bitkit uses Bluetooth to connect to your hardware wallet for signing transactions. NSFaceIDUsageDescription Bitkit uses Face ID to securely authenticate access to your wallet and protect your Bitcoin. UIAppFonts diff --git a/Bitkit/Managers/HwDeviceSessioning.swift b/Bitkit/Managers/HwDeviceSessioning.swift index 4b063e4fe..3c148c15a 100644 --- a/Bitkit/Managers/HwDeviceSessioning.swift +++ b/Bitkit/Managers/HwDeviceSessioning.swift @@ -7,14 +7,17 @@ import BitkitCore /// Everything here is device-level on purpose: resolving a wallet identity to the transport it is /// reachable over is the watch-only layer's job, since it owns the wallet grouping. @MainActor -protocol HwDeviceSessioning: AnyObject, Sendable { +protocol TrezorSessioning: AnyObject, Sendable { /// Stored entries read fresh. A connect that just wrote one lands here before the /// `updateDevices(...)` push does, so session operations must not read the pushed snapshot. - var storedDevices: [TrezorKnownDevice] { get } + var storedDevices: [HwKnownDevice] { get } var connectedDeviceId: String? { get } /// Identity the live session opened; nil when no session is open or none could be resolved. var connectedWalletId: String? { get } var connectedFeatures: TrezorFeatures? { get } + /// Whether a session is open, being opened or being restored, so another vendor has to wait for + /// it to be released before it can use the radio. + var isSessionActive: Bool { get } func ensureConnected(deviceId: String) async throws /// Opens `deviceId` with an explicit wallet selection, with or without a live session. @@ -25,6 +28,14 @@ protocol HwDeviceSessioning: AnyObject, Sendable { passphrase: String ) async throws -> TrezorFeatures func disconnectStaleSession(deviceId: String) async + /// Closes the session, after any connection work already running, so another vendor can take + /// over. Runs to completion even when the caller is cancelled. + func releaseSession() async + /// Starts a silent reconnect of a known device, as on returning to the foreground, unless one is + /// running. The session reads as active from this call on, and releasing it cancels the reconnect. + func startAutoReconnect() + /// Drops the session and any pending reconnect ahead of a wallet wipe. + func resetForWipe() async func isKnownBluetoothDevice(deviceId: String) -> Bool func warmUpConnection(deviceId: String) /// Forgets every stored entry of `walletId`, keeping transport credentials while another @@ -34,10 +45,11 @@ protocol HwDeviceSessioning: AnyObject, Sendable { /// device restores it, or nil to drop any name kept for it. It rides the same store write that /// forgets the entries, so the device list is never published while the name is missing. func forgetWallet(walletId: String, pendingName: PendingHwWalletName?) async + func renameWallet(walletId: String, newName: String) } -extension TrezorManager: HwDeviceSessioning { - var storedDevices: [TrezorKnownDevice] { +extension TrezorManager: TrezorSessioning { + var storedDevices: [HwKnownDevice] { knownDevices } @@ -49,3 +61,50 @@ extension TrezorManager: HwDeviceSessioning { deviceFeatures } } + +/// The live Jade session and its stored entries, as the watch-only layer needs them. +/// +/// The Jade counterpart of `TrezorSessioning`, injected into `HwWalletManager` so it can route every +/// device call by the vendor stored on a paired entry. A Jade holds one wallet and has no passphrase +/// wallets, so there is no wallet selection here; it compares receive addresses on the device itself +/// and signs a PSBT that the session finalizes. +@MainActor +protocol JadeSessioning: AnyObject, Sendable { + /// Stored Jade entries read fresh, for the same reason as `TrezorSessioning.storedDevices`. + var storedDevices: [HwKnownDevice] { get } + var connectedDeviceId: String? { get } + /// Identity the live session opened; nil when no session is open or none could be resolved. + var connectedWalletId: String? { get } + /// Whether a session is open, being opened, or a background reconnect is pending. + var isSessionActive: Bool { get } + + /// Reuses a live session of `deviceId`, else reconnects it. A locked session is unlocked, which + /// waits for the PIN on the device. + func ensureConnected(deviceId: String) async throws + /// Shows `expectedAddress` on the device, which compares it itself and throws + /// `JadeError.AddressMismatch` when it derives another one. + func verifyAddress(addressType: AddressScriptType, derivationPath: String, expectedAddress: String) async throws + func masterFingerprint() async throws -> String + /// Signs on the device, then finalizes the signed PSBT into a broadcastable transaction. + func signPsbt(_ psbtBase64: String) async throws -> CompletedTransaction + /// Closes the link together with the core session, so a device call waiting on the link lets go of + /// core. A no-op while another Jade is connected. + func disconnectStaleSession(deviceId: String) async + /// Disconnects, or cancels a pending connect and background reconnect, so another vendor can take + /// over the radio. + func releaseSession() async + func isKnownBluetoothDevice(deviceId: String) -> Bool + /// Best-effort silent pre-connect before signing. Never unlocks. + func warmUpConnection(deviceId: String) + /// Forgets every stored entry of `walletId`, closing the session when it holds that wallet. See + /// `TrezorSessioning.forgetWallet` for `pendingName`. + func forgetWallet(walletId: String, pendingName: PendingHwWalletName?) async + func renameWallet(walletId: String, newName: String) + /// Starts a silent background reconnect of the most recently used Jade, unless one is running. + /// Never unlocks. + func startAutoReconnect() + func onAppBackgrounded() + func onAppBecameActive() + /// Drops the session and any background work ahead of a wallet wipe. + func resetForWipe() async +} diff --git a/Bitkit/Managers/HwWalletManager.swift b/Bitkit/Managers/HwWalletManager.swift index f2419e415..1fa4d16d3 100644 --- a/Bitkit/Managers/HwWalletManager.swift +++ b/Bitkit/Managers/HwWalletManager.swift @@ -2,7 +2,7 @@ import BitkitCore import Combine import Foundation -/// Production hardware-wallet business layer. Tracks paired Trezor wallets as watch-only +/// Production hardware-wallet business layer. Tracks paired Trezor and Jade wallets as watch-only /// balances by running one on-chain xpub watcher per (wallet, address type), aggregating the /// per-wallet balance in memory, and persisting each wallet's on-chain activity into /// bitkit-core scoped by its `walletId` (core 0.3.x wallet-scoped storage). @@ -11,11 +11,15 @@ import Foundation /// wallet plus one identity per hidden wallet, all reached over the same transport id. /// /// Tile and watcher state come solely from `updateDevices(...)`, fed by the composition root -/// (`AppScene`). The identity-aware session operations — opening a passphrase wallet, proving the -/// live session belongs to the wallet being spent from — additionally read the device through the -/// injected `HwDeviceSessioning` seam, and read the stored entries fresh from it: a connect that -/// just wrote one lands there before the push does. Never references `TrezorManager` concretely. +/// (`AppScene`). The identity-aware session operations (opening a passphrase wallet, proving the +/// live session belongs to the wallet being spent from) additionally read the device through the +/// injected `TrezorSessioning` and `JadeSessioning` seams, and read the stored entries fresh from +/// them: a connect that just wrote one lands there before the push does. Never references a vendor +/// manager concretely. /// +/// Every device call is routed by the vendor stored on the wallet's entries. Only one vendor holds a +/// session at a time: the operations that open one run under a FIFO session lock and release the +/// other vendor's session first, so the two never compete for the radio. @Observable @MainActor final class HwWalletManager { @@ -27,6 +31,12 @@ final class HwWalletManager { AccountType ) async throws -> AccountInfoResult typealias AddressProvider = @MainActor (TrezorGetAddressParams) async throws -> TrezorAddressResponse + typealias ComposeProvider = @MainActor (ComposeParams) async throws -> [ComposeResult] + + /// How long reaching a wallet's device may take before the flow gives up. A Jade may be waiting + /// for its PIN, which is entered on the device. + static let trezorReconnectTimeout: Double = 30 + static let jadeReconnectTimeout: Double = 300 private enum Constants { static let watcherIdSeparator = "|" @@ -65,15 +75,17 @@ final class HwWalletManager { private let networkProvider: () -> TrezorCoinType private let accountInfoProvider: AccountInfoProvider private let addressProvider: AddressProvider + private let composeProvider: ComposeProvider private let persistSnapshot: @MainActor (HwWalletSnapshot) async throws -> Void private let deleteActivities: @MainActor (String) async throws -> Void private let readTagMetadata: @MainActor (String) async throws -> [PreActivityMetadata] private let writeTagMetadata: @MainActor ([PreActivityMetadata]) async throws -> Void - /// The live device session. Only the identity-aware operations need it; tile and watcher state - /// still come solely from `updateDevices(...)`. Nil in previews and in tests that don't reach - /// the device. - private weak var session: HwDeviceSessioning? + /// The live device sessions, one per vendor. Only the identity-aware operations need them; tile + /// and watcher state still come solely from `updateDevices(...)`. Nil in previews and in tests + /// that don't reach the device. Weak because the composition root owns the vendor managers. + private weak var trezorSession: TrezorSessioning? + private weak var jadeSession: JadeSessioning? /// One chain per wallet id, shared by both writes: a snapshot landing after the delete it was /// racing would resurrect the wallet `removeDevice` just wiped. @@ -81,7 +93,7 @@ final class HwWalletManager { // MARK: - Internal state - private var knownDevices: [TrezorKnownDevice] = [] + private var knownDevices: [HwKnownDevice] = [] private var connectedDeviceId: String? private var connectedWalletId: String? private var watcherData: [String: HwWatcherData] = [:] @@ -98,7 +110,7 @@ final class HwWalletManager { private var lastSyncedMonitored: Set? private var lastSyncedElectrumUrl: String? - /// Memoized `HwWalletId.derive` results keyed by an xpubs signature. The mapping is + /// Memoized `HwWalletId.derive` results keyed by vendor and xpubs signature. The mapping is /// deterministic and immutable, so caching avoids repeated FFI derivations on every watcher /// event and sync. Pruned to the live device set on `updateDevices`/`removeDevice`. private var walletIdCache: [String: String] = [:] @@ -121,9 +133,15 @@ final class HwWalletManager { private var emittedReceivedTxIds: Set = [] private var listeners: [String: TrezorEventListener] = [:] private var staleSessionCleanupTasks: [String: Task] = [:] + @ObservationIgnored private var isSessionOperationActive = false + @ObservationIgnored private var sessionOperationWaiters: [CheckedContinuation] = [] + @ObservationIgnored private var isAppActive = true + @ObservationIgnored private var jadeBluetoothPoweredOnSubscription: AnyCancellable? init( - session: HwDeviceSessioning? = nil, + trezorSession: TrezorSessioning? = nil, + jadeSession: JadeSessioning? = nil, + jadeBluetoothPoweredOn: AnyPublisher = JadeTransport.shared.bluetoothPoweredOn, watcherService: OnChainWatcherServicing = OnChainHwService.shared, monitoredTypes: (() -> Set)? = nil, electrumUrl: (() -> String)? = nil, @@ -140,12 +158,16 @@ final class HwWalletManager { addressProvider: @escaping AddressProvider = { params in try await TrezorService.shared.getAddress(params: params) }, + composeProvider: @escaping ComposeProvider = { params in + try await OnChainHwService.shared.composeTransaction(params: params) + }, persistSnapshot: (@MainActor (HwWalletSnapshot) async throws -> Void)? = nil, deleteActivities: (@MainActor (String) async throws -> Void)? = nil, readTagMetadata: (@MainActor (String) async throws -> [PreActivityMetadata])? = nil, writeTagMetadata: (@MainActor ([PreActivityMetadata]) async throws -> Void)? = nil ) { - self.session = session + self.trezorSession = trezorSession + self.jadeSession = jadeSession self.watcherService = watcherService networkProvider = network ?? { OnChainHwService.appDefaultCoinType } monitoredTypesProvider = monitoredTypes ?? { @@ -154,6 +176,7 @@ final class HwWalletManager { electrumUrlProvider = electrumUrl ?? { OnChainHwService.getElectrumUrl() } self.accountInfoProvider = accountInfoProvider self.addressProvider = addressProvider + self.composeProvider = composeProvider // Both seams are plain writes: queueing, failure handling and cache repair live in // `persist(_:)` / `delete(walletId:)`, so an injected seam exercises them too. self.persistSnapshot = persistSnapshot ?? { snapshot in @@ -174,6 +197,12 @@ final class HwWalletManager { self.writeTagMetadata = writeTagMetadata ?? { records in try await CoreService.shared.activity.upsertPreActivityMetadata(records) } + // The transport publishes on its Bluetooth queue. + jadeBluetoothPoweredOnSubscription = jadeBluetoothPoweredOn + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.onJadeBluetoothRestored() + } } // MARK: - Device input @@ -185,7 +214,7 @@ final class HwWalletManager { /// `connectedWalletId` is the identity the live session opened. A device holds one wallet open /// at a time, so it is what decides which tile shows as connected. func updateDevices( - knownDevices: [TrezorKnownDevice], + knownDevices: [HwKnownDevice], connectedDeviceId: String?, connectedWalletId: String? = nil ) { @@ -210,7 +239,7 @@ final class HwWalletManager { /// Stop watching a paired hardware wallet and delete its stored activities. Other wallets on the /// same physical device are left untouched. The caller is responsible for forgetting the stored - /// entries (via `TrezorManager`). + /// entries (via the vendor's session). /// /// - Parameter keptMetadata: tag metadata to re-apply after each delete of this wallet, or empty /// to keep nothing. Passed on every call so a removal that keeps nothing clears what an earlier @@ -223,7 +252,7 @@ final class HwWalletManager { delete(walletId: walletId) lastPersisted[walletId] = nil for device in knownDevices where device.resolvedWalletId == walletId { - walletIdCache[xpubsSignature(device.xpubs)] = nil + walletIdCache[walletIdCacheKey(xpubs: device.xpubs, vendor: device.vendor)] = nil } // Dropped here rather than left to the next `updateDevices(...)` push. Until the wallet leaves // `hwWalletIds`, the push's own cleanup deletes its activities a second time — after any kept @@ -278,27 +307,64 @@ final class HwWalletManager { } } + // Read before the removal: without sessions the vendor comes from the entries it drops. + let vendor = vendor(walletId: walletId) removeDevice(walletId: walletId, keptMetadata: keptMetadata) // The name rides the same store write that forgets the entries carrying it. A nil name is // passed deliberately when keeping nothing: it drops a name an earlier removal kept. - await session?.forgetWallet( - walletId: walletId, - pendingName: PendingHwWalletName(walletId: walletId, name: keptName) - ) + let pendingName = PendingHwWalletName(walletId: walletId, name: keptName) + switch vendor { + case .trezor: + await trezorSession?.forgetWallet(walletId: walletId, pendingName: pendingName) + case .blockstream: + await jadeSession?.forgetWallet(walletId: walletId, pendingName: pendingName) + } + } + + /// Sets the Bitkit-side name of `walletId` on the entries of its vendor. + func renameWallet(walletId: String, newName: String) { + switch vendor(walletId: walletId) { + case .trezor: + trezorSession?.renameWallet(walletId: walletId, newName: newName) + case .blockstream: + jadeSession?.renameWallet(walletId: walletId, newName: newName) + } } // MARK: - Wallet identity & the device session /// Stored entries tracking one wallet identity, read fresh: a connect that just wrote one lands /// there before the `updateDevices(...)` push does. - private func entries(for walletId: String) -> [TrezorKnownDevice] { - (session?.storedDevices ?? knownDevices).filter { $0.resolvedWalletId == walletId } + private func entries(for walletId: String) -> [HwKnownDevice] { + storedDevices.filter { $0.resolvedWalletId == walletId } + } + + /// Every vendor's stored entries, read fresh from the sessions, or the pushed snapshot when there + /// are none. + private var storedDevices: [HwKnownDevice] { + guard trezorSession != nil || jadeSession != nil else { return knownDevices } + return (trezorSession?.storedDevices ?? []) + (jadeSession?.storedDevices ?? []) + } + + /// The vendor of the device holding `walletId`, which decides how it is reached and signed with, + /// or Trezor when no stored entry holds it. + func vendor(walletId: String) -> HwWalletVendor { + entries(for: walletId).first?.vendor ?? .trezor + } + + /// How long reaching `walletId`'s device may take before the flow gives up. + func reconnectTimeout(walletId: String) -> Double { + switch vendor(walletId: walletId) { + case .trezor: Self.trezorReconnectTimeout + case .blockstream: Self.jadeReconnectTimeout + } } /// Transport id to reach `walletId` with: the connected entry, else the most recently used one. private func transportDeviceId(for walletId: String) -> String? { let entries = entries(for: walletId) - if let connected = entries.first(where: { $0.id == session?.connectedDeviceId }) { + let connectedIds = [trezorSession?.connectedDeviceId, jadeSession?.connectedDeviceId].compactMap { $0 } + if let connected = entries.first(where: { connectedIds.contains($0.id) }) { return connected.id } return entries.max(by: { $0.lastConnectedAt < $1.lastConnectedAt })?.id @@ -321,7 +387,7 @@ final class HwWalletManager { /// Only the passphrase reopens a hidden wallet, so that is what a hidden target asks for. For any /// other wallet the device is simply not holding it, which no passphrase can fix. private func requireIdentity(of walletId: String) throws { - let opened = session?.connectedWalletId + let opened = trezorSession?.connectedWalletId guard opened != walletId else { return } guard !entries(for: walletId).contains(where: \.passphraseProtected) else { throw HwPassphraseError.required @@ -333,14 +399,45 @@ final class HwWalletManager { } private func watchedWalletIds() -> Set { - Set((session?.storedDevices ?? knownDevices).compactMap(\.resolvedWalletId)) + Set(storedDevices.compactMap(\.resolvedWalletId)) + } + + /// Fails unless the live Jade session may sign for `walletId`. No session passes, since core then + /// reports it as not connected and the signer reconnects. A session whose accounts could not be + /// read reports no identity, so its entry has to belong to `walletId` instead. + /// + /// A foreground reconnect can replace the session between composing and signing, and a different + /// Jade would be asked to sign inputs it holds no keys for. + private func requireJadeSession(holding walletId: String) throws { + guard let jadeSession, let connectedDeviceId = jadeSession.connectedDeviceId else { return } + let holdsWallet = if let opened = jadeSession.connectedWalletId { + opened == walletId + } else { + entries(for: walletId).contains { $0.id == connectedDeviceId } + } + guard !holdsWallet else { return } + throw AppError( + message: "Reconnect Hardware Device", + debugMessage: "A different hardware wallet is connected than the one holding '\(walletId)'" + ) } /// Opens the passphrase (hidden) wallet of an already paired device and starts watching it as /// its own identity, returning its wallet id. The passphrase is bound to a fresh Trezor session - /// and is never persisted; re-entering it is what makes the wallet reachable again. + /// and is never persisted; re-entering it is what makes the wallet reachable again. A Jade has no + /// passphrase wallets, so it is refused as a device with passphrase protection turned off. func connectWithPassphrase(deviceId: String, passphrase: String) async throws -> String { - guard let session else { + try await withSessionLock { + let isJade = jadeSession?.connectedDeviceId == deviceId + || storedDevices.contains { $0.id == deviceId && $0.vendor == .blockstream } + guard !isJade else { throw HwPassphraseError.protectionDisabled } + await disconnectOtherVendor(.trezor) + return try await connectWithPassphraseLocked(deviceId: deviceId, passphrase: passphrase) + } + } + + private func connectWithPassphraseLocked(deviceId: String, passphrase: String) async throws -> String { + guard let session = trezorSession else { throw AppError(message: "Unavailable", debugMessage: "No device session to open a passphrase wallet with") } await waitForStaleSessionCleanup(deviceId: deviceId) @@ -378,10 +475,20 @@ final class HwWalletManager { /// otherwise be accepted and sign with the wrong seed. The standard wallet needs no secret to /// reopen; a passphrase wallet does, which the caller has to collect. func ensureConnected(walletId: String) async throws { - guard let session else { + try await withSessionLock { + try await ensureConnectedLocked(walletId: walletId) + } + } + + private func ensureConnectedLocked(walletId: String) async throws { + if vendor(walletId: walletId) == .blockstream { + return try await ensureJadeConnected(walletId: walletId) + } + guard let session = trezorSession else { throw AppError(message: "Unavailable", debugMessage: "No device session for wallet '\(walletId)'") } let deviceId = try requireTransportDeviceId(for: walletId) + await disconnectOtherVendor(.trezor) await waitForStaleSessionCleanup(deviceId: deviceId) try await session.ensureConnected(deviceId: deviceId) if session.connectedWalletId == walletId { @@ -399,11 +506,31 @@ final class HwWalletManager { try requireIdentity(of: walletId) } + /// A Jade holds a single wallet, so a live session that resolved to another one cannot be + /// reopened for this wallet: the device itself is the wrong one. + private func ensureJadeConnected(walletId: String) async throws { + guard let jadeSession else { + throw AppError(message: "Unavailable", debugMessage: "No Jade session for wallet '\(walletId)'") + } + let deviceId = try requireTransportDeviceId(for: walletId) + await disconnectOtherVendor(.blockstream) + await waitForStaleSessionCleanup(deviceId: deviceId) + // A cancelled send schedules the cleanup awaited above, so it must stop here rather than dial. + try Task.checkCancellation() + try await jadeSession.ensureConnected(deviceId: deviceId) + if let opened = jadeSession.connectedWalletId, opened != walletId { + throw AppError( + message: "Reconnect Hardware Device", + debugMessage: "Device '\(deviceId)' is not holding wallet '\(walletId)'" + ) + } + } + /// Whether reaching `walletId` needs the passphrase again. The device only holds one hidden /// wallet open at a time and forgets the passphrase with the session, so a passphrase wallet that /// is not the live session cannot be reconnected — or signed with — without it. func needsPassphrase(walletId: String) -> Bool { - entries(for: walletId).contains(where: \.passphraseProtected) && session?.connectedWalletId != walletId + entries(for: walletId).contains(where: \.passphraseProtected) && trezorSession?.connectedWalletId != walletId } func disconnectStaleSession(walletId: String) async { @@ -412,7 +539,7 @@ final class HwWalletManager { await cleanup.value return } - await performStaleSessionCleanup(deviceId: deviceId) + await performStaleSessionCleanup(deviceId: deviceId, vendor: vendor(walletId: walletId)) } /// Starts timeout recovery without blocking the current UI operation. Any subsequent connect @@ -420,10 +547,12 @@ final class HwWalletManager { func scheduleStaleSessionCleanup(walletId: String) { guard let deviceId = transportDeviceId(for: walletId) else { return } guard staleSessionCleanupTasks[deviceId] == nil else { return } + // Captured now: a removal landing before the task runs would leave nothing to read it from. + let vendor = vendor(walletId: walletId) staleSessionCleanupTasks[deviceId] = Task { @MainActor [weak self] in guard let self else { return } - await performStaleSessionCleanup(deviceId: deviceId) + await performStaleSessionCleanup(deviceId: deviceId, vendor: vendor) staleSessionCleanupTasks[deviceId] = nil } } @@ -432,13 +561,23 @@ final class HwWalletManager { await staleSessionCleanupTasks[deviceId]?.value } - private func performStaleSessionCleanup(deviceId: String) async { - await session?.disconnectStaleSession(deviceId: deviceId) + private func performStaleSessionCleanup(deviceId: String, vendor: HwWalletVendor) async { + switch vendor { + case .trezor: + await trezorSession?.disconnectStaleSession(deviceId: deviceId) + case .blockstream: + await jadeSession?.disconnectStaleSession(deviceId: deviceId) + } } func isKnownBluetoothDevice(walletId: String) -> Bool { guard let deviceId = transportDeviceId(for: walletId) else { return false } - return session?.isKnownBluetoothDevice(deviceId: deviceId) ?? false + switch vendor(walletId: walletId) { + case .trezor: + return trezorSession?.isKnownBluetoothDevice(deviceId: deviceId) ?? false + case .blockstream: + return jadeSession?.isKnownBluetoothDevice(deviceId: deviceId) ?? false + } } func warmUpConnection(walletId: String) { @@ -449,7 +588,15 @@ final class HwWalletManager { guard !needsPassphrase(walletId: walletId) else { return } guard let deviceId = transportDeviceId(for: walletId) else { return } guard staleSessionCleanupTasks[deviceId] == nil else { return } - session?.warmUpConnection(deviceId: deviceId) + // Best effort only: taking the radio from the other vendor is left to an explicit connect. + let vendor = vendor(walletId: walletId) + guard !isOtherVendorActive(vendor) else { return } + switch vendor { + case .trezor: + trezorSession?.warmUpConnection(deviceId: deviceId) + case .blockstream: + jadeSession?.warmUpConnection(deviceId: deviceId) + } } /// Reopens a watched passphrase wallet for signing. A wrong passphrase is not rejected by the @@ -457,7 +604,15 @@ final class HwWalletManager { /// its accounts resolve back to `walletId`; anything else is torn down again and reported as /// `HwPassphraseError.mismatch` rather than signing from the wrong wallet. func reconnectWithPassphrase(walletId: String, passphrase: String) async throws { - guard let session else { + try await withSessionLock { + guard vendor(walletId: walletId) == .trezor else { throw HwPassphraseError.protectionDisabled } + await disconnectOtherVendor(.trezor) + try await reconnectWithPassphraseLocked(walletId: walletId, passphrase: passphrase) + } + } + + private func reconnectWithPassphraseLocked(walletId: String, passphrase: String) async throws { + guard let session = trezorSession else { throw AppError(message: "Unavailable", debugMessage: "No device session for wallet '\(walletId)'") } let deviceId = try requireTransportDeviceId(for: walletId) @@ -497,6 +652,123 @@ final class HwWalletManager { throw HwPassphraseError.mismatch } + // MARK: - Vendor sessions + + /// Runs `operation` with the radio to `vendor` alone, releasing the other vendor's session first. + /// Pairing goes through here, so it queues behind the other session operations. + func withVendorSession(_ vendor: HwWalletVendor, _ operation: @MainActor () async throws -> T) async throws -> T { + try await withSessionLock { + await disconnectOtherVendor(vendor) + // Releasing the other vendor can take seconds, and a cancel landing then must not dial. + try Task.checkCancellation() + return try await operation() + } + } + + /// Silently reconnects the most relevant paired device after the app returns to the foreground. + /// Best effort: failures are logged by the vendor managers. + func reconnectOnForeground() async { + try? await withSessionLock { + let target = preferredReconnectVendor() + await disconnectOtherVendor(target) + switch target { + case .trezor: + // Started rather than awaited so a scan for a Trezor that is not around does not hold + // the lock. It reads as active before the lock is released, so the next operation + // releases it instead of dialling alongside it. + trezorSession?.startAutoReconnect() + case .blockstream: + jadeSession?.startAutoReconnect() + } + } + } + + /// Starts a silent Jade reconnect once Bluetooth is back on, when a Jade is paired, the Trezor is + /// idle and the app is in the foreground. + func onJadeBluetoothRestored() { + guard isAppActive, let jadeSession, !jadeSession.storedDevices.isEmpty else { return } + guard !isOtherVendorActive(.blockstream) else { return } + jadeSession.startAutoReconnect() + } + + func onAppBackgrounded() { + isAppActive = false + jadeSession?.onAppBackgrounded() + } + + func onAppBecameActive() { + isAppActive = true + jadeSession?.onAppBecameActive() + } + + func resetForWipe() async { + await trezorSession?.resetForWipe() + await jadeSession?.resetForWipe() + } + + /// The vendor a foreground reconnect targets: the one already connected, else the one whose + /// Bluetooth entry was used most recently. Read from the saved entries, because on a cold launch + /// this runs before the vendor managers have loaded theirs. + private func preferredReconnectVendor() -> HwWalletVendor { + if trezorSession?.connectedDeviceId != nil { + return .trezor + } + if jadeSession?.connectedDeviceId != nil { + return .blockstream + } + return HwKnownDeviceStorage.loadAll() + .filter { $0.transportType == "bluetooth" } + .max { $0.lastConnectedAt < $1.lastConnectedAt }? + .vendor ?? .trezor + } + + private func isOtherVendorActive(_ vendor: HwWalletVendor) -> Bool { + switch vendor { + case .trezor: jadeSession?.isSessionActive == true + case .blockstream: trezorSession?.isSessionActive == true + } + } + + /// Releases the other vendor's session so the radio and core belong to `vendor` alone. A release + /// cannot fail from here: each vendor drops its session state even when closing the device + /// fails, so the operation carries on regardless. + private func disconnectOtherVendor(_ vendor: HwWalletVendor) async { + guard isOtherVendorActive(vendor) else { return } + switch vendor { + case .trezor: + await jadeSession?.releaseSession() + case .blockstream: + await trezorSession?.releaseSession() + } + } + + private func withSessionLock(_ operation: @MainActor () async throws -> T) async throws -> T { + await acquireSessionLock() + defer { releaseSessionLock() } + try Task.checkCancellation() + return try await operation() + } + + private func acquireSessionLock() async { + guard isSessionOperationActive else { + isSessionOperationActive = true + return + } + + await withCheckedContinuation { continuation in + sessionOperationWaiters.append(continuation) + } + } + + private func releaseSessionLock() { + guard !sessionOperationWaiters.isEmpty else { + isSessionOperationActive = false + return + } + + sessionOperationWaiters.removeFirst().resume() + } + // MARK: - Watcher orchestration /// Reconcile watchers in response to a settings change, but only when the monitored address @@ -572,25 +844,30 @@ final class HwWalletManager { /// The wallet identity a stored entry belongs to: the id it was saved with, or one derived from /// its xpubs for entries written before the id was persisted. Returns nil when neither is /// available (no captured xpubs), so callers skip the entry. - private func resolvedWalletId(for device: TrezorKnownDevice) -> String? { + private func resolvedWalletId(for device: HwKnownDevice) -> String? { if let walletId = device.walletId, !walletId.isEmpty { return walletId } - return walletId(for: device.xpubs) + return walletId(for: device.xpubs, vendor: device.vendor) } - /// Derive (and memoize) the wallet id for a device's xpubs. Returns nil when derivation fails - /// (e.g. no captured xpubs — `HwWalletId.derive` throws on empty), so callers skip the device. - private func walletId(for xpubs: [String: String]) -> String? { - let signature = xpubsSignature(xpubs) - if let cached = walletIdCache[signature] { + /// Derive (and memoize) the wallet id for a device's xpubs in its vendor's namespace. Returns nil + /// when derivation fails (e.g. no captured xpubs, as `HwWalletId.derive` throws on empty), so + /// callers skip the device. + private func walletId(for xpubs: [String: String], vendor: HwWalletVendor) -> String? { + let cacheKey = walletIdCacheKey(xpubs: xpubs, vendor: vendor) + if let cached = walletIdCache[cacheKey] { return cached } - guard let derived = try? HwWalletId.derive(xpubs: xpubs) else { return nil } - walletIdCache[signature] = derived + guard let derived = try? HwWalletId.derive(xpubs: xpubs, vendor: vendor) else { return nil } + walletIdCache[cacheKey] = derived return derived } + private func walletIdCacheKey(xpubs: [String: String], vendor: HwWalletVendor) -> String { + "\(vendor.deviceType)\u{1e}\(xpubsSignature(xpubs))" + } + private func xpubsSignature(_ xpubs: [String: String]) -> String { xpubs.sorted { $0.key < $1.key } .map { dedupKey(addressType: $0.key, xpub: $0.value) } @@ -600,8 +877,10 @@ final class HwWalletManager { /// Drop cache entries for devices no longer in the snapshot, so the caches stay bounded to /// live devices. private func pruneCaches() { - let liveSignatures = Set(knownDevices.filter { !$0.xpubs.isEmpty }.map { xpubsSignature($0.xpubs) }) - walletIdCache = walletIdCache.filter { liveSignatures.contains($0.key) } + let liveCacheKeys = Set( + knownDevices.filter { !$0.xpubs.isEmpty }.map { walletIdCacheKey(xpubs: $0.xpubs, vendor: $0.vendor) } + ) + walletIdCache = walletIdCache.filter { liveCacheKeys.contains($0.key) } lastPersisted = lastPersisted.filter { hwWalletIds.contains($0.key) } } @@ -893,7 +1172,8 @@ final class HwWalletManager { balanceSats: walletWatchers.reduce(UInt64(0)) { $0.saturatingAdd($1.balanceSats) }, fundingBalanceSats: fundingBalance(group: group, addressType: hwFundingDefaultAddressType), deviceIds: group.ids, - passphraseProtected: group.devices.contains { $0.passphraseProtected } + passphraseProtected: group.devices.contains { $0.passphraseProtected }, + vendor: device.vendor ) } @@ -906,7 +1186,7 @@ final class HwWalletManager { /// without captured xpubs are skipped. private func deviceGroups() -> [DeviceGroup] { var order: [String] = [] - var grouped: [String: [TrezorKnownDevice]] = [:] + var grouped: [String: [HwKnownDevice]] = [:] for device in knownDevices where !device.xpubs.isEmpty { guard let walletId = resolvedWalletId(for: device) else { continue } if grouped[walletId] == nil { @@ -995,6 +1275,9 @@ final class HwWalletManager { /// Displays the exact address currently shown by Bitkit on the device and rejects a mismatch. func verifyReceiveAddress(walletId: String, receiveAddress: HwReceiveAddress) async throws { + if vendor(walletId: walletId) == .blockstream { + return try await verifyJadeReceiveAddress(walletId: walletId, receiveAddress: receiveAddress) + } try await ensureConnected(walletId: walletId) let response: TrezorAddressResponse @@ -1022,6 +1305,46 @@ final class HwWalletManager { } } + /// A Jade compares on the device itself: it shows the address and answers with a mismatch error. + private func verifyJadeReceiveAddress(walletId: String, receiveAddress: HwReceiveAddress) async throws { + guard let jadeSession else { + throw AppError(message: "Unavailable", debugMessage: "No Jade session for wallet '\(walletId)'") + } + func verifyOnDevice() async throws { + try await jadeSession.verifyAddress( + addressType: receiveAddress.addressType, + derivationPath: receiveAddress.path, + expectedAddress: receiveAddress.address + ) + } + + do { + try await ensureConnected(walletId: walletId) + do { + try await verifyOnDevice() + } catch { + guard error.isHwSessionFailure() else { throw error } + await disconnectStaleSession(walletId: walletId) + try await ensureConnected(walletId: walletId) + try Task.checkCancellation() + do { + try await verifyOnDevice() + } catch { + if error.isHwSessionFailure() { + await disconnectStaleSession(walletId: walletId) + } + throw error + } + } + } catch { + guard case let .AddressMismatch(_, returned)? = error.underlyingJadeError else { throw error } + throw AppError( + message: t("hardware__verify_address_error"), + debugMessage: "Jade returned '\(returned)' for '\(receiveAddress.path)', expected '\(receiveAddress.address)'" + ) + } + } + private func readAddressOnDevice(_ receiveAddress: HwReceiveAddress) async throws -> TrezorAddressResponse { try await addressProvider( TrezorGetAddressParams( @@ -1056,7 +1379,7 @@ final class HwWalletManager { feeRates: [Float(satsPerVByte)], coinSelection: .branchAndBound ) - let results = try await OnChainHwService.shared.composeTransaction(params: params) + let results = try await composeProvider(params) for result in results { if case let .success(_, fee, _, totalSpent) = result { return totalSpent > fee ? totalSpent - fee : 0 @@ -1077,7 +1400,8 @@ final class HwWalletManager { /// Compose the exact on-chain funding payment before prompting for the on-device signature. /// Requires the device to be connected (the fingerprint drives the PSBT derivation paths); the - /// caller must ensure the Trezor is connected first (via `TrezorManager`). + /// caller must ensure a Trezor is connected first (via `ensureConnected`). A Jade is connected + /// here, since its fingerprint is read from the live session. func composeFundingTransaction( walletId: String, address: String, @@ -1085,7 +1409,7 @@ final class HwWalletManager { satsPerVByte: UInt64, addressType: AddressScriptType = hwFundingDefaultAddressType ) async throws -> HwFundingTransaction { - let fingerprint = try await TrezorService.shared.getDeviceFingerprint() + let fingerprint = try await signingFingerprint(walletId: walletId) return try await composeFundingTransactionInternal( walletId: walletId, address: address, @@ -1096,6 +1420,26 @@ final class HwWalletManager { ) } + private func signingFingerprint(walletId: String) async throws -> String { + guard vendor(walletId: walletId) == .blockstream else { + return try await TrezorService.shared.getDeviceFingerprint() + } + guard let jadeSession else { + throw AppError(message: "Unavailable", debugMessage: "No Jade session for wallet '\(walletId)'") + } + // Without the key origins this carries, the Jade finds nothing of its own to sign. + try await ensureConnected(walletId: walletId) + do { + return try await jadeSession.masterFingerprint() + } catch { + // Core keeps a failed link marked connected, so release it or every retry reuses it. + if error.isJadeSessionFailure() { + await disconnectStaleSession(walletId: walletId) + } + throw error + } + } + /// Offline coin-selection for the exact funding amount; returns the mining fee only. func estimateOfflineFundingMiningFee( walletId: String, @@ -1137,7 +1481,7 @@ final class HwWalletManager { feeRates: [Float(satsPerVByte)], coinSelection: .branchAndBound ) - let results = try await OnChainHwService.shared.composeTransaction(params: params) + let results = try await composeProvider(params) for result in results { if case let .success(psbt, fee, feeRate, totalSpent) = result { return HwFundingTransaction( @@ -1164,12 +1508,15 @@ final class HwWalletManager { /// Sign a composed funding payment on the device. Requires the device to be connected. On signing /// failure the caller is responsible for clearing the stale session (via - /// `TrezorManager.disconnectStaleSession`). Broadcasting is a separate step so a device-signing + /// `disconnectStaleSession`). Broadcasting is a separate step so a device-signing /// timeout is never conflated with an in-flight broadcast. func signFunding( walletId: String, funding: HwFundingTransaction ) async throws -> HwFundingSignedTx { + if vendor(walletId: walletId) == .blockstream { + return try await signJadeFunding(walletId: walletId, funding: funding) + } // The session can change between connecting and signing, and signing from the wrong seed // would produce signatures that do not match the inputs being spent. try requireIdentity(of: walletId) @@ -1183,6 +1530,20 @@ final class HwWalletManager { ) } + private func signJadeFunding(walletId: String, funding: HwFundingTransaction) async throws -> HwFundingSignedTx { + guard let jadeSession else { + throw AppError(message: "Unavailable", debugMessage: "No Jade session for wallet '\(walletId)'") + } + try requireJadeSession(holding: walletId) + let signed = try await jadeSession.signPsbt(funding.psbt) + return HwFundingSignedTx( + serializedTx: signed.serializedTx, + miningFeeSats: funding.miningFeeSats, + feeRate: funding.feeRate, + totalSpent: funding.totalSpent + ) + } + /// Broadcast a signed funding transaction and return its txid. Does not require a connected device. func broadcastFunding(serializedTx: String) async throws -> String { try await OnChainHwService.shared.broadcastRawTx( @@ -1220,13 +1581,13 @@ final class HwWalletManager { private struct DeviceGroup { let walletId: String - let devices: [TrezorKnownDevice] + let devices: [HwKnownDevice] var ids: Set { Set(devices.map(\.id)) } - var representative: TrezorKnownDevice { + var representative: HwKnownDevice { devices.max(by: { $0.lastConnectedAt < $1.lastConnectedAt }) ?? devices[0] } } diff --git a/Bitkit/Managers/JadeManager.swift b/Bitkit/Managers/JadeManager.swift new file mode 100644 index 000000000..c1ff47c7e --- /dev/null +++ b/Bitkit/Managers/JadeManager.swift @@ -0,0 +1,904 @@ +import BitkitCore +import Combine +import Foundation +import LDKNode +import UIKit + +/// Device sessions for Blockstream Jade wallets: discovery, connect and unlock, the paired entries, +/// and the device operations Bitkit needs (address verification, the master fingerprint and PSBT +/// signing). Watchers, compose and broadcast are vendor neutral and stay in `HwWalletManager`. +/// +/// A Jade locks on every power cycle and unlocks with a PIN entered on the device, which needs the +/// pinserver round trip bitkit-core performs. Silent reconnects never unlock: only the user's own +/// action (pairing, verifying an address, signing) puts the PIN screen on the device. +/// +/// Core cancels a connect whenever a disconnect or cancel reaches it after that connect started, so a +/// teardown landing late would kill the connect that follows it. Every teardown therefore clears the +/// session state before its first suspension, invalidates the attempts in flight by bumping +/// `connectEpoch`, and queues its device work on `sessionTeardownTask`, which each connect waits for. +@Observable +@MainActor +final class JadeManager { + struct Timing { + /// How long the app may sit in the background before an open link is released. A Jade still + /// holding a link when the app is suspended can drop its bond, so the link is closed cleanly + /// first; the delay keeps a brief switch to another app during a PIN or signing prompt from + /// cancelling it. + var backgroundRelease: TimeInterval = 30 + /// Kept back from the background time left, so the release itself still fits in it. + var expirationMargin: TimeInterval = 5 + var reconnectBackoff: TimeInterval = 2 + var reconnectAttempts = 4 + var connectPollInterval: TimeInterval = 0.25 + var connectMaxWait: TimeInterval = 28 + } + + static let allAccountTypes: [AccountType] = [.legacy, .wrappedSegwit, .nativeSegwit, .taproot] + + private static let walletNameMaxLength = 50 + private static let backgroundTaskName = "JadeBluetoothRelease" + private nonisolated static let logContext = "JadeManager" + + private(set) var isScanning = false + private(set) var isConnecting = false + private(set) var isAutoReconnecting = false + /// Set while the device waits for its PIN. + private(set) var isUnlocking = false + + private(set) var knownDevices: [HwKnownDevice] = [] { + didSet { + devicesRevision &+= 1 + transport.setPairedPaths(Set(knownDevices.map(\.path).filter { HwDevicePath.isBle($0) })) + } + } + + /// Jades the last scan found that are not paired yet. + private(set) var nearbyDevices: [JadeDeviceInfo] = [] + + private(set) var connected: ConnectedJadeDevice? { + didSet { devicesRevision &+= 1 } + } + + /// Bumped whenever the paired entries or the session change, so observers can push them on. + private(set) var devicesRevision = 0 + + var isConnectInProgress: Bool { + isConnecting || isAutoReconnecting + } + + private let service: JadeServicing + private let transport: JadeTransportControlling + private let store: JadeKnownDeviceStoring + private let backgroundTasks: BackgroundTaskScheduling + private let timing: Timing + private let now: () -> Date + private let network: () -> LDKNode.Network + + @ObservationIgnored private var isSetup = false + @ObservationIgnored private var connectingPath: String? + @ObservationIgnored private var connectEpoch: UInt64 = 0 + @ObservationIgnored private var attemptTokenCounter: UInt64 = 0 + @ObservationIgnored private var attemptTokens: [AttemptFlag: UInt64] = [:] + @ObservationIgnored private var sessionTeardownTask: Task? + @ObservationIgnored private var transportReconnectTask: Task? + @ObservationIgnored private var reconnectLoopGeneration: UInt64 = 0 + @ObservationIgnored private var backgroundReleaseTask: Task? + @ObservationIgnored private var backgroundTaskId: UIBackgroundTaskIdentifier = .invalid + @ObservationIgnored private var cancellables = Set() + + private enum AttemptFlag { + case connecting + case autoReconnecting + case unlocking + } + + init( + service: JadeServicing = JadeService.shared, + transport: JadeTransportControlling = JadeTransport.shared, + store: JadeKnownDeviceStoring = JadeKnownDeviceStore(), + backgroundTasks: BackgroundTaskScheduling? = nil, + timing: Timing = Timing(), + now: @escaping () -> Date = Date.init, + network: @escaping () -> LDKNode.Network = { Env.network } + ) { + self.service = service + self.transport = transport + self.store = store + self.backgroundTasks = backgroundTasks ?? UIApplicationBackgroundTasks() + self.timing = timing + self.now = now + self.network = network + + transport.externalDisconnects + .receive(on: DispatchQueue.main) + .sink { [weak self] path in + self?.handleExternalDisconnect(path: path) + } + .store(in: &cancellables) + } + + // MARK: - Paired devices + + func loadKnownDevices() { + knownDevices = store.loadAll() + } + + /// Whether `deviceId` names a paired Jade. `advertisedName` is the name a scan reported for it: a + /// rebooted Jade advertises under a new Bluetooth identifier, so matching on the id alone would + /// treat a paired device as a stranger. + func hasKnownDevice(deviceId: String, advertisedName: String? = nil) -> Bool { + allKnownDevices().contains { $0.matches(deviceId: deviceId) || $0.advertisesAs(advertisedName) } + } + + func isKnownBluetoothDevice(deviceId: String) -> Bool { + knownDevice(deviceId)?.transportType == "bluetooth" + } + + // MARK: - Scan and pair + + /// Every Jade found nearby. Core refuses to scan while a session is open, so the devices of the + /// last scan are listed instead of failing the search. + func scan() async throws -> [JadeDeviceInfo] { + try await awaitSetup() + isScanning = true + defer { isScanning = false } + let devices = try await scanOrListDevices() + let paired = allKnownDevices() + nearbyDevices = devices.filter { device in !paired.contains { $0.isSameJade(as: device) } } + return devices + } + + /// Pairs a discovered Jade: connects, unlocks with the PIN entered on the device, reads its + /// accounts and stores it. A background reconnect or warm-up in flight is cancelled first, so its + /// teardown cannot cancel this connect. + @discardableResult + func connect(path: String) async throws -> ConnectedJadeDevice { + cancelReconnectLoop() + if isConnectInProgress { + await cancelPendingConnection(deviceId: "") + } + try await awaitSetup() + // A cancel before the epoch below is captured would otherwise be missed. + try Task.checkCancellation() + let token = beginAttempt(.connecting) + defer { endAttempt(.connecting, token: token) } + let epoch = connectEpoch + do { + let device = try await resolveDevice(path: path) + try requireCurrent(epoch) + let session = try await connectDevice(device, unlock: true, expected: nil, epoch: epoch) + nearbyDevices.removeAll { $0.path == path || $0.path == device.path } + return session + } catch { + Logger.error("Jade connect failed: \(error)", context: Self.logContext) + throw error + } + } + + /// Reconnects a paired Jade, which is found again by its stored path or, after a reboot, by the + /// name it advertises. With `unlock` off the session stays locked, as silent reconnects want. + @discardableResult + func connectKnownDevice(deviceId: String, forceSession: Bool = false, unlock: Bool = true) async throws -> ConnectedJadeDevice { + guard !isConnectInProgress else { + throw AppError(message: "Connection already in progress", debugMessage: "A Jade connect is already running") + } + return try await connectKnownDeviceUnguarded(deviceId: deviceId, forceSession: forceSession, unlock: unlock) + } + + /// Silent reconnect of the most recently used Jade; never asks for the PIN. Reads the saved entries + /// when none are loaded yet, since a cold launch reconnects before they are. + @discardableResult + func autoReconnect() async throws -> ConnectedJadeDevice { + guard !isConnectInProgress else { + throw AppError(message: "Connection already in progress", debugMessage: "A Jade connect is already running") + } + guard let entry = savedOrLoadedDevices() + .filter({ $0.transportType == "bluetooth" }) + .max(by: { $0.lastConnectedAt < $1.lastConnectedAt }) + else { + throw AppError(message: "Reconnect Hardware Device", debugMessage: "No paired Jade to reconnect") + } + + let token = beginAttempt(.autoReconnecting) + defer { endAttempt(.autoReconnecting, token: token) } + do { + try await awaitSetup() + if let current = connected, service.isConnected() { + return current + } + if service.isConnected() { + await beginTeardown(nil, releasingAttempts: false, core: Self.disconnectCore).value + } + return try await connectKnownDeviceUnguarded(deviceId: entry.id, forceSession: false, unlock: false) + } catch { + Logger.error("Jade auto-reconnect failed: \(error)", context: Self.logContext) + throw error + } + } + + /// Closes the session: the link and core at once, since a request still waiting on the link holds + /// core until the link goes. + func disconnect() async { + let link = connected.map { LinkRelease.path($0.path) } + await beginTeardown(link, releasingAttempts: true, core: Self.disconnectCore).value + } + + /// Stops a connect in flight, including one waiting for the PIN. A Swift task cancel never reaches + /// core, so a cancel from the UI has to come through here. + func cancelPendingConnection(deviceId: String) async { + let fallbackPath = deviceId.isEmpty ? nil : knownDevice(deviceId)?.path ?? deviceId + let path = connectingPath ?? connected?.path ?? fallbackPath + await beginTeardown(path.map(LinkRelease.path), releasingAttempts: true) { service in + do { + try await service.cancel() + } catch { + Logger.warn("Failed to cancel the Jade request in flight: \(error)", context: JadeManager.logContext) + } + await JadeManager.disconnectCore(service) + }.value + } + + // MARK: - Device events + + /// A link dropped without the app closing it. The notice to core is queued ahead of the next + /// connect, since a notice arriving after that connect would tear the new session down. + func handleExternalDisconnect(path: String) { + guard connected?.path == path || connectingPath == path else { return } + Logger.warn("External disconnect for Jade '\(path)'", context: Self.logContext) + connected = nil + let previous = sessionTeardownTask + let service = service + sessionTeardownTask = Task { + await previous?.value + await service.notifyDisconnected(path: path) + } + } + + // MARK: - Connect internals + + private func connectKnownDeviceUnguarded(deviceId: String, forceSession: Bool, unlock: Bool) async throws -> ConnectedJadeDevice { + let token = beginAttempt(.connecting) + defer { endAttempt(.connecting, token: token) } + var epoch = connectEpoch + do { + try await awaitSetup() + if forceSession, let staleTeardown = beginStaleSessionTeardown(deviceId: deviceId, releasingAttempts: false) { + // This attempt's own teardown moved the epoch; anything moving it again cancels the attempt. + epoch = connectEpoch + await staleTeardown.value + } + try requireCurrent(epoch) + guard let entry = knownDevice(deviceId) else { + throw AppError(message: "Reconnect Hardware Device", debugMessage: "Unknown Jade '\(deviceId)'") + } + let candidate = await knownDeviceCandidate(for: entry) + try requireCurrent(epoch) + let session = try await connectDevice(candidate, unlock: unlock, expected: entry, epoch: epoch) + Logger.info("Reconnected known Jade '\(entry.id)'", context: Self.logContext) + return session + } catch { + Logger.error("Jade reconnect failed: \(error)", context: Self.logContext) + if connectEpoch == epoch { + await beginStaleSessionTeardown(deviceId: deviceId, releasingAttempts: false)?.value + } + throw error + } + } + + private func connectDevice( + _ device: JadeDeviceInfo, + unlock: Bool, + expected: HwKnownDevice?, + epoch: UInt64 + ) async throws -> ConnectedJadeDevice { + connectingPath = device.path + defer { + if connectEpoch == epoch, connectingPath == device.path { + connectingPath = nil + } + } + do { + await awaitSessionTeardown() + try requireCurrent(epoch) + var version = try await service.connect(path: device.path) + try requireCurrent(epoch) + Logger.info( + "Connected Jade '\(device.path)' firmware '\(version.jadeVersion)' state '\(version.jadeState)'", + context: Self.logContext + ) + try rejectUnusableDevice(version, expected: expected) + if unlock, version.jadeState == .locked { + version = try await unlockConnected() + try requireCurrent(epoch) + } + let known = try await knownEntry(for: device, version: version, expected: expected, epoch: epoch) + let session = ConnectedJadeDevice(id: known.id, path: device.path, versionInfo: version, walletId: known.resolvedWalletId) + connected = session + return session + } catch { + if connectEpoch == epoch { + await beginTeardown(.path(device.path), releasingAttempts: false, core: Self.disconnectCore).value + } + throw error + } + } + + /// The entry this connect lands on: refreshed with the accounts an unlocked device reports, or, + /// for a device still locked, the entry that already holds its keys. + private func knownEntry( + for device: JadeDeviceInfo, + version: JadeVersionInfo, + expected: HwKnownDevice?, + epoch: UInt64 + ) async throws -> HwKnownDevice { + if version.jadeState.isUnlocked { + let xpubs = try await exportAccounts() + try requireCurrent(epoch) + return addOrUpdateKnownDevice(device, version: version, fetchedXpubs: xpubs) + } + let entryId = JadeDeviceIdentity.deviceId(efuseMac: version.efuseMac) ?? device.path + guard let entry = expected ?? knownDevice(entryId) else { + throw JadeError.DeviceLocked + } + return refreshKnownDevice(entry, path: device.path) + } + + /// Refuses a Jade with no wallet yet, and one that is not the paired device expected. Runs before + /// unlocking, so a wrong Jade never shows a PIN prompt. + private func rejectUnusableDevice(_ version: JadeVersionInfo, expected: HwKnownDevice?) throws { + if version.jadeState == .uninit { + throw JadeError.DeviceUninitialized + } + // A device reporting no efuse MAC cannot prove it is the paired one, so it fails closed. + guard let expectedMac = expected?.jadeDeviceId, !expectedMac.isEmpty else { return } + if expectedMac != version.efuseMac { + throw AppError(message: "Reconnect Hardware Device", debugMessage: "A different Jade is connected") + } + } + + private func unlockConnected() async throws -> JadeVersionInfo { + let token = beginAttempt(.unlocking) + defer { endAttempt(.unlocking, token: token) } + // Core enforces the five minute unlock deadline; the PIN is typed on the device. + try await service.unlock(network: jadeNetwork()) + return try await service.refreshVersionInfo() + } + + private func exportAccounts() async throws -> [String: String] { + let network = try jadeNetwork() + let export: JadeAccountExport + do { + export = try await service.getAccountExport(network: network, accountTypes: Self.allAccountTypes, accountIndex: 0) + } catch let error where error.isJadeFirmwareError() { + Logger.warn("Retrying the Jade account export without taproot: \(error)", context: Self.logContext) + let withoutTaproot = Self.allAccountTypes.filter { $0 != .taproot } + export = try await service.getAccountExport(network: network, accountTypes: withoutTaproot, accountIndex: 0) + } + var xpubs: [String: String] = [:] + for account in export.accounts { + xpubs[AddressScriptType(jadeVariant: account.variant).stringValue] = account.xpub + } + guard !xpubs.isEmpty else { + throw AppError( + message: "Could not read any account keys from your Jade. Reconnect and try again.", + debugMessage: "The Jade account export held no accounts" + ) + } + return xpubs + } + + private func addOrUpdateKnownDevice(_ device: JadeDeviceInfo, version: JadeVersionInfo, fetchedXpubs: [String: String]) -> HwKnownDevice { + let devices = allKnownDevices() + let id = JadeDeviceIdentity.deviceId(efuseMac: version.efuseMac) ?? device.path + let previous = HwKnownDeviceMatching.previous(in: devices, deviceId: id, fetchedXpubs: fetchedXpubs) + let xpubs = (previous?.xpubs ?? [:]).merging(fetchedXpubs) { _, fetched in fetched } + let walletKey = HwKnownDevice.walletKey(for: xpubs, fallback: id) + let named = HwKnownDeviceMatching.named(in: devices, previous: previous, walletKey: walletKey) + let walletId = resolvedWalletId(previous: previous, walletKey: walletKey, xpubs: xpubs, in: devices) + // A name restored from a backup, or kept when this wallet was removed, is adopted here; the + // store masks a pending name out once a paired entry carries it, so adopting it consumes it. + let pendingName = walletId.flatMap { store.loadPendingNames()[$0] }.flatMap { $0.isEmpty ? nil : $0 } + + let known = HwKnownDevice( + id: id, + name: device.name ?? previous?.name ?? JadeDeviceIdentity.defaultName, + path: device.path, + transportType: device.transport == .bluetooth ? "bluetooth" : "usb", + label: nil, + model: JadeDeviceIdentity.model(boardType: version.boardType), + lastConnectedAt: now(), + xpubs: xpubs, + customLabel: named?.customLabel ?? pendingName, + walletId: walletId, + passphraseProtected: false, + vendor: .blockstream, + jadeDeviceId: version.efuseMac + ) + let updated = HwKnownDeviceMatching.merged(devices, with: known, refreshed: previous) + store.saveAll(updated, pendingName: nil) + setKnownDevices(updated) + return known + } + + /// The wallet id this identity already carries, else one derived from its keys. Never the device + /// id: an id derived later would then disagree with the one stored. + private func resolvedWalletId(previous: HwKnownDevice?, walletKey: String, xpubs: [String: String], in devices: [HwKnownDevice]) -> String? { + if let carried = previous?.walletId ?? devices.first(where: { $0.walletKey == walletKey })?.walletId, !carried.isEmpty { + return carried + } + return try? HwWalletId.derive(xpubs: xpubs, vendor: .blockstream) + } + + private func refreshKnownDevice(_ entry: HwKnownDevice, path: String) -> HwKnownDevice { + let refreshed = entry.refreshed(path: path, at: now()) + let updated = allKnownDevices().map { $0.id == entry.id && $0.walletKey == entry.walletKey ? refreshed : $0 } + store.saveAll(updated, pendingName: nil) + setKnownDevices(updated) + return refreshed + } + + /// The device a pairing connect dials. Core only connects to devices of its last scan, so a path + /// it has not seen is scanned for, and a paired Jade that came back under a new Bluetooth + /// identifier is dialled there: its stored identifier would only fail once the connect timed out. + private func resolveDevice(path: String) async throws -> JadeDeviceInfo { + if let nearby = nearbyDevices.first(where: { $0.path == path }) { + return nearby + } + if let listed = await service.listDevices().first(where: { $0.path == path }) { + return listed + } + let scanned = try await scanOrListDevices() + if let match = scanned.first(where: { $0.path == path }) { + return match + } + if let entry = knownDevice(path), + let readvertised = scanned.first(where: { $0.transport == .bluetooth && entry.advertisesAs($0.name) }) + { + Logger.info("Resolved Jade '\(path)' to its new path '\(readvertised.path)'", context: Self.logContext) + return readvertised + } + return JadeDeviceInfo(path: path, transport: .bluetooth, name: nil, serialNumber: nil) + } + + /// Where a paired Jade is dialled: its stored path when a scan still sees it there, else the Jade + /// advertising under its name, else the stored path, which the transport can reach without a scan. + private func knownDeviceCandidate(for entry: HwKnownDevice) async -> JadeDeviceInfo { + let scanned: [JadeDeviceInfo] + do { + scanned = try await scanOrListDevices() + } catch { + Logger.warn("Scan before the Jade reconnect failed: \(error)", context: Self.logContext) + scanned = [] + } + let bluetooth = scanned.filter { $0.transport == .bluetooth } + if let exact = bluetooth.first(where: { $0.path == entry.path }) { + return exact + } + if let readvertised = bluetooth.first(where: { entry.advertisesAs($0.name) }) { + return readvertised + } + return JadeDeviceInfo(path: entry.path, transport: .bluetooth, name: entry.name, serialNumber: nil) + } + + private func scanOrListDevices() async throws -> [JadeDeviceInfo] { + if service.isConnected() { + return await service.listDevices() + } + return try await service.scan(timeoutMs: JadeService.scanTimeoutMs) + } + + // MARK: - Teardown + + /// Clears the session and invalidates every attempt in flight before the first suspension, then + /// queues closing the link and `core` behind any earlier teardown. Both start together: core + /// signals its change at once, while the link close still interrupts a request stuck on the link. + /// The work runs in its own task, so a cancelled caller cannot cut it short. + /// + /// - Parameter releasingAttempts: whether the caller gives the device up, which also stops the + /// background reconnect and clears every in-progress flag. An attempt cleaning up after itself + /// leaves them to its own unwinding. + @discardableResult + private func beginTeardown( + _ link: LinkRelease?, + releasingAttempts: Bool, + core: @escaping @Sendable (JadeServicing) async -> Void + ) -> Task { + connectEpoch &+= 1 + connected = nil + connectingPath = nil + if releasingAttempts { + cancelReconnectLoop() + resetAttempts() + } + let previous = sessionTeardownTask + let service = service + let transport = transport + let teardown = Task { + await previous?.value + async let linkReleased: Void = LinkRelease.release(link, on: transport) + async let coreReleased: Void = core(service) + _ = await (linkReleased, coreReleased) + } + sessionTeardownTask = teardown + return teardown + } + + /// Tears the session of `deviceId` down, or of the device being connected. Nil while another Jade + /// holds the session, which is left alone. + private func beginStaleSessionTeardown(deviceId: String, releasingAttempts: Bool) -> Task? { + if let current = connected, !current.matches(deviceId) { + return nil + } + let path = connected?.path ?? connectingPath ?? knownDevice(deviceId)?.path ?? deviceId + return beginTeardown(.path(path), releasingAttempts: releasingAttempts, core: Self.disconnectCore) + } + + private func awaitSessionTeardown() async { + var awaited: Task? + while let pending = sessionTeardownTask, pending != awaited { + await pending.value + awaited = pending + } + } + + private nonisolated static let disconnectCore: @Sendable (JadeServicing) async -> Void = { service in + do { + try await service.disconnect() + } catch { + Logger.warn("Failed to close the Jade core session: \(error)", context: logContext) + } + } + + private func requireCurrent(_ epoch: UInt64) throws { + guard connectEpoch == epoch else { throw JadeError.UserCancelled } + } + + // MARK: - Connection upkeep + + private func awaitConnectedOrNull(deviceId: String) async throws -> ConnectedJadeDevice? { + if let current = liveSession(deviceId: deviceId) { + return current + } + guard isConnectInProgress else { return nil } + let deadline = Date().addingTimeInterval(timing.connectMaxWait) + while isConnectInProgress, liveSession(deviceId: deviceId) == nil, Date() < deadline { + try await Task.sleep(for: .seconds(timing.connectPollInterval)) + } + return liveSession(deviceId: deviceId) + } + + private func liveSession(deviceId: String) -> ConnectedJadeDevice? { + guard let current = connected, current.matches(deviceId), service.isConnected() else { return nil } + return current + } + + private func retryAutoReconnect() async { + for attempt in 0 ..< timing.reconnectAttempts { + if connected != nil || isConnectInProgress { + return + } + do { + try await Task.sleep(for: .seconds(timing.reconnectBackoff * Double(attempt + 1))) + } catch { + return + } + if Task.isCancelled || connected != nil || isConnectInProgress { + return + } + Logger.info("Attempting Jade auto-reconnect, attempt \(attempt + 1)", context: Self.logContext) + do { + try await autoReconnect() + return + } catch { + if Task.isCancelled || error.isJadeDeviceBusy() { + return + } + } + } + } + + private func cancelReconnectLoop() { + transportReconnectTask?.cancel() + transportReconnectTask = nil + } + + private func beginAttempt(_ flag: AttemptFlag) -> UInt64 { + attemptTokenCounter &+= 1 + attemptTokens[flag] = attemptTokenCounter + setAttemptFlag(flag, to: true) + return attemptTokenCounter + } + + /// Clears `flag` unless a release already did, or a newer attempt owns it now. + private func endAttempt(_ flag: AttemptFlag, token: UInt64) { + guard attemptTokens[flag] == token else { return } + attemptTokens[flag] = nil + setAttemptFlag(flag, to: false) + } + + private func resetAttempts() { + attemptTokens.removeAll() + isConnecting = false + isAutoReconnecting = false + isUnlocking = false + } + + private func setAttemptFlag(_ flag: AttemptFlag, to value: Bool) { + switch flag { + case .connecting: isConnecting = value + case .autoReconnecting: isAutoReconnecting = value + case .unlocking: isUnlocking = value + } + } + + // MARK: - Background + + private func releaseInBackground(taskId: UIBackgroundTaskIdentifier) async { + Logger.info("Releasing the Jade Bluetooth link while the app is in the background", context: Self.logContext) + await releaseSession() + endBackgroundTask(taskId) + } + + /// The background time ran out: every link is cancelled without waiting, and core is told behind + /// the teardown chain so the next connect still waits for it. + private func releaseBeforeSuspension() { + backgroundReleaseTask?.cancel() + backgroundReleaseTask = nil + transport.releaseAllImmediately() + beginTeardown(nil, releasingAttempts: true, core: Self.disconnectCore) + endBackgroundTask(backgroundTaskId) + } + + private func backgroundReleaseDelay(for taskId: UIBackgroundTaskIdentifier) -> TimeInterval { + guard taskId != .invalid else { return 0 } + let budget = backgroundTasks.backgroundTimeRemaining - timing.expirationMargin + return max(0, min(timing.backgroundRelease, budget)) + } + + /// Ends `taskId` if it is still the one running, so no task is ended twice. + private func endBackgroundTask(_ taskId: UIBackgroundTaskIdentifier) { + guard taskId != .invalid, backgroundTaskId == taskId else { return } + backgroundTaskId = .invalid + backgroundTasks.endBackgroundTask(taskId) + } + + // MARK: - Helpers + + private func awaitSetup() async throws { + guard !isSetup else { return } + try await service.initialize() + guard !isSetup else { return } + loadKnownDevices() + isSetup = true + } + + private func jadeNetwork() throws -> JadeNetwork { + try network().toJadeNetwork() + } + + /// The stored entries plus any in memory the store does not hold, so a failed write never loses one. + private func allKnownDevices() -> [HwKnownDevice] { + let stored = store.loadAll() + let storedIds = Set(stored.map(\.entryId)) + return stored + knownDevices.filter { !storedIds.contains($0.entryId) } + } + + private func savedOrLoadedDevices() -> [HwKnownDevice] { + knownDevices.isEmpty ? store.loadAll() : knownDevices + } + + private func knownDevice(_ deviceId: String) -> HwKnownDevice? { + allKnownDevices().first { $0.matches(deviceId: deviceId) } + } + + private func setKnownDevices(_ devices: [HwKnownDevice]) { + knownDevices = devices.sorted { $0.lastConnectedAt > $1.lastConnectedAt } + } + + /// Retries once after unlocking when the device locked since the session was opened: a cached + /// unlocked state would otherwise report the Jade as busy until it is reconnected. + private func retryingOnceIfLocked(_ operation: () async throws -> T) async throws -> T { + do { + return try await operation() + } catch { + guard case .DeviceLocked? = error.underlyingJadeError, let current = connected else { throw error } + Logger.info("The Jade locked since it connected; unlocking and retrying", context: Self.logContext) + connected?.versionInfo.jadeState = .locked + try await ensureConnected(deviceId: current.id) + return try await operation() + } + } +} + +// MARK: - JadeSessioning + +extension JadeManager: JadeSessioning { + var storedDevices: [HwKnownDevice] { + knownDevices + } + + var connectedDeviceId: String? { + connected?.id + } + + var connectedWalletId: String? { + connected?.walletId + } + + var isSessionActive: Bool { + connected != nil || isConnectInProgress || transportReconnectTask != nil + } + + /// Reuses a live session of `deviceId`, unlocking it when locked, else reconnects it. A pending + /// background reconnect is dropped rather than waited out; only an attempt already dialling is. + func ensureConnected(deviceId: String) async throws { + cancelReconnectLoop() + try await awaitSetup() + guard let current = try await awaitConnectedOrNull(deviceId: deviceId) else { + try await connectKnownDevice(deviceId: deviceId, forceSession: true) + return + } + guard current.isLocked else { return } + let epoch = connectEpoch + let version = try await unlockConnected() + try requireCurrent(epoch) + connected?.versionInfo = version + } + + func verifyAddress(addressType: AddressScriptType, derivationPath: String, expectedAddress: String) async throws { + let network = try jadeNetwork() + try await retryingOnceIfLocked { + try await service.verifyAddress( + network: network, + variant: addressType.jadeVariant, + derivationPath: derivationPath, + expectedAddress: expectedAddress + ) + } + } + + func masterFingerprint() async throws -> String { + try await service.getMasterFingerprint(network: jadeNetwork()) + } + + func signPsbt(_ psbtBase64: String) async throws -> CompletedTransaction { + let network = try jadeNetwork() + let signed = try await retryingOnceIfLocked { + try await service.signPsbt(network: network, psbtBase64: psbtBase64) + } + return try await service.finalizePsbt(originalPsbt: psbtBase64, signedPsbt: signed) + } + + func disconnectStaleSession(deviceId: String) async { + await beginStaleSessionTeardown(deviceId: deviceId, releasingAttempts: true)?.value + } + + func releaseSession() async { + if connected != nil { + await disconnect() + } else if isConnectInProgress { + await cancelPendingConnection(deviceId: "") + } else { + cancelReconnectLoop() + } + } + + func warmUpConnection(deviceId: String) { + guard !isConnectInProgress, liveSession(deviceId: deviceId) == nil, isKnownBluetoothDevice(deviceId: deviceId) else { return } + Logger.info("Warming up paired Jade '\(deviceId)'", context: Self.logContext) + Task { + do { + try await connectKnownDevice(deviceId: deviceId, unlock: false) + } catch { + Logger.debug("Warm-up connect failed for '\(deviceId)': \(error)", context: Self.logContext) + } + } + } + + func forgetWallet(walletId: String, pendingName: PendingHwWalletName?) async { + let devices = allKnownDevices() + let forgotten = devices.filter { $0.resolvedWalletId == walletId } + guard !forgotten.isEmpty else { + Logger.warn("Nothing to forget for Jade wallet '\(walletId)'", context: Self.logContext) + return + } + let remaining = devices.filter { $0.resolvedWalletId != walletId } + store.saveAll(remaining, pendingName: pendingName) + setKnownDevices(remaining) + Logger.info("Forgot Jade wallet '\(walletId)'", context: Self.logContext) + + if let current = connected, forgotten.contains(where: { $0.id == current.id || $0.path == current.path }) { + await disconnect() + } + } + + func renameWallet(walletId: String, newName: String) { + let devices = allKnownDevices() + guard devices.contains(where: { $0.resolvedWalletId == walletId }) else { return } + let trimmed = String(newName.trimmingCharacters(in: .whitespacesAndNewlines).prefix(Self.walletNameMaxLength)) + let customLabel = trimmed.isEmpty ? nil : trimmed + let updated = devices.map { device -> HwKnownDevice in + guard device.resolvedWalletId == walletId else { return device } + var renamed = device + renamed.customLabel = customLabel + return renamed + } + // Dropped before the label is written, while the entry still masks it: a pending name left + // behind would come back the moment the user clears this label. + store.setPendingName(walletId: walletId, name: nil) + store.saveAll(updated, pendingName: nil) + setKnownDevices(updated) + Logger.info("Renamed Jade wallet '\(walletId)'", context: Self.logContext) + } + + func startAutoReconnect() { + guard connected == nil, !isConnectInProgress, transportReconnectTask == nil else { return } + guard savedOrLoadedDevices().contains(where: { $0.transportType == "bluetooth" }) else { return } + reconnectLoopGeneration &+= 1 + let generation = reconnectLoopGeneration + transportReconnectTask = Task { [weak self] in + await self?.retryAutoReconnect() + guard let self, reconnectLoopGeneration == generation else { return } + transportReconnectTask = nil + } + } + + /// Schedules releasing the link, unless the app returns first. A pending background reconnect is + /// dropped, and counts as a session to release in case it had already dialled. + func onAppBackgrounded() { + let hadPendingReconnect = transportReconnectTask != nil + cancelReconnectLoop() + // A release already scheduled or still running covers this one. + guard backgroundReleaseTask == nil, backgroundTaskId == .invalid else { return } + guard connected != nil || isConnectInProgress || hadPendingReconnect else { return } + + let taskId = backgroundTasks.beginBackgroundTask(named: Self.backgroundTaskName) { [weak self] in + self?.releaseBeforeSuspension() + } + backgroundTaskId = taskId + let delay = backgroundReleaseDelay(for: taskId) + backgroundReleaseTask = Task { [weak self] in + do { + try await Task.sleep(for: .seconds(delay)) + } catch { + return + } + guard !Task.isCancelled, let self else { return } + backgroundReleaseTask = nil + await releaseInBackground(taskId: taskId) + } + } + + func onAppBecameActive() { + backgroundReleaseTask?.cancel() + backgroundReleaseTask = nil + endBackgroundTask(backgroundTaskId) + } + + func resetForWipe() async { + backgroundReleaseTask?.cancel() + backgroundReleaseTask = nil + endBackgroundTask(backgroundTaskId) + await beginTeardown(.all, releasingAttempts: true, core: Self.disconnectCore).value + isSetup = false + nearbyDevices = [] + knownDevices = [] + } +} + +/// The links a teardown closes. +private enum LinkRelease { + case path(String) + case all + + static func release(_ link: LinkRelease?, on transport: JadeTransportControlling) async { + switch link { + case let .path(path)?: + await transport.disconnectDevice(path: path) + case .all?: + await transport.closeAllConnections() + case nil: + return + } + } +} diff --git a/Bitkit/Managers/TrezorManager.swift b/Bitkit/Managers/TrezorManager.swift index d50953584..6def3f1f9 100644 --- a/Bitkit/Managers/TrezorManager.swift +++ b/Bitkit/Managers/TrezorManager.swift @@ -84,7 +84,7 @@ final class TrezorManager { // MARK: - Known Devices & Auto-Reconnect - var knownDevices: [TrezorKnownDevice] = [] { + var knownDevices: [HwKnownDevice] = [] { didSet { devicesRevision &+= 1 } } @@ -96,6 +96,11 @@ final class TrezorManager { /// when the disconnected device list appears. private var suppressNextAutoReconnect = false + /// The foreground reconnect `startAutoReconnect()` launched, set before it first runs so the + /// session already reads as active to another vendor. + private var autoReconnectTask: Task? + private var autoReconnectGeneration = 0 + // MARK: - Bluetooth State /// Reads directly from BLEManager (@Observable chaining). @@ -429,6 +434,34 @@ final class TrezorManager { connectedDevice != nil } + var isSessionActive: Bool { + connectedDevice != nil || isAutoReconnecting || isConnectionOperationActive || autoReconnectTask != nil + } + + func releaseSession() async { + cancelAutoReconnect() + cancelPairingCode() + // Detached because `withConnectionOperation` bails out on a cancelled task, and a caller + // abandoning its own work must still leave the radio free for the other vendor. + await Task.detached { @MainActor [weak self] in + guard let self else { return } + try? await withConnectionOperation { + await self.disconnect() + // Handing the radio to the other vendor is not a manual disconnect, so the next + // foreground reconnect still runs. + self.suppressNextAutoReconnect = false + } + }.value + } + + /// Drops the session and a pending foreground reconnect ahead of a wallet wipe, after any + /// connection work already running, so none of it saves a paired device back once the wipe has + /// cleared them. Clearing the loaded entries keeps a later reconnect from starting at all. + func resetForWipe() async { + await releaseSession() + knownDevices = [] + } + // MARK: - UI Callbacks func submitPin(_ pin: String) { @@ -585,7 +618,7 @@ final class TrezorManager { // MARK: - Known Devices func loadKnownDevices() { - knownDevices = TrezorKnownDeviceStorage.loadAll() + knownDevices = HwKnownDeviceStorage.loadAll(vendor: .trezor) } /// Display name for the currently connected device, applying any Bitkit-side custom rename (from @@ -606,7 +639,7 @@ final class TrezorManager { /// entry sharing the target's xpub set so the same device renamed over either transport stays /// consistent, then reloads so the snapshot re-pushes and `HwWallet.name` updates. func renameDevice(id: String, newName: String) { - let devices = TrezorKnownDeviceStorage.loadAll() + let devices = HwKnownDeviceStorage.loadAll(vendor: .trezor) guard let target = devices.first(where: { $0.id == id }) else { return } applyCustomLabel(newName, to: devices) { device in @@ -618,7 +651,7 @@ final class TrezorManager { /// Set the Bitkit-side custom name for one wallet identity. The label belongs to the wallet, not /// to the device: renaming a passphrase wallet must leave its device's other wallets alone. func renameWallet(walletId: String, newName: String) { - let devices = TrezorKnownDeviceStorage.loadAll() + let devices = HwKnownDeviceStorage.loadAll(vendor: .trezor) guard devices.contains(where: { $0.resolvedWalletId == walletId }) else { return } applyCustomLabel(newName, to: devices) { $0.resolvedWalletId == walletId } @@ -627,13 +660,13 @@ final class TrezorManager { private func applyCustomLabel( _ newName: String, - to devices: [TrezorKnownDevice], - matching isTarget: (TrezorKnownDevice) -> Bool + to devices: [HwKnownDevice], + matching isTarget: (HwKnownDevice) -> Bool ) { let trimmed = String(newName.trimmingCharacters(in: .whitespacesAndNewlines).prefix(Self.deviceLabelMaxLength)) let customLabel = trimmed.isEmpty ? nil : trimmed - let updated = devices.map { device -> TrezorKnownDevice in + let updated = devices.map { device -> HwKnownDevice in guard isTarget(device) else { return device } var copy = device copy.customLabel = customLabel @@ -643,9 +676,9 @@ final class TrezorManager { // behind would resurface the moment the user clears this label, resurrecting a name they // replaced. Safe to drop first — the entry already carries whatever it adopted. for walletId in Set(devices.filter(isTarget).compactMap(\.resolvedWalletId)) { - TrezorKnownDeviceStorage.setPendingName(walletId: walletId, name: nil) + HwKnownDeviceStorage.setPendingName(walletId: walletId, name: nil) } - TrezorKnownDeviceStorage.saveAll(updated) + HwKnownDeviceStorage.saveAll(updated, vendor: .trezor) loadKnownDevices() } @@ -660,12 +693,12 @@ final class TrezorManager { @discardableResult func saveCurrentDeviceAsKnown() async -> Bool { guard let device = connectedDevice else { return false } - let stored = TrezorKnownDeviceStorage.loadAll() + let stored = HwKnownDeviceStorage.loadAll(vendor: .trezor) let (fetched, transientFailures) = await fetchAccountXpubs() // Not matched by transport id alone: a passphrase wallet is a separate identity on the same // device, so that would overwrite another identity or blend two identities' xpubs into one // record. Shared key material is the identity. - let previous = TrezorKnownDeviceMatching.previous(in: stored, deviceId: device.id, fetchedXpubs: fetched) + let previous = HwKnownDeviceMatching.previous(in: stored, deviceId: device.id, fetchedXpubs: fetched) let mergedXpubs = (previous?.xpubs ?? [:]).merging(fetched) { _, new in new } guard !mergedXpubs.isEmpty else { @@ -684,17 +717,17 @@ final class TrezorManager { // The label belongs to the wallet, not to the transport it happens to be reached over, so a // wallet showing up on a new path keeps the name the user gave it. - let identityKey = TrezorKnownDevice.walletKey(for: mergedXpubs, fallback: device.id) - let named = TrezorKnownDeviceMatching.named(in: stored, previous: previous, walletKey: identityKey) + let identityKey = HwKnownDevice.walletKey(for: mergedXpubs, fallback: device.id) + let named = HwKnownDeviceMatching.named(in: stored, previous: previous, walletKey: identityKey) // A name restored from a backup, or kept when this wallet was removed, waits as a pending one // until the wallet is paired again — which is here. A name set locally wins: it was chosen on // this device, after the backup was written. Adopting it is all the consuming needed, since // `loadPendingNames` masks out wallets the device list already names. let walletId = resolvedWalletId(previous: previous, identityKey: identityKey, xpubs: mergedXpubs, in: stored) - let pendingName = walletId.flatMap { TrezorKnownDeviceStorage.loadPendingNames()[$0] } + let pendingName = walletId.flatMap { HwKnownDeviceStorage.loadPendingNames()[$0] } - let known = TrezorKnownDevice( + let known = HwKnownDevice( id: device.id, name: device.name ?? "Trezor", path: device.path, @@ -706,9 +739,10 @@ final class TrezorManager { customLabel: named?.customLabel ?? pendingName, walletId: walletId, passphraseProtected: passphraseProtection(previous: previous), - trezorDeviceId: deviceFeatures?.deviceId ?? previous?.trezorDeviceId + trezorDeviceId: deviceFeatures?.deviceId ?? previous?.trezorDeviceId, + vendor: .trezor ) - TrezorKnownDeviceStorage.saveAll(TrezorKnownDeviceMatching.merged(stored, with: known, refreshed: previous)) + HwKnownDeviceStorage.saveAll(HwKnownDeviceMatching.merged(stored, with: known, refreshed: previous), vendor: .trezor) loadKnownDevices() connectedWalletId = known.resolvedWalletId trezorLog("Saved known device: \(known.name) with \(mergedXpubs.count) xpubs") @@ -716,24 +750,24 @@ final class TrezorManager { } private func resolvedWalletId( - previous: TrezorKnownDevice?, + previous: HwKnownDevice?, identityKey: String, xpubs: [String: String], - in stored: [TrezorKnownDevice] + in stored: [HwKnownDevice] ) -> String? { if let carried = previous?.walletId ?? stored.first(where: { $0.walletKey == identityKey })?.walletId, !carried.isEmpty { return carried } - return try? HwWalletId.derive(xpubs: xpubs) + return try? HwWalletId.derive(xpubs: xpubs, vendor: .trezor) } /// The selection that derived these keys is authoritative, so a wallet wrongly marked hidden is /// corrected the next time it is opened rather than staying gated behind a passphrase forever. /// On-device entry cannot say which wallet was opened, so it keeps what the entry already knew /// and assumes hidden only for one it has never seen. - private func passphraseProtection(previous: TrezorKnownDevice?) -> Bool { + private func passphraseProtection(previous: HwKnownDevice?) -> Bool { switch uiHandler.currentSelection() { case .standard: false case .hidden: true @@ -809,7 +843,7 @@ final class TrezorManager { if let device = known { await clearCredentials(path: device.path) } - TrezorKnownDeviceStorage.remove(id: id) + HwKnownDeviceStorage.remove(id: id, vendor: .trezor) loadKnownDevices() trezorLog("Forgot device: \(id)") @@ -823,7 +857,7 @@ final class TrezorManager { /// cleared once none remains — dropping them while a sibling is still paired would leave that /// wallet unable to reconnect. func forgetWallet(walletId: String, pendingName: PendingHwWalletName? = nil) async { - let stored = TrezorKnownDeviceStorage.loadAll() + let stored = HwKnownDeviceStorage.loadAll(vendor: .trezor) let forgotten = stored.filter { $0.resolvedWalletId == walletId } guard !forgotten.isEmpty else { trezorLog("Nothing to forget for hardware wallet '\(walletId)'", level: "warn") @@ -835,7 +869,7 @@ final class TrezorManager { await clearCredentials(path: entry.path) } - TrezorKnownDeviceStorage.saveAll(remaining, pendingName: pendingName) + HwKnownDeviceStorage.saveAll(remaining, vendor: .trezor, pendingName: pendingName) loadKnownDevices() trezorLog("Forgot hardware wallet: \(walletId)") @@ -858,6 +892,22 @@ final class TrezorManager { // MARK: - Auto-Reconnect + func startAutoReconnect() { + guard autoReconnectTask == nil else { return } + autoReconnectGeneration &+= 1 + let generation = autoReconnectGeneration + autoReconnectTask = Task { [weak self] in + await self?.autoReconnect() + guard let self, autoReconnectGeneration == generation else { return } + autoReconnectTask = nil + } + } + + private func cancelAutoReconnect() { + autoReconnectTask?.cancel() + autoReconnectTask = nil + } + func autoReconnect() async { do { try await withConnectionOperation { @@ -1024,7 +1074,7 @@ final class TrezorManager { /// Reconstruct a `TrezorDeviceInfo` for reconnecting to a known BLE device when a fresh scan /// hasn't surfaced it (BLE devices advertise intermittently). - private func deviceInfo(from known: TrezorKnownDevice) -> TrezorDeviceInfo { + private func deviceInfo(from known: HwKnownDevice) -> TrezorDeviceInfo { TrezorDeviceInfo( id: known.id, transportType: known.transportType == "bluetooth" ? .bluetooth : .usb, diff --git a/Bitkit/Models/HwWallet.swift b/Bitkit/Models/HwWallet.swift index 0a7f81a28..681c024b9 100644 --- a/Bitkit/Models/HwWallet.swift +++ b/Bitkit/Models/HwWallet.swift @@ -24,6 +24,8 @@ struct HwWallet: Identifiable { let deviceIds: Set /// Whether reaching this wallet needs a passphrase, i.e. it is a hidden wallet. let passphraseProtected: Bool + /// The maker of the device holding this wallet, which decides how it is reached and signed with. + let vendor: HwWalletVendor init( id: String, @@ -34,7 +36,8 @@ struct HwWallet: Identifiable { balanceSats: UInt64, fundingBalanceSats: UInt64? = nil, deviceIds: Set? = nil, - passphraseProtected: Bool = false + passphraseProtected: Bool = false, + vendor: HwWalletVendor = .trezor ) { self.id = id self.walletId = walletId @@ -45,6 +48,7 @@ struct HwWallet: Identifiable { self.fundingBalanceSats = fundingBalanceSats ?? balanceSats self.deviceIds = deviceIds ?? [id] self.passphraseProtected = passphraseProtected + self.vendor = vendor } } diff --git a/Bitkit/Models/HwWalletId.swift b/Bitkit/Models/HwWalletId.swift index 0f1c45cf8..26baa8c24 100644 --- a/Bitkit/Models/HwWalletId.swift +++ b/Bitkit/Models/HwWalletId.swift @@ -7,8 +7,9 @@ import Foundation /// iOS and Android produce identical ids for the same device. enum HwWalletId { /// Deterministic id derived from the device's account xpubs (transport-independent: the - /// same physical device shares its xpubs, hence its id). Throws if `xpubs` is empty. - static func derive(xpubs: [String: String], deviceType: String = "trezor") throws -> String { - try deriveWalletId(deviceType: deviceType, xpubs: Array(xpubs.values)) + /// same physical device shares its xpubs, hence its id), in the vendor's namespace, so equal + /// xpubs on two vendors derive two ids. Throws if `xpubs` is empty. + static func derive(xpubs: [String: String], vendor: HwWalletVendor = .trezor) throws -> String { + try deriveWalletId(deviceType: vendor.deviceType, xpubs: Array(xpubs.values)) } } diff --git a/Bitkit/Models/HwWalletVendor.swift b/Bitkit/Models/HwWalletVendor.swift new file mode 100644 index 000000000..198459164 --- /dev/null +++ b/Bitkit/Models/HwWalletVendor.swift @@ -0,0 +1,17 @@ +import Foundation + +/// The maker of a paired hardware wallet. Stored on every paired entry, so each device call is routed +/// to the manager that speaks that vendor's protocol. +enum HwWalletVendor: String, Codable, CaseIterable, Sendable { + case trezor + case blockstream + + /// Namespace passed to bitkit-core's `deriveWalletId`. Every wallet id carries it, so it must never + /// change: equal seeds on two vendors then derive two wallets. + var deviceType: String { + switch self { + case .trezor: "trezor" + case .blockstream: "jade" + } + } +} diff --git a/Bitkit/Models/JadeDevice.swift b/Bitkit/Models/JadeDevice.swift new file mode 100644 index 000000000..4ec567cbd --- /dev/null +++ b/Bitkit/Models/JadeDevice.swift @@ -0,0 +1,81 @@ +import BitkitCore +import Foundation + +/// The live Jade session: the paired entry it belongs to and the state the device last reported. +struct ConnectedJadeDevice: Equatable { + let id: String + let path: String + var versionInfo: JadeVersionInfo + /// Wallet the session holds; nil when its accounts could not be resolved to one. + let walletId: String? + + var isLocked: Bool { + versionInfo.jadeState == .locked + } + + var model: String { + JadeDeviceIdentity.model(boardType: versionInfo.boardType) + } + + func matches(_ deviceId: String) -> Bool { + id == deviceId || path == deviceId + } +} + +/// How a Jade is recognised across connections. Its Bluetooth identifier changes after a reboot or a +/// pairing reset, while its efuse MAC does not, so the MAC is the identity. +enum JadeDeviceIdentity { + /// A Jade advertises as "Jade" followed by the last six hex digits of its efuse MAC. + static let nameSuffixLength = 6 + static let defaultName = "Jade" + + /// Stable entry id from the efuse MAC, so a Jade reached under a new Bluetooth identifier refreshes + /// its entry instead of adding one. Nil when the device reported no MAC. + static func deviceId(efuseMac: String?) -> String? { + guard let efuseMac, !efuseMac.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil } + return "\(HwWalletVendor.blockstream.deviceType):bluetooth:\(efuseMac)" + } + + /// Jade Plus reports a v2 board; every other board is the original Jade. + static func model(boardType: String?) -> String { + boardType?.uppercased().contains("V2") == true ? "Jade Plus" : defaultName + } + + /// Whether `name` is what the Jade with `jadeDeviceId` advertises as. A MAC too short to carry the + /// suffix proves nothing, so it never matches. + static func advertises(_ name: String?, jadeDeviceId: String?) -> Bool { + guard let name, let jadeDeviceId, jadeDeviceId.count >= nameSuffixLength else { return false } + let suffix = jadeDeviceId.suffix(nameSuffixLength) + guard !suffix.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false } + return name.lowercased().hasSuffix(suffix.lowercased()) + } +} + +extension JadeState { + /// Whether the device can serve requests that need its keys. + var isUnlocked: Bool { + self == .ready || self == .temp + } +} + +extension HwKnownDevice { + func matches(deviceId: String) -> Bool { + id == deviceId || path == deviceId + } + + func advertisesAs(_ name: String?) -> Bool { + JadeDeviceIdentity.advertises(name, jadeDeviceId: jadeDeviceId) + } + + /// Whether a scanned Jade is this paired one, even when it came back under a new Bluetooth + /// identifier after a reboot. + func isSameJade(as device: JadeDeviceInfo) -> Bool { + switch device.transport { + case .bluetooth: + path == device.path || advertisesAs(device.name) + case .serial: + // A plugged-in Jade cannot be told from a paired one before connecting. + transportType == "usb" + } + } +} diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 9c968fc6d..6b00f4679 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -43,21 +43,32 @@ "hardware__search_error" = "Could not search for hardware wallets. Check your connection and try again."; "hardware__pairing_code_invalid" = "Incorrect pairing code. Put your Trezor back in pairing mode and try again."; "hardware__device_busy" = "Your Trezor is busy. Unlock it on the device, then try again."; +"hardware__device_model_jade" = "Jade"; "hardware__device_model_trezor" = "Trezor"; "hardware__bluetooth_open_settings" = "Open Settings"; "hardware__bluetooth_off_title" = "Bluetooth is Off"; -"hardware__bluetooth_off_text" = "Please enable Bluetooth in Settings to connect to your Trezor."; +"hardware__bluetooth_off_text" = "Please enable Bluetooth in Settings to connect to your hardware wallet."; "hardware__bluetooth_unauthorized_title" = "Bluetooth Unauthorized"; -"hardware__bluetooth_unauthorized_text" = "Bitkit needs Bluetooth permission to connect to your Trezor. Please enable it in Settings."; +"hardware__bluetooth_unauthorized_text" = "Bitkit needs Bluetooth permission to connect to your hardware wallet. Please enable it in Settings."; "hardware__bluetooth_unsupported_title" = "Bluetooth Unsupported"; "hardware__bluetooth_unsupported_text" = "This device does not support Bluetooth Low Energy."; "hardware__bluetooth_unavailable_title" = "Bluetooth Unavailable"; "hardware__bluetooth_unavailable_text" = "Bluetooth is not available. Please check your device settings."; "hardware__found_title" = "Found Device"; "hardware__found_header" = "Found\nTrezor"; +"hardware__found_header_jade" = "Found\nJade"; "hardware__found_text" = "Would you like to securely pair this {model} with Bitkit?"; +"hardware__jade_device_busy" = "Your Jade is busy. Finish what is shown on the device, then try again."; +"hardware__jade_enter_pin" = "Enter your PIN on your Jade to unlock it."; +"hardware__jade_firmware_outdated" = "Your Jade firmware is too old for this action. Update it with the Blockstream app, then try again."; +"hardware__jade_invalid_pin" = "Wrong PIN. Try again on your Jade."; +"hardware__jade_network_mismatch" = "Your Jade is set up for a different Bitcoin network."; +"hardware__jade_pinserver_error" = "Could not reach the Jade PIN server. Check your internet connection and try again."; +"hardware__jade_psbt_too_large" = "This transaction is too large for Jade to sign. Try sending a smaller amount."; +"hardware__jade_uninitialized" = "This Jade has not been set up yet. Create or restore a wallet on the device, then try again."; "hardware__paired_title" = "Device Connected"; "hardware__paired_header" = "Paired\nTrezor"; +"hardware__paired_header_jade" = "Paired\nJade"; "hardware__paired_text" = "Bitkit found funds on your device and added these to your balance."; "hardware__paired_label" = "Label Funds"; "hardware__paired_finish" = "Finish"; @@ -75,6 +86,7 @@ "hardware__pairing_title" = "Pair Device"; "hardware__pairing_text" = "Enter the 6-digit code shown on your hardware device."; "hardware__receive_address_error" = "Could not load the hardware wallet address."; +"hardware__receive_tab_hardware" = "Hardware"; "hardware__remove_button" = "Remove {name}"; "hardware__remove_dialog_title" = "Remove {name}"; "hardware__remove_dialog_text" = "Don't worry, your funds are safe and your coins won't be deleted. Bitkit will simply stop displaying the amounts in the wallet."; @@ -85,6 +97,7 @@ "hardware__send_broadcast_failed_title" = "Payment not confirmed"; "hardware__send_confirm_address" = "To address (confirm on device)"; "hardware__send_open_connect" = "Open Trezor Connect"; +"hardware__send_open_connect_jade" = "Sign With Jade"; "hardware__send_sign_title" = "Sign With Device"; "hardware__verify_address" = "Verify on Device"; "hardware__verify_address_error" = "Address verification failed. Check the address on your device and try again."; diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift index bbb13edbc..a5deb96cd 100644 --- a/Bitkit/Services/BackupService.swift +++ b/Bitkit/Services/BackupService.swift @@ -266,7 +266,7 @@ class BackupService { // App-owned, so it takes no part in the core field migration above and never sets // needsRewrite. Restored names wait as pending ones until each wallet is paired again. - TrezorKnownDeviceStorage.restoreNames(payload.hwWalletNames ?? [:]) + HwKnownDeviceStorage.restoreNames(payload.hwWalletNames ?? [:]) // Force address rotation by clearing onchain address UserDefaults.standard.set("", forKey: "onchainAddress") @@ -430,7 +430,7 @@ class BackupService { // METADATA (hardware wallet names). Scoped to the names alone: the known-device store is also // rewritten by every connect, and reconnect traffic must not re-upload the whole envelope. - TrezorKnownDeviceStorage.namesChangedPublisher + HwKnownDeviceStorage.namesChangedPublisher .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) .sink { [weak self] _ in guard let self, !self.shouldSkipBackup() else { return } @@ -792,7 +792,7 @@ class BackupService { // A UserDefaults read that cannot fail, so unlike the tags above there is no partial-read // case to guard against. Nil rather than an empty map when nothing is named, so an // envelope this app writes stays byte-comparable with one bitkit-android writes. - let hwWalletNames = TrezorKnownDeviceStorage.backupSnapshot() + let hwWalletNames = HwKnownDeviceStorage.backupSnapshot() let payload = MetadataBackupV1( version: 1, diff --git a/Bitkit/Services/Trezor/TrezorKnownDeviceMatching.swift b/Bitkit/Services/HwKnownDeviceMatching.swift similarity index 75% rename from Bitkit/Services/Trezor/TrezorKnownDeviceMatching.swift rename to Bitkit/Services/HwKnownDeviceMatching.swift index f2c0e88d4..de6ba51b4 100644 --- a/Bitkit/Services/Trezor/TrezorKnownDeviceMatching.swift +++ b/Bitkit/Services/HwKnownDeviceMatching.swift @@ -5,15 +5,15 @@ import Foundation /// A passphrase wallet is a separate identity on the same physical device, so the transport id /// alone no longer identifies an entry: matching by it would overwrite another identity or blend /// two identities' xpubs into one record. Shared key material is the identity. -enum TrezorKnownDeviceMatching { +enum HwKnownDeviceMatching { /// The entry this connect refreshed: among the entries of this transport, the one whose xpubs /// overlap the freshly read set. Only an entry stored before any xpub was captured has no /// identity to conflict with and can be adopted instead. Anything else is a new identity. static func previous( - in devices: [TrezorKnownDevice], + in devices: [HwKnownDevice], deviceId: String, fetchedXpubs: [String: String] - ) -> TrezorKnownDevice? { + ) -> HwKnownDevice? { let candidates = devices.filter { $0.id == deviceId } let fetched = Set(fetchedXpubs.values) if let overlapping = candidates.first(where: { !Set($0.xpubs.values).isDisjoint(with: fetched) }) { @@ -27,19 +27,19 @@ enum TrezorKnownDeviceMatching { /// not for the transport it happens to be reached over, so a wallet showing up on a new path /// keeps the name the user gave it instead of falling back to the device's own. static func named( - in devices: [TrezorKnownDevice], - previous: TrezorKnownDevice?, + in devices: [HwKnownDevice], + previous: HwKnownDevice?, walletKey: String - ) -> TrezorKnownDevice? { + ) -> HwKnownDevice? { previous ?? devices.first { $0.walletKey == walletKey } } /// The stored list after `known` supersedes what it replaces. static func merged( - _ devices: [TrezorKnownDevice], - with known: TrezorKnownDevice, - refreshed: TrezorKnownDevice? - ) -> [TrezorKnownDevice] { + _ devices: [HwKnownDevice], + with known: HwKnownDevice, + refreshed: HwKnownDevice? + ) -> [HwKnownDevice] { devices.filter { !isReplaced($0, by: known, refreshed: refreshed) } + [known] } @@ -47,20 +47,21 @@ enum TrezorKnownDeviceMatching { /// the entry this connect refreshed, since reading a previously rejected address type changes /// the wallet key and matching on the new key alone would leave the old entry behind as a /// duplicate. Wallets of a seed the device no longer carries go too: nothing would ever - /// supersede them by key material. An unknown device id proves nothing, so those are left alone. + /// supersede them by key material. An unknown device id proves nothing, so those are left alone, + /// and so is every entry of another vendor, even one holding the same seed. private static func isReplaced( - _ entry: TrezorKnownDevice, - by known: TrezorKnownDevice, - refreshed: TrezorKnownDevice? + _ entry: HwKnownDevice, + by known: HwKnownDevice, + refreshed: HwKnownDevice? ) -> Bool { - guard entry.id == known.id else { return false } + guard entry.vendor == known.vendor, entry.id == known.id else { return false } if entry.walletKey == known.walletKey { return true } if let refreshed, entry.walletKey == refreshed.walletKey { return true } - guard let knownTrezorId = known.trezorDeviceId, let entryTrezorId = entry.trezorDeviceId else { return false } - return entryTrezorId != knownTrezorId + guard let knownHardwareId = known.hardwareId, let entryHardwareId = entry.hardwareId else { return false } + return entryHardwareId != knownHardwareId } } diff --git a/Bitkit/Services/Trezor/TrezorKnownDeviceStorage.swift b/Bitkit/Services/HwKnownDeviceStorage.swift similarity index 54% rename from Bitkit/Services/Trezor/TrezorKnownDeviceStorage.swift rename to Bitkit/Services/HwKnownDeviceStorage.swift index 3b0850552..793b12603 100644 --- a/Bitkit/Services/Trezor/TrezorKnownDeviceStorage.swift +++ b/Bitkit/Services/HwKnownDeviceStorage.swift @@ -1,10 +1,10 @@ import Combine import Foundation -/// One wallet identity a Trezor holds. A device with passphrase protection carries its standard -/// wallet plus one entry per passphrase (hidden) wallet, so `id` — the transport-level device id — -/// is shared by several entries and no longer identifies one on its own. -struct TrezorKnownDevice: Codable, Identifiable { +/// One wallet identity a paired hardware wallet holds. A Trezor with passphrase protection carries +/// its standard wallet plus one entry per passphrase (hidden) wallet, so `id` (the transport-level +/// device id) is shared by several entries and no longer identifies one on its own. +struct HwKnownDevice: Codable, Equatable, Identifiable { let id: String let name: String let path: String @@ -22,13 +22,22 @@ struct TrezorKnownDevice: Codable, Identifiable { /// existed, where `resolvedWalletId` derives it from `xpubs` instead. var walletId: String? /// Whether this entry is a passphrase (hidden) wallet. Nothing else in the record can tell one - /// apart from the standard wallet — the xpubs are opaque and the selected mode only lives in + /// apart from the standard wallet: the xpubs are opaque and the selected mode only lives in /// memory, so reconnects would silently fall back to the standard wallet without this. The /// passphrase itself is never persisted. var passphraseProtected: Bool /// The Trezor's own device id, which it regenerates when wiped. Entries of the same transport /// reporting a different one belong to a seed the device can no longer sign for. var trezorDeviceId: String? + /// The maker of the device. Entries stored before other vendors existed carry none: they are + /// Trezor ones, unless their ids sit in the Jade namespace. + let vendor: HwWalletVendor + /// The Jade's own device id (its efuse MAC), which outlives a change of Bluetooth identifier. + var jadeDeviceId: String? + /// The stored vendor of an entry a newer build wrote for a vendor this one does not know. Such an + /// entry belongs to no vendor's slice and is written back with its vendor unchanged, so rolling + /// back never drops a wallet paired on the newer build. + private(set) var unknownVendor: String? init( id: String, @@ -42,7 +51,9 @@ struct TrezorKnownDevice: Codable, Identifiable { customLabel: String? = nil, walletId: String? = nil, passphraseProtected: Bool = false, - trezorDeviceId: String? = nil + trezorDeviceId: String? = nil, + vendor: HwWalletVendor = .trezor, + jadeDeviceId: String? = nil ) { self.id = id self.name = name @@ -56,6 +67,25 @@ struct TrezorKnownDevice: Codable, Identifiable { self.walletId = walletId self.passphraseProtected = passphraseProtected self.trezorDeviceId = trezorDeviceId + self.vendor = vendor + self.jadeDeviceId = jadeDeviceId + } + + private enum CodingKeys: String, CodingKey { + case id + case name + case path + case transportType + case label + case model + case lastConnectedAt + case xpubs + case customLabel + case walletId + case passphraseProtected + case trezorDeviceId + case vendor + case jadeDeviceId } init(from decoder: any Decoder) throws { @@ -72,34 +102,100 @@ struct TrezorKnownDevice: Codable, Identifiable { walletId = try container.decodeIfPresent(String.self, forKey: .walletId) passphraseProtected = try container.decodeIfPresent(Bool.self, forKey: .passphraseProtected) ?? false trezorDeviceId = try container.decodeIfPresent(String.self, forKey: .trezorDeviceId) + jadeDeviceId = try container.decodeIfPresent(String.self, forKey: .jadeDeviceId) + + // Decoded as a string, not as the enum: an unknown value would otherwise fail the whole + // device list, and the next write would then drop every paired wallet. + let storedVendor = try? container.decodeIfPresent(String.self, forKey: .vendor) + vendor = storedVendor.flatMap(HwWalletVendor.init(rawValue:)) ?? Self.inferredVendor(id: id, walletId: walletId) + unknownVendor = storedVendor.flatMap { HwWalletVendor(rawValue: $0) == nil ? $0 : nil } + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(name, forKey: .name) + try container.encode(path, forKey: .path) + try container.encode(transportType, forKey: .transportType) + try container.encodeIfPresent(label, forKey: .label) + try container.encodeIfPresent(model, forKey: .model) + try container.encode(lastConnectedAt, forKey: .lastConnectedAt) + try container.encode(xpubs, forKey: .xpubs) + try container.encodeIfPresent(customLabel, forKey: .customLabel) + try container.encodeIfPresent(walletId, forKey: .walletId) + try container.encode(passphraseProtected, forKey: .passphraseProtected) + try container.encodeIfPresent(trezorDeviceId, forKey: .trezorDeviceId) + try container.encode(unknownVendor ?? vendor.rawValue, forKey: .vendor) + try container.encodeIfPresent(jadeDeviceId, forKey: .jadeDeviceId) + } + + private static func inferredVendor(id: String, walletId: String?) -> HwWalletVendor { + let jadeNamespace = "\(HwWalletVendor.blockstream.deviceType):" + return id.hasPrefix(jadeNamespace) || walletId?.hasPrefix(jadeNamespace) == true ? .blockstream : .trezor } } -extension TrezorKnownDevice { +extension HwKnownDevice { /// Identity of the key material this entry holds: entries sharing it are the same wallet, on /// this device or on another transport. An entry read before any xpub was captured has no key /// material to compare, so it falls back to its transport id. var walletKey: String { - TrezorKnownDevice.walletKey(for: xpubs, fallback: id) + HwKnownDevice.walletKey(for: xpubs, fallback: id) } static func walletKey(for xpubs: [String: String], fallback: String) -> String { xpubs.isEmpty ? fallback : xpubs.values.sorted().joined(separator: "\u{1f}") } - /// Wallet id of this identity: the stored one, or derived from the xpubs for entries written - /// before it was persisted. The derivation is unchanged, so those keep the id they always had. + /// Wallet id of this identity: the stored one, or derived from the xpubs in the vendor's namespace + /// for entries written before it was persisted. The derivation is unchanged, so those keep the id + /// they always had. var resolvedWalletId: String? { if let walletId, !walletId.isEmpty { return walletId } - return try? HwWalletId.derive(xpubs: xpubs) + return try? HwWalletId.derive(xpubs: xpubs, vendor: vendor) } /// Stable key for lists and diffing, since `id` is shared by every identity of one device. var entryId: String { "\(id)\u{1f}\(walletKey)" } + + /// The vendor's own stable device id, when the device reported one. + var hardwareId: String? { + switch vendor { + case .trezor: trezorDeviceId + case .blockstream: jadeDeviceId + } + } + + /// Whether this entry is part of `vendor`'s slice of the store. + func belongs(to vendor: HwWalletVendor) -> Bool { + unknownVendor == nil && self.vendor == vendor + } + + /// This entry as last reached over `path` at `date`, keeping everything else it holds. + func refreshed(path: String, at date: Date) -> HwKnownDevice { + var refreshed = HwKnownDevice( + id: id, + name: name, + path: path, + transportType: transportType, + label: label, + model: model, + lastConnectedAt: date, + xpubs: xpubs, + customLabel: customLabel, + walletId: walletId, + passphraseProtected: passphraseProtected, + trezorDeviceId: trezorDeviceId, + vendor: vendor, + jadeDeviceId: jadeDeviceId + ) + refreshed.unknownVendor = unknownVendor + return refreshed + } } /// A pending-name change to apply together with a device-list write; a nil `name` drops the entry. @@ -108,60 +204,69 @@ struct PendingHwWalletName: Equatable { let name: String? } -/// Persists known Trezor device metadata in UserDefaults -/// THP credentials remain in Keychain via TrezorCredentialStorage -enum TrezorKnownDeviceStorage { +/// Persists paired hardware wallet entries of every vendor in UserDefaults. Each vendor's manager +/// reads and writes only its own slice; the wallet names and their backup span every vendor. +/// THP credentials remain in Keychain via TrezorCredentialStorage. +enum HwKnownDeviceStorage { /// Fires when the set of hardware wallet names changes, so the metadata backup can be marked /// stale. Every connect rewrites the device list to refresh `lastConnectedAt`, and reconnect /// traffic must not re-upload the whole envelope, so this only fires on a real name change. static let namesChangedPublisher = namesChangedSubject.eraseToAnyPublisher() + // Named before other vendors existed; renaming them would unpair every stored wallet. private static let key = "trezor.knownDevices" private static let pendingNamesKey = "trezor.pendingWalletNames" private static let namesChangedSubject = PassthroughSubject() - /// Load all known devices, sorted by most recently connected - static func loadAll() -> [TrezorKnownDevice] { - guard let data = UserDefaults.standard.data(forKey: key) else { return [] } - let devices = (try? JSONDecoder().decode([TrezorKnownDevice].self, from: data)) ?? [] - return devices.sorted { $0.lastConnectedAt > $1.lastConnectedAt } + /// Load the known devices of `vendor`, or of every vendor when nil, most recently connected first. + /// Entries of a vendor this build does not know are never returned. + static func loadAll(vendor: HwWalletVendor? = nil) -> [HwKnownDevice] { + storedDevices() + .filter { $0.unknownVendor == nil && (vendor == nil || $0.vendor == vendor) } + .sorted { $0.lastConnectedAt > $1.lastConnectedAt } } - /// Save or update one wallet identity. Scoped to the identity rather than to the transport it - /// was reached over, so a passphrase wallet is stored next to the device's standard wallet - /// instead of replacing it. - static func save(_ device: TrezorKnownDevice) { - var devices = loadAll() + /// Save or update one wallet identity within its vendor's slice. Scoped to the identity rather + /// than to the transport it was reached over, so a passphrase wallet is stored next to the + /// device's standard wallet instead of replacing it. + static func save(_ device: HwKnownDevice) { + var devices = loadAll(vendor: device.vendor) devices.removeAll { $0.id == device.id && $0.walletKey == device.walletKey } devices.insert(device, at: 0) - saveAll(devices) + saveAll(devices, vendor: device.vendor) } - /// Persist the full device list as-is. Used for bulk updates (e.g. renaming every entry of a - /// device shared across transports) without per-device reordering. + /// Replace `vendor`'s slice with `devices` as-is, keeping every other vendor's entries. Used for + /// bulk updates (e.g. renaming every entry of a device shared across transports) without + /// per-device reordering. /// + /// - Parameter vendor: the slice being replaced. Required, so a vendor's manager can never write + /// away the entries of another vendor. /// - Parameter pendingName: a pending-name change to apply in the same call, or nil to leave the /// pending names alone. It is written *first*: crashing between the two writes then leaves a name /// recorded for a wallet that is still paired, which the next pairing masks away, rather than a /// forgotten wallet whose name was recorded nowhere. - static func saveAll(_ devices: [TrezorKnownDevice], pendingName: PendingHwWalletName? = nil) { + static func saveAll(_ devices: [HwKnownDevice], vendor: HwWalletVendor, pendingName: PendingHwWalletName? = nil) { + let slice = devices.filter { $0.belongs(to: vendor) } + assert(slice.count == devices.count, "Only \(vendor) entries belong in the \(vendor) slice") let previousNames = backupSnapshot() if let pendingName { writePendingName(pendingName) } - writeDevices(devices) + writeDevices(loadAll().filter { !$0.belongs(to: vendor) } + slice) notifyIfNamesChanged(from: previousNames) } /// Entries tracking one wallet identity. - static func loadAll(walletId: String) -> [TrezorKnownDevice] { + static func loadAll(walletId: String) -> [HwKnownDevice] { loadAll().filter { $0.resolvedWalletId == walletId } } - /// Forget every identity of a device, whichever wallets it holds. - static func remove(id: String) { + /// Forget every identity of one of `vendor`'s devices, whichever wallets it holds. + static func remove(id: String, vendor: HwWalletVendor) { let devices = loadAll() - forget(devices.filter { $0.id == id }, keeping: devices.filter { $0.id != id }) + let isTarget = { (device: HwKnownDevice) in device.id == id && device.belongs(to: vendor) } + forget(devices.filter(isTarget), keeping: devices.filter { !isTarget($0) }) } /// Forget a single wallet identity, leaving the device's other wallets paired. @@ -173,7 +278,7 @@ enum TrezorKnownDeviceStorage { ) } - /// Remove all remembered Trezor devices. + /// Remove every remembered hardware wallet, whatever its vendor. static func removeAll() { let previousNames = backupSnapshot() UserDefaults.standard.removeObject(forKey: key) @@ -181,9 +286,9 @@ enum TrezorKnownDeviceStorage { notifyIfNamesChanged(from: previousNames) } - /// Check if a device is known - static func isKnown(id: String) -> Bool { - loadAll().contains { $0.id == id } + /// Whether a device is known, among `vendor`'s entries or among every vendor's when nil. + static func isKnown(id: String, vendor: HwWalletVendor? = nil) -> Bool { + loadAll(vendor: vendor).contains { $0.id == id } } // MARK: - Hardware wallet names @@ -192,7 +297,7 @@ enum TrezorKnownDeviceStorage { /// paired again, or kept when the wallet was removed. /// /// A wallet the device list already names is masked out rather than pruned, so pairing consumes - /// a pending name by simply adopting it — no second write that could be lost on its own. + /// a pending name by simply adopting it, with no second write that could be lost on its own. static func loadPendingNames() -> [String: String] { let paired = pairedNames() return storedPendingNames().filter { paired[$0.key] == nil } @@ -207,7 +312,7 @@ enum TrezorKnownDeviceStorage { /// Every hardware wallet name this wallet knows, keyed by wallet id: the pending ones overlaid /// with the name of each paired wallet. A paired name wins because it is what the user currently - /// sees. Entries without a wallet id are skipped — only a device stored before any account key + /// sees. Entries without a wallet id are skipped: only a device stored before any account key /// was captured has none, and such an entry is filtered out of the wallet list anyway, so it can /// never have been named. static func backupSnapshot() -> [String: String] { @@ -216,7 +321,7 @@ enum TrezorKnownDeviceStorage { /// Merges backed up names into the pending ones, so each is adopted the next time its wallet is /// paired. Names already held locally win: they were set on this device after the backup was - /// written. Never clears — an envelope without names predates the field and must not drop what is + /// written. Never clears: an envelope without names predates the field and must not drop what is /// stored. static func restoreNames(_ names: [String: String]) { guard !names.isEmpty else { return } @@ -229,7 +334,7 @@ enum TrezorKnownDeviceStorage { /// Drop `forgotten` from the device list and with it any name kept for the wallets it held: a /// removal that wanted to keep a name writes it back through `saveAll(_:pendingName:)` instead. - private static func forget(_ forgotten: [TrezorKnownDevice], keeping remaining: [TrezorKnownDevice]) { + private static func forget(_ forgotten: [HwKnownDevice], keeping remaining: [HwKnownDevice]) { let previousNames = backupSnapshot() let remainingWalletIds = Set(remaining.compactMap(\.resolvedWalletId)) var pending = storedPendingNames() @@ -241,8 +346,17 @@ enum TrezorKnownDeviceStorage { notifyIfNamesChanged(from: previousNames) } - private static func writeDevices(_ devices: [TrezorKnownDevice]) { - guard let data = try? JSONEncoder().encode(devices) else { return } + private static func storedDevices() -> [HwKnownDevice] { + guard let data = UserDefaults.standard.data(forKey: key) else { return [] } + return (try? JSONDecoder().decode([HwKnownDevice].self, from: data)) ?? [] + } + + /// Writes `devices` together with the entries of vendors this build does not know, which no + /// read ever returns and so no caller could pass back. + private static func writeDevices(_ devices: [HwKnownDevice]) { + let unknownVendorEntries = storedDevices().filter { $0.unknownVendor != nil } + let knownVendorEntries = devices.filter { $0.unknownVendor == nil } + guard let data = try? JSONEncoder().encode(knownVendorEntries + unknownVendorEntries) else { return } UserDefaults.standard.set(data, forKey: key) } diff --git a/Bitkit/Services/Jade/JadeBLEError.swift b/Bitkit/Services/Jade/JadeBLEError.swift new file mode 100644 index 000000000..0c5a3cf74 --- /dev/null +++ b/Bitkit/Services/Jade/JadeBLEError.swift @@ -0,0 +1,86 @@ +import BitkitCore +import Foundation + +/// Why the Bluetooth link to a Jade failed. +/// +/// Core hands these texts to the user: a failed open becomes `JadeError.ConnectionError` carrying the +/// text, and any other failure without a transport code becomes `JadeError.TransportError` carrying +/// it. Every case therefore reads as plain, user-facing English. +enum JadeBLEError: LocalizedError, Equatable { + case invalidPath(String) + case bluetoothOff + case bluetoothUnauthorized + case bluetoothUnsupported + case bluetoothNotReady + case deviceNotFound + case connectTimeout + case connectFailed(String) + case notAJade + case subscribeFailed(String) + case pairingNotConfirmed + case staleBond + case notOpen + case disconnected + case closed + case writeTimeout + case writeFailed(String) + + var errorDescription: String? { + switch self { + case let .invalidPath(path): + return "Invalid Jade Bluetooth path: \(path)" + case .bluetoothOff: + return "Bluetooth is turned off. Turn it on to reach your Jade." + case .bluetoothUnauthorized: + return "Bitkit is not allowed to use Bluetooth. Allow it in iOS Settings to reach your Jade." + case .bluetoothUnsupported: + return "This device does not support Bluetooth Low Energy." + case .bluetoothNotReady: + return "Bluetooth is not ready yet. Try again in a moment." + case .deviceNotFound: + return "Your Jade was not found nearby. Make sure it is on and close to your phone." + case .connectTimeout: + return "Could not reach your Jade over Bluetooth. Make sure it is on, nearby and not connected to another app." + case let .connectFailed(reason): + return "Could not connect to your Jade over Bluetooth (\(Self.detail(reason)))." + case .notAJade: + return "This Bluetooth device does not offer the Jade connection service." + case let .subscribeFailed(reason): + return "Your Jade did not accept the Bluetooth connection (\(Self.detail(reason)))." + case .pairingNotConfirmed: + return "Bluetooth pairing with your Jade was not confirmed. Try again and accept the pairing request." + case .staleBond: + return "Bluetooth pairing is no longer valid: forget the Jade in the iOS Bluetooth settings and pair it again." + case .notOpen: + return "Jade is not connected." + case .disconnected, .closed: + return "Your Jade disconnected." + case .writeTimeout: + return "Timed out sending data to your Jade." + case let .writeFailed(reason): + return "Sending data to your Jade failed (\(Self.detail(reason)))." + } + } + + /// The code core turns into a typed `JadeError`. Nil keeps the text instead: core then reports a + /// `TransportError` carrying it, which is how the stale pairing advice reaches the user verbatim. + var transportErrorCode: JadeTransportErrorCode? { + switch self { + case .deviceNotFound, .notOpen: + return .notConnected + case .connectTimeout, .writeTimeout: + return .timeout + case .disconnected, .closed: + return .disconnected + case .invalidPath, .bluetoothOff, .bluetoothUnauthorized, .bluetoothUnsupported, .bluetoothNotReady, .connectFailed, .notAJade, + .subscribeFailed, .pairingNotConfirmed, .staleBond, .writeFailed: + return nil + } + } + + private static func detail(_ reason: String) -> String { + let trimmed = reason.trimmingCharacters(in: .whitespacesAndNewlines) + let withoutFinalStop = trimmed.hasSuffix(".") ? String(trimmed.dropLast()) : trimmed + return withoutFinalStop.isEmpty ? "unknown error" : withoutFinalStop + } +} diff --git a/Bitkit/Services/Jade/JadeBLELinkState.swift b/Bitkit/Services/Jade/JadeBLELinkState.swift new file mode 100644 index 000000000..6a3bb6d31 --- /dev/null +++ b/Bitkit/Services/Jade/JadeBLELinkState.swift @@ -0,0 +1,249 @@ +import Foundation + +/// One result handed from a CoreBluetooth delegate callback to the thread blocked on it. The first +/// resolution wins, so a late callback can never overwrite a failure the waiter already acted on. +final class BLEOneShot: @unchecked Sendable { + private let lock = NSLock() + private let semaphore = DispatchSemaphore(value: 0) + private var result: Result? + + var isResolved: Bool { + lock.withLock { result != nil } + } + + func resolve(_ result: Result) { + let isFirst = lock.withLock { + guard self.result == nil else { return false } + self.result = result + return true + } + if isFirst { + semaphore.signal() + } + } + + /// Nil when nothing resolved it within `timeout`. + func wait(timeout: TimeInterval) -> Result? { + if semaphore.wait(timeout: .now() + max(timeout, 0)) == .success { + // Hand the permit back so a later wait on a resolved shot returns at once too. + semaphore.signal() + } + return lock.withLock { result } + } +} + +/// The state of one Bluetooth link to a Jade, from being dialled until it is closed. +/// +/// Every open creates a new instance with its own `generation`, so work queued for an earlier link to +/// the same device never lands on a newer one. Each setup step and each write registers a one-shot +/// waiter that a delegate callback resolves, and a drop or a close fails every waiter at once, so no +/// Rust thread stays blocked on a link that is gone. Free of CoreBluetooth types so it can be tested. +final class JadeBLELinkState: @unchecked Sendable { + enum Step: Hashable, CaseIterable { + case connect + case services + case characteristics + case subscribe + case write + case disconnect + } + + enum ReadOutcome: Equatable { + case data(Data) + case empty + case down + } + + let generation: UInt64 + + private let lock = NSLock() + private let notifications = BlockingQueue() + private let closedSignal = BLEOneShot() + private var waiters: [Step: BLEOneShot] = [:] + private var linkUp = false + private var ready = false + private var closing = false + private var dropReason: Error? + private var negotiatedChunkSize: UInt32? + private var completedWrites = 0 + + init(generation: UInt64) { + self.generation = generation + } + + var isLinkUp: Bool { + lock.withLock { linkUp } + } + + var isReady: Bool { + lock.withLock { ready } + } + + var isClosing: Bool { + lock.withLock { closing } + } + + /// Ready, still connected and nobody is closing it. + var isUsable: Bool { + lock.withLock { ready && linkUp && !closing } + } + + /// Nil until the link is ready. + var chunkSize: UInt32? { + lock.withLock { negotiatedChunkSize } + } + + var writesCompleted: Int { + lock.withLock { completedWrites } + } + + /// Registers the waiter for `step`, failing any earlier waiter of the same step. On a closing or + /// dropped link the waiter comes back already resolved, so nobody waits on a link that is gone. + func begin(_ step: Step) -> BLEOneShot { + let waiter = BLEOneShot() + let (immediate, replaced): (Result?, BLEOneShot?) = lock.withLock { + if step == .disconnect, dropReason != nil { + return (.success(()), nil) + } + if step != .disconnect, closing { + return (.failure(JadeBLEError.closed), nil) + } + if step != .disconnect, let dropReason { + return (.failure(dropReason), nil) + } + return (nil, waiters.updateValue(waiter, forKey: step)) + } + replaced?.resolve(.failure(JadeBLEError.closed)) + if let immediate { + waiter.resolve(immediate) + } + return waiter + } + + /// Resolves the waiter of `step`. False when nobody is waiting any more, for a late callback. + @discardableResult + func resolve(_ step: Step, error: Error?) -> Bool { + guard let waiter = lock.withLock({ waiters.removeValue(forKey: step) }) else { return false } + waiter.resolve(error.map { .failure($0) } ?? .success(())) + return true + } + + /// Marks the link up when its connect is still awaited. False for a connect nobody waits for any + /// more, which the caller has to cancel so the Jade is not left holding a connection. + func markConnected() -> Bool { + let waiter: BLEOneShot? = lock.withLock { + guard !closing, dropReason == nil, let waiter = waiters.removeValue(forKey: .connect) else { return nil } + linkUp = true + return waiter + } + guard let waiter else { return false } + waiter.resolve(.success(())) + return true + } + + /// Drops the waiter of `step` after it timed out, unless a newer waiter took its place. + func abandon(_ step: Step, _ waiter: BLEOneShot) { + lock.withLock { + if waiters[step] === waiter { + waiters[step] = nil + } + } + } + + func markReady(chunkSize: UInt32) throws { + try lock.withLock { + if closing { + throw JadeBLEError.closed + } + if let dropReason { + throw dropReason + } + guard linkUp else { throw JadeBLEError.disconnected } + ready = true + negotiatedChunkSize = chunkSize + } + } + + /// Takes the link down and fails every waiter with `reason`; a disconnect waiter succeeds instead. + /// Returns true when nobody was closing the link, so the drop came from outside the app. + @discardableResult + func markDown(reason: Error) -> Bool { + let (pending, isExternal): ([Step: BLEOneShot], Bool) = lock.withLock { + linkUp = false + ready = false + if dropReason == nil { + dropReason = reason + } + let pending = waiters + waiters.removeAll() + return (pending, !closing) + } + notifications.fail() + for (step, waiter) in pending { + waiter.resolve(step == .disconnect ? .success(()) : .failure(reason)) + } + return isExternal + } + + /// Starts closing the link: it stops being ready, and every waiter but the disconnect one fails. + /// False when a close is already under way. + func beginClosing() -> Bool { + let pending: [BLEOneShot]? = lock.withLock { + guard !closing else { return nil } + closing = true + ready = false + let pending = waiters.filter { $0.key != .disconnect } + for step in pending.keys { + waiters[step] = nil + } + return Array(pending.values) + } + guard let pending else { return false } + notifications.fail() + for waiter in pending { + waiter.resolve(.failure(JadeBLEError.closed)) + } + return true + } + + func finishClosing() { + closedSignal.resolve(.success(())) + } + + /// Waits for a close that another caller started to finish. + @discardableResult + func waitUntilClosed(timeout: TimeInterval) -> Bool { + closedSignal.wait(timeout: timeout) != nil + } + + /// Clears what an earlier session left unread, when the link can be reused as it is. + func reuseIfUsable() -> Bool { + lock.withLock { + guard ready, linkUp, !closing else { return false } + notifications.clear() + return true + } + } + + func recordWrite() { + lock.withLock { completedWrites += 1 } + } + + func enqueue(_ data: Data) { + guard !data.isEmpty else { return } + let isAccepting = lock.withLock { linkUp && !closing } + guard isAccepting else { return } + notifications.offer(data) + } + + /// Waits up to `timeout` for a notification, then returns it joined with any queued behind it, in + /// arrival order and untouched: frames are not aligned to notifications. + func read(timeout: TimeInterval) -> ReadOutcome { + if let first = notifications.poll(timeout: max(timeout, 0)) { + let joined = notifications.drain().reduce(into: first) { $0.append($1) } + return .data(joined) + } + let isDown = lock.withLock { !linkUp || closing } + return isDown ? .down : .empty + } +} diff --git a/Bitkit/Services/Jade/JadeBLEManager.swift b/Bitkit/Services/Jade/JadeBLEManager.swift new file mode 100644 index 000000000..aeecfc18c --- /dev/null +++ b/Bitkit/Services/Jade/JadeBLEManager.swift @@ -0,0 +1,735 @@ +import Combine +import CoreBluetooth +import Foundation + +struct JadeBLEDiscovery: Equatable { + let path: String + let name: String +} + +/// The Bluetooth byte pipe `JadeTransport` drives for core. The blocking calls run on Rust blocking +/// threads, never on the main thread or the CoreBluetooth queue. +protocol JadeBLEDriving: AnyObject, Sendable { + /// Paths whose link dropped without the app closing it: out of range, switched off, Bluetooth off. + var externalDisconnects: AnyPublisher { get } + /// Fires when Bluetooth turns back on after having been off, reset or unavailable. + var bluetoothPoweredOn: AnyPublisher { get } + + /// Scans for Jades advertising the Nordic UART Service, blocking for `duration`. + func scan(duration: TimeInterval) -> [JadeBLEDiscovery] + /// Connects, subscribes to notifications and learns the chunk size, blocking until the link is usable. + func open(path: String) throws + /// Releases the link, waiting briefly for the disconnect. A no-op for a path that is not open. + func close(path: String) + /// Writes one chunk with response, blocking until the Jade acknowledges it. + func write(path: String, data: Data) throws + /// Returns what has arrived, waiting at most `timeout`. Empty means nothing arrived yet. + func read(path: String, timeout: TimeInterval) throws -> Data + func chunkSize(path: String) -> UInt32 + func closeAll() + /// Cancels every link without waiting, for an app about to be suspended or terminated. + func releaseAllImmediately() + /// Paths that were paired before, so a stalled setup on one of them points at a stale bond. + func setPairedPaths(_ paths: Set) +} + +/// CoreBluetooth access for Jade, over the Nordic UART Service. +/// +/// Separate from `TrezorBLEManager` with a central of its own: the two vendors differ in almost every +/// Bluetooth rule, and neither may disturb the other's links or scan results. The central is created +/// only by `scan` and `open`, so nothing prompts for Bluetooth before a Jade is actually used. +/// +/// Every CoreBluetooth call runs on `centralQueue`, where the delegate callbacks arrive too. One lock +/// guards the manager's state and is never held while waiting, including while dispatching +/// synchronously onto `centralQueue`, whose callbacks take the same lock. +final class JadeBLEManager: NSObject, JadeBLEDriving, @unchecked Sendable { + static let shared = JadeBLEManager() + + static let serviceUUID = CBUUID(string: "6e400001-b5a3-f393-e0a9-e50e24dcca9e") + static let writeCharacteristicUUID = CBUUID(string: "6e400002-b5a3-f393-e0a9-e50e24dcca9e") + static let notifyCharacteristicUUID = CBUUID(string: "6e400003-b5a3-f393-e0a9-e50e24dcca9e") + /// jade-client-rs `MAX_CHUNK_BYTES`. + static let maxChunkSize: UInt32 = 509 + /// A default ATT MTU of 23 less the 3 byte header. + static let defaultChunkSize: UInt32 = 20 + static let fallbackName = "Jade" + + private static let settleTimeout: TimeInterval = 2 + private static let connectTimeout: TimeInterval = 15 + private static let discoveryTimeout: TimeInterval = 10 + /// iOS pairs on the first encrypted access, which can be this subscription, so the budget covers + /// the passkey being confirmed on the Jade and in the iOS pairing dialog. + private static let subscribeTimeout: TimeInterval = 35 + /// Same budget as the subscription, since the pairing dialog can instead appear on the first write. + private static let writeTimeout: TimeInterval = 35 + private static let disconnectTimeout: TimeInterval = 3 + private static let peripheralCacheLimit = 32 + private static let logContext = "JadeBLE" + + private final class Link { + let peripheral: CBPeripheral + let state: JadeBLELinkState + var writeCharacteristic: CBCharacteristic? + + init(peripheral: CBPeripheral, state: JadeBLELinkState) { + self.peripheral = peripheral + self.state = state + } + } + + private struct CachedPeripheral { + let peripheral: CBPeripheral + let lastSeen: Date + } + + private final class ScanSession { + let finished = BLEOneShot() + private(set) var discoveries: [JadeBLEDiscovery] = [] + + /// True when the path was not listed yet. + func upsert(_ discovery: JadeBLEDiscovery) -> Bool { + if let index = discoveries.firstIndex(where: { $0.path == discovery.path }) { + discoveries[index] = discovery + return false + } + discoveries.append(discovery) + return true + } + + func remove(path: String) { + discoveries.removeAll { $0.path == path } + } + } + + private let centralQueue = DispatchQueue(label: "jade.ble.central", qos: .userInitiated) + private let centralQueueKey = DispatchSpecificKey() + private let centralCreationLock = NSLock() + private let stateLock = NSLock() + + private var central: CBCentralManager? + private var centralState: CBManagerState = .unknown + private var sawBluetoothUnavailable = false + private var stateWaiters: [BLEOneShot] = [] + private var peripheralCache: [String: CachedPeripheral] = [:] + private var advertisedNames: [String: String] = [:] + private var scanSession: ScanSession? + private var links: [String: Link] = [:] + private var pairedPaths: Set = [] + private var lastGeneration: UInt64 = 0 + + private let externalDisconnectSubject = PassthroughSubject() + private let poweredOnSubject = PassthroughSubject() + + override init() { + super.init() + centralQueue.setSpecific(key: centralQueueKey, value: ()) + } + + var externalDisconnects: AnyPublisher { + externalDisconnectSubject.eraseToAnyPublisher() + } + + var bluetoothPoweredOn: AnyPublisher { + poweredOnSubject.eraseToAnyPublisher() + } + + /// Whether the central exists yet. Only `scan` and `open` create it. + var hasCentral: Bool { + stateLock.withLock { central != nil } + } + + static func chunkSize(maximumWriteLength: Int) -> UInt32 { + UInt32(min(max(maximumWriteLength, 1), Int(maxChunkSize))) + } + + /// Maps the CoreBluetooth errors that mean the Bluetooth bond is broken or was never confirmed. + /// Nil for any other error, which the caller reports with its own description. + static func pairingError(for error: Error?, isPaired: Bool) -> JadeBLEError? { + guard let error else { return nil } + let nsError = error as NSError + switch (nsError.domain, nsError.code) { + case (CBErrorDomain, CBError.Code.peerRemovedPairingInformation.rawValue): + return .staleBond + case (CBErrorDomain, CBError.Code.encryptionTimedOut.rawValue): + return .pairingNotConfirmed + case (CBATTErrorDomain, CBATTError.Code.insufficientEncryption.rawValue), + (CBATTErrorDomain, CBATTError.Code.insufficientAuthentication.rawValue): + return isPaired ? .staleBond : .pairingNotConfirmed + default: + return nil + } + } + + static func isJadeName(_ name: String?) -> Bool { + guard let name else { return true } + return name.range(of: fallbackName, options: [.caseInsensitive, .anchored]) != nil + } + + // MARK: - Scanning + + func scan(duration: TimeInterval) -> [JadeBLEDiscovery] { + dispatchPrecondition(condition: .notOnQueue(centralQueue)) + let central = startCentral() + let state = waitForSettledState() + guard state == .poweredOn else { + Logger.debug("Skipped the Jade scan, Bluetooth state is \(state.rawValue)", context: Self.logContext) + return [] + } + + let session = ScanSession() + let replaced: ScanSession? = stateLock.withLock { + let previous = scanSession + scanSession = session + return previous + } + replaced?.finished.resolve(.success(())) + + centralQueue.async { + guard central.state == .poweredOn else { return } + central.scanForPeripherals( + withServices: [Self.serviceUUID], + options: [CBCentralManagerScanOptionAllowDuplicatesKey: false] + ) + } + _ = session.finished.wait(timeout: duration) + + let (discoveries, isCurrentScan): ([JadeBLEDiscovery], Bool) = stateLock.withLock { + guard scanSession === session else { return (session.discoveries, false) } + scanSession = nil + return (session.discoveries, true) + } + if isCurrentScan { + centralQueue.async { + guard central.state == .poweredOn else { return } + central.stopScan() + } + } + Logger.debug("Jade scan found \(discoveries.count) device(s)", context: Self.logContext) + return discoveries + } + + // MARK: - Opening + + func open(path: String) throws { + dispatchPrecondition(condition: .notOnQueue(centralQueue)) + guard let identifier = HwDevicePath.bleIdentifier(path) else { + throw JadeBLEError.invalidPath(path) + } + + if let existing = link(for: path) { + if existing.state.reuseIfUsable() { + Logger.info("Reused the open Jade link \(path)", context: Self.logContext) + return + } + close(path: path) + } + + let central = startCentral() + try requirePoweredOn(waitForSettledState()) + + guard let peripheral = cachedPeripheral(path: path) ?? retrievePeripheral(identifier, path: path, central: central) else { + throw JadeBLEError.deviceNotFound + } + + let link = registerLink(path: path, peripheral: peripheral) + do { + try establish(link, path: path, central: central) + } catch { + let failure = error as? JadeBLEError ?? .connectFailed(error.localizedDescription) + Logger.warn("Could not open the Jade link \(path): \(failure.localizedDescription)", context: Self.logContext) + // Always cancel, even when setup failed after connecting: a Jade whose central went away + // without disconnecting can refuse new connections until it is restarted. + shutDown(link, path: path) + throw failure + } + } + + private func establish(_ link: Link, path: String, central: CBCentralManager) throws { + let peripheral = link.peripheral + + if onCentralQueue({ peripheral.state != .disconnected }) { + Logger.debug("Releasing a leftover connection to \(path) before dialling", context: Self.logContext) + let released = link.state.begin(.disconnect) + centralQueue.async { Self.cancelConnection(to: peripheral, on: central) } + if released.wait(timeout: Self.disconnectTimeout) == nil { + link.state.abandon(.disconnect, released) + Logger.warn("The leftover connection to \(path) did not report a disconnect", context: Self.logContext) + } + } + + let connected = link.state.begin(.connect) + centralQueue.async { [weak self] in + guard let self, isCurrent(link, path: path) else { return } + peripheral.delegate = self + central.connect(peripheral, options: nil) + } + try wait(for: connected, step: .connect, link: link, timeout: Self.connectTimeout, timeoutError: .connectTimeout) + + let servicesFound = link.state.begin(.services) + centralQueue.async { [weak self] in + guard let self, isCurrent(link, path: path) else { return } + peripheral.discoverServices([Self.serviceUUID]) + } + try wait(for: servicesFound, step: .services, link: link, timeout: Self.discoveryTimeout, timeoutError: .notAJade) + guard let service = onCentralQueue({ peripheral.services?.first { $0.uuid == Self.serviceUUID } }) else { + Logger.warn("\(path) does not offer the Nordic UART Service", context: Self.logContext) + throw JadeBLEError.notAJade + } + + let characteristicsFound = link.state.begin(.characteristics) + centralQueue.async { [weak self] in + guard let self, isCurrent(link, path: path) else { return } + peripheral.discoverCharacteristics([Self.writeCharacteristicUUID, Self.notifyCharacteristicUUID], for: service) + } + try wait(for: characteristicsFound, step: .characteristics, link: link, timeout: Self.discoveryTimeout, timeoutError: .notAJade) + let (writeCharacteristic, notifyCharacteristic) = onCentralQueue { + ( + service.characteristics?.first { $0.uuid == Self.writeCharacteristicUUID }, + service.characteristics?.first { $0.uuid == Self.notifyCharacteristicUUID } + ) + } + // Write without response silently drops chunks on the Jade's GATT stack. + guard let writeCharacteristic, writeCharacteristic.properties.contains(.write) else { + Logger.warn("\(path) offers no write with response characteristic", context: Self.logContext) + throw JadeBLEError.notAJade + } + // Real hardware offers indications only on its TX characteristic; iOS subscribes to either. + guard let notifyCharacteristic, !notifyCharacteristic.properties.isDisjoint(with: [.notify, .indicate]) else { + Logger.warn("\(path) offers no notify or indicate characteristic", context: Self.logContext) + throw JadeBLEError.notAJade + } + + let subscribed = link.state.begin(.subscribe) + centralQueue.async { [weak self] in + guard let self, isCurrent(link, path: path) else { return } + peripheral.setNotifyValue(true, for: notifyCharacteristic) + } + let unconfirmedPairing: JadeBLEError = isPaired(path) ? .staleBond : .pairingNotConfirmed + try wait(for: subscribed, step: .subscribe, link: link, timeout: Self.subscribeTimeout, timeoutError: unconfirmedPairing) + + // The write-without-response length is the true MTU less the header, so a chunk never needs a + // long write even though every chunk is written with response. + let chunkSize = Self.chunkSize(maximumWriteLength: onCentralQueue { peripheral.maximumWriteValueLength(for: .withoutResponse) }) + stateLock.withLock { link.writeCharacteristic = writeCharacteristic } + try link.state.markReady(chunkSize: chunkSize) + Logger.info("Opened the Jade link \(path) with chunk size \(chunkSize)", context: Self.logContext) + } + + private func wait( + for waiter: BLEOneShot, + step: JadeBLELinkState.Step, + link: Link, + timeout: TimeInterval, + timeoutError: JadeBLEError + ) throws { + guard let result = waiter.wait(timeout: timeout) else { + link.state.abandon(step, waiter) + throw timeoutError + } + try result.get() + } + + // MARK: - Reading and writing + + func write(path: String, data: Data) throws { + dispatchPrecondition(condition: .notOnQueue(centralQueue)) + let (link, characteristic, isPaired): (Link?, CBCharacteristic?, Bool) = stateLock.withLock { + (links[path], links[path]?.writeCharacteristic, pairedPaths.contains(path)) + } + guard let link else { throw JadeBLEError.notOpen } + guard link.state.isUsable, let characteristic else { throw JadeBLEError.disconnected } + + // No retries and no pause between chunks: the firmware drops a partly received message after + // two seconds of silence. + let written = link.state.begin(.write) + centralQueue.async { [weak self] in + guard let self, isCurrent(link, path: path) else { return } + link.peripheral.writeValue(data, for: characteristic, type: .withResponse) + } + + guard let result = written.wait(timeout: Self.writeTimeout) else { + link.state.abandon(.write, written) + // The very first write stalling on a Jade paired before means it rejected the stored key and + // the new pairing was not confirmed: only pairing afresh fixes that. + if link.state.writesCompleted == 0, isPaired { + Logger.warn("The first write to \(path) stalled on a paired Jade, its bond is stale", context: Self.logContext) + throw JadeBLEError.staleBond + } + throw JadeBLEError.writeTimeout + } + if case let .failure(error) = result { + let failure = error as? JadeBLEError ?? .writeFailed(error.localizedDescription) + switch failure { + case .staleBond, .pairingNotConfirmed, .closed: + throw failure + default: + throw link.state.isLinkUp ? failure : JadeBLEError.disconnected + } + } + link.state.recordWrite() + Logger.debug("Wrote \(data.count) bytes to \(path)", context: Self.logContext) + } + + func read(path: String, timeout: TimeInterval) throws -> Data { + dispatchPrecondition(condition: .notOnQueue(centralQueue)) + guard let link = link(for: path) else { throw JadeBLEError.notOpen } + switch link.state.read(timeout: timeout) { + case let .data(data): + Logger.debug("Read \(data.count) bytes from \(path)", context: Self.logContext) + return data + case .empty: + return Data() + case .down: + throw JadeBLEError.disconnected + } + } + + func chunkSize(path: String) -> UInt32 { + link(for: path)?.state.chunkSize ?? Self.defaultChunkSize + } + + // MARK: - Closing + + func close(path: String) { + dispatchPrecondition(condition: .notOnQueue(centralQueue)) + guard let link = link(for: path) else { return } + shutDown(link, path: path) + } + + func closeAll() { + dispatchPrecondition(condition: .notOnQueue(centralQueue)) + let snapshot = stateLock.withLock { links } + for (path, link) in snapshot { + shutDown(link, path: path) + } + } + + func releaseAllImmediately() { + let (released, releasingCentral): ([Link], CBCentralManager?) = stateLock.withLock { + let released = Array(links.values) + links.removeAll() + return (released, central) + } + guard !released.isEmpty else { return } + for link in released where link.state.beginClosing() { + link.state.finishClosing() + } + if let releasingCentral { + onCentralQueue { + for link in released { + Self.cancelConnection(to: link.peripheral, on: releasingCentral) + } + } + } + Logger.info("Released \(released.count) Jade link(s) without waiting", context: Self.logContext) + } + + func setPairedPaths(_ paths: Set) { + stateLock.withLock { pairedPaths = paths } + } + + /// Closing marks the link first, so the disconnect it causes is never reported as external. + private func shutDown(_ link: Link, path: String) { + guard link.state.beginClosing() else { + link.state.waitUntilClosed(timeout: Self.disconnectTimeout) + return + } + let disconnected = link.state.begin(.disconnect) + let mustWait = link.state.isLinkUp + if let central = stateLock.withLock({ central }) { + centralQueue.async { Self.cancelConnection(to: link.peripheral, on: central) } + } + if mustWait, disconnected.wait(timeout: Self.disconnectTimeout) == nil { + Logger.warn("The Jade link \(path) did not report a disconnect in time", context: Self.logContext) + } + link.state.abandon(.disconnect, disconnected) + stateLock.withLock { + if links[path] === link { + links[path] = nil + } + } + link.state.finishClosing() + Logger.info("Closed the Jade link \(path)", context: Self.logContext) + } + + // MARK: - Helpers + + private func startCentral() -> CBCentralManager { + centralCreationLock.withLock { + if let existing = stateLock.withLock({ central }) { + return existing + } + // The app shows its own Bluetooth guidance, so the system power alert stays off. + let created = CBCentralManager( + delegate: self, + queue: centralQueue, + options: [CBCentralManagerOptionShowPowerAlertKey: false] + ) + stateLock.withLock { central = created } + Logger.debug("Started the Jade Bluetooth central", context: Self.logContext) + return created + } + } + + /// Waits up to `settleTimeout` while the state is still unknown or resetting, as it is right after + /// the central is created or while the permission prompt is showing. + private func waitForSettledState() -> CBManagerState { + let waiter = BLEOneShot() + let settled: CBManagerState? = stateLock.withLock { + if Self.isSettled(centralState) { + return centralState + } + stateWaiters.append(waiter) + return nil + } + if let settled { + return settled + } + _ = waiter.wait(timeout: Self.settleTimeout) + return stateLock.withLock { + stateWaiters.removeAll { $0 === waiter } + return centralState + } + } + + /// Call on `centralQueue`. With Bluetooth off no connection is left to cancel, and CoreBluetooth + /// rejects the call as misuse. + private static func cancelConnection(to peripheral: CBPeripheral, on central: CBCentralManager) { + guard central.state == .poweredOn else { return } + central.cancelPeripheralConnection(peripheral) + } + + private static func isSettled(_ state: CBManagerState) -> Bool { + state != .unknown && state != .resetting + } + + private func requirePoweredOn(_ state: CBManagerState) throws { + switch state { + case .poweredOn: + return + case .poweredOff: + throw JadeBLEError.bluetoothOff + case .unauthorized: + throw JadeBLEError.bluetoothUnauthorized + case .unsupported: + throw JadeBLEError.bluetoothUnsupported + default: + throw JadeBLEError.bluetoothNotReady + } + } + + /// Runs `work` on `centralQueue` and returns its result. Must never be called while holding + /// `stateLock`, since the delegate callbacks queued ahead of `work` take that lock. + private func onCentralQueue(_ work: () -> T) -> T { + if DispatchQueue.getSpecific(key: centralQueueKey) != nil { + return work() + } + return centralQueue.sync(execute: work) + } + + private func link(for path: String) -> Link? { + stateLock.withLock { links[path] } + } + + private func isCurrent(_ link: Link, path: String) -> Bool { + stateLock.withLock { links[path] === link } && !link.state.isClosing + } + + private func isPaired(_ path: String) -> Bool { + stateLock.withLock { pairedPaths.contains(path) } + } + + private func registerLink(path: String, peripheral: CBPeripheral) -> Link { + let (link, displaced): (Link, Link?) = stateLock.withLock { + lastGeneration &+= 1 + let link = Link(peripheral: peripheral, state: JadeBLELinkState(generation: lastGeneration)) + return (link, links.updateValue(link, forKey: path)) + } + if let displaced, displaced.state.beginClosing() { + Logger.warn("Replaced a Jade link to \(path) that was still being set up", context: Self.logContext) + displaced.state.finishClosing() + } + return link + } + + private func cachedPeripheral(path: String) -> CBPeripheral? { + stateLock.withLock { peripheralCache[path]?.peripheral } + } + + private func retrievePeripheral(_ identifier: UUID, path: String, central: CBCentralManager) -> CBPeripheral? { + guard let peripheral = onCentralQueue({ central.retrievePeripherals(withIdentifiers: [identifier]).first }) else { + return nil + } + stateLock.withLock { cachePeripheralLocked(peripheral, path: path) } + return peripheral + } + + private func cachePeripheralLocked(_ peripheral: CBPeripheral, path: String) { + peripheralCache[path] = CachedPeripheral(peripheral: peripheral, lastSeen: Date()) + guard peripheralCache.count > Self.peripheralCacheLimit else { return } + let oldestIdle = peripheralCache + .filter { links[$0.key] == nil } + .min { $0.value.lastSeen < $1.value.lastSeen } + if let oldestIdle { + peripheralCache[oldestIdle.key] = nil + } + } + + private func delegateTarget(for peripheral: CBPeripheral) -> (link: Link, isPaired: Bool)? { + let path = HwDevicePath.ble(peripheral.identifier) + return stateLock.withLock { + guard let link = links[path] else { return nil } + return (link, pairedPaths.contains(path)) + } + } +} + +// MARK: - CBCentralManagerDelegate + +extension JadeBLEManager: CBCentralManagerDelegate { + func centralManagerDidUpdateState(_ central: CBCentralManager) { + let state = central.state + Logger.debug("Bluetooth state is \(state.rawValue)", context: Self.logContext) + + let (waiters, droppedLinks, endedScan, isBackOn): ([BLEOneShot], [String: Link], ScanSession?, Bool) = stateLock.withLock { + centralState = state + var waiters: [BLEOneShot] = [] + if Self.isSettled(state) { + waiters = stateWaiters + stateWaiters.removeAll() + } + if state == .poweredOn { + let isBackOn = sawBluetoothUnavailable + sawBluetoothUnavailable = false + return (waiters, [:], nil, isBackOn) + } + if state != .unknown { + sawBluetoothUnavailable = true + } + return (waiters, links, scanSession, false) + } + + for waiter in waiters { + waiter.resolve(.success(())) + } + endedScan?.finished.resolve(.success(())) + // iOS does not promise a disconnect callback per peripheral when Bluetooth goes away. + let reason: JadeBLEError = state == .poweredOff ? .bluetoothOff : .disconnected + for (path, link) in droppedLinks where link.state.markDown(reason: reason) { + externalDisconnectSubject.send(path) + } + if isBackOn { + Logger.info("Bluetooth is back on", context: Self.logContext) + poweredOnSubject.send(()) + } + } + + func centralManager( + _ central: CBCentralManager, + didDiscover peripheral: CBPeripheral, + advertisementData: [String: Any], + rssi _: NSNumber + ) { + let path = HwDevicePath.ble(peripheral.identifier) + let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String + let newlyFound: String? = stateLock.withLock { + // The scan response carries "Jade XXXXXX"; a later packet without a name keeps it. + if let advertisedName { + advertisedNames[path] = advertisedName + } + let name = advertisedNames[path] ?? peripheral.name + // The Nordic UART Service is not unique to Jade, so other gadgets offering it are skipped. + guard Self.isJadeName(name) else { + scanSession?.remove(path: path) + return nil + } + cachePeripheralLocked(peripheral, path: path) + let discovery = JadeBLEDiscovery(path: path, name: name ?? Self.fallbackName) + return scanSession?.upsert(discovery) == true ? discovery.name : nil + } + if let newlyFound { + Logger.debug("Found \(newlyFound) at \(path)", context: Self.logContext) + } + } + + func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + let path = HwDevicePath.ble(peripheral.identifier) + if let target = delegateTarget(for: peripheral), target.link.state.markConnected() { + Logger.debug("Connected to \(path)", context: Self.logContext) + return + } + Logger.info("Cancelling a connection to \(path) nobody is waiting for", context: Self.logContext) + central.cancelPeripheralConnection(peripheral) + } + + func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { + guard let target = delegateTarget(for: peripheral) else { return } + let failure = Self.pairingError(for: error, isPaired: target.isPaired) + ?? .connectFailed(error?.localizedDescription ?? "unknown error") + let path = HwDevicePath.ble(peripheral.identifier) + Logger.warn("Could not connect to \(path): \(failure.localizedDescription)", context: Self.logContext) + target.link.state.resolve(.connect, error: failure) + } + + func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { + let path = HwDevicePath.ble(peripheral.identifier) + guard let target = delegateTarget(for: peripheral) else { return } + let state = target.link.state + guard state.isLinkUp else { + // A leftover connection released before dialling, or a connect cancelled while pending. + state.resolve(.disconnect, error: nil) + return + } + + let reason: JadeBLEError = if let pairingError = Self.pairingError(for: error, isPaired: target.isPaired) { + pairingError + } else if let error, !state.isReady { + .connectFailed(error.localizedDescription) + } else { + .disconnected + } + if state.markDown(reason: reason) { + Logger.info("The Jade link \(path) dropped: \(reason.localizedDescription)", context: Self.logContext) + externalDisconnectSubject.send(path) + } + } +} + +// MARK: - CBPeripheralDelegate + +extension JadeBLEManager: CBPeripheralDelegate { + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + guard let target = delegateTarget(for: peripheral) else { return } + let failure = error.map { Self.pairingError(for: $0, isPaired: target.isPaired) ?? .connectFailed($0.localizedDescription) } + target.link.state.resolve(.services, error: failure) + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { + guard service.uuid == Self.serviceUUID, let target = delegateTarget(for: peripheral) else { return } + let failure = error.map { Self.pairingError(for: $0, isPaired: target.isPaired) ?? .connectFailed($0.localizedDescription) } + target.link.state.resolve(.characteristics, error: failure) + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { + guard characteristic.uuid == Self.notifyCharacteristicUUID, let target = delegateTarget(for: peripheral) else { return } + let failure: JadeBLEError? = if let error { + Self.pairingError(for: error, isPaired: target.isPaired) ?? .subscribeFailed(error.localizedDescription) + } else if !characteristic.isNotifying { + .subscribeFailed("notifications stayed off") + } else { + nil + } + target.link.state.resolve(.subscribe, error: failure) + } + + func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { + guard characteristic.uuid == Self.writeCharacteristicUUID, let target = delegateTarget(for: peripheral) else { return } + let failure = error.map { Self.pairingError(for: $0, isPaired: target.isPaired) ?? .writeFailed($0.localizedDescription) } + target.link.state.resolve(.write, error: failure) + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { + guard error == nil, characteristic.uuid == Self.notifyCharacteristicUUID, let value = characteristic.value else { return } + delegateTarget(for: peripheral)?.link.state.enqueue(value) + } +} diff --git a/Bitkit/Services/Jade/JadeKnownDeviceStore.swift b/Bitkit/Services/Jade/JadeKnownDeviceStore.swift new file mode 100644 index 000000000..01d880ee5 --- /dev/null +++ b/Bitkit/Services/Jade/JadeKnownDeviceStore.swift @@ -0,0 +1,31 @@ +import Foundation + +/// The paired Jade entries and the pending wallet names, as `JadeManager` reads and writes them. +protocol JadeKnownDeviceStoring { + /// Paired Jade entries, most recently connected first. + func loadAll() -> [HwKnownDevice] + /// Replaces the Jade entries, leaving every other vendor's alone. See `HwKnownDeviceStorage.saveAll` + /// for `pendingName`. + func saveAll(_ devices: [HwKnownDevice], pendingName: PendingHwWalletName?) + func loadPendingNames() -> [String: String] + func setPendingName(walletId: String, name: String?) +} + +/// The Jade slice of `HwKnownDeviceStorage`. +struct JadeKnownDeviceStore: JadeKnownDeviceStoring { + func loadAll() -> [HwKnownDevice] { + HwKnownDeviceStorage.loadAll(vendor: .blockstream) + } + + func saveAll(_ devices: [HwKnownDevice], pendingName: PendingHwWalletName?) { + HwKnownDeviceStorage.saveAll(devices, vendor: .blockstream, pendingName: pendingName) + } + + func loadPendingNames() -> [String: String] { + HwKnownDeviceStorage.loadPendingNames() + } + + func setPendingName(walletId: String, name: String?) { + HwKnownDeviceStorage.setPendingName(walletId: walletId, name: name) + } +} diff --git a/Bitkit/Services/Jade/JadeService.swift b/Bitkit/Services/Jade/JadeService.swift new file mode 100644 index 000000000..901076474 --- /dev/null +++ b/Bitkit/Services/Jade/JadeService.swift @@ -0,0 +1,157 @@ +import BitkitCore +import Foundation + +/// bitkit-core's Jade session, as `JadeManager` drives it. +protocol JadeServicing: AnyObject, Sendable { + func initialize() async throws + func scan(timeoutMs: UInt32) async throws -> [JadeDeviceInfo] + /// The devices of the last scan, without scanning again. + func listDevices() async -> [JadeDeviceInfo] + func connect(path: String) async throws -> JadeVersionInfo + func disconnect() async throws + /// Aborts the request in flight, which then fails with `JadeError.UserCancelled`. + func cancel() async throws + /// Reports a link that dropped without core closing it. Must reach core before the next connect to + /// the same path, or it tears that new session down. + func notifyDisconnected(path: String) async + func isConnected() -> Bool + func refreshVersionInfo() async throws -> JadeVersionInfo + func unlock(network: JadeNetwork) async throws + func getMasterFingerprint(network: JadeNetwork) async throws -> String + func getAccountExport(network: JadeNetwork, accountTypes: [AccountType], accountIndex: UInt32) async throws -> JadeAccountExport + func verifyAddress(network: JadeNetwork, variant: JadeAddressVariant, derivationPath: String, expectedAddress: String) async throws + /// Returns the signed PSBT, base64 encoded. + func signPsbt(network: JadeNetwork, psbtBase64: String) async throws -> String + func finalizePsbt(originalPsbt: String, signedPsbt: String) async throws -> CompletedTransaction +} + +/// Thin wrapper over bitkit-core's `jade*` functions, each run through `ServiceQueue.background(.core)`. +/// +/// A Swift task cancel never reaches Rust: aborting a request in flight takes `cancel()` or +/// `disconnect()`. `ServiceQueue` does not serialise async calls either, so ordering them is left to +/// `JadeManager`. +final class JadeService: JadeServicing, @unchecked Sendable { + static let shared = JadeService() + + /// Matches the Trezor scan window, so one search pass stays predictable. + static let scanTimeoutMs: UInt32 = 3000 + + private static let logContext = "JadeService" + + private let transport: JadeTransportCallback + private let callbackLock = NSLock() + private var isCallbackRegistered = false + + init(transport: JadeTransportCallback = JadeTransport.shared) { + self.transport = transport + } + + func initialize() async throws { + try await ServiceQueue.background(.core) { [self] in + ensureCallbackRegistered() + } + } + + func scan(timeoutMs: UInt32) async throws -> [JadeDeviceInfo] { + try await ServiceQueue.background(.core) { [self] in + ensureCallbackRegistered() + return try await jadeScan(timeoutMs: timeoutMs) + } + } + + func listDevices() async -> [JadeDeviceInfo] { + let devices = try? await ServiceQueue.background(.core) { + await jadeListDevices() + } + return devices ?? [] + } + + func connect(path: String) async throws -> JadeVersionInfo { + try await ServiceQueue.background(.core) { [self] in + ensureCallbackRegistered() + return try await jadeConnect(transport: .bluetooth, path: path) + } + } + + func disconnect() async throws { + try await ServiceQueue.background(.core) { + try await jadeDisconnect() + } + } + + func cancel() async throws { + try await ServiceQueue.background(.core) { + try await jadeCancel() + } + } + + func notifyDisconnected(path: String) async { + _ = try? await ServiceQueue.background(.core) { + await jadeNotifyDisconnected(path: path) + } + } + + func isConnected() -> Bool { + jadeIsConnected() + } + + func refreshVersionInfo() async throws -> JadeVersionInfo { + try await ServiceQueue.background(.core) { + try await jadeRefreshVersionInfo() + } + } + + func unlock(network: JadeNetwork) async throws { + try await ServiceQueue.background(.core) { + try await jadeUnlock(network: network) + } + } + + func getMasterFingerprint(network: JadeNetwork) async throws -> String { + try await ServiceQueue.background(.core) { + try await jadeGetMasterFingerprint(network: network) + } + } + + func getAccountExport(network: JadeNetwork, accountTypes: [AccountType], accountIndex: UInt32) async throws -> JadeAccountExport { + try await ServiceQueue.background(.core) { + try await jadeGetAccountExport(network: network, accountIndex: accountIndex, accountTypes: accountTypes) + } + } + + func verifyAddress(network: JadeNetwork, variant: JadeAddressVariant, derivationPath: String, expectedAddress: String) async throws { + try await ServiceQueue.background(.core) { + try await jadeVerifyAddress( + network: network, + variant: variant, + derivationPath: derivationPath, + expectedAddress: expectedAddress + ) + } + } + + func signPsbt(network: JadeNetwork, psbtBase64: String) async throws -> String { + try await ServiceQueue.background(.core) { + try await jadeSignPsbt(network: network, psbt: psbtBase64) + } + } + + func finalizePsbt(originalPsbt: String, signedPsbt: String) async throws -> CompletedTransaction { + try await ServiceQueue.background(.core) { + // Module-qualified: unqualified, the name resolves to this method and it calls itself. + try BitkitCore.finalizePsbt(originalPsbt: originalPsbt, signedPsbt: signedPsbt) + } + } + + private func ensureCallbackRegistered() { + callbackLock.lock() + defer { callbackLock.unlock() } + + guard !isCallbackRegistered else { return } + if jadeSetTransportCallback(callback: transport) { + Logger.warn("Replaced a previously registered Jade transport", context: Self.logContext) + } + isCallbackRegistered = true + Logger.info("Jade transport registered", context: Self.logContext) + } +} diff --git a/Bitkit/Services/Jade/JadeTransport.swift b/Bitkit/Services/Jade/JadeTransport.swift new file mode 100644 index 000000000..55a109f45 --- /dev/null +++ b/Bitkit/Services/Jade/JadeTransport.swift @@ -0,0 +1,144 @@ +import BitkitCore +import Combine +import Foundation + +/// The app's side of the Jade links, next to core's own use of the same transport. +protocol JadeTransportControlling: AnyObject, Sendable { + /// Paths whose link dropped without the app closing it. + var externalDisconnects: AnyPublisher { get } + /// Fires when Bluetooth turns back on after having been off. + var bluetoothPoweredOn: AnyPublisher { get } + + /// Closes the link to `path` off the main thread, waiting briefly for the disconnect. + func disconnectDevice(path: String) async + func closeAllConnections() async + /// Cancels every link without waiting. Safe to call on the main thread. + func releaseAllImmediately() + func setPairedPaths(_ paths: Set) +} + +/// The byte pipe between core's Jade protocol and Bluetooth. +/// +/// Core owns the CBOR framing, the pinserver exchange and every deadline; this only moves bytes over +/// the Nordic UART Service. Core calls every callback on one of its blocking threads, so blocking here +/// is expected, and the results carry plain text core shows to the user. +final class JadeTransport: JadeTransportCallback, JadeTransportControlling, @unchecked Sendable { + static let shared = JadeTransport(driver: JadeBLEManager.shared) + + private static let minimumScanDuration: TimeInterval = 0.5 + private static let maximumScanDuration: TimeInterval = 15 + private static let logContext = "JadeBLE" + + private let driver: JadeBLEDriving + private let isTrezorBridgeEnabled: () -> Bool + + init(driver: JadeBLEDriving, isTrezorBridgeEnabled: @escaping () -> Bool = { Env.trezorBridgeEnabled }) { + self.driver = driver + self.isTrezorBridgeEnabled = isTrezorBridgeEnabled + } + + var externalDisconnects: AnyPublisher { + driver.externalDisconnects + } + + var bluetoothPoweredOn: AnyPublisher { + driver.bluetoothPoweredOn + } + + static func scanDuration(timeoutMs: UInt32) -> TimeInterval { + min(max(Double(timeoutMs) / 1000, minimumScanDuration), maximumScanDuration) + } + + static func errorCode(for error: Error) -> JadeTransportErrorCode? { + (error as? JadeBLEError)?.transportErrorCode + } + + // MARK: - JadeTransportControlling + + func disconnectDevice(path: String) async { + await runOffMainThread { $0.close(path: path) } + } + + func closeAllConnections() async { + await runOffMainThread { $0.closeAll() } + } + + func releaseAllImmediately() { + driver.releaseAllImmediately() + } + + func setPairedPaths(_ paths: Set) { + driver.setPairedPaths(paths) + } + + // MARK: - JadeTransportCallback + + func scanDevices(timeoutMs: UInt32) -> [JadeNativeDevice] { + // Bridge runs (journeys and E2E) have no Jade nearby, and in the simulator the central never powers + // on, so a Bluetooth scan would only add its wait to every search pass. + guard !isTrezorBridgeEnabled() else { + Logger.debug("Skipped the Jade Bluetooth scan while the Trezor Bridge is enabled", context: Self.logContext) + return [] + } + let discoveries = driver.scan(duration: Self.scanDuration(timeoutMs: timeoutMs)) + Logger.info("Found \(discoveries.count) Jade device(s)", context: Self.logContext) + return discoveries.map { + JadeNativeDevice(path: $0.path, transport: .bluetooth, name: $0.name, serialNumber: nil) + } + } + + func openDevice(path: String) -> JadeTransportResult { + do { + try driver.open(path: path) + return Self.success + } catch { + return Self.failure(error) + } + } + + func closeDevice(path: String) -> JadeTransportResult { + driver.close(path: path) + return Self.success + } + + func writeChunk(path: String, data: Data) -> JadeTransportResult { + do { + try driver.write(path: path, data: data) + return Self.success + } catch { + Logger.warn("Jade write of \(data.count) bytes failed: \(error.localizedDescription)", context: Self.logContext) + return Self.failure(error) + } + } + + func readChunk(path: String, timeoutMs: UInt32) -> JadeTransportReadResult { + do { + let data = try driver.read(path: path, timeout: Double(timeoutMs) / 1000) + return JadeTransportReadResult(success: true, data: data, error: "", errorCode: nil) + } catch { + return JadeTransportReadResult(success: false, data: Data(), error: error.localizedDescription, errorCode: Self.errorCode(for: error)) + } + } + + func getChunkSize(path: String) -> UInt32 { + driver.chunkSize(path: path) + } + + // MARK: - Helpers + + private static let success = JadeTransportResult(success: true, error: "", errorCode: nil) + + private static func failure(_ error: Error) -> JadeTransportResult { + JadeTransportResult(success: false, error: error.localizedDescription, errorCode: errorCode(for: error)) + } + + private func runOffMainThread(_ work: @escaping (JadeBLEDriving) -> Void) async { + let driver = driver + await withCheckedContinuation { (continuation: CheckedContinuation) in + DispatchQueue.global(qos: .userInitiated).async { + work(driver) + continuation.resume() + } + } + } +} diff --git a/Bitkit/Services/Trezor/TrezorBLEManager.swift b/Bitkit/Services/Trezor/TrezorBLEManager.swift index 7f79cb4d1..e51ad6ef4 100644 --- a/Bitkit/Services/Trezor/TrezorBLEManager.swift +++ b/Bitkit/Services/Trezor/TrezorBLEManager.swift @@ -828,52 +828,3 @@ enum TrezorBLEError: LocalizedError { } } } - -// MARK: - BlockingQueue - -/// Thread-safe blocking queue for BLE notification data -private class BlockingQueue { - private var queue: [T] = [] - private let lock = NSCondition() - private var failed = false - - func offer(_ item: T) { - lock.lock() - queue.append(item) - lock.signal() - lock.unlock() - } - - func poll(timeout: TimeInterval) -> T? { - lock.lock() - defer { lock.unlock() } - - let deadline = Date().addingTimeInterval(timeout) - - while queue.isEmpty, !failed { - if !lock.wait(until: deadline) { - return nil - } - } - - if failed || queue.isEmpty { - return nil - } - - return queue.removeFirst() - } - - func clear() { - lock.lock() - queue.removeAll() - failed = false - lock.unlock() - } - - func fail() { - lock.lock() - failed = true - lock.broadcast() - lock.unlock() - } -} diff --git a/Bitkit/Utilities/AppReset.swift b/Bitkit/Utilities/AppReset.swift index 617cd3e4f..a5c5ab194 100644 --- a/Bitkit/Utilities/AppReset.swift +++ b/Bitkit/Utilities/AppReset.swift @@ -2,6 +2,11 @@ import BitkitCore import SwiftUI enum AppReset { + /// The live hardware wallet layer, registered by `AppScene`, which owns it. The wipe starts from + /// screens that have no other use for it, so it is not passed in. Weak, so it never keeps a replaced + /// app state tree alive. + @MainActor weak static var hardwareWallets: HwWalletManager? + @MainActor static func wipe( app: AppViewModel, @@ -24,6 +29,8 @@ enum AppReset { VssStoreIdProvider.shared.clearCache() OnChainHwService.shared.stopAllWatchers() + // Before the paired devices are wiped, so a reconnect still running cannot save one back. + await hardwareWallets?.resetForWipe() // Stop node and wipe LDK persistence via the wallet API. try await wallet.wipe() diff --git a/Bitkit/Utilities/BackgroundTaskScheduling.swift b/Bitkit/Utilities/BackgroundTaskScheduling.swift new file mode 100644 index 000000000..b7db5e421 --- /dev/null +++ b/Bitkit/Utilities/BackgroundTaskScheduling.swift @@ -0,0 +1,30 @@ +import UIKit + +/// The time the system grants the app to finish work after it leaves the foreground. +@MainActor +protocol BackgroundTaskScheduling { + /// Seconds left before the app is suspended; very large while it is in the foreground. + var backgroundTimeRemaining: TimeInterval { get } + /// Starts a background task. `expiration` runs on the main thread when the time is nearly up, and + /// must end the task quickly. + func beginBackgroundTask(named name: String, expiration: @escaping @MainActor () -> Void) -> UIBackgroundTaskIdentifier + func endBackgroundTask(_ identifier: UIBackgroundTaskIdentifier) +} + +@MainActor +struct UIApplicationBackgroundTasks: BackgroundTaskScheduling { + var backgroundTimeRemaining: TimeInterval { + UIApplication.shared.backgroundTimeRemaining + } + + func beginBackgroundTask(named name: String, expiration: @escaping @MainActor () -> Void) -> UIBackgroundTaskIdentifier { + UIApplication.shared.beginBackgroundTask(withName: name) { + // UIKit calls the expiration handler on the main thread. + MainActor.assumeIsolated { expiration() } + } + } + + func endBackgroundTask(_ identifier: UIBackgroundTaskIdentifier) { + UIApplication.shared.endBackgroundTask(identifier) + } +} diff --git a/Bitkit/Utilities/BlockingQueue.swift b/Bitkit/Utilities/BlockingQueue.swift new file mode 100644 index 000000000..0c64fdd64 --- /dev/null +++ b/Bitkit/Utilities/BlockingQueue.swift @@ -0,0 +1,56 @@ +import Foundation + +/// Thread-safe blocking queue for BLE notification data +final class BlockingQueue: @unchecked Sendable { + private var queue: [T] = [] + private let lock = NSCondition() + private var failed = false + + func offer(_ item: T) { + lock.lock() + queue.append(item) + lock.signal() + lock.unlock() + } + + func poll(timeout: TimeInterval) -> T? { + lock.lock() + defer { lock.unlock() } + + let deadline = Date().addingTimeInterval(timeout) + + while queue.isEmpty, !failed { + if !lock.wait(until: deadline) { + return nil + } + } + + if failed || queue.isEmpty { + return nil + } + + return queue.removeFirst() + } + + func drain() -> [T] { + lock.lock() + defer { lock.unlock() } + let items = queue + queue.removeAll() + return items + } + + func clear() { + lock.lock() + queue.removeAll() + failed = false + lock.unlock() + } + + func fail() { + lock.lock() + failed = true + lock.broadcast() + lock.unlock() + } +} diff --git a/Bitkit/Utilities/HwDevicePath.swift b/Bitkit/Utilities/HwDevicePath.swift new file mode 100644 index 000000000..a6f56ac7b --- /dev/null +++ b/Bitkit/Utilities/HwDevicePath.swift @@ -0,0 +1,20 @@ +import Foundation + +/// Transport paths of hardware wallet devices. A Bluetooth device is addressed as `ble:`, where +/// the UUID is the identifier CoreBluetooth gives the peripheral on this phone. +enum HwDevicePath { + static let blePrefix = "ble:" + + static func ble(_ identifier: UUID) -> String { + blePrefix + identifier.uuidString + } + + static func isBle(_ path: String) -> Bool { + path.hasPrefix(blePrefix) + } + + static func bleIdentifier(_ path: String) -> UUID? { + guard isBle(path) else { return nil } + return UUID(uuidString: String(path.dropFirst(blePrefix.count))) + } +} diff --git a/Bitkit/Utilities/HwEngagedSession.swift b/Bitkit/Utilities/HwEngagedSession.swift new file mode 100644 index 000000000..2e334fefe --- /dev/null +++ b/Bitkit/Utilities/HwEngagedSession.swift @@ -0,0 +1,50 @@ +import Foundation + +/// Closes a hardware wallet's device session without waiting for it: the next connect to that +/// device waits for the release instead. Implemented by `HwWalletManager`. +@MainActor +protocol HwSessionReleasing: AnyObject { + func scheduleStaleSessionCleanup(walletId: String) +} + +extension HwWalletManager: HwSessionReleasing {} + +/// The hardware wallet whose device a screen engaged by verifying an address or entering a +/// passphrase. Only that session is released on the way out: showing a watch-only address never +/// opens one, and dropping a live session there would ask for a passphrase again on the next send. +/// +/// The session stays engaged after the work ends, so leaving after a verification still releases +/// it: a device keeps its session open until something closes it. +@MainActor +final class HwEngagedSession { + private(set) var walletId: String? + private var runningWorkCount = 0 + + var isWorking: Bool { + runningWorkCount > 0 + } + + /// Runs device work for `walletId`, engaging its session from the moment the work starts. + func perform(walletId: String, _ work: () async throws -> Void) async rethrows { + self.walletId = walletId + runningWorkCount += 1 + defer { runningWorkCount -= 1 } + try await work() + } + + /// Releases the engaged session, if any, when the screen, the hardware tab or the passphrase + /// prompt is left. + func release(through releaser: HwSessionReleasing) { + guard let walletId else { return } + self.walletId = nil + releaser.scheduleStaleSessionCleanup(walletId: walletId) + } + + /// The wallet or address on screen changed. Work still running was for one no longer shown, so + /// the session it engaged is released; a session whose work already finished stays engaged until + /// the screen is left. + func invalidate(through releaser: HwSessionReleasing) { + guard isWorking else { return } + release(through: releaser) + } +} diff --git a/Bitkit/Utilities/HwErrorPresenter.swift b/Bitkit/Utilities/HwErrorPresenter.swift new file mode 100644 index 000000000..b00377e51 --- /dev/null +++ b/Bitkit/Utilities/HwErrorPresenter.swift @@ -0,0 +1,51 @@ +import BitkitCore +import Foundation + +/// User-facing messages for hardware wallet errors of every vendor: the Jade copy first, then the +/// Trezor rules. +enum HwErrorPresenter { + static func userMessage(from error: Error) -> String { + jadeMessage(from: error) ?? TrezorErrorPresenter.userMessage(from: error) + } + + /// Nil only when the error carries no `JadeError`. Every Jade error gets Jade or neutral copy, so + /// none reaches `TrezorErrorPresenter`, whose rules rewrite text into Trezor-branded messages. + static func jadeMessage(from error: Error) -> String? { + guard let jadeError = error.underlyingJadeError else { return nil } + switch jadeError { + case .InvalidPin: + return t("hardware__jade_invalid_pin") + case .DeviceUninitialized: + return t("hardware__jade_uninitialized") + case .UnsupportedFirmware: + return t("hardware__jade_firmware_outdated") + case .PsbtTooLarge: + return t("hardware__jade_psbt_too_large") + case .NetworkMismatch: + return t("hardware__jade_network_mismatch") + case .DeviceBusy, .DeviceLocked: + return t("hardware__jade_device_busy") + case .PinServerError: + return t("hardware__jade_pinserver_error") + case .AddressMismatch: + return t("hardware__verify_address_error") + case let .TransportError(details), let .ConnectionError(details): + // The Bluetooth transport describes what went wrong in words meant for the user. + if details.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return t("hardware__connect_error") + } + return details + default: + return t("hardware__connect_error") + } + } + + static func deviceBusyMessage(for vendor: HwWalletVendor) -> String { + switch vendor { + case .trezor: + return t("hardware__device_busy") + case .blockstream: + return t("hardware__jade_device_busy") + } + } +} diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 32ff7ac06..4db5cd9df 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -402,10 +402,10 @@ extension AppViewModel { } func toast(_ error: Error) { - if error is CancellationError || error.isTrezorUserCancellation() { + if error is CancellationError || error.isHwUserCancellation() { return } - toast(type: .error, title: "Error", description: error.localizedDescription) + toast(type: .error, title: "Error", description: HwErrorPresenter.jadeMessage(from: error) ?? error.localizedDescription) } func toast(_ error: HwTransferError) { @@ -440,8 +440,8 @@ extension AppViewModel { title: t("hardware__send_broadcast_failed_title"), description: t("hardware__send_broadcast_failed_text") ) - case .deviceBusy: - toast(type: .info, title: t("hardware__device_busy")) + case let .deviceBusy(vendor): + toast(type: .info, title: HwErrorPresenter.deviceBusyMessage(for: vendor)) case .firmwareReconnect: toast( type: .error, diff --git a/Bitkit/ViewModels/HwFundingSigner.swift b/Bitkit/ViewModels/HwFundingSigner.swift index d941469d4..2eb615118 100644 --- a/Bitkit/ViewModels/HwFundingSigner.swift +++ b/Bitkit/ViewModels/HwFundingSigner.swift @@ -19,7 +19,7 @@ struct HwFundingSigner { let feeRateProvider: () async -> UInt64? /// Provides a fee-estimation destination address (an app receive address); never broadcast to. let addressProvider: () async throws -> String - let timeouts: (reconnect: Double, compose: Double, sign: Double, broadcast: Double) + let timeouts: (compose: Double, sign: Double, broadcast: Double) /// Conservative vbyte reserve, used only as a fallback when the real coin-selection estimate /// (a `sendMax` compose) is unavailable. @@ -83,17 +83,25 @@ struct HwFundingSigner { } /// Reconnects, composes and signs a normal on-chain payment without broadcasting it. + /// `onConnectingDevice` brackets every reconnect (true before it, false once it returns or throws), + /// including the one before a sign retry: the phases a caller may abandon because nothing is on the + /// device to sign yet. func prepareSignedPayment( walletId: String, address: String, sats: UInt64, satsPerVByte: UInt64, - onComposed: (HwFundingTransaction) -> Void = { _ in } + onComposed: (HwFundingTransaction) -> Void = { _ in }, + onConnectingDevice: (Bool) -> Void = { _ in } ) async throws -> HwFundingSignedTx { - try await ensureConnected(walletId: walletId) + do { + onConnectingDevice(true) + defer { onConnectingDevice(false) } + try await ensureConnected(walletId: walletId) + } let tx = try await compose(walletId: walletId, address: address, sats: sats, satsPerVByte: satsPerVByte) onComposed(tx) - return try await signStep(walletId: walletId, funding: tx) + return try await signStep(walletId: walletId, funding: tx, onConnectingDevice: onConnectingDevice) } /// Broadcasts a signed funding transaction without requiring the hardware device. @@ -127,7 +135,7 @@ struct HwFundingSigner { private func ensureConnected(walletId: String) async throws { do { - try await withTimeout(timeouts.reconnect) { + try await withTimeout(connecting.reconnectTimeout(walletId: walletId)) { try await connecting.ensureConnected(walletId: walletId) } } catch is CancellationError { @@ -136,7 +144,7 @@ struct HwFundingSigner { disconnectAfterTimeout(walletId: walletId) throw HwTransferError.reconnect(isBluetooth: connecting.isKnownBluetoothDevice(walletId: walletId)) } catch { - if error.isTrezorUserCancellation() { + if error.isHwUserCancellation() { throw error } // Swift has no cause chain, so this must be rethrown explicitly: the catch-all below @@ -145,8 +153,13 @@ struct HwFundingSigner { if let passphrase = error as? HwPassphraseError { throw passphrase } - if error.isTrezorDeviceBusy() { - throw HwTransferError.deviceBusy + if let vendor = error.hwBusyVendor { + throw HwTransferError.deviceBusy(vendor) + } + // A Jade that refuses to open (wrong PIN, unreachable PIN server, wrong network) says why + // in words the user can act on, which the reconnect copy would hide. + if error.underlyingJadeError != nil, !error.isJadeSessionFailure() { + throw HwTransferError.generic(HwErrorPresenter.userMessage(from: error)) } throw HwTransferError.reconnect(isBluetooth: connecting.isKnownBluetoothDevice(walletId: walletId)) } @@ -173,13 +186,28 @@ struct HwFundingSigner { } catch is Timeout { disconnectAfterTimeout(walletId: walletId) throw HwTransferError.signingTimeout + } catch where error.underlyingJadeError != nil { + // A Jade compose reconnects to read the fingerprint, so it fails the way a reconnect does + // and is reported the same way instead of with the raw core description. + if error.isJadeUserCancellation() { + throw error + } + if error.isJadeDeviceBusy() { + throw HwTransferError.deviceBusy(.blockstream) + } + let message = HwErrorPresenter.userMessage(from: error) + throw error.isJadeSessionFailure() ? HwTransferError.funding(message) : HwTransferError.generic(message) } catch { let message = (error as? AppError)?.debugMessage ?? (error as? AppError)?.message ?? error.localizedDescription throw HwTransferError.funding(message) } } - private func signStep(walletId: String, funding tx: HwFundingTransaction) async throws -> HwFundingSignedTx { + private func signStep( + walletId: String, + funding tx: HwFundingTransaction, + onConnectingDevice: (Bool) -> Void = { _ in } + ) async throws -> HwFundingSignedTx { do { return try await signOnce(walletId: walletId, funding: tx) } catch is CancellationError { @@ -188,10 +216,14 @@ struct HwFundingSigner { disconnectAfterTimeout(walletId: walletId) throw HwTransferError.signingTimeout } catch { - guard error.isTrezorSessionFailure() else { throw error } + guard error.isHwSessionFailure() else { throw error } await connecting.disconnectStaleSession(walletId: walletId) - try await ensureConnected(walletId: walletId) + do { + onConnectingDevice(true) + defer { onConnectingDevice(false) } + try await ensureConnected(walletId: walletId) + } do { return try await signOnce(walletId: walletId, funding: tx) @@ -199,7 +231,7 @@ struct HwFundingSigner { disconnectAfterTimeout(walletId: walletId) throw HwTransferError.signingTimeout } catch { - if error.isTrezorSessionFailure() { + if error.isHwSessionFailure() { await connecting.disconnectStaleSession(walletId: walletId) } throw error @@ -316,6 +348,7 @@ final class HwSendCoordinator { private(set) var isFundingSourceLoading = false private(set) var isPreviewLoading = false private(set) var isSigning = false + private(set) var isConnectingDevice = false private(set) var isBroadcastUnresolved = false private(set) var isPassphraseRequired = false private(set) var isVerifyingPassphrase = false @@ -323,6 +356,10 @@ final class HwSendCoordinator { private var pendingPayment: PendingPayment? private var operationTask: Task? private var operationRequest: PaymentRequest? + private var operationSession: OperationSession? + /// Bumped by every sign attempt and every cancel. A cancelled task can keep running until its + /// device call returns, and must not write over the state of an attempt started after it. + private var signingAttempt = 0 private var availabilityRequestId = 0 private var previewRequestId = 0 private let signerFactory: @MainActor (HwWalletManager, String, UInt64) -> HwFundingSigner @@ -335,6 +372,13 @@ final class HwSendCoordinator { pendingPayment != nil } + /// Whether the sign screen may be left. Reaching the device (a Jade may wait minutes for its PIN) + /// can be abandoned, and leaving cancels it; once the device is asked to sign, or a broadcast may + /// have gone out, it cannot. + var canLeave: Bool { + (!isSigning || isConnectingDevice) && !isBroadcastUnresolved + } + init( walletId: String? = nil, signerFactory: @escaping @MainActor (HwWalletManager, String, UInt64) -> HwFundingSigner = { manager, address, satsPerVByte in @@ -371,6 +415,7 @@ final class HwSendCoordinator { isFundingSourceLoading = walletId != nil && showsLoading isPreviewLoading = false isSigning = false + isConnectingDevice = false isBroadcastUnresolved = false isPassphraseRequired = false isVerifyingPassphrase = false @@ -462,15 +507,23 @@ final class HwSendCoordinator { } let request = PaymentRequest(address: address, sats: sats, satsPerVByte: satsPerVByte) if let operationTask { - guard operationRequest == request else { throw HwTransferError.deviceBusy } + guard operationRequest == request else { throw HwTransferError.deviceBusy(manager.vendor(walletId: walletId)) } return try await operationTask.value } + signingAttempt += 1 + let attempt = signingAttempt + let signer = signerFactory(manager, address, satsPerVByte) + isSigning = true + let task = Task { @MainActor in - isSigning = true - defer { isSigning = false } + defer { + if signingAttempt == attempt { + isSigning = false + isConnectingDevice = false + } + } - let signer = signerFactory(manager, address, satsPerVByte) let signed: HwFundingSignedTx if let pendingPayment, pendingPayment.request == request { signed = pendingPayment.signedTx @@ -480,13 +533,22 @@ final class HwSendCoordinator { address: address, sats: sats, satsPerVByte: satsPerVByte, - onComposed: { [weak self] in self?.previewFeeSats = $0.miningFeeSats } + onComposed: { [weak self] composed in + guard let self, signingAttempt == attempt else { return } + previewFeeSats = composed.miningFeeSats + }, + onConnectingDevice: { [weak self] isConnecting in + guard let self, signingAttempt == attempt else { return } + isConnectingDevice = isConnecting + } ) + try Task.checkCancellation() pendingPayment = PendingPayment(request: request, signedTx: signed) } if pendingPayment?.isPreparedForBroadcast != true { try await beforeBroadcast() + try Task.checkCancellation() pendingPayment?.isPreparedForBroadcast = true } @@ -506,9 +568,13 @@ final class HwSendCoordinator { } operationRequest = request operationTask = task + operationSession = OperationSession(walletId: walletId, connecting: signer.connecting) defer { - operationRequest = nil - operationTask = nil + if signingAttempt == attempt { + operationRequest = nil + operationTask = nil + operationSession = nil + } } return try await task.value } @@ -546,11 +612,20 @@ final class HwSendCoordinator { isVerifyingPassphrase = false isPassphraseRequired = false guard !isBroadcastUnresolved else { return } + let abandonedSession = operationSession operationTask?.cancel() + signingAttempt += 1 operationTask = nil operationRequest = nil + operationSession = nil pendingPayment = nil isSigning = false + isConnectingDevice = false + // A Swift cancel never reaches the device, which would otherwise keep connecting (or wait for + // a PIN) for a payment nobody is waiting on. The next connect waits for this release. + if let abandonedSession { + abandonedSession.connecting.scheduleStaleSessionCleanup(walletId: abandonedSession.walletId) + } } private static func signer( @@ -563,7 +638,7 @@ final class HwSendCoordinator { connecting: manager, feeRateProvider: { satsPerVByte }, addressProvider: { address }, - timeouts: (reconnect: 30, compose: 45, sign: 120, broadcast: 120) + timeouts: (compose: 45, sign: 120, broadcast: 120) ) } @@ -578,4 +653,9 @@ final class HwSendCoordinator { let signedTx: HwFundingSignedTx var isPreparedForBroadcast = false } + + private struct OperationSession { + let walletId: String + let connecting: HwTransferConnecting + } } diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index c18497cf0..3c4d0e168 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -54,8 +54,9 @@ enum HwTransferError: Error, Equatable { case broadcastUncertain /// Signed tx is retained but Electrum/network is unreachable — retry broadcast later. case broadcastConnectivity - /// Trezor is locked or otherwise busy before signing can start. - case deviceBusy + /// The device is locked or otherwise busy before signing can start. Carries the vendor so the + /// toast can name the device. + case deviceBusy(HwWalletVendor) /// Firmware error (code 99) — user must reconnect the device. case firmwareReconnect /// The entered passphrase opened a different wallet than the one being spent from. @@ -96,10 +97,12 @@ protocol HwTransferFunding: Sendable { /// The device-session capability the transfer flow needs for on-device signing, addressed by wallet /// identity: a device holds one wallet open at a time, so reaching a given wallet is more than -/// reaching its transport. Implemented by `TrezorManager`. +/// reaching its transport. Implemented by `HwWalletManager`. @MainActor protocol HwTransferConnecting: Sendable { func ensureConnected(walletId: String) async throws + /// How long `ensureConnected` may take for this wallet's device before the flow gives up. + func reconnectTimeout(walletId: String) -> Double func disconnectStaleSession(walletId: String) async func scheduleStaleSessionCleanup(walletId: String) /// Whether the wallet is reachable over a known Bluetooth device, so a reconnect failure can show @@ -189,7 +192,7 @@ class TransferViewModel: ObservableObject { hwConnecting: HwTransferConnecting? = nil, hwFeeRateProvider: (() async -> UInt64?)? = nil, hwAddressProvider: (() async throws -> String)? = nil, - hwTimeouts: (reconnect: Double, compose: Double, sign: Double, broadcast: Double) = (reconnect: 30, compose: 45, sign: 120, broadcast: 120), + hwTimeouts: (compose: Double, sign: Double, broadcast: Double) = (compose: 45, sign: 120, broadcast: 120), onBalanceRefresh: (() async -> Void)? = nil ) { self.coreService = coreService @@ -240,7 +243,7 @@ class TransferViewModel: ObservableObject { hwConnecting: HwTransferConnecting?, hwFeeRateProvider: (() async -> UInt64?)? = nil, hwAddressProvider: (() async throws -> String)? = nil, - hwTimeouts: (reconnect: Double, compose: Double, sign: Double, broadcast: Double) = (reconnect: 30, compose: 45, sign: 120, broadcast: 120), + hwTimeouts: (compose: Double, sign: Double, broadcast: Double) = (compose: 45, sign: 120, broadcast: 120), coreService: CoreService = .shared, lightningService: LightningService = .shared, sheetViewModel: SheetViewModel = SheetViewModel() @@ -674,7 +677,7 @@ class TransferViewModel: ObservableObject { } catch let error as HwTransferError { self.handleHardwareTransferFailure(error, walletId: walletId) } catch { - if error.isTrezorUserCancellation() { + if error.isHwUserCancellation() { Logger.info("Hardware transfer cancelled on device for '\(walletId)'", context: "TransferViewModel") return } @@ -776,9 +779,9 @@ class TransferViewModel: ObservableObject { case .broadcastConnectivity: Logger.warn("Hardware funding broadcast connectivity failure for '\(walletId)'", context: "TransferViewModel") case .deviceBusy: - Logger.warn("Blocked hardware transfer for locked or busy Trezor '\(walletId)'", context: "TransferViewModel") + Logger.warn("Blocked hardware transfer for locked or busy device '\(walletId)'", context: "TransferViewModel") case .firmwareReconnect: - Logger.warn("Received Trezor firmware error for '\(walletId)'", context: "TransferViewModel") + Logger.warn("Received hardware firmware error for '\(walletId)'", context: "TransferViewModel") case .passphraseMismatch: Logger.warn("Rejected wrong passphrase for hardware wallet '\(walletId)'", context: "TransferViewModel") case let .funding(message): @@ -801,11 +804,11 @@ class TransferViewModel: ObservableObject { hwTransferError = .passphraseMismatch return } - if error.isTrezorDeviceBusy() { - hwTransferError = .deviceBusy + if let vendor = error.hwBusyVendor { + hwTransferError = .deviceBusy(vendor) return } - if error.isTrezorFirmwareError() { + if error.isHwFirmwareError() { hwTransferError = .firmwareReconnect return } @@ -816,7 +819,7 @@ class TransferViewModel: ObservableObject { } clearPendingHwFundingBroadcast() } - hwTransferError = .generic((error as? AppError)?.message ?? error.localizedDescription) + hwTransferError = .generic(HwErrorPresenter.jadeMessage(from: error) ?? (error as? AppError)?.message ?? error.localizedDescription) } // MARK: - Balance Calculation diff --git a/Bitkit/ViewModels/Trezor/HwConnectService.swift b/Bitkit/ViewModels/Trezor/HwConnectService.swift new file mode 100644 index 000000000..e44593e88 --- /dev/null +++ b/Bitkit/ViewModels/Trezor/HwConnectService.swift @@ -0,0 +1,195 @@ +import BitkitCore +import Foundation + +/// Production `HwConnectServicing` over the vendor managers. iOS is Bluetooth only, so discovery runs +/// a Trezor scan and a Jade scan side by side. Pairing goes through `HwWalletManager`, which releases +/// the other vendor's session first and queues the pairing behind the other device operations. +@MainActor +struct HwConnectService: HwConnectServicing { + let trezorManager: TrezorManager + let jadeManager: JadeManager + let hwWalletManager: HwWalletManager + + func scanForDevices() async throws -> [HwNearbyDevice] { + async let trezorScan = scanTrezor() + async let jadeScan = scanJade() + let trezor = await trezorScan + let jade = await jadeScan + return try Self.nearbyDevices(trezor: trezor, jade: jade, isPaired: isPaired) + } + + /// Unpaired devices of either vendor first, then the paired ones. A device that is already paired + /// is only offered once no new one is found, so its passphrase wallets can be added afterwards; + /// otherwise Add Hardware Wallet would search forever on the only device in range. + /// + /// A Jade scan fails quietly (core refuses to scan while it is busy with another request), so the + /// search only fails when the Trezor scan failed and nothing was found. + static func nearbyDevices( + trezor: Result<[HwNearbyDevice], Error>, + jade: Result<[HwNearbyDevice], Error>, + isPaired: (HwNearbyDevice) -> Bool + ) throws -> [HwNearbyDevice] { + let found = ((try? trezor.get()) ?? []) + ((try? jade.get()) ?? []) + if found.isEmpty, case let .failure(error) = trezor { + throw error + } + let (paired, unpaired) = found.partitioned(by: isPaired) + return unpaired + paired + } + + private func scanTrezor() async -> Result<[HwNearbyDevice], Error> { + await trezorManager.startScan() + if let error = trezorManager.error { + return .failure(AppError(message: error, debugMessage: nil)) + } + return .success(trezorManager.devices.map { HwNearbyDevice(source: .trezor($0)) }) + } + + private func scanJade() async -> Result<[HwNearbyDevice], Error> { + do { + let devices = try await jadeManager.scan() + return .success(devices.map { HwNearbyDevice(source: .jade($0)) }) + } catch { + Logger.warn("Jade scan failed: \(error)", context: "HwConnectService") + return .failure(error) + } + } + + /// A Jade counts as paired under the name it advertises too: after a reboot it comes back under a + /// new Bluetooth identifier. + func isPaired(_ device: HwNearbyDevice) -> Bool { + switch device.source { + case let .trezor(trezor): + HwKnownDeviceStorage.isKnown(id: trezor.id, vendor: .trezor) + case let .jade(jade): + jadeManager.hasKnownDevice(deviceId: jade.path, advertisedName: jade.name) + } + } + + func connect(to device: HwNearbyDevice) async throws -> HwConnectResult { + switch device.source { + case let .trezor(trezor): + try await connectTrezor(trezor) + case let .jade(jade): + try await connectJade(jade) + } + } + + /// `TrezorManager.connect` returns nothing and stores its state, so success is read from its + /// `connectedDevice` and `deviceFeatures`, and its error is surfaced otherwise. + private func connectTrezor(_ device: TrezorDeviceInfo) async throws -> HwConnectResult { + try await hwWalletManager.withVendorSession(.trezor) { + await trezorManager.connect(device: device) + guard let connected = trezorManager.connectedDevice, connected.id == device.id else { + throw AppError(message: trezorManager.error ?? t("hardware__connect_error"), debugMessage: nil) + } + let walletId = trezorManager.connectedWalletId + let deviceDefaultName = resolveHwWalletName( + label: connected.label ?? trezorManager.deviceFeatures?.label, + model: connected.model ?? trezorManager.deviceFeatures?.model + ) + return HwConnectResult( + deviceId: connected.id, + walletId: walletId, + name: Self.pairedName( + walletId: walletId, + storedEntries: HwKnownDeviceStorage.loadAll(), + deviceDefaultName: deviceDefaultName + ), + deviceDefaultName: deviceDefaultName + ) + } + } + + private func connectJade(_ device: JadeDeviceInfo) async throws -> HwConnectResult { + let connected = try await hwWalletManager.withVendorSession(.blockstream) { + try await jadeManager.connect(path: device.path) + } + let deviceDefaultName = resolveHwWalletName(label: nil, model: connected.model, vendor: .blockstream) + return HwConnectResult( + deviceId: connected.id, + walletId: connected.walletId, + name: Self.pairedName( + walletId: connected.walletId, + storedEntries: HwKnownDeviceStorage.loadAll(), + deviceDefaultName: deviceDefaultName + ), + deviceDefaultName: deviceDefaultName, + vendor: .blockstream + ) + } + + /// The name to show the paired step under, so re-pairing doesn't appear to rename the wallet. + /// + /// Read from the store rather than from the published wallet list: connecting has just written + /// this identity's entry, and the tiles only catch up on the next device push. A wallet that was + /// removed and is now being re-added is not in that list at all, so its name (restored from a + /// backup or kept through the removal, and adopted onto the entry a moment ago) would fall back + /// to the device's own. Finishing the step then persists that fallback over it. + static func pairedName( + walletId: String?, + storedEntries: [HwKnownDevice], + deviceDefaultName: String + ) -> String { + storedName(walletId: walletId, storedEntries: storedEntries) ?? deviceDefaultName + } + + /// The Bitkit-side name stored for `walletId`, or nil when it has none of its own. + static func storedName(walletId: String?, storedEntries: [HwKnownDevice]) -> String? { + guard let walletId, + let label = storedEntries.first(where: { $0.resolvedWalletId == walletId })?.customLabel, + !label.isEmpty + else { + return nil + } + return label + } + + func connectWithPassphrase(deviceId: String, passphrase: String) async throws -> String { + try await hwWalletManager.connectWithPassphrase(deviceId: deviceId, passphrase: passphrase) + } + + func storedName(forWallet walletId: String) -> String? { + Self.storedName(walletId: walletId, storedEntries: HwKnownDeviceStorage.loadAll()) + } + + func setWalletLabel(walletId: String, label: String) { + hwWalletManager.renameWallet(walletId: walletId, newName: label) + } + + func cancelPairingCode() { + trezorManager.cancelPairingCode() + } + + /// The release runs in its own task, so it carries on after the sheet goes away. + func cancelPendingConnection(to device: HwNearbyDevice) { + switch device.source { + case let .trezor(trezor): + trezorManager.cancelPairingCode() + Task { [trezorManager] in + // A session another Trezor holds was not opened by this connect. + if let connected = trezorManager.connectedDevice, connected.id != trezor.id { + return + } + await trezorManager.disconnectStaleSession(deviceId: trezor.id) + } + case let .jade(jade): + Task { [jadeManager] in + await jadeManager.cancelPendingConnection(deviceId: jade.path) + } + } + } +} + +private extension Array { + /// Splits into (matching, rest), preserving order within each group. + func partitioned(by isMatch: (Element) -> Bool) -> (matching: [Element], rest: [Element]) { + reduce(into: ([Element](), [Element]())) { result, element in + if isMatch(element) { + result.0.append(element) + } else { + result.1.append(element) + } + } + } +} diff --git a/Bitkit/ViewModels/Trezor/HwConnectViewModel.swift b/Bitkit/ViewModels/Trezor/HwConnectViewModel.swift index 079c75d21..acb7e890f 100644 --- a/Bitkit/ViewModels/Trezor/HwConnectViewModel.swift +++ b/Bitkit/ViewModels/Trezor/HwConnectViewModel.swift @@ -6,36 +6,96 @@ import Foundation struct HwConnectResult: Equatable { let deviceId: String let walletId: String? - /// Name of the identity this session opened — its Bitkit-side label once it has one. + /// Name of the identity this session opened: its Bitkit-side label once it has one. let name: String /// The device's own name, from its label/model. A passphrase wallet has no label of its own - /// until the user gives it one, so this is what its step is prefilled with — the label of the + /// until the user gives it one, so this is what its step is prefilled with; the label of the /// identity that happened to be open before it is not its name. let deviceDefaultName: String + let vendor: HwWalletVendor - init(deviceId: String, walletId: String?, name: String, deviceDefaultName: String? = nil) { + init( + deviceId: String, + walletId: String?, + name: String, + deviceDefaultName: String? = nil, + vendor: HwWalletVendor = .trezor + ) { self.deviceId = deviceId self.walletId = walletId self.name = name self.deviceDefaultName = deviceDefaultName ?? name + self.vendor = vendor } } -/// Device discovery/connection seam the Connect Hardware flow drives. `TrezorHwConnectService` is -/// the production adapter over `TrezorManager`; tests inject a fake so the flow can be exercised +/// A hardware wallet a scan found nearby. It carries the record its vendor's scan returned, so +/// connecting dials the device exactly as it was found. +struct HwNearbyDevice: Equatable, Identifiable { + enum Source: Equatable { + case trezor(TrezorDeviceInfo) + case jade(JadeDeviceInfo) + } + + let source: Source + + var vendor: HwWalletVendor { + switch source { + case .trezor: .trezor + case .jade: .blockstream + } + } + + /// A Jade is known by its path until connecting reads its identity from the device. + var id: String { + switch source { + case let .trezor(device): device.id + case let .jade(device): device.path + } + } + + var path: String { + switch source { + case let .trezor(device): device.path + case let .jade(device): device.path + } + } + + var name: String? { + switch source { + case let .trezor(device): device.name + case let .jade(device): device.name + } + } + + /// A Jade reports its model only once connected. + var model: String? { + switch source { + case let .trezor(device): device.model + case .jade: nil + } + } +} + +/// Device discovery/connection seam the Connect Hardware flow drives. `HwConnectService` is the +/// production adapter over the vendor managers; tests inject a fake so the flow can be exercised /// without the BLE stack. @MainActor protocol HwConnectServicing { - /// Reachable devices, unpaired first. Discovery normally hides paired devices; one is offered as - /// a fallback so its passphrase wallets can be added after the initial pairing. - func scanForDevices() async throws -> [TrezorDeviceInfo] - func connect(to device: TrezorDeviceInfo) async throws -> HwConnectResult + /// Reachable devices of every vendor, unpaired first. Discovery normally hides paired devices; one + /// is offered as a fallback so its passphrase wallets can be added after the initial pairing. + func scanForDevices() async throws -> [HwNearbyDevice] + func connect(to device: HwNearbyDevice) async throws -> HwConnectResult /// Opens the hidden wallet the passphrase unlocks and starts watching it; returns its wallet id. func connectWithPassphrase(deviceId: String, passphrase: String) async throws -> String /// The Bitkit-side name already stored for `walletId`, or nil when it has none. func storedName(forWallet walletId: String) -> String? func setWalletLabel(walletId: String, label: String) func cancelPairingCode() + /// Stops a connect in flight to `device` and releases its session. A task cancel alone never + /// reaches a Jade waiting for its PIN, and a Trezor session opened for a pairing the user left + /// must not linger. + func cancelPendingConnection(to device: HwNearbyDevice) } /// Backs the Connect Hardware bottom-sheet flow (Intro → Searching → Found → Paired). Drives device @@ -67,7 +127,11 @@ final class HwConnectViewModel { private(set) var phase: Phase = .intro private(set) var isConnecting = false - private(set) var foundDevice: TrezorDeviceInfo? + private(set) var foundDevice: HwNearbyDevice? + /// Vendor of the device being paired, which decides the copy, illustration and steps shown. + private(set) var vendor: HwWalletVendor = .trezor + /// A Jade being connected is waiting for its PIN on the device. + private(set) var isUnlocking = false private(set) var foundDeviceModel = "" private(set) var pairedDeviceId: String? /// Identity paired on `pairedDeviceId`; resolved once its watch-only wallet is known. @@ -136,11 +200,12 @@ final class HwConnectViewModel { } } - private func onDeviceFound(_ device: TrezorDeviceInfo) { + private func onDeviceFound(_ device: HwNearbyDevice) { searchTask?.cancel() searchTask = nil foundDevice = device - foundDeviceModel = resolveHwWalletName(label: nil, model: device.model) + vendor = device.vendor + foundDeviceModel = resolveHwWalletName(label: nil, model: device.model, vendor: device.vendor) errorMessage = nil phase = .found } @@ -173,6 +238,8 @@ final class HwConnectViewModel { private func onConnected(_ result: HwConnectResult) { isConnecting = false + isUnlocking = false + vendor = result.vendor pairedDeviceId = result.deviceId // The device may hold several identities, so take the one this session opened rather than // any wallet sharing its transport id. @@ -189,17 +256,39 @@ final class HwConnectViewModel { private func onConnectFailed(_ error: Error) { isConnecting = false - errorMessage = (error as? AppError)?.message ?? t("hardware__connect_error") + isUnlocking = false + errorMessage = connectErrorMessage(for: error) phase = .found } - /// The device asked for its one-time pairing code mid-connect; surface the inline step. Only - /// while a connect is in flight, so a stray flag can't hijack the flow. + /// A Jade failure keeps its own copy (a wrong PIN, the pinserver, a stale Bluetooth bond), which + /// the generic connect message would hide. + private func connectErrorMessage(for error: Error) -> String { + guard vendor == .blockstream else { + return (error as? AppError)?.message ?? t("hardware__connect_error") + } + if let jadeMessage = HwErrorPresenter.jadeMessage(from: error) { + return jadeMessage + } + if let appError = error as? AppError, !appError.isGeneric { + return appError.message + } + return t("hardware__connect_error") + } + + /// The Trezor asked for its one-time pairing code mid-connect; surface the inline step. Only + /// while a Trezor connect is in flight, so a stray flag can't hijack the flow. func onPairingCodeRequested() { - guard isConnecting else { return } + guard isConnecting, vendor == .trezor else { return } phase = .pairCode } + /// The device started or stopped waiting for its PIN. The hint only belongs to a Jade this flow is + /// connecting; a background reconnect never unlocks. + func onUnlockingChanged(_ isDeviceUnlocking: Bool) { + isUnlocking = isDeviceUnlocking && isConnecting && vendor == .blockstream + } + // MARK: - Paired /// The paired wallet's aggregated balance/name landed; reflect it on the Paired step. @@ -249,6 +338,7 @@ final class HwConnectViewModel { /// Each identity is labelled on its own paired step, so the one being left is persisted before /// the next passphrase wallet takes over the field. func onPassphraseClick() { + guard vendor.supportsPassphraseWallets else { return } persistLabel() passphraseInput = "" errorMessage = nil @@ -270,7 +360,11 @@ final class HwConnectViewModel { /// The passphrase is dropped from state as soon as the device answers: it lives in the Trezor /// session, never in Bitkit. func onPassphraseSubmit() { - guard let deviceId = pairedDeviceId, !passphraseInput.isEmpty, connectTask == nil else { return } + guard vendor.supportsPassphraseWallets, + let deviceId = pairedDeviceId, + !passphraseInput.isEmpty, + connectTask == nil + else { return } let passphrase = passphraseInput isSubmittingPassphrase = true errorMessage = nil @@ -341,12 +435,22 @@ final class HwConnectViewModel { // MARK: - Teardown - /// Cancels a pending connect/pairing-code request when the user backs out mid-connect. + /// Cancels a pending connect when the user backs out mid-connect, releasing the device it was + /// opening. Nothing is released when no connect is in flight, so leaving the sheet after pairing + /// keeps the session the flow just opened. func cancelConnect() { + let wasConnecting = connectTask != nil || isConnecting connectTask?.cancel() connectTask = nil - service.cancelPairingCode() + if wasConnecting { + if let foundDevice { + service.cancelPendingConnection(to: foundDevice) + } else { + service.cancelPairingCode() + } + } isConnecting = false + isUnlocking = false } /// Called when the sheet is dismissed: stop scanning/connecting and drop any pending pairing. @@ -358,102 +462,3 @@ final class HwConnectViewModel { isSubmittingPassphrase = false } } - -/// Production `HwConnectServicing` over `TrezorManager`. iOS is BLE-only, so discovery is a single -/// BLE scan filtered to unpaired devices; `connect(to:)` reports success by inspecting the manager's -/// `connectedDevice`/`deviceFeatures` (its own `connect` returns void and stores state) and surfaces -/// the manager's error otherwise. -@MainActor -struct TrezorHwConnectService: HwConnectServicing { - let trezorManager: TrezorManager - let hwWalletManager: HwWalletManager - - func scanForDevices() async throws -> [TrezorDeviceInfo] { - await trezorManager.startScan() - if let error = trezorManager.error { - throw AppError(message: error, debugMessage: nil) - } - // A device that is already paired is only offered once no new one is found, so its - // passphrase wallets can be added afterwards — otherwise Add Hardware Wallet would search - // forever on the only device in range. - let (paired, unpaired) = trezorManager.devices.partitioned { TrezorKnownDeviceStorage.isKnown(id: $0.id) } - return unpaired + paired - } - - func connect(to device: TrezorDeviceInfo) async throws -> HwConnectResult { - await trezorManager.connect(device: device) - guard let connected = trezorManager.connectedDevice, connected.id == device.id else { - throw AppError(message: trezorManager.error ?? t("hardware__connect_error"), debugMessage: nil) - } - let walletId = trezorManager.connectedWalletId - let deviceDefaultName = resolveHwWalletName( - label: connected.label ?? trezorManager.deviceFeatures?.label, - model: connected.model ?? trezorManager.deviceFeatures?.model - ) - return HwConnectResult( - deviceId: connected.id, - walletId: walletId, - name: Self.pairedName( - walletId: walletId, - storedEntries: TrezorKnownDeviceStorage.loadAll(), - deviceDefaultName: deviceDefaultName - ), - deviceDefaultName: deviceDefaultName - ) - } - - /// The name to show the paired step under, so re-pairing doesn't appear to rename the wallet. - /// - /// Read from the store rather than from the published wallet list: connecting has just written - /// this identity's entry, and the tiles only catch up on the next device push. A wallet that was - /// removed and is now being re-added is not in that list at all, so its name — restored from a - /// backup or kept through the removal, and adopted onto the entry a moment ago — would fall back - /// to the device's own. Finishing the step then persists that fallback over it. - static func pairedName( - walletId: String?, - storedEntries: [TrezorKnownDevice], - deviceDefaultName: String - ) -> String { - storedName(walletId: walletId, storedEntries: storedEntries) ?? deviceDefaultName - } - - /// The Bitkit-side name stored for `walletId`, or nil when it has none of its own. - static func storedName(walletId: String?, storedEntries: [TrezorKnownDevice]) -> String? { - guard let walletId, - let label = storedEntries.first(where: { $0.resolvedWalletId == walletId })?.customLabel, - !label.isEmpty - else { - return nil - } - return label - } - - func connectWithPassphrase(deviceId: String, passphrase: String) async throws -> String { - try await hwWalletManager.connectWithPassphrase(deviceId: deviceId, passphrase: passphrase) - } - - func storedName(forWallet walletId: String) -> String? { - Self.storedName(walletId: walletId, storedEntries: TrezorKnownDeviceStorage.loadAll()) - } - - func setWalletLabel(walletId: String, label: String) { - trezorManager.renameWallet(walletId: walletId, newName: label) - } - - func cancelPairingCode() { - trezorManager.cancelPairingCode() - } -} - -private extension Array { - /// Splits into (matching, rest), preserving order within each group. - func partitioned(by isMatch: (Element) -> Bool) -> (matching: [Element], rest: [Element]) { - reduce(into: ([Element](), [Element]())) { result, element in - if isMatch(element) { - result.0.append(element) - } else { - result.1.append(element) - } - } - } -} diff --git a/Bitkit/Views/Sheets/HardwareConnect/HwFoundView.swift b/Bitkit/Views/Sheets/HardwareConnect/HwFoundView.swift index 1f7e63b60..58ffec1cc 100644 --- a/Bitkit/Views/Sheets/HardwareConnect/HwFoundView.swift +++ b/Bitkit/Views/Sheets/HardwareConnect/HwFoundView.swift @@ -1,10 +1,13 @@ import SwiftUI /// Found step: a discovered device with a Connect confirmation. Connect shows a spinner and -/// surfaces an inline error on failure. +/// surfaces an inline error on failure. A Jade waiting for its PIN adds a hint to enter it on the +/// device; Cancel stays available throughout, so the user can back out of the PIN wait. struct HwFoundView: View { let deviceModel: String + var vendor: HwWalletVendor = .trezor let isConnecting: Bool + var isUnlocking = false let errorMessage: String? let onConnect: () -> Void let onCancel: () -> Void @@ -15,10 +18,17 @@ struct HwFoundView: View { .padding(.horizontal, 16) VStack(alignment: .leading, spacing: 8) { - DisplayText(t("hardware__found_header"), accentColor: .blueAccent) + DisplayText(vendor.foundHeader, accentColor: .blueAccent) BodyMText(t("hardware__found_text", variables: ["model": deviceModel])) + if isUnlocking { + BodySText(t("hardware__jade_enter_pin"), textColor: .textPrimary) + .padding(.top, 8) + .transition(.opacity) + .accessibilityIdentifier("HwFoundUnlockHint") + } + if let errorMessage { BodyMText(errorMessage, textColor: .redAccent) .padding(.top, 8) @@ -27,8 +37,9 @@ struct HwFoundView: View { } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 32) + .animation(.easeInOut(duration: 0.2), value: isUnlocking) - Image("trezor-device") + Image(vendor.deviceImageName) .resizable() .scaledToFit() .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -64,3 +75,18 @@ struct HwFoundView: View { .background(Color.black) .preferredColorScheme(.dark) } + +#Preview("Jade unlocking") { + HwFoundView( + deviceModel: "Jade", + vendor: .blockstream, + isConnecting: true, + isUnlocking: true, + errorMessage: nil, + onConnect: {}, + onCancel: {} + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black) + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Sheets/HardwareConnect/HwPairedView.swift b/Bitkit/Views/Sheets/HardwareConnect/HwPairedView.swift index c41365bfe..dccd1489e 100644 --- a/Bitkit/Views/Sheets/HardwareConnect/HwPairedView.swift +++ b/Bitkit/Views/Sheets/HardwareConnect/HwPairedView.swift @@ -2,13 +2,15 @@ import SwiftUI /// Paired step, shared by the standard wallet and by a passphrase wallet found afterwards: both /// confirm the watched balance and its Bitkit-side label over the coin illustration, and both can add -/// another passphrase wallet from the same device before finishing. +/// another passphrase wallet from the same device before finishing. A Jade holds one wallet per +/// device, so its step offers Finish alone. struct HwPairedView: View { let deviceName: String let balanceSats: UInt64 @Binding var labelText: String let onPassphrase: () -> Void let onFinish: () -> Void + var vendor: HwWalletVendor = .trezor /// Set for the step confirming a passphrase wallet, which says so in its own words. var isPassphraseWallet = false @@ -16,7 +18,7 @@ struct HwPairedView: View { private let coinsWidthRatio: CGFloat = 256.0 / 375.0 private var header: String { - isPassphraseWallet ? t("hardware__passphrase_paired_header") : t("hardware__paired_header") + isPassphraseWallet ? t("hardware__passphrase_paired_header") : vendor.pairedHeader } private var text: String { @@ -67,10 +69,12 @@ struct HwPairedView: View { Spacer(minLength: 0) HStack(spacing: 16) { - CustomButton(title: t("hardware__passphrase_button"), variant: .secondary, shouldExpand: true) { - onPassphrase() + if vendor.supportsPassphraseWallets { + CustomButton(title: t("hardware__passphrase_button"), variant: .secondary, shouldExpand: true) { + onPassphrase() + } + .accessibilityIdentifier("HardwareWalletPairedPassphrase") } - .accessibilityIdentifier("HardwareWalletPairedPassphrase") CustomButton(title: t("hardware__paired_finish"), shouldExpand: true) { onFinish() @@ -122,6 +126,21 @@ private struct HwPairedBalanceView: View { .preferredColorScheme(.dark) } +#Preview("Jade") { + HwPairedView( + deviceName: "Jade", + balanceSats: 10_562_411, + labelText: .constant("Jade"), + onPassphrase: {}, + onFinish: {}, + vendor: .blockstream + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black) + .environmentObject(CurrencyViewModel()) + .preferredColorScheme(.dark) +} + #Preview("Passphrase funds found") { HwPairedView( deviceName: "Trezor Safe 3", diff --git a/Bitkit/Views/Sheets/HardwareConnectSheet.swift b/Bitkit/Views/Sheets/HardwareConnectSheet.swift index 0d9628891..ec27c64c0 100644 --- a/Bitkit/Views/Sheets/HardwareConnectSheet.swift +++ b/Bitkit/Views/Sheets/HardwareConnectSheet.swift @@ -9,12 +9,13 @@ struct HardwareConnectSheetItem: SheetItem { /// Entry point for the Connect Hardware flow. struct HardwareConnectSheet: View { @Environment(TrezorManager.self) private var trezorManager + @Environment(JadeManager.self) private var jadeManager @Environment(HwWalletManager.self) private var hwWalletManager let config: HardwareConnectSheetItem var body: some View { HardwareConnectFlow( - service: TrezorHwConnectService(trezorManager: trezorManager, hwWalletManager: hwWalletManager), + service: HwConnectService(trezorManager: trezorManager, jadeManager: jadeManager, hwWalletManager: hwWalletManager), config: config ) } @@ -22,6 +23,7 @@ struct HardwareConnectSheet: View { private struct HardwareConnectFlow: View { @Environment(TrezorManager.self) private var trezorManager + @Environment(JadeManager.self) private var jadeManager @Environment(HwWalletManager.self) private var hwWalletManager @EnvironmentObject private var app: AppViewModel @EnvironmentObject private var sheets: SheetViewModel @@ -51,6 +53,9 @@ private struct HardwareConnectFlow: View { viewModel.onPairingCodeRequested() } } + .onChange(of: jadeManager.isUnlocking) { _, isUnlocking in + viewModel.onUnlockingChanged(isUnlocking) + } .onChange(of: viewModel.isConnecting) { _, connecting in sheets.hardwareConnectHandlesPairing = connecting } @@ -86,7 +91,9 @@ private struct HardwareConnectFlow: View { case .found: HwFoundView( deviceModel: foundDeviceModel, + vendor: viewModel.vendor, isConnecting: viewModel.isConnecting, + isUnlocking: viewModel.isUnlocking, errorMessage: viewModel.errorMessage, onConnect: viewModel.onConnect, onCancel: { @@ -100,7 +107,8 @@ private struct HardwareConnectFlow: View { balanceSats: viewModel.balanceSats, labelText: labelBinding, onPassphrase: viewModel.onPassphraseClick, - onFinish: viewModel.onFinish + onFinish: viewModel.onFinish, + vendor: viewModel.vendor ) case .passphrase: HwPassphraseView( @@ -117,6 +125,7 @@ private struct HardwareConnectFlow: View { labelText: labelBinding, onPassphrase: viewModel.onPassphraseClick, onFinish: viewModel.onFinish, + vendor: viewModel.vendor, isPassphraseWallet: true ) case .pairCode: @@ -182,7 +191,7 @@ private struct HardwareConnectFlow: View { // MARK: - Helpers private var foundDeviceModel: String { - viewModel.foundDeviceModel.isEmpty ? t("hardware__device_model_trezor") : viewModel.foundDeviceModel + viewModel.foundDeviceModel.isEmpty ? viewModel.vendor.modelName : viewModel.foundDeviceModel } private var labelBinding: Binding { diff --git a/Bitkit/Views/Sheets/RenameHardwareWalletSheet.swift b/Bitkit/Views/Sheets/RenameHardwareWalletSheet.swift index 8f5282622..e84cdf3ee 100644 --- a/Bitkit/Views/Sheets/RenameHardwareWalletSheet.swift +++ b/Bitkit/Views/Sheets/RenameHardwareWalletSheet.swift @@ -13,10 +13,10 @@ struct RenameHardwareWalletSheetItem: SheetItem, Equatable { } /// Renames a paired hardware wallet: a single NAME field pre-filled with the current name and a Save -/// button. Persists the custom name via `TrezorManager.renameWallet`, which re-pushes the device -/// snapshot so `HwWallet.name` updates everywhere. +/// button. Persists the custom name via `HwWalletManager.renameWallet`, which hands it to the wallet's +/// vendor; that re-pushes the device snapshot so `HwWallet.name` updates everywhere. struct RenameHardwareWalletSheet: View { - @Environment(TrezorManager.self) private var trezorManager + @Environment(HwWalletManager.self) private var hwWalletManager @EnvironmentObject private var sheets: SheetViewModel let config: RenameHardwareWalletSheetItem @@ -67,7 +67,7 @@ struct RenameHardwareWalletSheet: View { private func save() { guard !trimmedName.isEmpty else { return } - trezorManager.renameWallet(walletId: config.walletId, newName: trimmedName) + hwWalletManager.renameWallet(walletId: config.walletId, newName: trimmedName) sheets.hideSheet() } } diff --git a/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift b/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift index f7730e4a0..ebed2f492 100644 --- a/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift +++ b/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift @@ -1,8 +1,8 @@ import BitkitCore import SwiftUI -/// "Sign with your device" — shows the Blocktank order fees and asks the user to sign the funding -/// transaction on the Trezor. Reuses the existing Learn More / Advanced controls; on-device signing +/// "Sign with your device": shows the Blocktank order fees and asks the user to sign the funding +/// transaction on the hardware wallet. Reuses the existing Learn More / Advanced controls; on-device signing /// replaces the local swipe-to-pay. Advances to the Signed screen on success. struct SpendingHwSign: View { let walletId: String @@ -10,6 +10,7 @@ struct SpendingHwSign: View { @EnvironmentObject var app: AppViewModel @EnvironmentObject var navigation: NavigationViewModel @EnvironmentObject var transfer: TransferViewModel + @Environment(HwWalletManager.self) private var hwWalletManager var body: some View { if let order = transfer.uiState.order { @@ -31,10 +32,10 @@ struct SpendingHwSign: View { NavigationBar(title: t("lightning__transfer__nav_title")) .padding(.bottom, 16) - // The Trezor is a background visual behind the content (including the bottom button), so + // The device is a background visual behind the content (including the bottom button), so // it renders at its natural aspect and doesn't get squeezed by the vertical layout. ZStack(alignment: .top) { - trezorIllustration + deviceIllustration belowNav(order: order) } @@ -142,11 +143,7 @@ struct SpendingHwSign: View { Spacer() CustomButton( - title: t( - transfer.hwSpending.hasPendingBroadcast - ? "common__retry" - : "lightning__transfer_hw__open_connect" - ), + title: transfer.hwSpending.hasPendingBroadcast ? t("common__retry") : vendor.transferSignButtonTitle, isDisabled: transfer.hwSpending.isSigning, isLoading: transfer.hwSpending.isSigning ) { @@ -157,10 +154,14 @@ struct SpendingHwSign: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } - private var trezorIllustration: some View { + private var vendor: HwWalletVendor { + hwWalletManager.wallets.first { $0.id == walletId }?.vendor ?? .trezor + } + + private var deviceIllustration: some View { GeometryReader { geo in let side = geo.size.width * illustrationWidthRatio - Image("trezor-card") + Image(vendor.signImageName) .resizable() .aspectRatio(contentMode: .fit) .frame(width: side, height: side) diff --git a/Bitkit/Views/Trezor/TrezorDeviceListView.swift b/Bitkit/Views/Trezor/TrezorDeviceListView.swift index 93c150f00..74ff614c7 100644 --- a/Bitkit/Views/Trezor/TrezorDeviceListView.swift +++ b/Bitkit/Views/Trezor/TrezorDeviceListView.swift @@ -166,7 +166,7 @@ struct TrezorDeviceListView: View { } } - private func connectToKnownDevice(_ knownDevice: TrezorKnownDevice) { + private func connectToKnownDevice(_ knownDevice: HwKnownDevice) { connectingDevicePath = knownDevice.path Task { diff --git a/Bitkit/Views/Wallets/HardwareWalletScreen.swift b/Bitkit/Views/Wallets/HardwareWalletScreen.swift index 008c27480..813a507e5 100644 --- a/Bitkit/Views/Wallets/HardwareWalletScreen.swift +++ b/Bitkit/Views/Wallets/HardwareWalletScreen.swift @@ -95,18 +95,18 @@ struct HardwareWalletScreen: View { } .padding(.horizontal) .background(alignment: .topTrailing) { - trezorIllustration + deviceIllustration(for: wallet.vendor) // Align the device's top with the balance header and bleed off the trailing edge. .offset(x: 118, y: ScreenLayout.topPaddingWithoutSafeArea) } } - /// The shared upright Trezor device, transformed to match the Figma "Wallet Overview" visual: + /// The vendor's shared upright device, transformed to match the Figma "Wallet Overview" visual: /// cover-filled into a square, rotated -15°, and clipped to a 256pt box that bleeds off the - /// screen's trailing edge. Reuses the generic `trezor-device` asset — no screen-specific crop is - /// baked in, so the same image can be adapted elsewhere with SwiftUI. - private var trezorIllustration: some View { - Image("trezor-device") + /// screen's trailing edge. Reuses the generic device asset (no screen-specific crop is baked + /// in), so the same image can be adapted elsewhere with SwiftUI. + private func deviceIllustration(for vendor: HwWalletVendor) -> some View { + Image(vendor.deviceImageName) .resizable() .aspectRatio(contentMode: .fill) .frame(width: 268, height: 268) diff --git a/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift b/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift index 1b675ebd8..1c6a889fc 100644 --- a/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift +++ b/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift @@ -36,7 +36,7 @@ struct ReceiveEdit: View { return .auto case .spending: return .spending - case .trezor: + case .hardware: return .savings } } diff --git a/Bitkit/Views/Wallets/Receive/ReceiveQr.swift b/Bitkit/Views/Wallets/Receive/ReceiveQr.swift index 0417ca1c5..d1219eef6 100644 --- a/Bitkit/Views/Wallets/Receive/ReceiveQr.swift +++ b/Bitkit/Views/Wallets/Receive/ReceiveQr.swift @@ -25,6 +25,7 @@ struct ReceiveQr: View { @State private var isVerifyingPassphrase = false @State private var verifyTask: Task? @State private var passphraseTask: Task? + @State private var engagedSession = HwEngagedSession() init( navigationPath: Binding<[ReceiveRoute]>, @@ -49,7 +50,7 @@ struct ReceiveQr: View { } enum ReceiveTab: CaseIterable, CustomStringConvertible { - case savings, unified, spending, trezor + case savings, unified, spending, hardware var description: String { switch self { @@ -59,8 +60,8 @@ struct ReceiveQr: View { return "Auto" case .spending: return t("lightning__spending") - case .trezor: - return t("hardware__device_model_trezor") + case .hardware: + return t("hardware__receive_tab_hardware") } } } @@ -76,11 +77,16 @@ struct ReceiveQr: View { } if selectedHardwareWalletId != nil { - items.insert(TabItem(.trezor), at: 0) + items.insert(TabItem(.hardware, label: selectedHardwareWallet?.vendor.modelName), at: 0) } return items } + private var selectedHardwareWallet: HwWallet? { + guard let walletId = selectedHardwareWalletId else { return nil } + return hwWalletManager.wallets.first { $0.id == walletId } + } + private var selectedHardwareWalletId: String? { if let hardwareWalletId { return hardwareWalletId @@ -126,7 +132,7 @@ struct ReceiveQr: View { VStack(spacing: 0) { TabView(selection: selectedTabBinding) { if selectedHardwareWalletId != nil { - tabContent(for: .trezor) + tabContent(for: .hardware) } tabContent(for: .savings) @@ -160,7 +166,7 @@ struct ReceiveQr: View { } } else if showDetails { VStack(spacing: 16) { - if selectedTab == .trezor { + if selectedTab == .hardware { CustomButton( title: t("hardware__verify_address"), variant: .secondary, @@ -188,7 +194,7 @@ struct ReceiveQr: View { CustomButton( title: t("common__show_details"), variant: .tertiary, - isDisabled: selectedTab == .trezor && displayedHardwareAddress == nil + isDisabled: selectedTab == .hardware && displayedHardwareAddress == nil ) { showDetails.toggle() } @@ -199,11 +205,12 @@ struct ReceiveQr: View { } .onChange(of: selectedTab) { _, newTab in showDetails = false - if newTab == .trezor { + if newTab == .hardware { Task { await loadHardwareAddress() } } else { verifyTask?.cancel() verifyTask = nil + engagedSession.release(through: hwWalletManager) } } .onAppear { @@ -222,10 +229,17 @@ struct ReceiveQr: View { ) } .task(id: selectedHardwareWalletId) { - if selectedTab == .trezor { + if selectedTab == .hardware { await loadHardwareAddress() } } + .onChange(of: selectedHardwareWalletId) { + abandonHardwareVerification() + } + .onChange(of: displayedHardwareAddress?.address) { previousAddress, _ in + guard previousAddress != nil else { return } + abandonHardwareVerification() + } .task { do { try await withThrowingTaskGroup(of: Void.self) { group in @@ -258,6 +272,7 @@ struct ReceiveQr: View { verifyTask = nil passphraseTask?.cancel() passphraseTask = nil + engagedSession.release(through: hwWalletManager) } } @@ -309,7 +324,7 @@ struct ReceiveQr: View { @ViewBuilder func qrContent(for tab: ReceiveTab) -> some View { - if tab == .trezor { + if tab == .hardware { if let hardwareAddress = displayedHardwareAddress { let uri = Bip21Utils.hardwareInvoice( address: hardwareAddress.address, @@ -322,7 +337,7 @@ struct ReceiveQr: View { accentColor: .blueAccent, navigationPath: $navigationPath, copyValue: uri.contains("?") ? uri : hardwareAddress.address, - editRoute: .edit(tab: .trezor, onchainOnly: true) + editRoute: .edit(tab: .hardware, onchainOnly: true) ) } else if hardwareAddressLoadFailed { VStack(spacing: 16) { @@ -374,7 +389,7 @@ struct ReceiveQr: View { imageAsset: "ln", accentColor: .purpleAccent ) - case .trezor: + case .hardware: return (uri: "", imageAsset: "btc-circle-blue", accentColor: .blueAccent) } } @@ -472,7 +487,7 @@ struct ReceiveQr: View { ) ) } - case .trezor: + case .hardware: if let hardwareAddress = displayedHardwareAddress { pairs.append( CopyAddressPair( @@ -492,7 +507,7 @@ struct ReceiveQr: View { addresses: addressPairs, navigationPath: $navigationPath, editRoute: editRoute(for: tab), - accentColor: tab == .trezor ? .blueAccent : nil + accentColor: tab == .hardware ? .blueAccent : nil ) } @@ -501,7 +516,7 @@ struct ReceiveQr: View { } private func editRoute(for tab: ReceiveTab) -> ReceiveRoute? { - .edit(tab: tab, onchainOnly: tab == .trezor, replacesCurrentQr: tab == .spending && cjitInvoice != nil) + .edit(tab: tab, onchainOnly: tab == .hardware, replacesCurrentQr: tab == .spending && cjitInvoice != nil) } private struct ImageConfig { @@ -562,15 +577,15 @@ struct ReceiveQr: View { isVerifyingHardwareAddress = true defer { isVerifyingHardwareAddress = false } do { - try await hwWalletManager.verifyReceiveAddress(walletId: walletId, receiveAddress: hardwareAddress) + try await engagedSession.perform(walletId: walletId) { + try await hwWalletManager.verifyReceiveAddress(walletId: walletId, receiveAddress: hardwareAddress) + } } catch is CancellationError { return } catch HwPassphraseError.required { isPassphraseRequired = true } catch { - if !error.isTrezorUserCancellation() { - app.toast(error) - } + showHardwareVerifyError(error) } } @@ -591,18 +606,18 @@ struct ReceiveQr: View { passphraseTask = nil } do { - try await hwWalletManager.reconnectWithPassphrase(walletId: walletId, passphrase: passphrase) - guard isPassphraseRequired else { throw CancellationError() } - isPassphraseRequired = false - await verifyHardwareAddress() + try await engagedSession.perform(walletId: walletId) { + try await hwWalletManager.reconnectWithPassphrase(walletId: walletId, passphrase: passphrase) + guard isPassphraseRequired else { throw CancellationError() } + isPassphraseRequired = false + await verifyHardwareAddress() + } } catch is CancellationError { return } catch HwPassphraseError.mismatch { app.toast(HwTransferError.passphraseMismatch) } catch { - if !error.isTrezorUserCancellation() { - app.toast(error) - } + showHardwareVerifyError(error) } } } @@ -611,6 +626,30 @@ struct ReceiveQr: View { passphraseTask?.cancel() isPassphraseRequired = false isVerifyingPassphrase = false + engagedSession.release(through: hwWalletManager) + } + + /// The wallet or address on screen changed, so a verification still in progress is for one no + /// longer shown. + private func abandonHardwareVerification() { + engagedSession.invalidate(through: hwWalletManager) + verifyTask?.cancel() + verifyTask = nil + passphraseTask?.cancel() + if isPassphraseRequired { + dismissPassphrase() + } + } + + private func showHardwareVerifyError(_ error: Error) { + if error.isHwUserCancellation() { + return + } + if let vendor = error.hwBusyVendor { + app.toast(HwTransferError.deviceBusy(vendor)) + } else { + app.toast(error) + } } /// Strips the lightning parameter from a BIP21 URI while keeping other parameters diff --git a/Bitkit/Views/Wallets/Send/HwSendSignView.swift b/Bitkit/Views/Wallets/Send/HwSendSignView.swift index 47d13c8e2..fb942e572 100644 --- a/Bitkit/Views/Wallets/Send/HwSendSignView.swift +++ b/Bitkit/Views/Wallets/Send/HwSendSignView.swift @@ -19,7 +19,7 @@ struct HwSendSignView: View { VStack(alignment: .leading, spacing: 0) { SheetHeader( title: t("hardware__send_sign_title"), - showBackButton: !hwSend.isSigning && !hwSend.isBroadcastUnresolved + showBackButton: hwSend.canLeave ) if let invoice = app.scannedOnchainInvoice { @@ -41,7 +41,7 @@ struct HwSendSignView: View { Spacer(minLength: 16) - Image("trezor-card") + Image(vendor.signImageName) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 256, height: 256) @@ -52,7 +52,7 @@ struct HwSendSignView: View { Spacer(minLength: 0) CustomButton( - title: t(hwSend.hasPendingBroadcast ? "common__retry" : "hardware__send_open_connect"), + title: hwSend.hasPendingBroadcast ? t("common__retry") : vendor.sendSignButtonTitle, isDisabled: hwSend.isSigning, isLoading: hwSend.isSigning ) { @@ -84,6 +84,10 @@ struct HwSendSignView: View { .accessibilityIdentifier("HardwareSendSign") } + private var vendor: HwWalletVendor { + hwWalletManager.wallets.first { $0.id == hwSend.walletId }?.vendor ?? .trezor + } + private var passphrasePromptBinding: Binding { Binding( get: { hwSend.isPassphraseRequired }, @@ -174,12 +178,12 @@ struct HwSendSignView: View { } private func showHardwareError(_ error: Error) { - if error.isTrezorUserCancellation() { + if error.isHwUserCancellation() { return } - if error.isTrezorDeviceBusy() { - app.toast(HwTransferError.deviceBusy) - } else if error.isTrezorFirmwareError() { + if let vendor = error.hwBusyVendor { + app.toast(HwTransferError.deviceBusy(vendor)) + } else if error.isHwFirmwareError() { app.toast(HwTransferError.firmwareReconnect) } else if hwSend.hasPendingBroadcast, error.isBroadcastConnectivityFailure() { app.toast(HwTransferError.broadcastConnectivity) diff --git a/Bitkit/Views/Wallets/Send/SendAmountView.swift b/Bitkit/Views/Wallets/Send/SendAmountView.swift index 11cf84e39..65d2d449f 100644 --- a/Bitkit/Views/Wallets/Send/SendAmountView.swift +++ b/Bitkit/Views/Wallets/Send/SendAmountView.swift @@ -61,7 +61,7 @@ struct SendAmountView: View { t("wallet__savings__title") case let .hardware(walletId): hwWalletManager.wallets.first(where: { $0.id == walletId })?.name - ?? t("hardware__device_model_trezor") + ?? hwWalletManager.vendor(walletId: walletId).modelName } } diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 8c4385039..147602a40 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -77,7 +77,7 @@ struct SendConfirmationView: View { private var hardwareWalletName: String? { guard let walletId = hwSend.walletId else { return nil } return hwWalletManager.wallets.first(where: { $0.id == walletId })?.name - ?? t("hardware__device_model_trezor") + ?? hwWalletManager.vendor(walletId: walletId).modelName } private var isHardwarePreparationLoading: Bool { diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index dcdd01f62..3fc48893c 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -197,7 +197,7 @@ struct SendSheet: View { } } .animation(.easeInOut(duration: 0.3), value: shouldShowSyncOverlay) - .interactiveDismissDisabled(hwSend.isSigning || hwSend.isBroadcastUnresolved) + .interactiveDismissDisabled(!hwSend.canLeave) .sheet(isPresented: reconnectPairingBinding) { HardwarePairingSheet(config: HardwarePairingSheetItem()) } diff --git a/BitkitTests/BlockingQueueTests.swift b/BitkitTests/BlockingQueueTests.swift new file mode 100644 index 000000000..87ca70bde --- /dev/null +++ b/BitkitTests/BlockingQueueTests.swift @@ -0,0 +1,66 @@ +@testable import Bitkit +import XCTest + +final class BlockingQueueTests: XCTestCase { + func testPollReturnsItemsInFifoOrder() { + let queue = BlockingQueue() + queue.offer(1) + queue.offer(2) + queue.offer(3) + + XCTAssertEqual(queue.poll(timeout: 0), 1) + XCTAssertEqual(queue.poll(timeout: 0), 2) + XCTAssertEqual(queue.poll(timeout: 0), 3) + } + + func testPollTimesOutWithNil() { + let queue = BlockingQueue() + let start = Date() + + XCTAssertNil(queue.poll(timeout: 0.05)) + XCTAssertGreaterThanOrEqual(Date().timeIntervalSince(start), 0.04) + } + + func testPollWakesWhenAnItemArrives() { + let queue = BlockingQueue() + DispatchQueue.global().asyncAfter(deadline: .now() + 0.05) { queue.offer(7) } + + XCTAssertEqual(queue.poll(timeout: 5), 7) + } + + func testFailWakesABlockedPoll() { + let queue = BlockingQueue() + let returned = expectation(description: "blocked poll returned") + DispatchQueue.global().async { + XCTAssertNil(queue.poll(timeout: 10)) + returned.fulfill() + } + Thread.sleep(forTimeInterval: 0.05) + + queue.fail() + + wait(for: [returned], timeout: 1) + } + + func testClearResetsTheFailedFlag() { + let queue = BlockingQueue() + queue.fail() + queue.offer(1) + XCTAssertNil(queue.poll(timeout: 0)) + + queue.clear() + queue.offer(2) + + XCTAssertEqual(queue.poll(timeout: 0), 2) + } + + func testDrainRemovesEverythingWithoutWaiting() { + let queue = BlockingQueue() + queue.offer(1) + queue.offer(2) + + XCTAssertEqual(queue.drain(), [1, 2]) + XCTAssertEqual(queue.drain(), []) + XCTAssertNil(queue.poll(timeout: 0)) + } +} diff --git a/BitkitTests/HwConnectViewModelTests.swift b/BitkitTests/HwConnectViewModelTests.swift index 767550cd7..78eadbbc3 100644 --- a/BitkitTests/HwConnectViewModelTests.swift +++ b/BitkitTests/HwConnectViewModelTests.swift @@ -6,17 +6,24 @@ import XCTest final class HwConnectViewModelTests: XCTestCase { private var service: FakeHwConnectService! private var sut: HwConnectViewModel! + private var jadeLog: JadeCallLog! + private var jadeService: FakeJadeService! override func setUp() { super.setUp() service = FakeHwConnectService() sut = HwConnectViewModel(service: service) + jadeLog = JadeCallLog() + jadeService = FakeJadeService(log: jadeLog) } override func tearDown() { + service.connectGate?.open() sut.reset() sut = nil service = nil + jadeService = nil + jadeLog = nil super.tearDown() } @@ -31,9 +38,18 @@ final class HwConnectViewModelTests: XCTestCase { XCTAssertEqual(sut.phase, .found) XCTAssertEqual(sut.foundDevice?.id, "dev1") XCTAssertEqual(sut.foundDeviceModel, "Trezor Safe 3") + XCTAssertEqual(sut.vendor, .trezor) XCTAssertNil(sut.errorMessage) } + func testFoundJadeTakesItsVendorAndModelName() async { + await givenJadeFound() + + XCTAssertEqual(sut.foundDevice, jadeDevice) + XCTAssertEqual(sut.vendor, .blockstream) + XCTAssertEqual(sut.foundDeviceModel, "Jade") + } + func testOnIntroContinueSurfacesSearchFailureWhileSearching() async { service.scanError = TestError.stub @@ -53,7 +69,7 @@ final class HwConnectViewModelTests: XCTestCase { sut.onConnect() await waitUntil { self.sut.phase == .paired } - XCTAssertEqual(service.connectedDeviceIds, ["dev1"]) + XCTAssertEqual(service.connectedDevices, [makeDevice(id: "dev1", model: "Safe 3")], "the scanned record is dialled as found") XCTAssertEqual(sut.pairedDeviceId, "dev1") XCTAssertEqual(sut.deviceName, "Trezor Safe 3") XCTAssertEqual(sut.labelInput, "Trezor Safe 3") @@ -89,6 +105,172 @@ final class HwConnectViewModelTests: XCTestCase { XCTAssertEqual(sut.errorMessage, t("hardware__connect_error")) } + func testConnectingAJadePairsUnderItsVendor() async { + await givenJadeFound() + service.connectResult = .success(jadeResult) + + sut.onConnect() + + await waitUntil { self.sut.phase == .paired } + XCTAssertEqual(service.connectedDevices, [jadeDevice]) + XCTAssertEqual(sut.vendor, .blockstream) + XCTAssertEqual(sut.pairedDeviceId, JadeFixtures.deviceId) + XCTAssertEqual(sut.pairedWalletId, JadeFixtures.walletId) + XCTAssertEqual(sut.labelInput, "Jade") + } + + /// A wrong PIN, the pinserver or a stale Bluetooth bond each have their own copy, which the + /// generic connect message would hide, whether core's error arrives as is or boxed. + func testJadeConnectFailureShowsJadeCopy() async { + await givenJadeFound() + service.connectResult = .failure(JadeError.InvalidPin) + + sut.onConnect() + + await waitUntil { self.sut.errorMessage != nil } + XCTAssertEqual(sut.phase, .found) + XCTAssertEqual(sut.errorMessage, t("hardware__jade_invalid_pin")) + + service.connectResult = .failure(Bitkit.AppError(error: JadeError.InvalidPin)) + sut.onConnect() + XCTAssertNil(sut.errorMessage) + + await waitUntil { self.sut.errorMessage != nil } + XCTAssertEqual(sut.errorMessage, t("hardware__jade_invalid_pin")) + XCTAssertFalse(sut.isConnecting) + } + + func testJadeConnectFailureWithoutAJadeErrorKeepsItsOwnWordsOrTheConnectError() async { + await givenJadeFound() + service.connectResult = .failure(Bitkit.AppError(message: "Could not read any account keys", debugMessage: nil)) + + sut.onConnect() + + await waitUntil { self.sut.errorMessage != nil } + XCTAssertEqual(sut.errorMessage, "Could not read any account keys") + + service.connectResult = .failure(Bitkit.AppError(error: TestError.stub)) + sut.onConnect() + + await waitUntil { self.sut.errorMessage != nil } + XCTAssertEqual(sut.errorMessage, t("hardware__connect_error"), "a generic error has no words of its own") + } + + // MARK: - Unlocking + + func testUnlockHintShowsOnlyWhileAJadeIsConnecting() async { + await givenJadeFound() + sut.onUnlockingChanged(true) + XCTAssertFalse(sut.isUnlocking, "no connect is in flight") + + service.connectGate = AsyncGate() + service.connectResult = .success(jadeResult) + sut.onConnect() + sut.onUnlockingChanged(true) + XCTAssertTrue(sut.isUnlocking) + + sut.onUnlockingChanged(false) + XCTAssertFalse(sut.isUnlocking) + + sut.onUnlockingChanged(true) + service.connectGate?.open() + await waitUntil { self.sut.phase == .paired } + XCTAssertFalse(sut.isUnlocking, "pairing ends the PIN wait") + } + + func testUnlockHintNeverShowsForATrezor() async { + await givenDeviceFound() + service.connectGate = AsyncGate() + + sut.onConnect() + sut.onUnlockingChanged(true) + + XCTAssertFalse(sut.isUnlocking) + } + + func testAFailedJadeConnectEndsTheUnlockHint() async { + await givenJadeFound() + service.connectGate = AsyncGate() + service.connectResult = .failure(JadeError.InvalidPin) + sut.onConnect() + sut.onUnlockingChanged(true) + + service.connectGate?.open() + + await waitUntil { self.sut.errorMessage != nil } + XCTAssertFalse(sut.isUnlocking) + } + + // MARK: - Cancel + + func testCancelConnectCancelsThePendingJadeConnection() async { + await givenJadeConnecting() + sut.onUnlockingChanged(true) + + sut.cancelConnect() + + XCTAssertEqual(service.cancelledConnections, [jadeDevice]) + XCTAssertEqual(service.cancelledConnections.first?.path, jadeDevice.path) + XCTAssertEqual(service.cancelPairingCount, 0, "a Jade has no pairing code to cancel") + XCTAssertFalse(sut.isConnecting) + XCTAssertFalse(sut.isUnlocking) + } + + func testDismissingTheSheetMidConnectCancelsThePendingConnection() async { + await givenJadeConnecting() + + sut.reset() + + XCTAssertEqual(service.cancelledConnections, [jadeDevice]) + XCTAssertFalse(sut.isConnecting) + } + + func testCancelWithoutAConnectInFlightLeavesTheJadeAlone() async { + await givenJadeFound() + + sut.cancelConnect() + sut.reset() + + XCTAssertTrue(service.cancelledConnections.isEmpty) + XCTAssertEqual(service.cancelPairingCount, 0) + } + + /// Leaving the sheet once pairing finished must keep the session the flow just opened. + func testDismissingAfterPairingKeepsTheSession() async { + await givenDevicePaired() + + sut.reset() + + XCTAssertTrue(service.cancelledConnections.isEmpty) + XCTAssertEqual(service.cancelPairingCount, 0) + } + + /// The Trezor side drops its pairing code prompt and closes the session opened for the pairing + /// the user left. + func testCancelConnectReleasesTheTrezorBeingPaired() async { + await givenDeviceFound() + service.connectGate = AsyncGate() + sut.onConnect() + await waitUntil { !self.service.connectedDevices.isEmpty } + + sut.cancelConnect() + + XCTAssertEqual(service.cancelledConnections, [makeDevice(id: "dev1", model: "Safe 3")]) + XCTAssertFalse(sut.isConnecting) + } + + func testACancelledConnectDoesNotReportItsResult() async { + await givenJadeConnecting() + service.connectResult = .success(jadeResult) + + sut.cancelConnect() + service.connectGate?.open() + try? await Task.sleep(nanoseconds: 50_000_000) + + XCTAssertEqual(sut.phase, .found) + XCTAssertNil(sut.pairedDeviceId) + } + // MARK: - Pairing code func testPairingCodeRequestSurfacesInlinePairCodeStepWhileConnecting() async { @@ -109,6 +291,15 @@ final class HwConnectViewModelTests: XCTestCase { XCTAssertEqual(sut.phase, .intro) } + func testPairingCodeRequestIgnoredWhileConnectingAJade() async { + await givenJadeConnecting() + + sut.onPairingCodeRequested() + + XCTAssertEqual(sut.phase, .found) + XCTAssertTrue(sut.isConnecting) + } + // MARK: - Paired func testConnectedWalletUpdatesBalanceOnPairedStep() async { @@ -316,6 +507,27 @@ final class HwConnectViewModelTests: XCTestCase { XCTAssertEqual(service.setLabelCalls.first?.label, "Standard Funds") } + /// A Jade holds one wallet per device, so there are no passphrase wallets to add. + func testJadeHidesPassphraseAndIgnoresItsClick() async { + await givenJadeFound() + service.connectResult = .success(jadeResult) + sut.onConnect() + await waitUntil { self.sut.phase == .paired } + XCTAssertFalse(sut.vendor.supportsPassphraseWallets) + + sut.onPassphraseClick() + XCTAssertEqual(sut.phase, .paired) + XCTAssertTrue(service.setLabelCalls.isEmpty, "nothing is left, so nothing is persisted") + + sut.onPassphraseChange("secret") + sut.onPassphraseSubmit() + try? await Task.sleep(nanoseconds: 50_000_000) + + XCTAssertFalse(sut.isSubmittingPassphrase) + XCTAssertTrue(service.passphraseCalls.isEmpty) + XCTAssertEqual(sut.phase, .paired) + } + func testBackFromThePassphraseStepDropsWhatWasTyped() async { await givenDevicePaired() sut.onPassphraseClick() @@ -398,8 +610,123 @@ final class HwConnectViewModelTests: XCTestCase { XCTAssertTrue(finished) } + // MARK: - Connect service + + func testScanOffersUnpairedDevicesOfEitherVendorBeforePairedOnes() throws { + let pairedTrezor = makeDevice(id: "paired-trezor", model: "Safe 3") + let newTrezor = makeDevice(id: "new-trezor", model: "Safe 5") + let pairedJade = makeJadeDevice() + let newJade = makeJadeDevice(path: "ble:new-jade", name: "Jade 111111") + let pairedIds: Set = [pairedTrezor.id, pairedJade.id] + + let devices = try HwConnectService.nearbyDevices( + trezor: .success([pairedTrezor, newTrezor]), + jade: .success([pairedJade, newJade]), + isPaired: { pairedIds.contains($0.id) } + ) + + XCTAssertEqual(devices, [newTrezor, newJade, pairedTrezor, pairedJade]) + } + + /// A rebooted Jade advertises under a new Bluetooth identifier, so only its name ties it to its + /// paired entry. It is still offered, after any new device, so it can be paired again. + func testScanOffersAPairedJadeThatCameBackUnderANewIdentifier() throws { + let connectService = makeConnectService(jadeManager: makeJadeManager(knownDevices: [JadeFixtures.knownEntry()])) + let readvertised = makeJadeDevice(path: JadeFixtures.readvertisedPath) + let newJade = makeJadeDevice(path: "ble:new-jade", name: "Jade 111111") + + XCTAssertTrue(connectService.isPaired(readvertised)) + XCTAssertFalse(connectService.isPaired(newJade)) + XCTAssertEqual( + try HwConnectService.nearbyDevices(trezor: .success([]), jade: .success([readvertised, newJade]), isPaired: connectService.isPaired), + [newJade, readvertised] + ) + XCTAssertEqual( + try HwConnectService.nearbyDevices(trezor: .success([]), jade: .success([readvertised]), isPaired: connectService.isPaired), + [readvertised] + ) + } + + /// A Jade scan fails quietly while core is busy, so only a failed Trezor scan with nothing found + /// is reported. + func testScanFailsOnlyWhenTheTrezorScanFailedAndNothingWasFound() throws { + let trezorFailure = Bitkit.AppError(message: "Bluetooth scan failed", debugMessage: nil) + let trezor = makeDevice(id: "dev1", model: "Safe 3") + + XCTAssertThrowsError( + try HwConnectService.nearbyDevices(trezor: .failure(trezorFailure), jade: .failure(TestError.stub), isPaired: { _ in false }) + ) { error in + XCTAssertEqual((error as? Bitkit.AppError)?.message, "Bluetooth scan failed") + } + XCTAssertThrowsError( + try HwConnectService.nearbyDevices(trezor: .failure(trezorFailure), jade: .success([]), isPaired: { _ in false }) + ) + XCTAssertEqual( + try HwConnectService.nearbyDevices(trezor: .success([]), jade: .failure(TestError.stub), isPaired: { _ in false }), + [] + ) + XCTAssertEqual( + try HwConnectService.nearbyDevices(trezor: .success([trezor]), jade: .failure(TestError.stub), isPaired: { _ in false }), + [trezor] + ) + } + + func testScanKeepsJadeResultsWhenTheTrezorScanFails() throws { + let devices = try HwConnectService.nearbyDevices( + trezor: .failure(TestError.stub), + jade: .success([jadeDevice]), + isPaired: { _ in false } + ) + + XCTAssertEqual(devices, [jadeDevice]) + } + + func testTheServicePairsAJadeUnderItsVendor() async throws { + jadeService.stubs.scanned = [JadeFixtures.device()] + let jadeManager = makeJadeManager() + let connectService = makeConnectService(jadeManager: jadeManager) + + let result = try await connectService.connect(to: jadeDevice) + + XCTAssertEqual(result.vendor, .blockstream) + XCTAssertEqual(result.deviceId, JadeFixtures.deviceId) + XCTAssertEqual(result.deviceDefaultName, "Jade") + XCTAssertEqual(jadeService.calls.connectPaths, [JadeFixtures.blePath]) + XCTAssertEqual(jadeManager.connected?.id, JadeFixtures.deviceId) + } + + /// A task cancel never reaches core, so leaving the pairing has to cancel the Jade request itself. + func testTheServiceCancelsAPendingJadeConnectionOnTheDevice() async { + let jadeManager = makeJadeManager() + let connectService = makeConnectService(jadeManager: jadeManager) + + connectService.cancelPendingConnection(to: jadeDevice) + + await waitUntil { self.jadeLog.contains("service.disconnect.done") } + XCTAssertTrue(jadeLog.contains("service.cancel")) + XCTAssertTrue(jadeLog.contains("transport.disconnect:\(JadeFixtures.blePath)")) + } + // MARK: - Helpers + private func makeJadeManager(knownDevices: [HwKnownDevice] = []) -> JadeManager { + JadeManager( + service: jadeService, + transport: FakeJadeTransportControl(log: jadeLog), + store: InMemoryJadeKnownDeviceStore(devices: knownDevices), + backgroundTasks: FakeBackgroundTasks(), + network: { .regtest } + ) + } + + private func makeConnectService(jadeManager: JadeManager) -> HwConnectService { + HwConnectService( + trezorManager: TrezorManager(), + jadeManager: jadeManager, + hwWalletManager: HwWalletManager(jadeSession: jadeManager) + ) + } + private func givenDeviceFound() async { service.nearbyDevices = [makeDevice(id: "dev1", model: "Safe 3")] sut.onIntroContinue() @@ -413,12 +740,26 @@ final class HwConnectViewModelTests: XCTestCase { await waitUntil { self.sut.phase == .paired } } + private func givenJadeFound() async { + service.nearbyDevices = [jadeDevice] + sut.onIntroContinue() + await waitUntil { self.sut.phase == .found } + } + + /// A Jade connect held in flight, as while the device waits for its PIN. + private func givenJadeConnecting() async { + await givenJadeFound() + service.connectGate = AsyncGate() + sut.onConnect() + await waitUntil { !self.service.connectedDevices.isEmpty } + } + // MARK: - Paired step name /// Re-adding a removed wallet is the case the published wallet list cannot answer: the wallet is /// not in it yet, but the entry pairing just wrote already carries the name kept for it. func testPairedNameUsesTheStoredLabelOfAWalletMissingFromTheWalletList() { - let name = TrezorHwConnectService.pairedName( + let name = HwConnectService.pairedName( walletId: standardWalletId, storedEntries: [makeStoredEntry(walletId: standardWalletId, customLabel: "No Pass")], deviceDefaultName: "Trezor T" @@ -428,7 +769,7 @@ final class HwConnectViewModelTests: XCTestCase { } func testPairedNameFallsBackToTheDeviceNameWhenTheWalletWasNeverNamed() { - let name = TrezorHwConnectService.pairedName( + let name = HwConnectService.pairedName( walletId: standardWalletId, storedEntries: [makeStoredEntry(walletId: standardWalletId, customLabel: nil)], deviceDefaultName: "Trezor T" @@ -440,7 +781,7 @@ final class HwConnectViewModelTests: XCTestCase { /// A brand-new passphrase wallet has no entry of its own yet, and must not borrow the name of the /// identity that happened to be open before it. func testPairedNameIgnoresAnotherIdentitysLabel() { - let name = TrezorHwConnectService.pairedName( + let name = HwConnectService.pairedName( walletId: hiddenWalletId, storedEntries: [makeStoredEntry(walletId: standardWalletId, customLabel: "No Pass")], deviceDefaultName: "Trezor T" @@ -450,7 +791,7 @@ final class HwConnectViewModelTests: XCTestCase { } func testPairedNameFallsBackToTheDeviceNameBeforeTheIdentityResolves() { - let name = TrezorHwConnectService.pairedName( + let name = HwConnectService.pairedName( walletId: nil, storedEntries: [makeStoredEntry(walletId: standardWalletId, customLabel: "No Pass")], deviceDefaultName: "Trezor T" @@ -459,8 +800,8 @@ final class HwConnectViewModelTests: XCTestCase { XCTAssertEqual(name, "Trezor T") } - private func makeStoredEntry(walletId: String, customLabel: String?) -> TrezorKnownDevice { - TrezorKnownDevice( + private func makeStoredEntry(walletId: String, customLabel: String?) -> HwKnownDevice { + HwKnownDevice( id: "dev1", name: "Trezor", path: "ble://dev1", @@ -472,8 +813,8 @@ final class HwConnectViewModelTests: XCTestCase { ) } - private func makeDevice(id: String, model: String?) -> TrezorDeviceInfo { - TrezorDeviceInfo( + private func makeDevice(id: String, model: String?) -> HwNearbyDevice { + HwNearbyDevice(source: .trezor(TrezorDeviceInfo( id: id, transportType: .bluetooth, name: nil, @@ -481,7 +822,19 @@ final class HwConnectViewModelTests: XCTestCase { label: nil, model: model, isBootloader: false - ) + ))) + } + + private func makeJadeDevice(path: String = JadeFixtures.blePath, name: String? = JadeFixtures.advertisedName) -> HwNearbyDevice { + HwNearbyDevice(source: .jade(JadeFixtures.device(path: path, name: name))) + } + + private var jadeDevice: HwNearbyDevice { + makeJadeDevice() + } + + private var jadeResult: HwConnectResult { + HwConnectResult(deviceId: JadeFixtures.deviceId, walletId: JadeFixtures.walletId, name: "Jade", vendor: .blockstream) } private let standardWalletId = "trezor:standard" @@ -519,19 +872,22 @@ private enum TestError: Error { @MainActor private final class FakeHwConnectService: HwConnectServicing { - var nearbyDevices: [TrezorDeviceInfo] = [] + var nearbyDevices: [HwNearbyDevice] = [] var scanError: Error? var connectResult: Result = .failure(TestError.stub) + /// Holds every connect until opened, so a test can act while one is in flight. + var connectGate: AsyncGate? var passphraseResult: Result = .failure(TestError.stub) var storedNames: [String: String] = [:] private(set) var scanCount = 0 - private(set) var connectedDeviceIds: [String] = [] + private(set) var connectedDevices: [HwNearbyDevice] = [] private(set) var passphraseCalls: [(deviceId: String, passphrase: String)] = [] private(set) var setLabelCalls: [(walletId: String, label: String)] = [] private(set) var cancelPairingCount = 0 + private(set) var cancelledConnections: [HwNearbyDevice] = [] - func scanForDevices() async throws -> [TrezorDeviceInfo] { + func scanForDevices() async throws -> [HwNearbyDevice] { scanCount += 1 if let scanError { throw scanError @@ -539,8 +895,9 @@ private final class FakeHwConnectService: HwConnectServicing { return nearbyDevices } - func connect(to device: TrezorDeviceInfo) async throws -> HwConnectResult { - connectedDeviceIds.append(device.id) + func connect(to device: HwNearbyDevice) async throws -> HwConnectResult { + connectedDevices.append(device) + await connectGate?.wait() return try connectResult.get() } @@ -560,4 +917,8 @@ private final class FakeHwConnectService: HwConnectServicing { func cancelPairingCode() { cancelPairingCount += 1 } + + func cancelPendingConnection(to device: HwNearbyDevice) { + cancelledConnections.append(device) + } } diff --git a/BitkitTests/HwEngagedSessionTests.swift b/BitkitTests/HwEngagedSessionTests.swift new file mode 100644 index 000000000..b022089d1 --- /dev/null +++ b/BitkitTests/HwEngagedSessionTests.swift @@ -0,0 +1,128 @@ +@testable import Bitkit +import XCTest + +/// Ports Android `HwReceiveViewModelTest`'s session rules: the receive sheet releases only a device +/// session it engaged by verifying an address or entering a passphrase. +@MainActor +final class HwEngagedSessionTests: XCTestCase { + private let walletId = "trezor:wallet" + private let otherWalletId = "jade:wallet" + + private var releaser: RecordingSessionReleaser! + private var session: HwEngagedSession! + + override func setUp() async throws { + releaser = RecordingSessionReleaser() + session = HwEngagedSession() + } + + func testCancelWithoutUsingTheDeviceKeepsItsSession() { + session.release(through: releaser) + + XCTAssertEqual(releaser.releasedWalletIds, []) + XCTAssertNil(session.walletId) + } + + func testCancelAfterVerificationClosesTheSession() async { + await session.perform(walletId: walletId) {} + + session.release(through: releaser) + + XCTAssertEqual(releaser.releasedWalletIds, [walletId]) + XCTAssertNil(session.walletId) + } + + func testCancelAfterAFailedVerificationStillClosesTheSession() async { + do { + try await session.perform(walletId: walletId) { throw CancellationError() } + XCTFail("Expected the verification to fail") + } catch {} + + session.release(through: releaser) + + XCTAssertEqual(releaser.releasedWalletIds, [walletId]) + } + + func testCancelDuringVerificationClosesTheSession() async { + let gate = AsyncGate() + let verification = Task { await session.perform(walletId: walletId) { await gate.wait() } } + await waitUntil { self.session.isWorking } + + session.release(through: releaser) + gate.open() + await verification.value + session.release(through: releaser) + + XCTAssertEqual(releaser.releasedWalletIds, [walletId]) + XCTAssertFalse(session.isWorking) + XCTAssertNil(session.walletId) + } + + func testWatcherAddressChangeKeepsASessionTheSheetNeverUsed() { + session.invalidate(through: releaser) + + XCTAssertEqual(releaser.releasedWalletIds, []) + } + + func testSelectedWalletChangeDuringVerificationReleasesTheSession() async { + let gate = AsyncGate() + let verification = Task { await session.perform(walletId: walletId) { await gate.wait() } } + await waitUntil { self.session.isWorking } + + session.invalidate(through: releaser) + gate.open() + await verification.value + session.release(through: releaser) + + XCTAssertEqual(releaser.releasedWalletIds, [walletId]) + } + + func testAddressChangeAfterVerificationKeepsTheSessionUntilTheSheetIsLeft() async { + await session.perform(walletId: walletId) {} + + session.invalidate(through: releaser) + XCTAssertEqual(releaser.releasedWalletIds, []) + + session.release(through: releaser) + XCTAssertEqual(releaser.releasedWalletIds, [walletId]) + } + + func testReleasesTheWalletTheLatestWorkEngaged() async { + await session.perform(walletId: walletId) {} + await session.perform(walletId: otherWalletId) {} + + session.release(through: releaser) + + XCTAssertEqual(releaser.releasedWalletIds, [otherWalletId]) + } + + func testPassphraseWorkThatVerifiesEngagesTheSessionOnce() async { + await session.perform(walletId: walletId) { + await session.perform(walletId: walletId) {} + XCTAssertTrue(session.isWorking) + } + XCTAssertFalse(session.isWorking) + + session.release(through: releaser) + session.release(through: releaser) + + XCTAssertEqual(releaser.releasedWalletIds, [walletId]) + } + + private func waitUntil(timeout: TimeInterval = 2, _ condition: () -> Bool) async { + let deadline = Date().addingTimeInterval(timeout) + while !condition(), Date() < deadline { + try? await Task.sleep(nanoseconds: 10_000_000) + } + XCTAssertTrue(condition(), "Timed out waiting for condition") + } +} + +@MainActor +private final class RecordingSessionReleaser: HwSessionReleasing { + private(set) var releasedWalletIds: [String] = [] + + func scheduleStaleSessionCleanup(walletId: String) { + releasedWalletIds.append(walletId) + } +} diff --git a/BitkitTests/HwErrorPredicateTests.swift b/BitkitTests/HwErrorPredicateTests.swift new file mode 100644 index 000000000..6e80b9bd7 --- /dev/null +++ b/BitkitTests/HwErrorPredicateTests.swift @@ -0,0 +1,148 @@ +@testable import Bitkit +import BitkitCore +import XCTest + +/// Truth tables for the `isJade*` predicates and the vendor-neutral `isHw*` ones built on them. +/// `ServiceQueue` boxes core errors into an `AppError`, so every Jade case is checked raw and wrapped. +/// `AppError` is qualified as `Bitkit.AppError` because `Errors.swift` is also compiled into this test +/// target, so an unqualified name would resolve to the duplicate and fail the cast. +final class HwErrorPredicateTests: XCTestCase { + private let sessionFailures: [JadeError] = [ + .TransportError(errorDetails: "link lost"), + .DeviceDisconnected, + .ConnectionError(errorDetails: "refused"), + .Timeout, + .NotConnected, + .NotInitialized, + .IoError(errorDetails: "broken pipe"), + ] + + private let otherErrors: [JadeError] = [ + .DeviceNotFound, + .ProtocolError(errorDetails: "bad frame"), + .DeviceUninitialized, + .InvalidPin, + .NetworkMismatch(errorDetails: "testnet"), + .InvalidPath(errorDetails: "m/0"), + .InvalidPsbt(errorDetails: "bad psbt"), + .PsbtTooLarge(size: 20000, max: 16384), + .FingerprintMismatch(device: "aaaa", psbt: "bbbb"), + .NothingSigned, + .AddressMismatch(expected: "bc1qexpected", returned: "bc1qreturned"), + .PinServerError(errorDetails: "unreachable"), + .DeviceError(errorDetails: "device said no"), + ] + + private func rawAndWrapped(_ error: JadeError) -> [Error] { + [error, Bitkit.AppError(error: error)] + } + + // MARK: - Unwrapping + + func testUnderlyingJadeErrorSeesThroughAppError() { + XCTAssertEqual(JadeError.InvalidPin.underlyingJadeError, .InvalidPin) + XCTAssertEqual(Bitkit.AppError(error: JadeError.InvalidPin).underlyingJadeError, .InvalidPin) + XCTAssertNil(TrezorError.DeviceBusy.underlyingJadeError) + XCTAssertNil(Bitkit.AppError(message: "boom", debugMessage: nil).underlyingJadeError) + XCTAssertNil(CancellationError().underlyingJadeError) + } + + // MARK: - Jade + + func testJadeUserCancellation() { + for error in rawAndWrapped(.UserCancelled) { + XCTAssertTrue(error.isJadeUserCancellation(), "\(error)") + } + for error in (sessionFailures + otherErrors + [.DeviceBusy, .DeviceLocked]).flatMap(rawAndWrapped) { + XCTAssertFalse(error.isJadeUserCancellation(), "\(error)") + } + XCTAssertFalse(TrezorError.UserCancelled.isJadeUserCancellation()) + XCTAssertFalse(CancellationError().isJadeUserCancellation()) + } + + func testABusyOrLockedJadeIsBusy() { + for error in rawAndWrapped(.DeviceBusy) + rawAndWrapped(.DeviceLocked) { + XCTAssertTrue(error.isJadeDeviceBusy(), "\(error)") + } + for error in (sessionFailures + otherErrors + [.UserCancelled]).flatMap(rawAndWrapped) { + XCTAssertFalse(error.isJadeDeviceBusy(), "\(error)") + } + XCTAssertFalse(TrezorError.DeviceBusy.isJadeDeviceBusy()) + } + + func testOutdatedJadeFirmwareIsAFirmwareError() { + for error in rawAndWrapped(.UnsupportedFirmware(installed: "0.1.0", required: "1.0.34")) { + XCTAssertTrue(error.isJadeFirmwareError(), "\(error)") + } + for error in (sessionFailures + otherErrors + [.UserCancelled, .DeviceBusy]).flatMap(rawAndWrapped) { + XCTAssertFalse(error.isJadeFirmwareError(), "\(error)") + } + } + + func testTransportLevelJadeFailuresAreSessionFailures() { + for error in sessionFailures.flatMap(rawAndWrapped) { + XCTAssertTrue(error.isJadeSessionFailure(), "\(error)") + } + let notSessionFailures: [JadeError] = otherErrors + [ + .UserCancelled, + .DeviceBusy, + .DeviceLocked, + .UnsupportedFirmware(installed: "0.1.0", required: "1.0.34"), + ] + for error in notSessionFailures.flatMap(rawAndWrapped) { + XCTAssertFalse(error.isJadeSessionFailure(), "\(error)") + } + XCTAssertFalse(TrezorError.DeviceDisconnected.isJadeSessionFailure()) + } + + // MARK: - Either vendor + + func testHwUserCancellationCoversBothVendors() { + XCTAssertTrue(JadeError.UserCancelled.isHwUserCancellation()) + XCTAssertTrue(Bitkit.AppError(error: JadeError.UserCancelled).isHwUserCancellation()) + XCTAssertTrue(TrezorError.UserCancelled.isHwUserCancellation()) + XCTAssertTrue(Bitkit.AppError(error: TrezorError.PinCancelled).isHwUserCancellation()) + XCTAssertFalse(JadeError.DeviceBusy.isHwUserCancellation()) + XCTAssertFalse(TrezorError.Timeout.isHwUserCancellation()) + XCTAssertFalse(CancellationError().isHwUserCancellation()) + } + + func testHwDeviceBusyCoversBothVendors() { + XCTAssertTrue(JadeError.DeviceLocked.isHwDeviceBusy()) + XCTAssertTrue(Bitkit.AppError(error: JadeError.DeviceBusy).isHwDeviceBusy()) + XCTAssertTrue(TrezorError.DeviceBusy.isHwDeviceBusy()) + XCTAssertTrue(Bitkit.AppError(error: TrezorError.DeviceBusy).isHwDeviceBusy()) + XCTAssertFalse(JadeError.Timeout.isHwDeviceBusy()) + XCTAssertFalse(TrezorError.Timeout.isHwDeviceBusy()) + } + + func testHwFirmwareErrorCoversBothVendors() { + XCTAssertTrue(Bitkit.AppError(error: JadeError.UnsupportedFirmware(installed: "0.1.0", required: "1.0.34")).isHwFirmwareError()) + XCTAssertTrue( + Bitkit.AppError(message: "Firmware error", debugMessage: "Device error (code 99): Firmware error").isHwFirmwareError() + ) + XCTAssertFalse(JadeError.InvalidPin.isHwFirmwareError()) + XCTAssertFalse(TrezorError.Timeout.isHwFirmwareError()) + } + + func testHwSessionFailureCoversBothVendors() { + XCTAssertTrue(JadeError.Timeout.isHwSessionFailure()) + XCTAssertTrue(Bitkit.AppError(error: JadeError.TransportError(errorDetails: "link lost")).isHwSessionFailure()) + XCTAssertTrue(TrezorError.DeviceDisconnected.isHwSessionFailure()) + XCTAssertTrue(Bitkit.AppError(error: TrezorError.ProtocolError(errorDetails: "THP decryption error")).isHwSessionFailure()) + XCTAssertFalse(JadeError.InvalidPin.isHwSessionFailure()) + XCTAssertFalse(JadeError.AddressMismatch(expected: "a", returned: "b").isHwSessionFailure()) + XCTAssertFalse(TrezorError.ProtocolError(errorDetails: "Invalid PSBT").isHwSessionFailure()) + } + + func testBusyVendorNamesTheBusyDevice() { + XCTAssertEqual(JadeError.DeviceBusy.hwBusyVendor, .blockstream) + XCTAssertEqual(Bitkit.AppError(error: JadeError.DeviceLocked).hwBusyVendor, .blockstream) + XCTAssertEqual(TrezorError.DeviceBusy.hwBusyVendor, .trezor) + XCTAssertEqual(Bitkit.AppError(error: TrezorError.DeviceBusy).hwBusyVendor, .trezor) + XCTAssertNil(JadeError.Timeout.hwBusyVendor) + XCTAssertNil(TrezorError.Timeout.hwBusyVendor) + XCTAssertNil(Bitkit.AppError(message: "sign failed", debugMessage: nil).hwBusyVendor) + XCTAssertNil(CancellationError().hwBusyVendor) + } +} diff --git a/BitkitTests/HwErrorPresenterTests.swift b/BitkitTests/HwErrorPresenterTests.swift new file mode 100644 index 000000000..a165c40ac --- /dev/null +++ b/BitkitTests/HwErrorPresenterTests.swift @@ -0,0 +1,81 @@ +@testable import Bitkit +import BitkitCore +import XCTest + +/// `HwErrorPresenter` gives every Jade error Jade or neutral copy and leaves the rest to the Trezor +/// rules. `AppError` is qualified as `Bitkit.AppError` because `Errors.swift` is also compiled into this +/// test target, so an unqualified name would resolve to the duplicate and fail the cast. +final class HwErrorPresenterTests: XCTestCase { + func testMapsTypedJadeErrorsToTheirMessages() { + let expectations: [(JadeError, String)] = [ + (.InvalidPin, t("hardware__jade_invalid_pin")), + (.DeviceUninitialized, t("hardware__jade_uninitialized")), + (.UnsupportedFirmware(installed: "0.1.0", required: "1.0.34"), t("hardware__jade_firmware_outdated")), + (.PsbtTooLarge(size: 20000, max: 16384), t("hardware__jade_psbt_too_large")), + (.NetworkMismatch(errorDetails: "testnet"), t("hardware__jade_network_mismatch")), + (.DeviceBusy, t("hardware__jade_device_busy")), + (.DeviceLocked, t("hardware__jade_device_busy")), + (.PinServerError(errorDetails: "unreachable"), t("hardware__jade_pinserver_error")), + (.AddressMismatch(expected: "bc1qexpected", returned: "bc1qreturned"), t("hardware__verify_address_error")), + ] + + for (error, message) in expectations { + XCTAssertEqual(HwErrorPresenter.userMessage(from: error), message, "\(error)") + XCTAssertEqual(HwErrorPresenter.jadeMessage(from: error), message, "\(error)") + } + } + + func testMapsAWrappedJadeError() { + XCTAssertEqual(HwErrorPresenter.userMessage(from: Bitkit.AppError(error: JadeError.InvalidPin)), t("hardware__jade_invalid_pin")) + XCTAssertEqual( + HwErrorPresenter.userMessage(from: Bitkit.AppError(error: JadeError.PinServerError(errorDetails: "x"))), + t("hardware__jade_pinserver_error") + ) + } + + func testTransportAndConnectionDetailsPassThrough() { + let staleBond = "Bluetooth pairing is no longer valid: forget the Jade in the iOS Bluetooth settings and pair it again." + + XCTAssertEqual(HwErrorPresenter.userMessage(from: JadeError.TransportError(errorDetails: staleBond)), staleBond) + XCTAssertEqual(HwErrorPresenter.userMessage(from: Bitkit.AppError(error: JadeError.TransportError(errorDetails: "stale text"))), "stale text") + XCTAssertEqual(HwErrorPresenter.userMessage(from: JadeError.ConnectionError(errorDetails: "Jade refused")), "Jade refused") + } + + func testBlankDetailsAndOtherJadeErrorsGiveTheConnectError() { + let connectError = t("hardware__connect_error") + let errors: [JadeError] = [ + .TransportError(errorDetails: ""), + .TransportError(errorDetails: " \n"), + .ConnectionError(errorDetails: ""), + .Timeout, + .DeviceDisconnected, + .NotConnected, + .UserCancelled, + .NothingSigned, + .ProtocolError(errorDetails: "Device disconnected"), + ] + + for error in errors { + XCTAssertEqual(HwErrorPresenter.userMessage(from: error), connectError, "\(error)") + XCTAssertEqual(HwErrorPresenter.userMessage(from: Bitkit.AppError(error: error)), connectError, "\(error)") + } + } + + func testFallsBackToTheTrezorRulesForOtherErrors() { + XCTAssertEqual(HwErrorPresenter.userMessage(from: TrezorError.DeviceBusy), t("hardware__device_busy")) + XCTAssertEqual(HwErrorPresenter.userMessage(from: Bitkit.AppError(error: TrezorError.DeviceBusy)), t("hardware__device_busy")) + XCTAssertEqual(HwErrorPresenter.userMessage(from: Bitkit.AppError(message: "boom", debugMessage: nil)), "boom") + } + + func testJadeMessageIsNilWithoutAJadeError() { + XCTAssertNil(HwErrorPresenter.jadeMessage(from: TrezorError.DeviceBusy)) + XCTAssertNil(HwErrorPresenter.jadeMessage(from: Bitkit.AppError(message: "boom", debugMessage: nil))) + XCTAssertNil(HwErrorPresenter.jadeMessage(from: CancellationError())) + } + + func testDeviceBusyMessageNamesTheVendor() { + XCTAssertEqual(HwErrorPresenter.deviceBusyMessage(for: .trezor), t("hardware__device_busy")) + XCTAssertEqual(HwErrorPresenter.deviceBusyMessage(for: .blockstream), t("hardware__jade_device_busy")) + XCTAssertNotEqual(HwErrorPresenter.deviceBusyMessage(for: .trezor), HwErrorPresenter.deviceBusyMessage(for: .blockstream)) + } +} diff --git a/BitkitTests/HwFundingSignerTests.swift b/BitkitTests/HwFundingSignerTests.swift index 8ed3139a8..84e65dc51 100644 --- a/BitkitTests/HwFundingSignerTests.swift +++ b/BitkitTests/HwFundingSignerTests.swift @@ -11,7 +11,7 @@ final class HwFundingSignerTests: XCTestCase { connecting: MockHwConnecting, feeRate: UInt64? = 2, address: String? = "bc1qtest", - timeouts: (reconnect: Double, compose: Double, sign: Double, broadcast: Double) = (reconnect: 5, compose: 5, sign: 5, broadcast: 5) + timeouts: (compose: Double, sign: Double, broadcast: Double) = (compose: 5, sign: 5, broadcast: 5) ) -> HwFundingSigner { HwFundingSigner( funding: funding, @@ -289,6 +289,288 @@ final class HwFundingSignerTests: XCTestCase { XCTAssertEqual(completedTransactionIds, [funding.broadcastTxId]) } + // MARK: - Leaving the sign screen + + func testCoordinatorCanBeLeftWhileTheDeviceConnects() async throws { + for walletId in ["jade:wallet", "trezor:wallet"] { + let funding = MockHwFunding() + let connecting = MockHwConnecting() + let connect = AsyncGate() + connecting.connectGate = connect + let manager = HwWalletManager() + let coordinator = makeCoordinator(walletId: walletId, funding: funding, connecting: connecting) + + let payment = Task { try await self.signAndBroadcast(coordinator, manager: manager) } + await waitUntil { coordinator.isConnectingDevice } + + XCTAssertTrue(coordinator.isSigning, walletId) + XCTAssertTrue(coordinator.isConnectingDevice, walletId) + XCTAssertTrue(coordinator.canLeave, walletId) + + connect.open() + _ = try await payment.value + + XCTAssertFalse(coordinator.isConnectingDevice, walletId) + XCTAssertFalse(coordinator.isSigning, walletId) + XCTAssertEqual(funding.broadcastCalls, 1, walletId) + } + } + + func testCoordinatorCannotBeLeftWhileTheDeviceSigns() async throws { + let funding = MockHwFunding() + let sign = AsyncGate() + funding.signGate = sign + let manager = HwWalletManager() + let coordinator = makeCoordinator(walletId: "jade:wallet", funding: funding, connecting: MockHwConnecting()) + + let payment = Task { try await self.signAndBroadcast(coordinator, manager: manager) } + await waitUntil { funding.signCalls == 1 } + + XCTAssertTrue(coordinator.isSigning) + XCTAssertFalse(coordinator.isConnectingDevice) + XCTAssertFalse(coordinator.canLeave) + + sign.open() + _ = try await payment.value + } + + func testCoordinatorCannotBeLeftWhileABroadcastIsUnresolved() async throws { + let funding = MockHwFunding() + let broadcast = AsyncGate() + funding.broadcastGate = broadcast + let connecting = MockHwConnecting() + let manager = HwWalletManager() + let coordinator = makeCoordinator(walletId: "jade:wallet", funding: funding, connecting: connecting) + + let payment = Task { try await self.signAndBroadcast(coordinator, manager: manager) } + await waitUntil { funding.broadcastCalls == 1 } + + XCTAssertTrue(coordinator.isBroadcastUnresolved) + XCTAssertFalse(coordinator.canLeave) + + coordinator.cancel() + + XCTAssertTrue(coordinator.isSigning, "a broadcast that may have gone out is not cancelled") + XCTAssertTrue(connecting.staleDisconnects.isEmpty) + + broadcast.open() + let result = try await payment.value + + XCTAssertEqual(result.txId, funding.broadcastTxId) + XCTAssertFalse(coordinator.canLeave, "the outcome stays unresolved until the sheet records it") + coordinator.completeBroadcast() + XCTAssertTrue(coordinator.canLeave) + } + + func testCancelWhileConnectingStopsBeforeSigningAndReleasesTheDevice() async throws { + for walletId in ["jade:wallet", "trezor:wallet"] { + let funding = MockHwFunding() + let connecting = MockHwConnecting() + let abandonedConnect = AsyncGate() + connecting.connectGate = abandonedConnect + let manager = HwWalletManager() + let coordinator = makeCoordinator(walletId: walletId, funding: funding, connecting: connecting) + + let payment = Task { try await self.signAndBroadcast(coordinator, manager: manager) } + await waitUntil { coordinator.isConnectingDevice } + coordinator.cancel() + + XCTAssertEqual(connecting.staleDisconnects, [walletId], "leaving releases the device") + XCTAssertFalse(coordinator.isSigning, walletId) + XCTAssertFalse(coordinator.isConnectingDevice, walletId) + XCTAssertTrue(coordinator.canLeave, walletId) + await assertThrowsAsync { + _ = try await payment.value + } _: { error in + XCTAssertTrue(error is CancellationError, "\(error)") + } + + connecting.connectGate = nil + abandonedConnect.open() + await Task.yield() + + XCTAssertTrue(funding.composeCalls.isEmpty, walletId) + XCTAssertEqual(funding.signCalls, 0, walletId) + XCTAssertEqual(funding.broadcastCalls, 0, walletId) + + let result = try await signAndBroadcast(coordinator, manager: manager) + + XCTAssertEqual(result.txId, funding.broadcastTxId, walletId) + XCTAssertEqual(funding.composeCalls.count, 1, walletId) + XCTAssertEqual(funding.broadcastCalls, 1, walletId) + XCTAssertEqual(connecting.staleDisconnects, [walletId], "the new attempt keeps its session") + } + } + + func testACancelledAttemptDoesNotResetANewerAttempt() async throws { + let funding = MockHwFunding() + let manager = HwWalletManager() + let coordinator = makeCoordinator(walletId: "jade:wallet", funding: funding, connecting: MockHwConnecting()) + let preparations = JadeCallLog() + let firstPreparation = AsyncGate() + let secondPreparation = AsyncGate() + + let first = Task { + try await self.signAndBroadcast(coordinator, manager: manager) { + preparations.record("first") + await firstPreparation.wait() + } + } + await waitUntil { preparations.contains("first") } + coordinator.cancel() + + let second = Task { + try await self.signAndBroadcast(coordinator, manager: manager) { + preparations.record("second") + await secondPreparation.wait() + } + } + await waitUntil { preparations.contains("second") } + firstPreparation.open() + await assertThrowsAsync { + _ = try await first.value + } _: { error in + XCTAssertTrue(error is CancellationError, "\(error)") + } + + XCTAssertTrue(coordinator.isSigning, "the cancelled attempt must not end the newer one") + XCTAssertFalse(coordinator.canLeave) + XCTAssertEqual(funding.broadcastCalls, 0, "a cancelled attempt never broadcasts") + + secondPreparation.open() + let result = try await second.value + + XCTAssertEqual(result.txId, funding.broadcastTxId) + XCTAssertEqual(funding.broadcastCalls, 1) + XCTAssertFalse(coordinator.isSigning) + } + + func testCancelWithNothingInFlightKeepsTheSession() async throws { + let funding = MockHwFunding() + let connecting = MockHwConnecting() + let manager = HwWalletManager() + let coordinator = makeCoordinator(walletId: "jade:wallet", funding: funding, connecting: connecting) + + coordinator.cancel() + + XCTAssertTrue(connecting.staleDisconnects.isEmpty) + + _ = try await signAndBroadcast(coordinator, manager: manager) + coordinator.completeBroadcast() + coordinator.cancel() + + XCTAssertTrue(connecting.staleDisconnects.isEmpty, "a finished payment keeps its session") + } + + func testCoordinatorCanBeLeftWhileTheDeviceReconnectsBeforeASignRetry() async throws { + let walletId = "jade:wallet" + let funding = MockHwFunding() + funding.signErrors = [Bitkit.AppError(error: JadeError.DeviceDisconnected)] + let sign = AsyncGate() + funding.signGate = sign + let connecting = MockHwConnecting() + let manager = HwWalletManager() + let coordinator = makeCoordinator(walletId: walletId, funding: funding, connecting: connecting) + + let payment = Task { try await self.signAndBroadcast(coordinator, manager: manager) } + await waitUntil { funding.signCalls == 1 } + let abandonedReconnect = AsyncGate() + connecting.connectGate = abandonedReconnect + sign.open() + await waitUntil { coordinator.isConnectingDevice } + + XCTAssertTrue(coordinator.isSigning) + XCTAssertTrue(coordinator.isConnectingDevice) + XCTAssertTrue(coordinator.canLeave, "nothing is on the device to sign while it reconnects") + + coordinator.cancel() + + await assertThrowsAsync { + _ = try await payment.value + } _: { error in + XCTAssertTrue(error is CancellationError, "\(error)") + } + XCTAssertEqual(connecting.staleDisconnects, [walletId, walletId], "the failed sign and leaving each release the device") + + abandonedReconnect.open() + await Task.yield() + + XCTAssertEqual(funding.signCalls, 1, "the abandoned reconnect never signs again") + XCTAssertEqual(funding.broadcastCalls, 0) + } + + func testConnectingIsReportedAroundEveryReconnect() async throws { + let funding = MockHwFunding() + funding.signErrors = [Bitkit.AppError(error: JadeError.DeviceDisconnected)] + let connecting = MockHwConnecting() + let signer = makeSigner(funding: funding, connecting: connecting) + var reports: [Bool] = [] + + _ = try await signer.prepareSignedPayment( + walletId: "jade:wallet", + address: "bc1qtest", + sats: 42000, + satsPerVByte: 2, + onConnectingDevice: { reports.append($0) } + ) + + XCTAssertEqual(connecting.ensureCalls, 2) + XCTAssertEqual(reports, [true, false, true, false], "the reconnect before the sign retry is reported too") + + reports = [] + connecting.connectError = MockHwFunding.TestError() + await assertThrowsAsync { + _ = try await signer.prepareSignedPayment( + walletId: "jade:wallet", + address: "bc1qtest", + sats: 42000, + satsPerVByte: 2, + onConnectingDevice: { reports.append($0) } + ) + } + + XCTAssertEqual(reports, [true, false], "a failed reconnect still ends the report") + } + + private func makeCoordinator( + walletId: String, + funding: MockHwFunding, + connecting: MockHwConnecting + ) -> HwSendCoordinator { + HwSendCoordinator( + walletId: walletId, + signerFactory: { [self] _, address, satsPerVByte in + makeSigner( + funding: funding, + connecting: connecting, + feeRate: satsPerVByte, + address: address + ) + } + ) + } + + private func signAndBroadcast( + _ coordinator: HwSendCoordinator, + manager: HwWalletManager, + beforeBroadcast: @escaping () async throws -> Void = {} + ) async throws -> HwFundingBroadcastResult { + try await coordinator.signAndBroadcast( + manager: manager, + address: "bc1qtest", + sats: 42000, + satsPerVByte: 2, + beforeBroadcast: beforeBroadcast + ) + } + + private func waitUntil(timeout: TimeInterval = 2, _ condition: () -> Bool) async { + let deadline = Date().addingTimeInterval(timeout) + while !condition(), Date() < deadline { + try? await Task.sleep(nanoseconds: 10_000_000) + } + } + // MARK: - Availability func testAvailabilityUsesRealMaxSpendable() async throws { @@ -377,6 +659,23 @@ final class HwFundingSignerTests: XCTestCase { XCTAssertEqual(funding.signCalls, 0) } + /// The reconnect deadline belongs to the wallet's device: a Jade may be waiting for its PIN. + func testReconnectUsesTheWalletsTimeout() async { + let funding = MockHwFunding() + let connecting = MockHwConnecting() + connecting.connectDelay = 0.4 + connecting.reconnectTimeoutSeconds = 0.05 + let signer = makeSigner(funding: funding, connecting: connecting) + + await assertThrowsAsync { + _ = try await signer.prepareSignedFunding(order: .mock(), walletId: "jade:wallet", address: "bc1q...") + } _: { error in + XCTAssertEqual(error as? HwTransferError, .reconnect(isBluetooth: false)) + } + XCTAssertEqual(connecting.staleDisconnects, ["jade:wallet"], "the timed-out session is cleaned up") + XCTAssertTrue(funding.composeCalls.isEmpty) + } + func testComposeFailureThrowsFundingError() async { let funding = MockHwFunding() funding.composeError = MockHwFunding.TestError() @@ -396,7 +695,7 @@ final class HwFundingSignerTests: XCTestCase { let funding = MockHwFunding() funding.signDelay = 0.4 let connecting = MockHwConnecting() - let signer = makeSigner(funding: funding, connecting: connecting, timeouts: (reconnect: 5, compose: 5, sign: 0.05, broadcast: 5)) + let signer = makeSigner(funding: funding, connecting: connecting, timeouts: (compose: 5, sign: 0.05, broadcast: 5)) await assertThrowsAsync { _ = try await signer.prepareSignedFunding(order: .mock(), walletId: "trezor:wallet", address: "bc1q...") @@ -415,7 +714,7 @@ final class HwFundingSignerTests: XCTestCase { let signer = makeSigner( funding: funding, connecting: connecting, - timeouts: (reconnect: 5, compose: 5, sign: 0.05, broadcast: 5) + timeouts: (compose: 5, sign: 0.05, broadcast: 5) ) let start = ContinuousClock.now @@ -434,7 +733,7 @@ final class HwFundingSignerTests: XCTestCase { let funding = MockHwFunding() funding.broadcastDelay = 0.4 let connecting = MockHwConnecting() - let signer = makeSigner(funding: funding, connecting: connecting, timeouts: (reconnect: 5, compose: 5, sign: 5, broadcast: 0.05)) + let signer = makeSigner(funding: funding, connecting: connecting, timeouts: (compose: 5, sign: 5, broadcast: 0.05)) await assertThrowsAsync { _ = try await signer.broadcastSignedFunding(funding.signedTx) @@ -481,7 +780,7 @@ final class HwFundingSignerTests: XCTestCase { let funding = MockHwFunding() funding.composeDelay = 0.4 let connecting = MockHwConnecting() - let signer = makeSigner(funding: funding, connecting: connecting, timeouts: (reconnect: 5, compose: 0.05, sign: 5, broadcast: 5)) + let signer = makeSigner(funding: funding, connecting: connecting, timeouts: (compose: 0.05, sign: 5, broadcast: 5)) await assertThrowsAsync { _ = try await signer.prepareSignedFunding(order: .mock(), walletId: "trezor:wallet", address: "bc1q...") @@ -527,6 +826,135 @@ final class HwFundingSignerTests: XCTestCase { XCTAssertEqual(connecting.staleDisconnects, ["trezor:wallet"]) XCTAssertEqual(connecting.ensureCalls, 2) } + + // MARK: - Vendor errors + + func testBusyJadeReportsJadeVendor() async { + let funding = MockHwFunding() + let connecting = MockHwConnecting() + connecting.connectError = Bitkit.AppError(error: JadeError.DeviceLocked) + let signer = makeSigner(funding: funding, connecting: connecting) + + await assertThrowsAsync { + _ = try await signer.prepareSignedFunding(order: .mock(), walletId: "jade:wallet", address: "bc1q...") + } _: { error in + XCTAssertEqual(error as? HwTransferError, .deviceBusy(.blockstream)) + } + XCTAssertTrue(funding.composeCalls.isEmpty) + } + + func testBusyTrezorReportsTrezorVendor() async { + let funding = MockHwFunding() + let connecting = MockHwConnecting() + connecting.connectError = Bitkit.AppError(error: TrezorError.DeviceBusy) + let signer = makeSigner(funding: funding, connecting: connecting) + + await assertThrowsAsync { + _ = try await signer.prepareSignedFunding(order: .mock(), walletId: "trezor:wallet", address: "bc1q...") + } _: { error in + XCTAssertEqual(error as? HwTransferError, .deviceBusy(.trezor)) + } + XCTAssertTrue(funding.composeCalls.isEmpty) + } + + func testJadeWrongPinDuringReconnectShowsJadeCopy() async { + let funding = MockHwFunding() + let connecting = MockHwConnecting() + connecting.isBluetooth = true + let signer = makeSigner(funding: funding, connecting: connecting) + + for (error, key) in [ + (JadeError.InvalidPin, "hardware__jade_invalid_pin"), + (JadeError.PinServerError(errorDetails: "unreachable"), "hardware__jade_pinserver_error"), + ] { + connecting.connectError = Bitkit.AppError(error: error) + await assertThrowsAsync { + _ = try await signer.prepareSignedFunding(order: .mock(), walletId: "jade:wallet", address: "bc1q...") + } _: { thrown in + XCTAssertEqual(thrown as? HwTransferError, .generic(t(key)), "\(error)") + } + } + XCTAssertTrue(funding.composeCalls.isEmpty) + } + + func testAJadeLinkFailureDuringReconnectStillReportsAReconnect() async { + let connecting = MockHwConnecting() + connecting.connectError = Bitkit.AppError(error: JadeError.DeviceDisconnected) + connecting.isBluetooth = true + let signer = makeSigner(funding: MockHwFunding(), connecting: connecting) + + await assertThrowsAsync { + _ = try await signer.prepareSignedFunding(order: .mock(), walletId: "jade:wallet", address: "bc1q...") + } _: { error in + XCTAssertEqual(error as? HwTransferError, .reconnect(isBluetooth: true)) + } + } + + func testAJadeComposeFailureKeepsTheJadeCopy() async { + let funding = MockHwFunding() + funding.composeError = Bitkit.AppError(error: JadeError.InvalidPin) + let signer = makeSigner(funding: funding, connecting: MockHwConnecting()) + + await assertThrowsAsync { + _ = try await signer.prepareSignedFunding(order: .mock(), walletId: "jade:wallet", address: "bc1q...") + } _: { error in + XCTAssertEqual(error as? HwTransferError, .generic(t("hardware__jade_invalid_pin"))) + } + + funding.composeError = Bitkit.AppError(error: JadeError.DeviceBusy) + await assertThrowsAsync { + _ = try await signer.prepareSignedFunding(order: .mock(), walletId: "jade:wallet", address: "bc1q...") + } _: { error in + XCTAssertEqual(error as? HwTransferError, .deviceBusy(.blockstream)) + } + + funding.composeError = Bitkit.AppError(error: JadeError.Timeout) + await assertThrowsAsync { + _ = try await signer.prepareSignedFunding(order: .mock(), walletId: "jade:wallet", address: "bc1q...") + } _: { error in + XCTAssertEqual(error as? HwTransferError, .funding(t("hardware__connect_error"))) + } + XCTAssertEqual(funding.signCalls, 0) + } + + func testAJadeSessionFailureRetriesSigningOnce() async throws { + let funding = MockHwFunding() + funding.signErrors = [Bitkit.AppError(error: JadeError.DeviceDisconnected)] + let connecting = MockHwConnecting() + let signer = makeSigner(funding: funding, connecting: connecting) + + let result = try await signer.prepareSignedFunding(order: .mock(), walletId: "jade:wallet", address: "bc1q...") + + XCTAssertEqual(result, funding.signedTx) + XCTAssertEqual(funding.signCalls, 2) + XCTAssertEqual(connecting.staleDisconnects, ["jade:wallet"]) + XCTAssertEqual(connecting.ensureCalls, 2) + } + + func testAJadeCancellationOnDeviceIsRethrown() async { + let funding = MockHwFunding() + let connecting = MockHwConnecting() + connecting.connectError = JadeError.UserCancelled + let signer = makeSigner(funding: funding, connecting: connecting) + + await assertThrowsAsync { + _ = try await signer.prepareSignedFunding(order: .mock(), walletId: "jade:wallet", address: "bc1q...") + } _: { error in + XCTAssertEqual(error as? JadeError, .UserCancelled, "a cancel on the Jade must not become a reconnect failure") + } + XCTAssertTrue(funding.composeCalls.isEmpty) + + connecting.connectError = nil + funding.signError = Bitkit.AppError(error: JadeError.UserCancelled) + await assertThrowsAsync { + _ = try await signer.prepareSignedFunding(order: .mock(), walletId: "jade:wallet", address: "bc1q...") + } _: { error in + XCTAssertTrue(error.isJadeUserCancellation()) + XCTAssertNil(error as? HwTransferError) + } + XCTAssertEqual(funding.signCalls, 1, "a cancel on the Jade is not retried") + XCTAssertTrue(connecting.staleDisconnects.isEmpty) + } } /// Async variant of `XCTAssertThrowsError` using a plain (non-autoclosure) operation closure, so the diff --git a/BitkitTests/HwKnownDeviceMatchingTests.swift b/BitkitTests/HwKnownDeviceMatchingTests.swift new file mode 100644 index 000000000..94354b780 --- /dev/null +++ b/BitkitTests/HwKnownDeviceMatchingTests.swift @@ -0,0 +1,385 @@ +@testable import Bitkit +import XCTest + +/// Covers how a connect resolves which stored entry it refreshes and which entries it supersedes, +/// now that one physical device can hold a standard wallet plus its passphrase (hidden) wallets. +final class HwKnownDeviceMatchingTests: XCTestCase { + // MARK: - previous(in:deviceId:fetchedXpubs:) + + func testRefreshesTheEntrySharingKeyMaterial() { + let standard = makeDevice(xpubs: ["nativeSegwit": "zStandard"]) + let hidden = makeDevice(xpubs: ["nativeSegwit": "zHidden"], walletId: "trezor:hidden") + + let previous = HwKnownDeviceMatching.previous( + in: [standard, hidden], + deviceId: "dev1", + fetchedXpubs: ["nativeSegwit": "zHidden", "taproot": "zHiddenTR"] + ) + + XCTAssertEqual(previous?.walletId, "trezor:hidden") + } + + /// A passphrase wallet read for the first time overlaps nothing, so it must not adopt the + /// standard wallet's entry, since that would blend two seeds' xpubs into one record. + func testTreatsUnseenKeyMaterialAsANewIdentity() { + let standard = makeDevice(xpubs: ["nativeSegwit": "zStandard"]) + + let previous = HwKnownDeviceMatching.previous( + in: [standard], + deviceId: "dev1", + fetchedXpubs: ["nativeSegwit": "zHidden"] + ) + + XCTAssertNil(previous) + } + + func testAdoptsALoneEntryStoredBeforeAnyXpubWasCaptured() { + let bare = makeDevice(xpubs: [:], customLabel: "My Trezor") + + let previous = HwKnownDeviceMatching.previous( + in: [bare], + deviceId: "dev1", + fetchedXpubs: ["nativeSegwit": "zStandard"] + ) + + XCTAssertEqual(previous?.customLabel, "My Trezor") + } + + func testIgnoresEntriesOfAnotherDevice() { + let other = makeDevice(id: "dev2", xpubs: ["nativeSegwit": "zStandard"]) + + let previous = HwKnownDeviceMatching.previous( + in: [other], + deviceId: "dev1", + fetchedXpubs: ["nativeSegwit": "zStandard"] + ) + + XCTAssertNil(previous) + } + + // MARK: - named(in:previous:walletKey:) + + /// The wallet reappears on a fresh transport path, so nothing matches by device id, but it is + /// the same key material, and the user's label belongs to the wallet, not to the path. + func testInheritsTheLabelOfTheSameWalletOnAnotherPath() { + let previouslyPaired = makeDevice(id: "old-path", xpubs: ["nativeSegwit": "zStandard"], customLabel: "Savings") + + let named = HwKnownDeviceMatching.named( + in: [previouslyPaired], + previous: nil, + walletKey: HwKnownDevice.walletKey(for: ["nativeSegwit": "zStandard"], fallback: "dev1") + ) + + XCTAssertEqual(named?.customLabel, "Savings") + } + + func testPrefersTheRefreshedEntryForTheLabel() { + let refreshed = makeDevice(xpubs: ["nativeSegwit": "zStandard"], customLabel: "Refreshed") + let sameKey = makeDevice(id: "old-path", xpubs: ["nativeSegwit": "zStandard"], customLabel: "Stale") + + let named = HwKnownDeviceMatching.named( + in: [sameKey, refreshed], + previous: refreshed, + walletKey: refreshed.walletKey + ) + + XCTAssertEqual(named?.customLabel, "Refreshed") + } + + // MARK: - merged(_:with:refreshed:) + + func testKeepsTheStandardWalletWhenAPassphraseWalletIsAdded() { + let standard = makeDevice(xpubs: ["nativeSegwit": "zStandard"], walletId: "trezor:standard") + let hidden = makeDevice(xpubs: ["nativeSegwit": "zHidden"], walletId: "trezor:hidden", passphraseProtected: true) + + let merged = HwKnownDeviceMatching.merged([standard], with: hidden, refreshed: nil) + + XCTAssertEqual(merged.map(\.walletId), ["trezor:standard", "trezor:hidden"]) + } + + func testReplacesTheEntryHoldingTheSameIdentity() { + let stored = makeDevice(xpubs: ["nativeSegwit": "zStandard"], customLabel: "Old") + let known = makeDevice(xpubs: ["nativeSegwit": "zStandard"], customLabel: "New") + + let merged = HwKnownDeviceMatching.merged([stored], with: known, refreshed: stored) + + XCTAssertEqual(merged.map(\.customLabel), ["New"]) + } + + /// Reading a previously rejected address type changes the wallet key, so matching on the new + /// key alone would leave the entry this connect refreshed behind as a duplicate. + func testReplacesTheRefreshedEntryWhenReadingMoreAccountsChangesItsKey() { + let partial = makeDevice(xpubs: ["nativeSegwit": "zStandard"]) + let complete = makeDevice(xpubs: ["nativeSegwit": "zStandard", "taproot": "zTaproot"]) + + let merged = HwKnownDeviceMatching.merged([partial], with: complete, refreshed: partial) + + XCTAssertEqual(merged.count, 1) + XCTAssertEqual(merged[0].xpubs.count, 2) + } + + func testSupersedesWalletsOfASeedTheDeviceNoLongerCarries() { + let wiped = makeDevice(xpubs: ["nativeSegwit": "zOldSeed"], trezorDeviceId: "trezor-before-wipe") + let known = makeDevice(xpubs: ["nativeSegwit": "zNewSeed"], trezorDeviceId: "trezor-after-wipe") + + let merged = HwKnownDeviceMatching.merged([wiped], with: known, refreshed: nil) + + XCTAssertEqual(merged.map(\.xpubs), [["nativeSegwit": "zNewSeed"]]) + } + + /// Two identities of one device report the same Trezor device id, so the wipe rule must not + /// sweep away the sibling wallet. + func testKeepsAnotherIdentityOfTheSameDevice() { + let standard = makeDevice( + xpubs: ["nativeSegwit": "zStandard"], + walletId: "trezor:standard", + trezorDeviceId: "trezor-id" + ) + let hidden = makeDevice( + xpubs: ["nativeSegwit": "zHidden"], + walletId: "trezor:hidden", + passphraseProtected: true, + trezorDeviceId: "trezor-id" + ) + + let merged = HwKnownDeviceMatching.merged([standard], with: hidden, refreshed: nil) + + XCTAssertEqual(merged.map(\.walletId), ["trezor:standard", "trezor:hidden"]) + } + + func testLeavesEntriesOfAnotherDeviceAlone() { + let other = makeDevice(id: "dev2", xpubs: ["nativeSegwit": "zOther"], trezorDeviceId: "other-trezor") + let known = makeDevice(xpubs: ["nativeSegwit": "zStandard"], trezorDeviceId: "trezor-id") + + let merged = HwKnownDeviceMatching.merged([other], with: known, refreshed: nil) + + XCTAssertEqual(merged.count, 2) + } + + // MARK: - Identity helpers + + func testWalletKeyIsIndependentOfAddressTypeKeys() { + let a = makeDevice(xpubs: ["nativeSegwit": "zA", "taproot": "zB"]) + let b = makeDevice(xpubs: ["taproot": "zA", "nativeSegwit": "zB"]) + + XCTAssertEqual(a.walletKey, b.walletKey) + } + + func testWalletKeyFallsBackToTheTransportIdWithoutXpubs() { + XCTAssertEqual(makeDevice(xpubs: [:]).walletKey, "dev1") + } + + func testEntryIdSeparatesTwoIdentitiesOfOneDevice() { + let standard = makeDevice(xpubs: ["nativeSegwit": "zStandard"]) + let hidden = makeDevice(xpubs: ["nativeSegwit": "zHidden"]) + + XCTAssertNotEqual(standard.entryId, hidden.entryId) + } + + func testResolvedWalletIdPrefersTheStoredValue() { + let device = makeDevice(xpubs: ["nativeSegwit": "zStandard"], walletId: "trezor:stored") + + XCTAssertEqual(device.resolvedWalletId, "trezor:stored") + } + + // MARK: - Decoding entries stored before hidden wallets existed + + func testDecodesLegacyEntriesAndDerivesTheirWalletId() throws { + let legacy = """ + { + "id": "dev1", + "name": "Trezor", + "path": "ble://dev1", + "transportType": "bluetooth", + "lastConnectedAt": 0, + "xpubs": { "nativeSegwit": "zStandard" } + } + """ + + let decoded = try JSONDecoder().decode(HwKnownDevice.self, from: Data(legacy.utf8)) + + XCTAssertNil(decoded.walletId) + XCTAssertFalse(decoded.passphraseProtected) + XCTAssertNil(decoded.trezorDeviceId) + XCTAssertEqual(decoded.resolvedWalletId, try HwWalletId.derive(xpubs: ["nativeSegwit": "zStandard"])) + } + + // MARK: - Vendors + + func testDecodesLegacyEntriesAsTrezor() throws { + let decoded = try decode(legacyJson(id: "dev1", walletId: "trezor:standard")) + + XCTAssertEqual(decoded.vendor, .trezor) + XCTAssertTrue(decoded.belongs(to: .trezor)) + XCTAssertNil(decoded.jadeDeviceId) + XCTAssertNil(decoded.hardwareId) + } + + func testInfersJadeForALegacyEntryInTheJadeNamespace() throws { + XCTAssertEqual(try decode(legacyJson(id: "jade:bluetooth:aabbcc", walletId: nil)).vendor, .blockstream) + XCTAssertEqual(try decode(legacyJson(id: "dev1", walletId: "jade:wallet")).vendor, .blockstream) + } + + func testAJadeEntrySurvivesARoundTrip() throws { + let jade = makeDevice( + id: "jade:bluetooth:aabbcc", + xpubs: ["nativeSegwit": "zJade"], + customLabel: "Travel", + walletId: "jade:wallet", + vendor: .blockstream, + jadeDeviceId: "aabbcc" + ) + + let decoded = try JSONDecoder().decode(HwKnownDevice.self, from: JSONEncoder().encode(jade)) + + XCTAssertEqual(decoded, jade) + XCTAssertEqual(decoded.vendor, .blockstream) + XCTAssertEqual(decoded.hardwareId, "aabbcc") + } + + /// A newer build may store a vendor this one does not know. Decoding it must not fail the whole + /// device list, and writing it back must keep the vendor it was stored under. + func testAnUnknownVendorIsWrittenBackUnchanged() throws { + let decoded = try decode(legacyJson(id: "passport1", walletId: "foundation:wallet", vendor: "foundation")) + + XCTAssertEqual(decoded.unknownVendor, "foundation") + XCTAssertFalse(HwWalletVendor.allCases.contains(where: decoded.belongs(to:))) + + let reencoded = try JSONEncoder().encode(decoded.refreshed(path: "ble://moved", at: Date(timeIntervalSince1970: 5))) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: reencoded) as? [String: Any]) + XCTAssertEqual(json["vendor"] as? String, "foundation") + } + + func testADeviceOfAnotherVendorNeverReplacesAnEntry() { + let trezor = makeDevice(id: "shared", xpubs: ["nativeSegwit": "zShared"], walletId: "trezor:wallet") + let jade = makeDevice( + id: "shared", + xpubs: ["nativeSegwit": "zShared"], + walletId: "jade:wallet", + vendor: .blockstream, + jadeDeviceId: "aabbcc" + ) + + let merged = HwKnownDeviceMatching.merged([trezor], with: jade, refreshed: nil) + + XCTAssertEqual(merged.map(\.walletId), ["trezor:wallet", "jade:wallet"]) + } + + func testAJadeEntryIsSupersededByADifferentJadeDeviceId() { + let wiped = makeDevice( + id: "jade:bluetooth:aabbcc", + xpubs: ["nativeSegwit": "zOldSeed"], + vendor: .blockstream, + jadeDeviceId: "aabbcc" + ) + let known = makeDevice( + id: "jade:bluetooth:aabbcc", + xpubs: ["nativeSegwit": "zNewSeed"], + vendor: .blockstream, + jadeDeviceId: "ddeeff" + ) + + let merged = HwKnownDeviceMatching.merged([wiped], with: known, refreshed: nil) + + XCTAssertEqual(merged.map(\.xpubs), [["nativeSegwit": "zNewSeed"]]) + } + + func testAJadeEntryIsReplacedByAReReadOfTheSameHardwareWithMoreKeys() { + let stored = makeDevice( + id: "jade:bluetooth:aabbcc", + path: "ble:old", + xpubs: ["nativeSegwit": "zJade"], + vendor: .blockstream, + jadeDeviceId: "aabbcc" + ) + let reread = makeDevice( + id: "jade:bluetooth:aabbcc", + path: "ble:new", + xpubs: ["nativeSegwit": "zJade", "taproot": "zJadeTR"], + vendor: .blockstream, + jadeDeviceId: "aabbcc" + ) + + let merged = HwKnownDeviceMatching.merged([stored], with: reread, refreshed: stored) + + XCTAssertEqual(merged.map(\.path), ["ble:new"]) + XCTAssertEqual(merged.first?.xpubs.count, 2) + } + + func testAJadeEntryWithoutAStoredIdDerivesAJadeWalletId() throws { + let jade = makeDevice(xpubs: ["nativeSegwit": "zJade"], vendor: .blockstream) + + let walletId = try XCTUnwrap(jade.resolvedWalletId) + + XCTAssertTrue(walletId.hasPrefix("jade:")) + XCTAssertEqual(walletId, try HwWalletId.derive(xpubs: ["nativeSegwit": "zJade"], vendor: .blockstream)) + } + + func testRefreshingAnEntryOnlyMovesItsPathAndTime() { + let stored = makeDevice( + id: "jade:bluetooth:aabbcc", + path: "ble:old", + xpubs: ["nativeSegwit": "zJade"], + customLabel: "Travel", + walletId: "jade:wallet", + vendor: .blockstream, + jadeDeviceId: "aabbcc" + ) + + let refreshed = stored.refreshed(path: "ble:new", at: Date(timeIntervalSince1970: 50)) + + XCTAssertEqual(refreshed.path, "ble:new") + XCTAssertEqual(refreshed.lastConnectedAt, Date(timeIntervalSince1970: 50)) + XCTAssertEqual(refreshed.entryId, stored.entryId) + XCTAssertEqual(refreshed.customLabel, "Travel") + XCTAssertEqual(refreshed.resolvedWalletId, "jade:wallet") + XCTAssertEqual(refreshed.vendor, .blockstream) + XCTAssertEqual(refreshed.hardwareId, "aabbcc") + } + + private func makeDevice( + id: String = "dev1", + path: String? = nil, + xpubs: [String: String], + customLabel: String? = nil, + walletId: String? = nil, + passphraseProtected: Bool = false, + trezorDeviceId: String? = nil, + vendor: HwWalletVendor = .trezor, + jadeDeviceId: String? = nil + ) -> HwKnownDevice { + HwKnownDevice( + id: id, + name: vendor == .trezor ? "Trezor" : "Jade", + path: path ?? "ble://\(id)", + transportType: "bluetooth", + lastConnectedAt: Date(timeIntervalSince1970: 0), + xpubs: xpubs, + customLabel: customLabel, + walletId: walletId, + passphraseProtected: passphraseProtected, + trezorDeviceId: trezorDeviceId, + vendor: vendor, + jadeDeviceId: jadeDeviceId + ) + } + + private func legacyJson(id: String, walletId: String?, vendor: String? = nil) -> String { + let walletIdField = walletId.map { ", \"walletId\": \"\($0)\"" } ?? "" + let vendorField = vendor.map { ", \"vendor\": \"\($0)\"" } ?? "" + return """ + { + "id": "\(id)", + "name": "Device", + "path": "ble://\(id)", + "transportType": "bluetooth", + "lastConnectedAt": 0, + "xpubs": { "nativeSegwit": "zStandard" }\(walletIdField)\(vendorField) + } + """ + } + + private func decode(_ json: String) throws -> HwKnownDevice { + try JSONDecoder().decode(HwKnownDevice.self, from: Data(json.utf8)) + } +} diff --git a/BitkitTests/HwKnownDeviceStorageTests.swift b/BitkitTests/HwKnownDeviceStorageTests.swift new file mode 100644 index 000000000..db7c7adce --- /dev/null +++ b/BitkitTests/HwKnownDeviceStorageTests.swift @@ -0,0 +1,341 @@ +@testable import Bitkit +import Combine +import XCTest + +/// Covers identity-scoped reads and writes: one physical device can hold a standard wallet plus its +/// passphrase wallets, so `id` no longer identifies a stored entry on its own. Each vendor also reads +/// and writes only its own slice of the store. +final class HwKnownDeviceStorageTests: XCTestCase { + private static let storageKey = "trezor.knownDevices" + private static let pendingNamesKey = "trezor.pendingWalletNames" + private var savedDefaults: Data? + private var savedPendingNames: [String: String]? + private var cancellables: Set = [] + + override func setUp() { + super.setUp() + savedDefaults = UserDefaults.standard.data(forKey: Self.storageKey) + savedPendingNames = UserDefaults.standard.dictionary(forKey: Self.pendingNamesKey) as? [String: String] + cancellables = [] + HwKnownDeviceStorage.removeAll() + } + + override func tearDown() { + cancellables = [] + HwKnownDeviceStorage.removeAll() + if let savedDefaults { + UserDefaults.standard.set(savedDefaults, forKey: Self.storageKey) + } + if let savedPendingNames { + UserDefaults.standard.set(savedPendingNames, forKey: Self.pendingNamesKey) + } + super.tearDown() + } + + func testSavingAPassphraseWalletKeepsTheStandardWalletOfTheSameDevice() { + HwKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zStandard"], walletId: "trezor:standard")) + HwKnownDeviceStorage.save(makeDevice( + xpubs: ["nativeSegwit": "zHidden"], + walletId: "trezor:hidden", + passphraseProtected: true + )) + + let stored = HwKnownDeviceStorage.loadAll() + XCTAssertEqual(Set(stored.compactMap(\.walletId)), ["trezor:standard", "trezor:hidden"]) + XCTAssertEqual(stored.filter(\.passphraseProtected).count, 1) + } + + func testSavingTheSameIdentityAgainReplacesIt() { + HwKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zStandard"], customLabel: "Old")) + HwKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zStandard"], customLabel: "New")) + + XCTAssertEqual(HwKnownDeviceStorage.loadAll().map(\.customLabel), ["New"]) + } + + func testRemovingOneWalletLeavesTheDevicesOtherWalletsPaired() { + HwKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zStandard"], walletId: "trezor:standard")) + HwKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zHidden"], walletId: "trezor:hidden")) + + HwKnownDeviceStorage.remove(walletId: "trezor:hidden") + + XCTAssertEqual(HwKnownDeviceStorage.loadAll().compactMap(\.walletId), ["trezor:standard"]) + XCTAssertTrue(HwKnownDeviceStorage.isKnown(id: "dev1"), "the device itself stays paired") + } + + /// Entries written before the wallet id was persisted resolve it from their xpubs. + func testRemovingAWalletMatchesEntriesWithoutAStoredWalletId() throws { + let legacy = makeDevice(xpubs: ["nativeSegwit": "zStandard"], walletId: nil) + HwKnownDeviceStorage.save(legacy) + + try HwKnownDeviceStorage.remove(walletId: HwWalletId.derive(xpubs: legacy.xpubs)) + + XCTAssertTrue(HwKnownDeviceStorage.loadAll().isEmpty) + } + + func testRemovingByDeviceIdForgetsEveryWalletItHolds() { + HwKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zStandard"], walletId: "trezor:standard")) + HwKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zHidden"], walletId: "trezor:hidden")) + HwKnownDeviceStorage.save(makeDevice(id: "dev2", xpubs: ["nativeSegwit": "zOther"], walletId: "trezor:other")) + + HwKnownDeviceStorage.remove(id: "dev1", vendor: .trezor) + + XCTAssertEqual(HwKnownDeviceStorage.loadAll().compactMap(\.walletId), ["trezor:other"]) + } + + func testLoadingByWalletIdReturnsOnlyThatIdentitysEntries() { + HwKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zStandard"], walletId: "trezor:standard")) + HwKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zHidden"], walletId: "trezor:hidden")) + + let entries = HwKnownDeviceStorage.loadAll(walletId: "trezor:hidden") + + XCTAssertEqual(entries.count, 1) + XCTAssertEqual(entries.first?.xpubs, ["nativeSegwit": "zHidden"]) + } + + func testNewFieldsSurviveAStorageRoundTrip() { + HwKnownDeviceStorage.save(makeDevice( + xpubs: ["nativeSegwit": "zHidden"], + walletId: "trezor:hidden", + passphraseProtected: true, + trezorDeviceId: "trezor-id" + )) + + let stored = HwKnownDeviceStorage.loadAll().first + XCTAssertEqual(stored?.walletId, "trezor:hidden") + XCTAssertTrue(stored?.passphraseProtected == true) + XCTAssertEqual(stored?.trezorDeviceId, "trezor-id") + } + + // MARK: - Hardware wallet names + + func testAPendingNameAndTheDeviceListAreWrittenTogether() { + let device = makeDevice(xpubs: ["nativeSegwit": "zStandard"], customLabel: "Cold", walletId: "trezor:standard") + HwKnownDeviceStorage.save(device) + + HwKnownDeviceStorage.saveAll( + [], + vendor: .trezor, + pendingName: PendingHwWalletName(walletId: "trezor:standard", name: "Cold") + ) + + XCTAssertTrue(HwKnownDeviceStorage.loadAll().isEmpty) + XCTAssertEqual(HwKnownDeviceStorage.loadPendingNames(), ["trezor:standard": "Cold"]) + } + + /// Adoption on pairing consumes a pending name by masking rather than by a second write, so a + /// wallet the device list already names must not report one. + func testAPendingNameIsMaskedOnceTheWalletIsPairedAndNamed() { + HwKnownDeviceStorage.setPendingName(walletId: "trezor:standard", name: "Cold") + HwKnownDeviceStorage.save( + makeDevice(xpubs: ["nativeSegwit": "zStandard"], customLabel: "Cold", walletId: "trezor:standard") + ) + + XCTAssertTrue(HwKnownDeviceStorage.loadPendingNames().isEmpty) + XCTAssertEqual(HwKnownDeviceStorage.backupSnapshot(), ["trezor:standard": "Cold"]) + } + + func testTheNameOfAPairedWalletWinsOverAPendingOne() { + HwKnownDeviceStorage.setPendingName(walletId: "trezor:standard", name: "Restored") + HwKnownDeviceStorage.save( + makeDevice(xpubs: ["nativeSegwit": "zStandard"], customLabel: "Renamed", walletId: "trezor:standard") + ) + + XCTAssertEqual(HwKnownDeviceStorage.backupSnapshot(), ["trezor:standard": "Renamed"]) + } + + func testSettingAPendingNameToNilDropsIt() { + HwKnownDeviceStorage.setPendingName(walletId: "trezor:standard", name: "Cold") + HwKnownDeviceStorage.setPendingName(walletId: "trezor:standard", name: nil) + + XCTAssertTrue(HwKnownDeviceStorage.backupSnapshot().isEmpty) + } + + func testRestoringNamesLetsALocalNameWinAndNeverClears() { + HwKnownDeviceStorage.setPendingName(walletId: "trezor:standard", name: "Local") + + HwKnownDeviceStorage.restoreNames(["trezor:standard": "Backed up", "trezor:hidden": "Hidden"]) + XCTAssertEqual( + HwKnownDeviceStorage.backupSnapshot(), + ["trezor:standard": "Local", "trezor:hidden": "Hidden"] + ) + + // An envelope written before the field carries no names, and must not drop what is stored. + HwKnownDeviceStorage.restoreNames([:]) + XCTAssertEqual( + HwKnownDeviceStorage.backupSnapshot(), + ["trezor:standard": "Local", "trezor:hidden": "Hidden"] + ) + } + + func testForgettingAWalletDropsTheNameKeptForIt() { + HwKnownDeviceStorage.setPendingName(walletId: "trezor:hidden", name: "Hidden") + HwKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zHidden"], walletId: "trezor:hidden")) + + HwKnownDeviceStorage.remove(walletId: "trezor:hidden") + + XCTAssertTrue(HwKnownDeviceStorage.backupSnapshot().isEmpty) + } + + func testRemoveAllClearsPendingNamesToo() { + HwKnownDeviceStorage.setPendingName(walletId: "trezor:standard", name: "Cold") + + HwKnownDeviceStorage.removeAll() + + XCTAssertTrue(HwKnownDeviceStorage.backupSnapshot().isEmpty) + } + + /// Every connect rewrites the device list to refresh `lastConnectedAt`; only a name change may + /// mark the metadata backup stale. + func testTheNameSignalFiresOnARenameButNotOnAReconnect() { + let device = makeDevice(xpubs: ["nativeSegwit": "zStandard"], customLabel: "Cold", walletId: "trezor:standard") + HwKnownDeviceStorage.save(device) + + var fires = 0 + HwKnownDeviceStorage.namesChangedPublisher + .sink { fires += 1 } + .store(in: &cancellables) + + var reconnected = device + reconnected.lastConnectedAt = Date(timeIntervalSince1970: 5000) + HwKnownDeviceStorage.saveAll([reconnected], vendor: .trezor) + XCTAssertEqual(fires, 0, "a reconnect must not re-upload the metadata envelope") + + var renamed = reconnected + renamed.customLabel = "Vault" + HwKnownDeviceStorage.saveAll([renamed], vendor: .trezor) + XCTAssertEqual(fires, 1) + } + + // MARK: - Vendor slices + + func testSavingOneVendorsSliceKeepsTheOtherVendorsEntries() { + HwKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zTrezor"], walletId: "trezor:standard")) + HwKnownDeviceStorage.save(makeJade(walletId: "jade:wallet")) + + HwKnownDeviceStorage.saveAll([], vendor: .trezor) + + XCTAssertEqual(HwKnownDeviceStorage.loadAll().compactMap(\.walletId), ["jade:wallet"]) + } + + func testLoadingAVendorsSliceReturnsOnlyItsEntries() { + HwKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zTrezor"], walletId: "trezor:standard")) + HwKnownDeviceStorage.save(makeJade(walletId: "jade:wallet")) + + XCTAssertEqual(HwKnownDeviceStorage.loadAll(vendor: .trezor).compactMap(\.walletId), ["trezor:standard"]) + XCTAssertEqual(HwKnownDeviceStorage.loadAll(vendor: .blockstream).compactMap(\.walletId), ["jade:wallet"]) + XCTAssertEqual(Set(HwKnownDeviceStorage.loadAll().compactMap(\.walletId)), ["trezor:standard", "jade:wallet"]) + } + + /// Both vendors share a transport-level id here only to prove the removal stays inside its slice. + func testRemovingByIdOnlyTouchesThatVendor() { + HwKnownDeviceStorage.save(makeDevice(id: "shared", xpubs: ["nativeSegwit": "zTrezor"], walletId: "trezor:standard")) + HwKnownDeviceStorage.save(makeJade(id: "shared", walletId: "jade:wallet")) + + HwKnownDeviceStorage.remove(id: "shared", vendor: .trezor) + + XCTAssertEqual(HwKnownDeviceStorage.loadAll().compactMap(\.walletId), ["jade:wallet"]) + XCTAssertFalse(HwKnownDeviceStorage.isKnown(id: "shared", vendor: .trezor)) + XCTAssertTrue(HwKnownDeviceStorage.isKnown(id: "shared", vendor: .blockstream)) + XCTAssertTrue(HwKnownDeviceStorage.isKnown(id: "shared")) + } + + func testTheBackupSnapshotCoversEveryVendor() { + HwKnownDeviceStorage.save( + makeDevice(xpubs: ["nativeSegwit": "zTrezor"], customLabel: "Cold", walletId: "trezor:standard") + ) + HwKnownDeviceStorage.save(makeJade(customLabel: "Travel", walletId: "jade:wallet")) + HwKnownDeviceStorage.setPendingName(walletId: "jade:removed", name: "Old Jade") + + XCTAssertEqual( + HwKnownDeviceStorage.backupSnapshot(), + ["trezor:standard": "Cold", "jade:wallet": "Travel", "jade:removed": "Old Jade"] + ) + } + + func testEntriesStoredBeforeVendorsExistedStayInTheTrezorSlice() throws { + try storeRawEntries([legacyEntry(id: "dev1", walletId: "trezor:legacy")]) + + XCTAssertEqual(HwKnownDeviceStorage.loadAll(vendor: .trezor).map(\.id), ["dev1"]) + XCTAssertTrue(HwKnownDeviceStorage.loadAll(vendor: .blockstream).isEmpty) + } + + /// A newer build may pair a vendor this one does not know. Rolling back must neither show that + /// wallet as another vendor's nor drop it on the next write. + func testAnEntryOfAnUnknownVendorIsKeptOutOfEverySliceAndWrittenBack() throws { + var foreign = legacyEntry(id: "passport1", walletId: "foundation:wallet") + foreign["vendor"] = "foundation" + try storeRawEntries([legacyEntry(id: "dev1", walletId: "trezor:standard"), foreign]) + + XCTAssertEqual(HwKnownDeviceStorage.loadAll().map(\.id), ["dev1"]) + XCTAssertFalse(HwKnownDeviceStorage.isKnown(id: "passport1")) + + HwKnownDeviceStorage.saveAll([], vendor: .trezor) + HwKnownDeviceStorage.save(makeJade(walletId: "jade:wallet")) + HwKnownDeviceStorage.remove(walletId: "jade:wallet") + + XCTAssertTrue(HwKnownDeviceStorage.loadAll().isEmpty) + let stored = try XCTUnwrap(UserDefaults.standard.data(forKey: Self.storageKey)) + let entries = try XCTUnwrap(JSONSerialization.jsonObject(with: stored) as? [[String: Any]]) + XCTAssertEqual(entries.compactMap { $0["id"] as? String }, ["passport1"]) + XCTAssertEqual(entries.compactMap { $0["vendor"] as? String }, ["foundation"]) + } + + private func makeDevice( + id: String = "dev1", + xpubs: [String: String], + customLabel: String? = nil, + walletId: String? = nil, + passphraseProtected: Bool = false, + trezorDeviceId: String? = nil + ) -> HwKnownDevice { + HwKnownDevice( + id: id, + name: "Trezor", + path: "ble://\(id)", + transportType: "bluetooth", + lastConnectedAt: Date(timeIntervalSince1970: 0), + xpubs: xpubs, + customLabel: customLabel, + walletId: walletId, + passphraseProtected: passphraseProtected, + trezorDeviceId: trezorDeviceId + ) + } + + private func makeJade( + id: String = "jade:bluetooth:aabbcc", + customLabel: String? = nil, + walletId: String + ) -> HwKnownDevice { + HwKnownDevice( + id: id, + name: "Jade AABBCC", + path: "ble:jade", + transportType: "bluetooth", + model: "Jade", + lastConnectedAt: Date(timeIntervalSince1970: 0), + xpubs: ["nativeSegwit": "zJade"], + customLabel: customLabel, + walletId: walletId, + vendor: .blockstream, + jadeDeviceId: "aabbcc" + ) + } + + /// An entry as a build without vendors wrote it: no `vendor` key at all. + private func legacyEntry(id: String, walletId: String) -> [String: Any] { + [ + "id": id, + "name": "Device", + "path": "ble://\(id)", + "transportType": "bluetooth", + "lastConnectedAt": 0, + "xpubs": ["nativeSegwit": "z\(id)"], + "walletId": walletId, + ] + } + + private func storeRawEntries(_ entries: [[String: Any]]) throws { + try UserDefaults.standard.set(JSONSerialization.data(withJSONObject: entries), forKey: Self.storageKey) + } +} diff --git a/BitkitTests/HwTransferMocks.swift b/BitkitTests/HwTransferMocks.swift index b66268caf..31a4f5ebc 100644 --- a/BitkitTests/HwTransferMocks.swift +++ b/BitkitTests/HwTransferMocks.swift @@ -19,8 +19,12 @@ final class MockHwFunding: HwTransferFunding { var signErrors: [Error] = [] var signDelay: Double = 0 var cancellationIgnoringSignDelay: Double = 0 + /// Holds every sign until opened, so a test can act while the device is signing. + var signGate: AsyncGate? var broadcastError: Error? var broadcastDelay: Double = 0 + /// Holds every broadcast until opened, so a test can act while its outcome is unknown. + var broadcastGate: AsyncGate? var funding = HwFundingTransaction(psbt: "psbt", miningFeeSats: 141, feeRate: 1, totalSpent: 43186, satsPerVByte: 1) var signedTx = HwFundingSignedTx(serializedTx: "rawtx", miningFeeSats: 141, feeRate: 1, totalSpent: 43186) var broadcastTxId = "txid" @@ -88,6 +92,9 @@ final class MockHwFunding: HwTransferFunding { func signFunding(walletId _: String, funding _: HwFundingTransaction) async throws -> HwFundingSignedTx { signCalls += 1 + if let signGate { + await signGate.wait() + } if signDelay > 0 { try await Task.sleep(nanoseconds: UInt64(signDelay * 1_000_000_000)) } @@ -110,6 +117,9 @@ final class MockHwFunding: HwTransferFunding { func broadcastFunding(serializedTx: String) async throws -> String { broadcastCalls += 1 broadcastTransactions.append(serializedTx) + if let broadcastGate { + await broadcastGate.wait() + } if broadcastDelay > 0 { try await Task.sleep(nanoseconds: UInt64(broadcastDelay * 1_000_000_000)) } @@ -123,6 +133,10 @@ final class MockHwFunding: HwTransferFunding { @MainActor final class MockHwConnecting: HwTransferConnecting { var connectError: Error? + var connectDelay: Double = 0 + /// Holds every connect until opened, ignoring cancellation the way a device waiting for its PIN does. + var connectGate: AsyncGate? + var reconnectTimeoutSeconds: Double = 5 var isBluetooth = false /// Wallets whose passphrase the device no longer holds, so signing has to ask for it again. var walletsNeedingPassphrase: Set = [] @@ -134,11 +148,21 @@ final class MockHwConnecting: HwTransferConnecting { func ensureConnected(walletId _: String) async throws { ensureCalls += 1 + if let connectGate { + await connectGate.wait() + } + if connectDelay > 0 { + try await Task.sleep(nanoseconds: UInt64(connectDelay * 1_000_000_000)) + } if let connectError { throw connectError } } + func reconnectTimeout(walletId _: String) -> Double { + reconnectTimeoutSeconds + } + func needsPassphrase(walletId: String) -> Bool { walletsNeedingPassphrase.contains(walletId) } diff --git a/BitkitTests/HwWalletIdTests.swift b/BitkitTests/HwWalletIdTests.swift index 46bce75ed..aa8fe15ef 100644 --- a/BitkitTests/HwWalletIdTests.swift +++ b/BitkitTests/HwWalletIdTests.swift @@ -43,6 +43,21 @@ final class HwWalletIdTests: XCTestCase { XCTAssertThrowsError(try HwWalletId.derive(xpubs: [:])) } + func testJadeWalletIdsUseTheJadeNamespace() throws { + let xpubs = ["taproot": "zTR", "nativeSegwit": "zNS"] + let expected = "jade:" + expectedHash(ofSortedValues: xpubs) + XCTAssertEqual(try HwWalletId.derive(xpubs: xpubs, vendor: .blockstream), expected) + } + + /// The same seed paired on a Trezor and on a Jade must stay two wallets. + func testEqualXpubsOnTwoVendorsDeriveTwoIds() throws { + let xpubs = ["nativeSegwit": "zNS"] + let trezor = try HwWalletId.derive(xpubs: xpubs, vendor: .trezor) + let jade = try HwWalletId.derive(xpubs: xpubs, vendor: .blockstream) + XCTAssertNotEqual(trezor, jade) + XCTAssertEqual(trezor, try HwWalletId.derive(xpubs: xpubs), "Trezor ids keep the namespace they always had") + } + private func expectedHash(ofSortedValues xpubs: [String: String]) -> String { let joined = xpubs.values.sorted().joined(separator: "\n") return SHA256.hash(data: Data(joined.utf8)) diff --git a/BitkitTests/HwWalletManagerFundingTests.swift b/BitkitTests/HwWalletManagerFundingTests.swift index 047261942..a877c8468 100644 --- a/BitkitTests/HwWalletManagerFundingTests.swift +++ b/BitkitTests/HwWalletManagerFundingTests.swift @@ -27,9 +27,9 @@ final class HwWalletManagerFundingTests: XCTestCase { ) } - private func makeDevice(id: String, xpubs: [String: String]) -> TrezorKnownDevice { + private func makeDevice(id: String, xpubs: [String: String]) -> HwKnownDevice { xpubsByDeviceId[id] = xpubs - return TrezorKnownDevice( + return HwKnownDevice( id: id, name: id, path: "ble:\(id)", diff --git a/BitkitTests/HwWalletManagerPassphraseTests.swift b/BitkitTests/HwWalletManagerPassphraseTests.swift index f4571f5c7..d8b5e8d78 100644 --- a/BitkitTests/HwWalletManagerPassphraseTests.swift +++ b/BitkitTests/HwWalletManagerPassphraseTests.swift @@ -9,11 +9,12 @@ import XCTest final class HwWalletManagerPassphraseTests: XCTestCase { // MARK: - Fake session - private final class MockHwDeviceSession: HwDeviceSessioning { - var storedDevices: [TrezorKnownDevice] = [] + private final class MockTrezorSession: TrezorSessioning { + var storedDevices: [HwKnownDevice] = [] var connectedDeviceId: String? var connectedWalletId: String? var connectedFeatures: TrezorFeatures? + var isSessionActive = false /// Wallet id the next `connectWithWalletMode` resolves to, per mode. A hidden open that is /// not listed here resolves to `openedWalletIdOnHidden`, standing in for the different wallet @@ -22,7 +23,7 @@ final class HwWalletManagerPassphraseTests: XCTestCase { var openedWalletIdOnStandard: String? /// An entry the device writes when a hidden open reads a wallet Bitkit has never seen, /// mirroring how reading accounts persists the wallet before anything can reject it. - var writesEntryOnHiddenOpen: TrezorKnownDevice? + var writesEntryOnHiddenOpen: HwKnownDevice? var ensureConnectedError: Error? var connectWithWalletModeError: Error? @@ -35,6 +36,9 @@ final class HwWalletManagerPassphraseTests: XCTestCase { private(set) var staleDisconnects: [String] = [] private(set) var forgottenWalletIds: [String] = [] private(set) var warmUpCalls: [String] = [] + private(set) var releaseCalls = 0 + private(set) var startAutoReconnectCalls = 0 + private(set) var renameCalls: [(walletId: String, newName: String)] = [] func ensureConnected(deviceId: String) async throws { ensureCalls.append(deviceId) @@ -99,6 +103,21 @@ final class HwWalletManagerPassphraseTests: XCTestCase { connectedWalletId = nil } } + + func releaseSession() async { + releaseCalls += 1 + isSessionActive = false + } + + func startAutoReconnect() { + startAutoReconnectCalls += 1 + } + + func resetForWipe() async {} + + func renameWallet(walletId: String, newName: String) { + renameCalls.append((walletId, newName)) + } } private final class NoopWatcher: OnChainWatcherServicing, @unchecked Sendable { @@ -107,12 +126,12 @@ final class HwWalletManagerPassphraseTests: XCTestCase { func stopAllWatchers() {} } - private var session = MockHwDeviceSession() + private var session = MockTrezorSession() private var deletedWalletIds: [String] = [] override func setUp() { super.setUp() - session = MockHwDeviceSession() + session = MockTrezorSession() deletedWalletIds = [] } @@ -587,7 +606,7 @@ final class HwWalletManagerPassphraseTests: XCTestCase { addressProvider: @escaping HwWalletManager.AddressProvider = { _ in throw TrezorError.DeviceDisconnected } ) -> HwWalletManager { HwWalletManager( - session: session, + trezorSession: session, watcherService: NoopWatcher(), monitoredTypes: { ["nativeSegwit"] }, electrumUrl: { "ssl://test:1" }, @@ -611,8 +630,8 @@ final class HwWalletManagerPassphraseTests: XCTestCase { xpubs: [String: String] = ["nativeSegwit": "zStandard"], walletId: String, passphraseProtected: Bool = false - ) -> TrezorKnownDevice { - TrezorKnownDevice( + ) -> HwKnownDevice { + HwKnownDevice( id: id, name: "Trezor", path: "ble://\(id)", diff --git a/BitkitTests/HwWalletManagerTests.swift b/BitkitTests/HwWalletManagerTests.swift index f81c597ef..d57e89e12 100644 --- a/BitkitTests/HwWalletManagerTests.swift +++ b/BitkitTests/HwWalletManagerTests.swift @@ -159,9 +159,9 @@ final class HwWalletManagerTests: XCTestCase { label: String? = nil, model: String? = "Safe 5", lastConnectedAt: Date = Date(timeIntervalSince1970: 1000) - ) -> TrezorKnownDevice { + ) -> HwKnownDevice { xpubsByDeviceId[id] = xpubs - return TrezorKnownDevice( + return HwKnownDevice( id: id, name: id, path: "ble:\(id)", @@ -284,7 +284,7 @@ final class HwWalletManagerTests: XCTestCase { /// Needed when one device id holds several identities, where the registry above can only /// remember the last one written for it. - private func watcherId(_ device: TrezorKnownDevice, _ addressType: String) -> String { + private func watcherId(_ device: HwKnownDevice, _ addressType: String) -> String { let derived = (try? HwWalletId.derive(xpubs: device.xpubs)) ?? device.id return "\(derived)|\(addressType)" } @@ -441,6 +441,47 @@ final class HwWalletManagerTests: XCTestCase { XCTAssertEqual(vm.hwWalletIds.count, 1) } + /// Unlike the same seed reached over two transports, the same seed on two vendors is two wallets: + /// each is reached and signed with through its own device. + func testSameSeedOnTrezorAndJadeStaysTwoWallets() async throws { + let xpubs = ["nativeSegwit": "zpubShared"] + let watcherService = MockWatcherService() + let trezor = makeDevice(id: "trezor1", xpubs: xpubs) + let jade = HwKnownDevice( + id: "jade:bluetooth:aabbcc", + name: "Jade AABBCC", + path: "ble:jade", + transportType: "bluetooth", + model: "Jade Plus", + lastConnectedAt: Date(timeIntervalSince1970: 2000), + xpubs: xpubs, + vendor: .blockstream, + jadeDeviceId: "aabbcc" + ) + let vm = makeViewModel(watcherService: watcherService) + + vm.updateDevices(knownDevices: [trezor, jade], connectedDeviceId: nil) + + XCTAssertEqual(vm.wallets.count, 2) + let trezorWallet = try XCTUnwrap(vm.wallets.first { $0.vendor == .trezor }) + let jadeWallet = try XCTUnwrap(vm.wallets.first { $0.vendor == .blockstream }) + XCTAssertTrue(trezorWallet.walletId.hasPrefix("trezor:")) + XCTAssertTrue(jadeWallet.walletId.hasPrefix("jade:")) + XCTAssertEqual(jadeWallet.walletId, try HwWalletId.derive(xpubs: xpubs, vendor: .blockstream)) + XCTAssertEqual(trezorWallet.name, "Trezor Safe 5") + XCTAssertEqual(jadeWallet.name, "Jade Plus") + XCTAssertEqual(jadeWallet.deviceIds, [jade.id]) + XCTAssertEqual(vm.vendor(walletId: jadeWallet.walletId), .blockstream) + XCTAssertEqual(vm.vendor(walletId: trezorWallet.walletId), .trezor) + XCTAssertEqual(vm.hwWalletIds, [trezorWallet.walletId, jadeWallet.walletId]) + await waitUntil { watcherService.startedParams.count == 2 } + XCTAssertEqual( + Set(watcherService.startedParams.map(\.walletId)), + [trezorWallet.walletId, jadeWallet.walletId], + "each wallet watches the shared account under its own id" + ) + } + func testActivityPersistedWithDeviceWalletId() async throws { let xpubs = ["nativeSegwit": "zpubNS"] let device = makeDevice(id: "dev1", xpubs: xpubs) diff --git a/BitkitTests/HwWalletManagerVendorTests.swift b/BitkitTests/HwWalletManagerVendorTests.swift new file mode 100644 index 000000000..f67c845cf --- /dev/null +++ b/BitkitTests/HwWalletManagerVendorTests.swift @@ -0,0 +1,1032 @@ +@testable import Bitkit +import BitkitCore +import Combine +import XCTest + +/// Covers how `HwWalletManager` routes device calls by the vendor stored on a wallet's entries, and +/// keeps a single vendor on the radio, adapting the Jade cases in bitkit-android's +/// `HwWalletRepoTest`. Both sessions are fakes writing to one ordered log, so the tests can assert +/// that the other vendor is released before a device is reached. +@MainActor +final class HwWalletManagerVendorTests: XCTestCase { + // MARK: - Fakes + + @MainActor + private final class CallLog { + private(set) var entries: [String] = [] + + func record(_ entry: String) { + entries.append(entry) + } + } + + private final class FakeTrezorSession: TrezorSessioning { + private let log: CallLog + + var storedDevices: [HwKnownDevice] = [] + var connectedDeviceId: String? + var connectedWalletId: String? + var connectedFeatures: TrezorFeatures? + var isSessionActive = false + var knownBluetoothIds: Set = [] + /// Holds every release until opened, as closing a Trezor link can take seconds. + var releaseGate: AsyncGate? + + private(set) var openCalls: [TrezorWalletMode] = [] + private(set) var staleDisconnects: [String] = [] + private(set) var warmUpCalls: [String] = [] + private(set) var forgottenWalletIds: [String] = [] + private(set) var renameCalls: [(walletId: String, newName: String)] = [] + private(set) var releaseCalls = 0 + private(set) var startAutoReconnectCalls = 0 + + init(log: CallLog) { + self.log = log + } + + func ensureConnected(deviceId: String) async throws { + log.record("trezor.ensure:\(deviceId)") + } + + @discardableResult + func connectWithWalletMode( + deviceId: String, + mode: TrezorWalletMode, + passphrase _: String + ) async throws -> TrezorFeatures { + log.record("trezor.open:\(deviceId)") + openCalls.append(mode) + return TrezorFeatures( + vendor: "trezor.io", + model: "Safe 5", + label: "Trezor", + deviceId: "trezor-id", + majorVersion: 2, + minorVersion: 8, + patchVersion: 0, + pinProtection: false, + unlocked: true, + passphraseProtection: true, + initialized: true, + needsBackup: false, + passphraseEntryCapable: false + ) + } + + func disconnectStaleSession(deviceId: String) async { + log.record("trezor.stale:\(deviceId)") + staleDisconnects.append(deviceId) + } + + func releaseSession() async { + log.record("trezor.release") + releaseCalls += 1 + await releaseGate?.wait() + isSessionActive = false + connectedDeviceId = nil + connectedWalletId = nil + } + + /// Reads as active at once, as `TrezorManager` registers the reconnect before it first runs. + func startAutoReconnect() { + log.record("trezor.startAutoReconnect") + startAutoReconnectCalls += 1 + isSessionActive = true + } + + func resetForWipe() async { + log.record("trezor.resetForWipe") + isSessionActive = false + } + + func isKnownBluetoothDevice(deviceId: String) -> Bool { + knownBluetoothIds.contains(deviceId) + } + + func warmUpConnection(deviceId: String) { + warmUpCalls.append(deviceId) + } + + func forgetWallet(walletId: String, pendingName _: PendingHwWalletName?) async { + forgottenWalletIds.append(walletId) + storedDevices.removeAll { $0.resolvedWalletId == walletId } + } + + func renameWallet(walletId: String, newName: String) { + renameCalls.append((walletId, newName)) + } + } + + private final class FakeJadeSession: JadeSessioning { + private let log: CallLog + + var storedDevices: [HwKnownDevice] = [] + var connectedDeviceId: String? + var connectedWalletId: String? + var isSessionActive = false + var knownBluetoothIds: Set = [] + var ensureError: Error? + var verifyErrors: [Error] = [] + var fingerprint = "deadbeef" + var fingerprintError: Error? + var completedTransaction = CompletedTransaction(serializedTx: "rawtx", txid: "txid") + var blocksEnsure = false + var onEnsure: (() -> Void)? + private var ensureContinuation: CheckedContinuation? + + private(set) var verifyCalls: [(addressType: AddressScriptType, derivationPath: String, expectedAddress: String)] = [] + private(set) var signedPsbts: [String] = [] + private(set) var staleDisconnects: [String] = [] + private(set) var warmUpCalls: [String] = [] + private(set) var forgottenWalletIds: [String] = [] + private(set) var renameCalls: [(walletId: String, newName: String)] = [] + private(set) var releaseCalls = 0 + private(set) var startAutoReconnectCalls = 0 + + init(log: CallLog) { + self.log = log + } + + func ensureConnected(deviceId: String) async throws { + log.record("jade.ensure:\(deviceId)") + onEnsure?() + if blocksEnsure { + await withCheckedContinuation { ensureContinuation = $0 } + } + if let ensureError { + throw ensureError + } + connectedDeviceId = deviceId + } + + func finishEnsure() { + blocksEnsure = false + ensureContinuation?.resume() + ensureContinuation = nil + } + + func verifyAddress(addressType: AddressScriptType, derivationPath: String, expectedAddress: String) async throws { + log.record("jade.verify") + verifyCalls.append((addressType, derivationPath, expectedAddress)) + if !verifyErrors.isEmpty { + throw verifyErrors.removeFirst() + } + } + + func masterFingerprint() async throws -> String { + log.record("jade.fingerprint") + if let fingerprintError { + throw fingerprintError + } + return fingerprint + } + + func signPsbt(_ psbtBase64: String) async throws -> CompletedTransaction { + log.record("jade.sign") + signedPsbts.append(psbtBase64) + return completedTransaction + } + + func disconnectStaleSession(deviceId: String) async { + log.record("jade.stale:\(deviceId)") + staleDisconnects.append(deviceId) + } + + func releaseSession() async { + log.record("jade.release") + releaseCalls += 1 + isSessionActive = false + connectedDeviceId = nil + connectedWalletId = nil + } + + func isKnownBluetoothDevice(deviceId: String) -> Bool { + knownBluetoothIds.contains(deviceId) + } + + func warmUpConnection(deviceId: String) { + warmUpCalls.append(deviceId) + } + + func forgetWallet(walletId: String, pendingName _: PendingHwWalletName?) async { + forgottenWalletIds.append(walletId) + storedDevices.removeAll { $0.resolvedWalletId == walletId } + } + + func renameWallet(walletId: String, newName: String) { + renameCalls.append((walletId, newName)) + } + + func startAutoReconnect() { + log.record("jade.startAutoReconnect") + startAutoReconnectCalls += 1 + } + + func onAppBackgrounded() { + log.record("jade.backgrounded") + } + + func onAppBecameActive() { + log.record("jade.active") + } + + func resetForWipe() async { + log.record("jade.resetForWipe") + } + } + + private final class NoopWatcher: OnChainWatcherServicing, @unchecked Sendable { + func startWatcher(params _: WatcherParams, listener _: EventListener) async throws {} + func stopWatcher(watcherId _: String) throws {} + func stopAllWatchers() {} + } + + // MARK: - Setup + + private static let storageKey = "trezor.knownDevices" + private static let pendingNamesKey = "trezor.pendingWalletNames" + + private let trezorDeviceId = "dev1" + private let trezorWalletId = "trezor:wallet" + private let jadeDeviceId = "jade:bluetooth:246F288F6B64" + private let jadeWalletId = "jade:wallet" + + private var log = CallLog() + private var trezor: FakeTrezorSession! + private var jade: FakeJadeSession! + private var jadeBluetoothPoweredOn: PassthroughSubject! + private var savedDefaults: Data? + private var savedPendingNames: [String: String]? + + override func setUp() { + super.setUp() + log = CallLog() + trezor = FakeTrezorSession(log: log) + jade = FakeJadeSession(log: log) + jadeBluetoothPoweredOn = PassthroughSubject() + savedDefaults = UserDefaults.standard.data(forKey: Self.storageKey) + savedPendingNames = UserDefaults.standard.dictionary(forKey: Self.pendingNamesKey) as? [String: String] + HwKnownDeviceStorage.removeAll() + } + + override func tearDown() { + HwKnownDeviceStorage.removeAll() + if let savedDefaults { + UserDefaults.standard.set(savedDefaults, forKey: Self.storageKey) + } + if let savedPendingNames { + UserDefaults.standard.set(savedPendingNames, forKey: Self.pendingNamesKey) + } + trezor = nil + jade = nil + jadeBluetoothPoweredOn = nil + super.tearDown() + } + + // MARK: - Wallet identity + + func testSameSeedOnTrezorAndJadeStaysTwoWallets() { + let xpubs = ["nativeSegwit": "zSharedSeed"] + let manager = makeManager() + + manager.updateDevices( + knownDevices: [ + makeTrezorEntry(xpubs: xpubs, walletId: nil), + makeJadeEntry(xpubs: xpubs, walletId: nil), + ], + connectedDeviceId: nil + ) + + XCTAssertEqual(manager.wallets.count, 2) + XCTAssertEqual(Set(manager.wallets.map(\.vendor)), [.trezor, .blockstream]) + XCTAssertTrue(manager.wallets.first { $0.vendor == .trezor }?.walletId.hasPrefix("trezor:") == true) + XCTAssertTrue(manager.wallets.first { $0.vendor == .blockstream }?.walletId.hasPrefix("jade:") == true) + } + + func testReconnectTimeoutFollowsTheVendor() { + trezor.storedDevices = [makeTrezorEntry()] + jade.storedDevices = [makeJadeEntry()] + let manager = makeManager() + + XCTAssertEqual(manager.reconnectTimeout(walletId: trezorWalletId), 30) + XCTAssertEqual(manager.reconnectTimeout(walletId: jadeWalletId), 300) + XCTAssertEqual(manager.reconnectTimeout(walletId: "trezor:unknown"), 30) + } + + // MARK: - One vendor at a time + + func testEnsuringAJadeWalletReleasesTheTrezorFirst() async throws { + trezor.storedDevices = [makeTrezorEntry()] + jade.storedDevices = [makeJadeEntry()] + trezor.isSessionActive = true + let manager = makeManager() + + try await manager.ensureConnected(walletId: jadeWalletId) + + XCTAssertEqual(log.entries, ["trezor.release", "jade.ensure:\(jadeDeviceId)"]) + } + + func testEnsuringATrezorWalletReleasesTheJadeFirst() async throws { + trezor.storedDevices = [makeTrezorEntry()] + trezor.connectedWalletId = trezorWalletId + jade.storedDevices = [makeJadeEntry()] + jade.isSessionActive = true + let manager = makeManager() + + try await manager.ensureConnected(walletId: trezorWalletId) + + XCTAssertEqual(log.entries, ["jade.release", "trezor.ensure:\(trezorDeviceId)"]) + } + + func testEnsuringLeavesAnIdleOtherVendorAlone() async throws { + trezor.storedDevices = [makeTrezorEntry()] + trezor.connectedWalletId = trezorWalletId + jade.storedDevices = [makeJadeEntry()] + let manager = makeManager() + + try await manager.ensureConnected(walletId: trezorWalletId) + try await manager.ensureConnected(walletId: jadeWalletId) + + XCTAssertEqual(log.entries, ["trezor.ensure:\(trezorDeviceId)", "jade.ensure:\(jadeDeviceId)"]) + } + + /// A Jade holds a single wallet, so a session that resolved to another one is the wrong device. + func testEnsureRejectsAJadeHoldingAnotherWallet() async { + jade.storedDevices = [makeJadeEntry()] + jade.connectedWalletId = "jade:other" + let manager = makeManager() + + do { + try await manager.ensureConnected(walletId: jadeWalletId) + XCTFail("expected the other wallet's session to be refused") + } catch { + XCTAssertEqual(log.entries, ["jade.ensure:\(jadeDeviceId)"]) + } + } + + /// A session whose accounts could not be read proves nothing either way; signing still checks + /// which device it is. + func testEnsureAcceptsAJadeThatResolvedNoWallet() async throws { + jade.storedDevices = [makeJadeEntry()] + jade.connectedWalletId = nil + let manager = makeManager() + + try await manager.ensureConnected(walletId: jadeWalletId) + + XCTAssertEqual(jade.connectedDeviceId, jadeDeviceId) + } + + func testPairingThroughWithVendorSessionReleasesTheOtherVendor() async throws { + trezor.isSessionActive = true + let manager = makeManager() + + let result = try await manager.withVendorSession(.blockstream) { + log.record("pair") + return 7 + } + + XCTAssertEqual(result, 7) + XCTAssertEqual(log.entries, ["trezor.release", "pair"]) + } + + func testSessionOperationsRunOneAtATimeInArrivalOrder() async throws { + trezor.storedDevices = [makeTrezorEntry()] + trezor.connectedWalletId = trezorWalletId + jade.storedDevices = [makeJadeEntry()] + jade.blocksEnsure = true + let jadeEnsureStarted = expectation(description: "jade ensure started") + jade.onEnsure = { jadeEnsureStarted.fulfill() } + let manager = makeManager() + + let jadeOperation = Task { @MainActor in + try await manager.ensureConnected(walletId: self.jadeWalletId) + } + await fulfillment(of: [jadeEnsureStarted], timeout: 1) + let trezorOperation = Task { @MainActor in + try await manager.ensureConnected(walletId: self.trezorWalletId) + } + for _ in 0 ..< 5 { + await Task.yield() + } + + XCTAssertEqual(log.entries, ["jade.ensure:\(jadeDeviceId)"], "the Trezor waits for the Jade operation") + + jade.isSessionActive = true + jade.finishEnsure() + try await jadeOperation.value + try await trezorOperation.value + + XCTAssertEqual(log.entries, ["jade.ensure:\(jadeDeviceId)", "jade.release", "trezor.ensure:\(trezorDeviceId)"]) + } + + func testACancelledOperationWaitingForTheLockNeverReachesTheDevice() async throws { + trezor.storedDevices = [makeTrezorEntry()] + trezor.connectedWalletId = trezorWalletId + jade.storedDevices = [makeJadeEntry()] + jade.blocksEnsure = true + let jadeEnsureStarted = expectation(description: "jade ensure started") + jade.onEnsure = { jadeEnsureStarted.fulfill() } + let manager = makeManager() + + let jadeOperation = Task { @MainActor in + try await manager.ensureConnected(walletId: self.jadeWalletId) + } + await fulfillment(of: [jadeEnsureStarted], timeout: 1) + let trezorOperation = Task { @MainActor in + try await manager.ensureConnected(walletId: self.trezorWalletId) + } + await Task.yield() + trezorOperation.cancel() + jade.finishEnsure() + try await jadeOperation.value + + do { + try await trezorOperation.value + XCTFail("expected the queued operation to be cancelled") + } catch { + XCTAssertTrue(error is CancellationError) + } + XCTAssertEqual(log.entries, ["jade.ensure:\(jadeDeviceId)"]) + } + + func testACancelWhileTheTrezorIsReleasedStopsThePairingBeforeItRuns() async { + trezor.isSessionActive = true + let release = AsyncGate() + trezor.releaseGate = release + let manager = makeManager() + + let pairing = Task { @MainActor in + try await manager.withVendorSession(.blockstream) { + self.log.record("pair") + } + } + await waitUntil { self.trezor.releaseCalls == 1 } + pairing.cancel() + release.open() + + do { + try await pairing.value + XCTFail("expected the cancelled pairing to stop") + } catch { + XCTAssertTrue(error is CancellationError, "\(error)") + } + XCTAssertEqual(log.entries, ["trezor.release"]) + } + + func testACancelWhileTheTrezorIsReleasedNeverReachesTheJade() async { + trezor.storedDevices = [makeTrezorEntry()] + jade.storedDevices = [makeJadeEntry()] + trezor.isSessionActive = true + let release = AsyncGate() + trezor.releaseGate = release + let manager = makeManager() + + let ensure = Task { @MainActor in + try await manager.ensureConnected(walletId: self.jadeWalletId) + } + await waitUntil { self.trezor.releaseCalls == 1 } + ensure.cancel() + release.open() + + do { + try await ensure.value + XCTFail("expected the cancelled connect to stop") + } catch { + XCTAssertTrue(error is CancellationError, "\(error)") + } + XCTAssertEqual(log.entries, ["trezor.release"]) + } + + // MARK: - Foreground reconnect + + func testForegroundReconnectTargetsTheConnectedJade() async { + HwKnownDeviceStorage.saveAll([makeTrezorEntry(lastConnectedAt: 50)], vendor: .trezor) + HwKnownDeviceStorage.saveAll([makeJadeEntry(lastConnectedAt: 0)], vendor: .blockstream) + jade.connectedDeviceId = jadeDeviceId + let manager = makeManager() + + await manager.reconnectOnForeground() + await settle() + + XCTAssertEqual(jade.startAutoReconnectCalls, 1) + XCTAssertEqual(trezor.startAutoReconnectCalls, 0) + } + + func testForegroundReconnectPicksTheMostRecentlyUsedVendor() async { + HwKnownDeviceStorage.saveAll([makeTrezorEntry(lastConnectedAt: 0)], vendor: .trezor) + HwKnownDeviceStorage.saveAll([makeJadeEntry(lastConnectedAt: 20)], vendor: .blockstream) + trezor.storedDevices = [makeTrezorEntry(lastConnectedAt: 0)] + jade.storedDevices = [makeJadeEntry(lastConnectedAt: 20)] + let manager = makeManager() + + await manager.reconnectOnForeground() + await settle() + + XCTAssertEqual(jade.startAutoReconnectCalls, 1) + XCTAssertEqual(trezor.startAutoReconnectCalls, 0) + } + + /// On a cold launch the foreground reconnect runs before the vendor managers load their entries, + /// so the choice has to come from what is saved. + func testForegroundReconnectReadsSavedJadeBeforeTheManagersLoadIt() async { + HwKnownDeviceStorage.saveAll([makeJadeEntry()], vendor: .blockstream) + let manager = makeManager() + + await manager.reconnectOnForeground() + await settle() + + XCTAssertEqual(jade.startAutoReconnectCalls, 1) + XCTAssertEqual(trezor.startAutoReconnectCalls, 0) + } + + func testForegroundReconnectDefaultsToTrezor() async { + let manager = makeManager() + + await manager.reconnectOnForeground() + + XCTAssertEqual(trezor.startAutoReconnectCalls, 1) + XCTAssertEqual(jade.startAutoReconnectCalls, 0) + } + + func testForegroundReconnectOfATrezorReleasesAPendingJadeFirst() async { + trezor.connectedDeviceId = trezorDeviceId + jade.isSessionActive = true + let manager = makeManager() + + await manager.reconnectOnForeground() + + XCTAssertEqual(log.entries, ["jade.release", "trezor.startAutoReconnect"]) + } + + /// The Trezor reconnect outlives the lock, so it must already read as active when the next + /// operation takes it, or a Jade would dial alongside it. + func testAJadeOperationAfterAForegroundTrezorReconnectReleasesItFirst() async throws { + jade.storedDevices = [makeJadeEntry()] + let manager = makeManager() + + await manager.reconnectOnForeground() + try await manager.ensureConnected(walletId: jadeWalletId) + + XCTAssertEqual(log.entries, ["trezor.startAutoReconnect", "trezor.release", "jade.ensure:\(jadeDeviceId)"]) + } + + // MARK: - Bluetooth restored + + func testBluetoothRestoredStartsASilentJadeReconnect() { + jade.storedDevices = [makeJadeEntry()] + let manager = makeManager() + + manager.onJadeBluetoothRestored() + + XCTAssertEqual(jade.startAutoReconnectCalls, 1) + } + + func testBluetoothRestoredLeavesAnActiveTrezorAlone() { + jade.storedDevices = [makeJadeEntry()] + trezor.isSessionActive = true + let manager = makeManager() + + manager.onJadeBluetoothRestored() + + XCTAssertEqual(jade.startAutoReconnectCalls, 0) + XCTAssertEqual(trezor.releaseCalls, 0) + } + + func testBluetoothRestoredWaitsForTheForegroundAndAPairedJade() { + let manager = makeManager() + + manager.onJadeBluetoothRestored() + XCTAssertEqual(jade.startAutoReconnectCalls, 0, "nothing is paired") + + jade.storedDevices = [makeJadeEntry()] + manager.onAppBackgrounded() + manager.onJadeBluetoothRestored() + XCTAssertEqual(jade.startAutoReconnectCalls, 0, "the app is in the background") + + manager.onAppBecameActive() + manager.onJadeBluetoothRestored() + XCTAssertEqual(jade.startAutoReconnectCalls, 1) + } + + func testTransportReportingBluetoothOnStartsASilentJadeReconnect() async { + jade.storedDevices = [makeJadeEntry()] + let manager = makeManager() + + jadeBluetoothPoweredOn.send(()) + await waitUntil { self.jade.startAutoReconnectCalls == 1 } + + XCTAssertEqual(jade.startAutoReconnectCalls, 1) + XCTAssertEqual(trezor.startAutoReconnectCalls, 0) + withExtendedLifetime(manager) {} + } + + /// Reports reach the manager on the main queue in the order sent, so a single reconnect after the + /// second report means the first, sent while in the background, was dropped. + func testTransportReportingBluetoothOnInTheBackgroundIsIgnored() async { + jade.storedDevices = [makeJadeEntry()] + let manager = makeManager() + + manager.onAppBackgrounded() + jadeBluetoothPoweredOn.send(()) + await settle() + manager.onAppBecameActive() + jadeBluetoothPoweredOn.send(()) + await waitUntil { self.jade.startAutoReconnectCalls > 0 } + await settle() + + XCTAssertEqual(jade.startAutoReconnectCalls, 1) + withExtendedLifetime(manager) {} + } + + // MARK: - Passphrase + + func testPassphraseIsRejectedForJade() async { + trezor.storedDevices = [makeTrezorEntry()] + trezor.isSessionActive = true + jade.storedDevices = [makeJadeEntry()] + let manager = makeManager() + + await assertThrows(HwPassphraseError.protectionDisabled) { + _ = try await manager.connectWithPassphrase(deviceId: self.jadeDeviceId, passphrase: "correct horse") + } + await assertThrows(HwPassphraseError.protectionDisabled) { + try await manager.reconnectWithPassphrase(walletId: self.jadeWalletId, passphrase: "correct horse") + } + + XCTAssertFalse(manager.needsPassphrase(walletId: jadeWalletId)) + XCTAssertTrue(trezor.openCalls.isEmpty) + XCTAssertEqual(trezor.releaseCalls, 0, "a refused request leaves the Trezor session alone") + } + + // MARK: - Receive address verification + + func testVerifiesAJadeAddressOnTheDevice() async throws { + jade.storedDevices = [makeJadeEntry()] + var trezorAddressCalls = 0 + let manager = makeManager(addressProvider: { _ in + trezorAddressCalls += 1 + throw TrezorError.DeviceDisconnected + }) + let receiveAddress = makeReceiveAddress() + + try await manager.verifyReceiveAddress(walletId: jadeWalletId, receiveAddress: receiveAddress) + + XCTAssertEqual(trezorAddressCalls, 0, "the Trezor address call is never made for a Jade") + XCTAssertEqual(log.entries, ["jade.ensure:\(jadeDeviceId)", "jade.verify"]) + XCTAssertEqual(jade.verifyCalls.first?.addressType, .nativeSegwit) + XCTAssertEqual(jade.verifyCalls.first?.derivationPath, receiveAddress.path) + XCTAssertEqual(jade.verifyCalls.first?.expectedAddress, receiveAddress.address) + } + + func testReportsAJadeAddressMismatch() async { + jade.storedDevices = [makeJadeEntry()] + jade.verifyErrors = [Bitkit.AppError(error: JadeError.AddressMismatch(expected: "bcrt1qreceive", returned: "bcrt1qother"))] + let manager = makeManager() + + do { + try await manager.verifyReceiveAddress(walletId: jadeWalletId, receiveAddress: makeReceiveAddress()) + XCTFail("expected the mismatch to be reported") + } catch { + XCTAssertEqual(error.localizedDescription, t("hardware__verify_address_error")) + } + XCTAssertTrue(jade.staleDisconnects.isEmpty, "a mismatch is not a broken session") + } + + func testRetriesJadeVerificationAfterAStaleSession() async throws { + jade.storedDevices = [makeJadeEntry()] + jade.verifyErrors = [JadeError.Timeout] + let manager = makeManager() + + try await manager.verifyReceiveAddress(walletId: jadeWalletId, receiveAddress: makeReceiveAddress()) + + XCTAssertEqual(log.entries, [ + "jade.ensure:\(jadeDeviceId)", + "jade.verify", + "jade.stale:\(jadeDeviceId)", + "jade.ensure:\(jadeDeviceId)", + "jade.verify", + ]) + } + + func testDisconnectsAfterJadeVerificationRetryFails() async { + jade.storedDevices = [makeJadeEntry()] + jade.verifyErrors = [JadeError.Timeout, Bitkit.AppError(error: JadeError.DeviceDisconnected)] + let manager = makeManager() + + do { + try await manager.verifyReceiveAddress(walletId: jadeWalletId, receiveAddress: makeReceiveAddress()) + XCTFail("expected verification to fail") + } catch { + XCTAssertTrue(error.isJadeSessionFailure()) + } + + XCTAssertEqual(jade.verifyCalls.count, 2) + XCTAssertEqual(jade.staleDisconnects, [jadeDeviceId, jadeDeviceId]) + } + + func testAJadeVerificationDeclinedOnTheDeviceIsNotRetried() async { + jade.storedDevices = [makeJadeEntry()] + jade.verifyErrors = [JadeError.UserCancelled] + let manager = makeManager() + + do { + try await manager.verifyReceiveAddress(walletId: jadeWalletId, receiveAddress: makeReceiveAddress()) + XCTFail("expected the cancellation to be rethrown") + } catch { + XCTAssertTrue(error.isJadeUserCancellation()) + } + + XCTAssertEqual(jade.verifyCalls.count, 1) + XCTAssertTrue(jade.staleDisconnects.isEmpty) + } + + // MARK: - Funding + + /// Without the Jade's key origins in the PSBT the device finds nothing of its own to sign. + func testComposingForJadeUsesItsMasterFingerprint() async throws { + let entry = makeJadeEntry() + jade.storedDevices = [entry] + var composedParams: ComposeParams? + let manager = makeManager(composeProvider: { [log] params in + log.record("compose") + composedParams = params + return [.success(psbt: "psbt", fee: 141, feeRate: 2, totalSpent: 1141)] + }) + manager.updateDevices(knownDevices: [entry], connectedDeviceId: nil) + + let funding = try await manager.composeFundingTransaction( + walletId: jadeWalletId, + address: "bcrt1qdestination", + sats: 1000, + satsPerVByte: 2 + ) + + XCTAssertEqual(funding.psbt, "psbt") + XCTAssertEqual(composedParams?.wallet.fingerprint, "deadbeef") + XCTAssertEqual(composedParams?.wallet.extendedKey, "zJade") + XCTAssertEqual(log.entries, ["jade.ensure:\(jadeDeviceId)", "jade.fingerprint", "compose"]) + } + + /// Core keeps a link that failed mid-request marked connected, so the next attempt would reuse it. + func testAJadeLinkFailureReadingTheFingerprintReleasesTheStaleSession() async { + jade.storedDevices = [makeJadeEntry()] + jade.fingerprintError = JadeError.DeviceDisconnected + let manager = makeManager() + + do { + _ = try await manager.composeFundingTransaction( + walletId: jadeWalletId, + address: "bcrt1qdestination", + sats: 1000, + satsPerVByte: 2 + ) + XCTFail("expected the link failure to be rethrown") + } catch { + XCTAssertEqual(error.underlyingJadeError, .DeviceDisconnected) + } + + XCTAssertEqual(jade.staleDisconnects, [jadeDeviceId]) + XCTAssertEqual(log.entries, ["jade.ensure:\(jadeDeviceId)", "jade.fingerprint", "jade.stale:\(jadeDeviceId)"]) + } + + func testSignFundingRefusesAJadeSessionOfAnotherWallet() async { + jade.storedDevices = [makeJadeEntry()] + jade.connectedDeviceId = jadeDeviceId + jade.connectedWalletId = "jade:other" + let manager = makeManager() + + do { + _ = try await manager.signFunding(walletId: jadeWalletId, funding: makeFunding()) + XCTFail("expected the other wallet's session to be refused") + } catch { + XCTAssertTrue(jade.signedPsbts.isEmpty) + } + } + + func testSignFundingRefusesAnUnresolvedJadeSessionOfAnotherDevice() async { + jade.storedDevices = [makeJadeEntry()] + jade.connectedDeviceId = "jade:bluetooth:AAAAAAAAAAAA" + jade.connectedWalletId = nil + let manager = makeManager() + + do { + _ = try await manager.signFunding(walletId: jadeWalletId, funding: makeFunding()) + XCTFail("expected another device's session to be refused") + } catch { + XCTAssertTrue(jade.signedPsbts.isEmpty) + } + } + + func testSignFundingSignsOnTheJadeHoldingTheWallet() async throws { + jade.storedDevices = [makeJadeEntry()] + jade.connectedDeviceId = jadeDeviceId + jade.connectedWalletId = jadeWalletId + let manager = makeManager() + + let signed = try await manager.signFunding(walletId: jadeWalletId, funding: makeFunding()) + + XCTAssertEqual(signed.serializedTx, "rawtx") + XCTAssertEqual(signed.miningFeeSats, 141) + XCTAssertEqual(signed.totalSpent, 43186) + XCTAssertEqual(jade.signedPsbts, ["psbt"]) + } + + func testSignFundingAcceptsAnUnresolvedSessionOfTheWalletsJade() async throws { + jade.storedDevices = [makeJadeEntry()] + jade.connectedDeviceId = jadeDeviceId + jade.connectedWalletId = nil + let manager = makeManager() + + _ = try await manager.signFunding(walletId: jadeWalletId, funding: makeFunding()) + + XCTAssertEqual(jade.signedPsbts, ["psbt"]) + } + + // MARK: - Routed maintenance + + func testRenameAndRemoveRouteToTheWalletsVendor() async throws { + trezor.storedDevices = [makeTrezorEntry()] + jade.storedDevices = [makeJadeEntry()] + let manager = makeManager() + + manager.renameWallet(walletId: jadeWalletId, newName: "Cold") + manager.renameWallet(walletId: trezorWalletId, newName: "Hot") + try await manager.removeWallet(walletId: jadeWalletId, keepBackupData: false) + await manager.drainPendingPersists() + + XCTAssertEqual(jade.renameCalls.map(\.walletId), [jadeWalletId]) + XCTAssertEqual(jade.renameCalls.map(\.newName), ["Cold"]) + XCTAssertEqual(trezor.renameCalls.map(\.walletId), [trezorWalletId]) + XCTAssertEqual(jade.forgottenWalletIds, [jadeWalletId]) + XCTAssertTrue(trezor.forgottenWalletIds.isEmpty) + } + + func testWarmUpSkipsWhileTheOtherVendorIsActive() { + jade.storedDevices = [makeJadeEntry()] + trezor.isSessionActive = true + let manager = makeManager() + + manager.warmUpConnection(walletId: jadeWalletId) + XCTAssertTrue(jade.warmUpCalls.isEmpty) + XCTAssertEqual(trezor.releaseCalls, 0, "a warm-up never takes the radio from the other vendor") + + trezor.isSessionActive = false + manager.warmUpConnection(walletId: jadeWalletId) + XCTAssertEqual(jade.warmUpCalls, [jadeDeviceId]) + XCTAssertTrue(trezor.warmUpCalls.isEmpty) + } + + func testStaleSessionCleanupRoutesToTheWalletsVendor() async { + trezor.storedDevices = [makeTrezorEntry()] + jade.storedDevices = [makeJadeEntry()] + let manager = makeManager() + + await manager.disconnectStaleSession(walletId: jadeWalletId) + await manager.disconnectStaleSession(walletId: trezorWalletId) + + XCTAssertEqual(log.entries, ["jade.stale:\(jadeDeviceId)", "trezor.stale:\(trezorDeviceId)"]) + } + + /// The wallet can be forgotten before a scheduled cleanup runs, which would leave nothing to read + /// its vendor from. + func testScheduledCleanupReachesTheVendorItWasScheduledFor() async { + jade.storedDevices = [makeJadeEntry()] + let manager = makeManager() + + manager.scheduleStaleSessionCleanup(walletId: jadeWalletId) + jade.storedDevices = [] + await waitUntil { !self.jade.staleDisconnects.isEmpty } + + XCTAssertEqual(jade.staleDisconnects, [jadeDeviceId]) + XCTAssertTrue(trezor.staleDisconnects.isEmpty) + } + + func testKnownBluetoothDeviceIsAskedOfTheWalletsVendor() { + trezor.storedDevices = [makeTrezorEntry()] + jade.storedDevices = [makeJadeEntry()] + jade.knownBluetoothIds = [jadeDeviceId] + let manager = makeManager() + + XCTAssertTrue(manager.isKnownBluetoothDevice(walletId: jadeWalletId)) + XCTAssertFalse(manager.isKnownBluetoothDevice(walletId: trezorWalletId)) + + trezor.knownBluetoothIds = [trezorDeviceId] + XCTAssertTrue(manager.isKnownBluetoothDevice(walletId: trezorWalletId)) + } + + // MARK: - App lifecycle + + func testLifecycleHooksReachTheJade() async { + let manager = makeManager() + + manager.onAppBackgrounded() + manager.onAppBecameActive() + + XCTAssertEqual(log.entries, ["jade.backgrounded", "jade.active"]) + } + + /// A Trezor reconnect still running would otherwise save its device back after the wipe. + func testWipeResetsBothVendors() async { + trezor.isSessionActive = true + let manager = makeManager() + + await manager.resetForWipe() + + XCTAssertEqual(log.entries, ["trezor.resetForWipe", "jade.resetForWipe"]) + } + + // MARK: - Helpers + + private func makeManager( + addressProvider: @escaping HwWalletManager.AddressProvider = { _ in throw TrezorError.DeviceDisconnected }, + composeProvider: @escaping HwWalletManager.ComposeProvider = { _ in [] } + ) -> HwWalletManager { + HwWalletManager( + trezorSession: trezor, + jadeSession: jade, + jadeBluetoothPoweredOn: jadeBluetoothPoweredOn.eraseToAnyPublisher(), + watcherService: NoopWatcher(), + monitoredTypes: { ["nativeSegwit"] }, + electrumUrl: { "ssl://test:1" }, + network: { .regtest }, + addressProvider: addressProvider, + composeProvider: composeProvider, + persistSnapshot: { _ in }, + deleteActivities: { _ in } + ) + } + + private func makeTrezorEntry( + xpubs: [String: String] = ["nativeSegwit": "zTrezor"], + walletId: String? = "trezor:wallet", + lastConnectedAt: TimeInterval = 1000 + ) -> HwKnownDevice { + HwKnownDevice( + id: trezorDeviceId, + name: "Trezor", + path: "ble:\(trezorDeviceId)", + transportType: "bluetooth", + model: "Safe 5", + lastConnectedAt: Date(timeIntervalSince1970: lastConnectedAt), + xpubs: xpubs, + walletId: walletId + ) + } + + private func makeJadeEntry( + xpubs: [String: String] = ["nativeSegwit": "zJade"], + walletId: String? = "jade:wallet", + lastConnectedAt: TimeInterval = 1000 + ) -> HwKnownDevice { + HwKnownDevice( + id: jadeDeviceId, + name: "Jade 8F6B64", + path: "ble:jade", + transportType: "bluetooth", + model: "Jade", + lastConnectedAt: Date(timeIntervalSince1970: lastConnectedAt), + xpubs: xpubs, + walletId: walletId, + vendor: .blockstream, + jadeDeviceId: "246F288F6B64" + ) + } + + private func makeReceiveAddress() -> HwReceiveAddress { + HwReceiveAddress(address: "bcrt1qreceive", path: "m/84'/1'/0'/0/7", addressType: .nativeSegwit) + } + + private func makeFunding() -> HwFundingTransaction { + HwFundingTransaction(psbt: "psbt", miningFeeSats: 141, feeRate: 1, totalSpent: 43186, satsPerVByte: 1) + } + + /// Lets a launched reconnect run, so a test can assert that one was not launched. + private func settle() async { + for _ in 0 ..< 10 { + await Task.yield() + } + } + + private func waitUntil(timeout: TimeInterval = 2, _ condition: () -> Bool) async { + let deadline = Date().addingTimeInterval(timeout) + while !condition(), Date() < deadline { + try? await Task.sleep(nanoseconds: 10_000_000) + } + } + + private func assertThrows( + _ expected: HwPassphraseError, + file: StaticString = #filePath, + line: UInt = #line, + _ operation: () async throws -> Void + ) async { + do { + try await operation() + XCTFail("expected \(expected)", file: file, line: line) + } catch let error as HwPassphraseError { + XCTAssertEqual(error, expected, file: file, line: line) + } catch { + XCTFail("expected \(expected), got \(error)", file: file, line: line) + } + } +} diff --git a/BitkitTests/HwWalletNameTests.swift b/BitkitTests/HwWalletNameTests.swift index 5b2f28fc3..3cbc47a8e 100644 --- a/BitkitTests/HwWalletNameTests.swift +++ b/BitkitTests/HwWalletNameTests.swift @@ -46,4 +46,48 @@ final class HwWalletNameTests: XCTestCase { "Trezor Safe 5" ) } + + // MARK: - Jade + + func testJadeUsesItsModel() { + XCTAssertEqual(resolveHwWalletName(label: nil, model: "Jade Plus", vendor: .blockstream), "Jade Plus") + } + + func testJadeFallsBackToJadeWithoutModel() { + XCTAssertEqual(resolveHwWalletName(label: nil, model: nil, vendor: .blockstream), "Jade") + } + + func testJadeBlankModelFallsBackToJade() { + XCTAssertEqual(resolveHwWalletName(label: nil, model: " ", vendor: .blockstream), "Jade") + } + + func testJadeIgnoresTheDeviceLabel() { + XCTAssertEqual(resolveHwWalletName(label: "Jade 8F6B64", model: "Jade", vendor: .blockstream), "Jade") + } + + func testJadeCustomLabelWins() { + XCTAssertEqual( + resolveHwWalletName(label: nil, model: "Jade", customLabel: "Travel", vendor: .blockstream), + "Travel" + ) + } + + func testJadeModelIsNotPrefixedWithTrezor() { + XCTAssertEqual(resolveHwWalletName(label: nil, model: "Jade", vendor: .blockstream), "Jade") + } + + func testAJadeEntryIsNamedAsAJade() { + let entry = HwKnownDevice( + id: "jade:bluetooth:aabbcc", + name: "Jade AABBCC", + path: "ble:jade", + transportType: "bluetooth", + label: "Jade AABBCC", + model: "Jade Plus", + lastConnectedAt: Date(timeIntervalSince1970: 0), + vendor: .blockstream + ) + + XCTAssertEqual(entry.displayName, "Jade Plus") + } } diff --git a/BitkitTests/HwWalletVendorPresentationTests.swift b/BitkitTests/HwWalletVendorPresentationTests.swift new file mode 100644 index 000000000..80f1c4edc --- /dev/null +++ b/BitkitTests/HwWalletVendorPresentationTests.swift @@ -0,0 +1,79 @@ +@testable import Bitkit +import UIKit +import XCTest + +/// Each vendor's illustrations resolve in the app bundle and its copy comes from its own keys, so a +/// Jade never shows Trezor art or wording. +final class HwWalletVendorPresentationTests: XCTestCase { + func testTheDeviceIllustrationsResolveInTheAppBundle() { + for name in ["jade-placeholder", "trezor-device", "trezor-card"] { + XCTAssertNotNil(UIImage(named: name), name) + } + for vendor in HwWalletVendor.allCases { + XCTAssertNotNil(UIImage(named: vendor.deviceImageName), "\(vendor)") + XCTAssertNotNil(UIImage(named: vendor.signImageName), "\(vendor)") + } + } + + func testTheJadePlaceholderKeepsItsVectorSize() throws { + let image = try XCTUnwrap(UIImage(named: "jade-placeholder")) + + XCTAssertEqual(image.size, CGSize(width: 256, height: 256)) + } + + func testTrezorKeepsItsArtAndCopy() { + let vendor = HwWalletVendor.trezor + + XCTAssertEqual(vendor.deviceImageName, "trezor-device") + XCTAssertEqual(vendor.signImageName, "trezor-card") + XCTAssertEqual(vendor.modelName, t("hardware__device_model_trezor")) + XCTAssertEqual(vendor.foundHeader, t("hardware__found_header")) + XCTAssertEqual(vendor.pairedHeader, t("hardware__paired_header")) + XCTAssertEqual(vendor.sendSignButtonTitle, t("hardware__send_open_connect")) + XCTAssertEqual(vendor.transferSignButtonTitle, t("lightning__transfer_hw__open_connect")) + XCTAssertTrue(vendor.supportsPassphraseWallets) + } + + func testJadeHasItsOwnArtAndCopy() { + let vendor = HwWalletVendor.blockstream + + XCTAssertEqual(vendor.deviceImageName, "jade-placeholder") + XCTAssertEqual(vendor.signImageName, "jade-placeholder") + XCTAssertEqual(vendor.modelName, t("hardware__device_model_jade")) + XCTAssertEqual(vendor.modelName, "Jade") + XCTAssertEqual(vendor.foundHeader, t("hardware__found_header_jade")) + XCTAssertEqual(vendor.pairedHeader, t("hardware__paired_header_jade")) + XCTAssertEqual(vendor.sendSignButtonTitle, t("hardware__send_open_connect_jade")) + XCTAssertEqual(vendor.transferSignButtonTitle, t("hardware__send_open_connect_jade")) + XCTAssertFalse(vendor.supportsPassphraseWallets) + } + + func testNoJadeCopyMentionsTrezor() { + let vendor = HwWalletVendor.blockstream + let copy = [vendor.modelName, vendor.foundHeader, vendor.pairedHeader, vendor.sendSignButtonTitle, vendor.transferSignButtonTitle] + + for text in copy { + XCTAssertFalse(text.contains("Trezor"), text) + XCTAssertTrue(text.contains("Jade"), text) + } + } + + /// The shared e2e helper taps `Tab-trezor`, so a Trezor wallet's receive tab keeps its name. + func testTheHardwareReceiveTabIsNamedAfterTheWalletVendor() { + let trezorTab = TabItem(ReceiveQr.ReceiveTab.hardware, label: HwWalletVendor.trezor.modelName) + let jadeTab = TabItem(ReceiveQr.ReceiveTab.hardware, label: HwWalletVendor.blockstream.modelName) + + XCTAssertEqual(trezorTab.title, "Trezor") + XCTAssertEqual(trezorTab.resolvedAccessibilityIdentifier, "Tab-trezor") + XCTAssertEqual(jadeTab.title, "Jade") + XCTAssertEqual(jadeTab.resolvedAccessibilityIdentifier, "Tab-jade") + } + + func testTheHardwareReceiveTabWithoutAWalletIsNamedHardware() { + let tab = TabItem(ReceiveQr.ReceiveTab.hardware) + + XCTAssertEqual(tab.title, t("hardware__receive_tab_hardware")) + XCTAssertEqual(tab.title, "Hardware") + XCTAssertEqual(tab.resolvedAccessibilityIdentifier, "Tab-hardware") + } +} diff --git a/BitkitTests/JadeBLELinkStateTests.swift b/BitkitTests/JadeBLELinkStateTests.swift new file mode 100644 index 000000000..fb51d2656 --- /dev/null +++ b/BitkitTests/JadeBLELinkStateTests.swift @@ -0,0 +1,262 @@ +@testable import Bitkit +import XCTest + +final class JadeBLELinkStateTests: XCTestCase { + private final class Outcomes: @unchecked Sendable { + private let lock = NSLock() + private var results: [String: Result?] = [:] + private var reads: [JadeBLELinkState.ReadOutcome] = [] + + func record(_ name: String, _ result: Result?) { + lock.withLock { results[name] = result } + } + + func recordRead(_ outcome: JadeBLELinkState.ReadOutcome) { + lock.withLock { reads.append(outcome) } + } + + func error(of name: String) -> JadeBLEError? { + lock.withLock { + guard case let .failure(error)?? = results[name] else { return nil } + return error as? JadeBLEError + } + } + + var readOutcomes: [JadeBLELinkState.ReadOutcome] { + lock.withLock { reads } + } + } + + private func makeConnectedLink() -> JadeBLELinkState { + let state = JadeBLELinkState(generation: 1) + _ = state.begin(.connect) + XCTAssertTrue(state.markConnected()) + return state + } + + private func failure(of waiter: BLEOneShot, timeout: TimeInterval = 0) -> JadeBLEError? { + guard case let .failure(error)? = waiter.wait(timeout: timeout) else { return nil } + return error as? JadeBLEError + } + + private func succeeded(_ waiter: BLEOneShot, timeout: TimeInterval = 0) -> Bool { + guard case .success? = waiter.wait(timeout: timeout) else { return false } + return true + } + + // MARK: - Closing + + func testClosingReleasesEveryPendingWaiter() { + let state = JadeBLELinkState(generation: 1) + let waiters: [(String, BLEOneShot)] = [ + ("connect", state.begin(.connect)), + ("subscribe", state.begin(.subscribe)), + ("write", state.begin(.write)), + ] + let outcomes = Outcomes() + let returned = expectation(description: "every blocked thread returned") + returned.expectedFulfillmentCount = waiters.count + 1 + + for (name, waiter) in waiters { + DispatchQueue.global().async { + outcomes.record(name, waiter.wait(timeout: 10)) + returned.fulfill() + } + } + DispatchQueue.global().async { + outcomes.recordRead(state.read(timeout: 10)) + returned.fulfill() + } + Thread.sleep(forTimeInterval: 0.1) + + XCTAssertTrue(state.beginClosing()) + wait(for: [returned], timeout: 1) + + for (name, _) in waiters { + XCTAssertEqual(outcomes.error(of: name), .closed, name) + } + XCTAssertEqual(outcomes.readOutcomes, [.down]) + } + + func testCloseIsIdempotent() { + let state = makeConnectedLink() + + XCTAssertTrue(state.beginClosing()) + XCTAssertFalse(state.beginClosing()) + XCTAssertTrue(state.isClosing) + } + + func testSecondCloserWaitsForTheFirstToFinish() { + let state = makeConnectedLink() + XCTAssertTrue(state.beginClosing()) + + XCTAssertFalse(state.waitUntilClosed(timeout: 0.05)) + state.finishClosing() + XCTAssertTrue(state.waitUntilClosed(timeout: 0)) + } + + func testClosingKeepsTheDisconnectWaiter() { + let state = makeConnectedLink() + let disconnected = state.begin(.disconnect) + + XCTAssertTrue(state.beginClosing()) + XCTAssertNil(disconnected.wait(timeout: 0)) + + XCTAssertFalse(state.markDown(reason: JadeBLEError.disconnected)) + XCTAssertTrue(succeeded(disconnected)) + } + + func testClosingLinkIsNeverReady() throws { + let state = makeConnectedLink() + try state.markReady(chunkSize: 182) + XCTAssertTrue(state.isReady) + XCTAssertTrue(state.isUsable) + XCTAssertEqual(state.chunkSize, 182) + + XCTAssertTrue(state.beginClosing()) + + XCTAssertFalse(state.isReady) + XCTAssertFalse(state.isUsable) + XCTAssertFalse(state.reuseIfUsable()) + XCTAssertThrowsError(try state.markReady(chunkSize: 182)) { XCTAssertEqual($0 as? JadeBLEError, .closed) } + XCTAssertEqual(failure(of: state.begin(.write)), .closed) + } + + func testReadyLinkCanBeReused() throws { + let state = makeConnectedLink() + try state.markReady(chunkSize: 20) + state.enqueue(Data([0x01])) + + XCTAssertTrue(state.reuseIfUsable()) + XCTAssertEqual(state.read(timeout: 0.01), .empty) + } + + // MARK: - Reads + + func testReadReturnsEmptyAfterTimeout() { + let state = makeConnectedLink() + let start = Date() + + XCTAssertEqual(state.read(timeout: 0.05), .empty) + XCTAssertGreaterThanOrEqual(Date().timeIntervalSince(start), 0.04) + } + + func testNotificationsReturnedInOrderJoined() { + let state = makeConnectedLink() + state.enqueue(Data([0x01, 0x02])) + state.enqueue(Data([0x03])) + state.enqueue(Data([0x04, 0x05])) + + XCTAssertEqual(state.read(timeout: 0.05), .data(Data([0x01, 0x02, 0x03, 0x04, 0x05]))) + XCTAssertEqual(state.read(timeout: 0.01), .empty) + } + + func testEmptyNotificationsAndNotificationsAfterClosingAreIgnored() { + let state = makeConnectedLink() + state.enqueue(Data()) + XCTAssertEqual(state.read(timeout: 0.01), .empty) + + XCTAssertTrue(state.beginClosing()) + state.enqueue(Data([0x01])) + XCTAssertEqual(state.read(timeout: 0.01), .down) + } + + func testReadReportsDownAfterLinkDrops() { + let state = makeConnectedLink() + state.enqueue(Data([0x01])) + + XCTAssertTrue(state.markDown(reason: JadeBLEError.disconnected)) + + XCTAssertEqual(state.read(timeout: 0.05), .down) + XCTAssertFalse(state.isLinkUp) + } + + // MARK: - Drops + + func testDropDuringSetupFailsWaitersWithTheReason() { + let state = makeConnectedLink() + let subscribed = state.begin(.subscribe) + let disconnected = state.begin(.disconnect) + + XCTAssertTrue(state.markDown(reason: JadeBLEError.staleBond)) + + XCTAssertEqual(failure(of: subscribed), .staleBond) + XCTAssertTrue(succeeded(disconnected)) + XCTAssertEqual(failure(of: state.begin(.write)), .staleBond) + XCTAssertTrue(succeeded(state.begin(.disconnect))) + XCTAssertThrowsError(try state.markReady(chunkSize: 20)) { XCTAssertEqual($0 as? JadeBLEError, .staleBond) } + } + + func testDropWhileClosingIsNotExternal() { + let state = makeConnectedLink() + XCTAssertTrue(state.beginClosing()) + + XCTAssertFalse(state.markDown(reason: JadeBLEError.disconnected)) + } + + // MARK: - Waiters + + func testTimedOutWaiterIgnoresLateResolution() { + let state = makeConnectedLink() + let written = state.begin(.write) + XCTAssertNil(written.wait(timeout: 0.01)) + + state.abandon(.write, written) + + XCTAssertFalse(state.resolve(.write, error: nil)) + XCTAssertFalse(written.isResolved) + } + + func testAbandoningAnOlderWaiterKeepsTheNewerOne() { + let state = makeConnectedLink() + let older = state.begin(.subscribe) + let newer = state.begin(.subscribe) + XCTAssertEqual(failure(of: older), .closed) + + state.abandon(.subscribe, older) + + XCTAssertTrue(state.resolve(.subscribe, error: nil)) + XCTAssertTrue(succeeded(newer)) + } + + func testResolutionCarriesTheError() { + let state = makeConnectedLink() + let written = state.begin(.write) + + XCTAssertTrue(state.resolve(.write, error: JadeBLEError.writeFailed("busy"))) + + XCTAssertEqual(failure(of: written), .writeFailed("busy")) + } + + func testConnectNobodyWaitsForIsNotMarkedUp() { + let state = JadeBLELinkState(generation: 1) + + XCTAssertFalse(state.markConnected()) + XCTAssertFalse(state.isLinkUp) + + let connected = state.begin(.connect) + XCTAssertTrue(state.beginClosing()) + XCTAssertEqual(failure(of: connected), .closed) + XCTAssertFalse(state.markConnected()) + XCTAssertFalse(state.isLinkUp) + } + + func testFirstResolutionWins() { + let waiter = BLEOneShot() + + waiter.resolve(.failure(JadeBLEError.writeTimeout)) + waiter.resolve(.success(())) + + XCTAssertEqual(failure(of: waiter), .writeTimeout) + XCTAssertEqual(failure(of: waiter), .writeTimeout) + } + + func testWritesAreCounted() { + let state = makeConnectedLink() + + state.recordWrite() + state.recordWrite() + + XCTAssertEqual(state.writesCompleted, 2) + } +} diff --git a/BitkitTests/JadeDeviceIdentityTests.swift b/BitkitTests/JadeDeviceIdentityTests.swift new file mode 100644 index 000000000..61bbafc0e --- /dev/null +++ b/BitkitTests/JadeDeviceIdentityTests.swift @@ -0,0 +1,117 @@ +@testable import Bitkit +import BitkitCore +import LDKNode +import XCTest + +final class JadeDeviceIdentityTests: XCTestCase { + // MARK: - Device id and model + + func testTheDeviceIdCarriesTheEfuseMacInTheJadeNamespace() { + XCTAssertEqual(JadeDeviceIdentity.deviceId(efuseMac: "246F288F6B64"), "jade:bluetooth:246F288F6B64") + } + + func testThereIsNoDeviceIdWithoutAnEfuseMac() { + XCTAssertNil(JadeDeviceIdentity.deviceId(efuseMac: nil)) + XCTAssertNil(JadeDeviceIdentity.deviceId(efuseMac: "")) + XCTAssertNil(JadeDeviceIdentity.deviceId(efuseMac: " ")) + } + + func testAV2BoardIsAJadePlus() { + XCTAssertEqual(JadeDeviceIdentity.model(boardType: "JADE_V2"), "Jade Plus") + XCTAssertEqual(JadeDeviceIdentity.model(boardType: "jade_v2"), "Jade Plus") + } + + func testEveryOtherBoardIsTheOriginalJade() { + XCTAssertEqual(JadeDeviceIdentity.model(boardType: "JADE_V1_1"), "Jade") + XCTAssertEqual(JadeDeviceIdentity.model(boardType: nil), "Jade") + } + + // MARK: - Advertised name + + func testAJadeIsRecognisedByTheSuffixOfItsAdvertisedName() { + XCTAssertTrue(JadeDeviceIdentity.advertises("Jade 8F6B64", jadeDeviceId: "246F288F6B64")) + XCTAssertTrue(JadeDeviceIdentity.advertises("jade 8f6b64", jadeDeviceId: "246F288F6B64")) + } + + func testAnotherNameOrNoNameIsNotTheJade() { + XCTAssertFalse(JadeDeviceIdentity.advertises("Jade AAAAAA", jadeDeviceId: "246F288F6B64")) + XCTAssertFalse(JadeDeviceIdentity.advertises(nil, jadeDeviceId: "246F288F6B64")) + } + + func testAnIdTooShortOrBlankNeverMatches() { + XCTAssertFalse(JadeDeviceIdentity.advertises("Jade 8F6B", jadeDeviceId: "8F6B")) + XCTAssertFalse(JadeDeviceIdentity.advertises("Jade", jadeDeviceId: nil)) + XCTAssertFalse(JadeDeviceIdentity.advertises("Jade ", jadeDeviceId: " ")) + } + + func testAPairedEntryIsTheSameJadeUnderItsPathOrItsName() { + let entry = HwKnownDevice( + id: "jade:bluetooth:246F288F6B64", + name: "Jade 8F6B64", + path: "ble:old", + transportType: "bluetooth", + lastConnectedAt: Date(), + vendor: .blockstream, + jadeDeviceId: "246F288F6B64" + ) + + XCTAssertTrue(entry.isSameJade(as: JadeDeviceInfo(path: "ble:old", transport: .bluetooth, name: nil, serialNumber: nil))) + XCTAssertTrue(entry.isSameJade(as: JadeDeviceInfo(path: "ble:new", transport: .bluetooth, name: "Jade 8F6B64", serialNumber: nil))) + XCTAssertFalse(entry.isSameJade(as: JadeDeviceInfo(path: "ble:new", transport: .bluetooth, name: "Jade AAAAAA", serialNumber: nil))) + XCTAssertTrue(entry.matches(deviceId: "ble:old")) + XCTAssertTrue(entry.matches(deviceId: "jade:bluetooth:246F288F6B64")) + XCTAssertFalse(entry.matches(deviceId: "ble:new")) + } + + // MARK: - Session state + + func testOnlyAReadyOrTemporaryJadeIsUnlocked() { + XCTAssertTrue(JadeState.ready.isUnlocked) + XCTAssertTrue(JadeState.temp.isUnlocked) + for state in [JadeState.locked, .uninit, .unsaved, .unknown] { + XCTAssertFalse(state.isUnlocked, "\(state)") + } + } + + func testAConnectedJadeReportsItsLockAndModel() { + let session = ConnectedJadeDevice( + id: "jade:bluetooth:246F288F6B64", + path: "ble:path", + versionInfo: JadeFixtures.version(.locked), + walletId: nil + ) + + XCTAssertTrue(session.isLocked) + XCTAssertEqual(session.model, "Jade") + XCTAssertTrue(session.matches("ble:path")) + XCTAssertTrue(session.matches("jade:bluetooth:246F288F6B64")) + XCTAssertFalse(session.matches("ble:other")) + } + + // MARK: - Network and address variants + + func testNetworksMapToTheirJadeNetwork() throws { + XCTAssertEqual(try LDKNode.Network.bitcoin.toJadeNetwork(), .mainnet) + XCTAssertEqual(try LDKNode.Network.testnet.toJadeNetwork(), .testnet) + XCTAssertEqual(try LDKNode.Network.regtest.toJadeNetwork(), .regtest) + } + + func testSignetIsNotSupportedByJade() { + XCTAssertThrowsError(try LDKNode.Network.signet.toJadeNetwork()) { error in + XCTAssertEqual((error as? Bitkit.AppError)?.message, "Signet is not supported by Jade") + } + } + + func testEveryAddressTypeRoundTripsThroughItsJadeVariant() { + for addressType in AddressScriptType.allAddressTypes { + XCTAssertEqual(AddressScriptType(jadeVariant: addressType.jadeVariant), addressType, "\(addressType)") + } + } + + func testAddressVariantsMatchCore() { + XCTAssertEqual(AddressScriptType.nativeSegwit.jadeVariant, .wpkh) + for addressType in AddressScriptType.allAddressTypes { + XCTAssertEqual(addressType.jadeVariant, jadeAccountTypeToVariant(accountType: addressType.accountType), "\(addressType)") + } + } +} diff --git a/BitkitTests/JadeManagerTests.swift b/BitkitTests/JadeManagerTests.swift new file mode 100644 index 000000000..f76b8e5e9 --- /dev/null +++ b/BitkitTests/JadeManagerTests.swift @@ -0,0 +1,1009 @@ +@testable import Bitkit +import BitkitCore +import XCTest + +/// Ports bitkit-android's `JadeRepoTest` to `JadeManager` (cases 1-8, 11-19 and 22-27; the USB-only +/// cases 9, 10 and 20 have no iOS counterpart, and case 21 becomes the background expiration case), +/// then covers what iOS adds: teardown ordering against core, the connect epoch, the in-progress +/// flags and the background budget. Core and the transport are fakes writing to one ordered log. +@MainActor +final class JadeManagerTests: XCTestCase { + private nonisolated static let fastTiming = JadeManager.Timing( + backgroundRelease: 0.05, + expirationMargin: 0.01, + reconnectBackoff: 0.02, + reconnectAttempts: 4, + connectPollInterval: 0.01, + connectMaxWait: 2 + ) + + private var log: JadeCallLog! + private var service: FakeJadeService! + private var transport: FakeJadeTransportControl! + private var store: InMemoryJadeKnownDeviceStore! + private var backgroundTasks: FakeBackgroundTasks! + + override func setUp() { + super.setUp() + log = JadeCallLog() + service = FakeJadeService(log: log) + transport = FakeJadeTransportControl(log: log) + store = InMemoryJadeKnownDeviceStore() + backgroundTasks = FakeBackgroundTasks() + } + + override func tearDown() { + backgroundTasks = nil + store = nil + transport = nil + service = nil + log = nil + super.tearDown() + } + + // MARK: - Scan and pair (Android cases 1-6) + + func testScanListsTheLastDevicesWhileASessionIsOpen() async throws { + service.stubs.isConnected = true + service.stubs.listed = [JadeFixtures.device()] + let sut = makeManager() + + let devices = try await sut.scan() + + XCTAssertEqual(devices, [JadeFixtures.device()]) + XCTAssertFalse(log.contains("service.scan")) + XCTAssertEqual(sut.nearbyDevices, [JadeFixtures.device()]) + } + + func testPairingUnlocksALockedJadeReadsItsAccountsAndStoresTheEntry() async throws { + service.stubs.scanned = [JadeFixtures.device()] + service.stubs.connectResult = .success(JadeFixtures.version(.locked)) + let sut = makeManager() + _ = try await sut.scan() + + let connected = try await sut.connect(path: JadeFixtures.blePath) + + XCTAssertEqual(service.calls.unlockNetworks, [.regtest]) + XCTAssertEqual(service.calls.exportTypes, [JadeManager.allAccountTypes]) + XCTAssertEqual(store.saves.count, 1) + let stored = try XCTUnwrap(store.saves.last?.first) + XCTAssertEqual(store.saves.last?.count, 1) + XCTAssertEqual(stored.id, JadeFixtures.deviceId) + XCTAssertEqual(stored.vendor, .blockstream) + XCTAssertEqual(stored.jadeDeviceId, JadeFixtures.efuseMac) + XCTAssertEqual(stored.path, JadeFixtures.blePath) + XCTAssertEqual(stored.name, JadeFixtures.advertisedName) + XCTAssertEqual(stored.transportType, "bluetooth") + XCTAssertEqual(stored.xpubs["nativeSegwit"], JadeFixtures.xpub) + XCTAssertEqual(stored.model, "Jade") + XCTAssertTrue(stored.walletId?.hasPrefix("jade:") == true, "walletId=\(stored.walletId ?? "nil")") + XCTAssertEqual(connected.id, stored.id) + XCTAssertEqual(connected.walletId, stored.walletId) + XCTAssertFalse(connected.isLocked) + XCTAssertEqual(sut.connected, connected) + XCTAssertEqual(sut.knownDevices, [stored]) + XCTAssertTrue(sut.nearbyDevices.isEmpty) + XCTAssertFalse(sut.isConnecting) + XCTAssertFalse(sut.isUnlocking) + } + + func testPairingRefusesAJadeThatHasNoWalletYet() async throws { + service.stubs.scanned = [JadeFixtures.device()] + service.stubs.connectResult = .success(JadeFixtures.version(.uninit)) + let sut = makeManager() + _ = try await sut.scan() + + do { + try await sut.connect(path: JadeFixtures.blePath) + XCTFail("an uninitialised Jade must be refused") + } catch { + XCTAssertEqual(error.underlyingJadeError, .DeviceUninitialized) + } + + XCTAssertTrue(log.contains("transport.disconnect:\(JadeFixtures.blePath)")) + XCTAssertTrue(log.contains("service.disconnect")) + XCTAssertTrue(service.calls.unlockNetworks.isEmpty) + XCTAssertNil(sut.connected) + XCTAssertTrue(store.saves.isEmpty) + } + + func testAFailedAccountExportClosesTheLinkAndTheCoreSession() async throws { + service.stubs.scanned = [JadeFixtures.device()] + service.stubs.exportHandler = { _ in throw JadeError.IoError(errorDetails: "export failed") } + let sut = makeManager() + _ = try await sut.scan() + + do { + try await sut.connect(path: JadeFixtures.blePath) + XCTFail("the connect must fail with the export") + } catch {} + + XCTAssertTrue(log.contains("transport.disconnect:\(JadeFixtures.blePath)")) + XCTAssertTrue(log.contains("service.disconnect")) + XCTAssertNil(sut.connected) + XCTAssertTrue(store.saves.isEmpty) + } + + func testTheAccountsAreReadAgainWithoutTaprootOnOldFirmware() async throws { + service.stubs.scanned = [JadeFixtures.device()] + service.stubs.exportHandler = { types in + if types.contains(.taproot) { + throw Bitkit.AppError(error: JadeError.UnsupportedFirmware(installed: "1.0.30", required: "1.0.34")) + } + return JadeFixtures.accountExport() + } + let sut = makeManager() + _ = try await sut.scan() + + try await sut.connect(path: JadeFixtures.blePath) + + XCTAssertEqual(service.calls.exportTypes, [JadeManager.allAccountTypes, [.legacy, .wrappedSegwit, .nativeSegwit]]) + XCTAssertNotNil(sut.connected) + } + + func testAJadeReachedUnderANewPathRefreshesItsStoredEntry() async throws { + store.devices = [JadeFixtures.knownEntry(path: JadeFixtures.stalePath)] + service.stubs.scanned = [JadeFixtures.device(path: JadeFixtures.readvertisedPath)] + let sut = makeManager() + _ = try await sut.scan() + + try await sut.connect(path: JadeFixtures.readvertisedPath) + + let saved = try XCTUnwrap(store.saves.last) + XCTAssertEqual(saved.count, 1) + XCTAssertEqual(saved.first?.id, JadeFixtures.deviceId) + XCTAssertEqual(saved.first?.path, JadeFixtures.readvertisedPath) + XCTAssertEqual(saved.first?.walletId, JadeFixtures.walletId) + } + + // MARK: - Known device reconnects (Android cases 7, 8 and 11-18) + + func testReconnectingAKnownJadeRejectsADifferentDevice() async throws { + store.devices = [JadeFixtures.knownEntry()] + service.stubs.scanned = [JadeFixtures.device()] + service.stubs.connectResult = .success(JadeFixtures.version(.ready, efuseMac: "other")) + let sut = makeManager() + + do { + try await sut.connectKnownDevice(deviceId: JadeFixtures.deviceId) + XCTFail("a different Jade must be rejected") + } catch {} + + XCTAssertGreaterThanOrEqual(log.count("service.disconnect"), 1) + XCTAssertTrue(service.calls.exportTypes.isEmpty) + XCTAssertNil(sut.connected) + } + + func testReconnectingAKnownJadeRejectsADeviceReportingNoEfuseMac() async throws { + store.devices = [JadeFixtures.knownEntry()] + service.stubs.scanned = [JadeFixtures.device()] + service.stubs.connectResult = .success(JadeFixtures.version(.ready, efuseMac: nil)) + let sut = makeManager() + + do { + try await sut.connectKnownDevice(deviceId: JadeFixtures.deviceId) + XCTFail("a Jade that cannot prove its identity must be rejected") + } catch {} + + XCTAssertGreaterThanOrEqual(log.count("service.disconnect"), 1) + XCTAssertTrue(service.calls.exportTypes.isEmpty) + XCTAssertNil(sut.connected) + } + + func testAWrongJadeIsRejectedBeforeThePinPrompt() async throws { + store.devices = [JadeFixtures.knownEntry()] + service.stubs.scanned = [JadeFixtures.device()] + service.stubs.connectResult = .success(JadeFixtures.version(.locked, efuseMac: "other")) + let sut = makeManager() + + do { + try await sut.connectKnownDevice(deviceId: JadeFixtures.deviceId) + XCTFail("a different Jade must be rejected") + } catch {} + + XCTAssertTrue(service.calls.unlockNetworks.isEmpty) + XCTAssertFalse(sut.isUnlocking) + } + + func testCancellingAPendingConnectionClosesTheLinkThenCancelsThenDisconnects() async { + store.devices = [JadeFixtures.knownEntry()] + let sut = makeManager() + + await sut.cancelPendingConnection(deviceId: JadeFixtures.deviceId) + + XCTAssertTrue(log.contains("transport.disconnect:\(JadeFixtures.blePath)")) + XCTAssertEqual(log.entries.filter { $0 == "service.cancel" || $0 == "service.disconnect" }, ["service.cancel", "service.disconnect"]) + XCTAssertNil(sut.connected) + } + + /// Android closes the link before core. Here both start together, which keeps that guarantee: the + /// link is released without waiting for core, so a request stuck on the link lets go of core. + func testClosingAStaleSessionReleasesTheLinkWithoutWaitingForCore() async throws { + let sut = try await connectedManager() + let coreGate = service.gate(.disconnect) + + let teardown = Task { await sut.disconnectStaleSession(deviceId: JadeFixtures.deviceId) } + let linkReleased = await waitUntil { self.log.contains("transport.disconnect.done:\(JadeFixtures.blePath)") } + + XCTAssertTrue(linkReleased, "the link closes while core is still busy") + XCTAssertFalse(log.contains("service.disconnect.done")) + XCTAssertNil(sut.connected) + coreGate.open() + await teardown.value + XCTAssertTrue(log.contains("service.disconnect.done")) + } + + func testATeardownReachesCoreWithoutWaitingForTheLinkToClose() async throws { + let sut = try await connectedManager() + let linkGate = transport.gateDisconnects() + + let teardown = Task { await sut.disconnectStaleSession(deviceId: JadeFixtures.deviceId) } + let coreReached = await waitUntil { self.log.contains("service.disconnect.done") } + + XCTAssertTrue(coreReached, "core hears of the teardown while the link is still closing") + XCTAssertFalse(log.contains("transport.disconnect.done:\(JadeFixtures.blePath)")) + linkGate.open() + await teardown.value + } + + func testAKnownJadeIsRecognisedByNameAfterItsPathChanged() async throws { + let readvertised = JadeFixtures.device(path: JadeFixtures.readvertisedPath) + store.devices = [JadeFixtures.knownEntry(path: JadeFixtures.stalePath)] + service.stubs.scanned = [readvertised] + let sut = makeManager() + + let connected = try await sut.connectKnownDevice(deviceId: JadeFixtures.deviceId) + + XCTAssertEqual(connected.path, JadeFixtures.readvertisedPath) + XCTAssertEqual(service.calls.connectPaths, [JadeFixtures.readvertisedPath]) + let found = try await sut.scan() + XCTAssertEqual(found, [readvertised]) + XCTAssertTrue(sut.nearbyDevices.isEmpty, "a paired Jade is not offered as new") + } + + func testPairingDialsThePathAReadvertisedJadeIsScannedUnder() async throws { + store.devices = [JadeFixtures.knownEntry(path: JadeFixtures.stalePath)] + service.stubs.scanned = [JadeFixtures.device(path: JadeFixtures.readvertisedPath)] + let sut = makeManager() + + let connected = try await sut.connect(path: JadeFixtures.stalePath) + + XCTAssertEqual(connected.path, JadeFixtures.readvertisedPath) + XCTAssertEqual(service.calls.connectPaths, [JadeFixtures.readvertisedPath]) + } + + func testPairingKeepsTheRequestedPathWhenNoScannedJadeAdvertisesAsIt() async throws { + store.devices = [JadeFixtures.knownEntry(path: JadeFixtures.stalePath)] + service.stubs.scanned = [JadeFixtures.device(path: JadeFixtures.readvertisedPath, name: "Jade AAAAAA")] + let sut = makeManager() + + try await sut.connect(path: JadeFixtures.stalePath) + + XCTAssertEqual(service.calls.connectPaths, [JadeFixtures.stalePath]) + } + + func testARebootedJadeIsStillKnownUnderItsNewPath() { + store.devices = [JadeFixtures.knownEntry(path: JadeFixtures.stalePath)] + let sut = makeManager() + + XCTAssertTrue(sut.hasKnownDevice(deviceId: JadeFixtures.readvertisedPath, advertisedName: JadeFixtures.advertisedName)) + XCTAssertFalse(sut.hasKnownDevice(deviceId: JadeFixtures.readvertisedPath)) + XCTAssertFalse(sut.hasKnownDevice(deviceId: JadeFixtures.readvertisedPath, advertisedName: "Jade AAAAAA")) + } + + func testSilentAutoReconnectNeverAsksForThePin() async throws { + store.devices = [JadeFixtures.knownEntry()] + service.stubs.scanned = [JadeFixtures.device()] + service.stubs.connectResult = .success(JadeFixtures.version(.locked)) + let sut = makeManager() + + let connected = try await sut.autoReconnect() + + XCTAssertTrue(connected.isLocked) + XCTAssertEqual(connected.walletId, JadeFixtures.walletId) + XCTAssertTrue(service.calls.unlockNetworks.isEmpty) + XCTAssertTrue(service.calls.exportTypes.isEmpty) + XCTAssertFalse(sut.isAutoReconnecting) + XCTAssertFalse(sut.isConnecting) + } + + func testEnsureConnectedUnlocksALockedSessionWithoutReconnecting() async throws { + store.devices = [JadeFixtures.knownEntry()] + service.stubs.scanned = [JadeFixtures.device()] + service.stubs.connectResult = .success(JadeFixtures.version(.locked)) + let sut = makeManager() + try await sut.autoReconnect() + service.stubs.isConnected = true + + try await sut.ensureConnected(deviceId: JadeFixtures.deviceId) + + XCTAssertEqual(sut.connected?.isLocked, false) + XCTAssertEqual(service.calls.unlockNetworks, [.regtest]) + XCTAssertEqual(service.calls.connectPaths.count, 1, "the live session is reused") + } + + // MARK: - Background (Android cases 19 and 22, case 21 replaced) + + func testABluetoothLinkIsReleasedAfterTheAppStaysInTheBackground() async throws { + let sut = try await connectedManager(timing: JadeManager.Timing(backgroundRelease: 0.1, expirationMargin: 0.01)) + + sut.onAppBackgrounded() + XCTAssertEqual(backgroundTasks.begun.count, 1) + sut.onAppBecameActive() + XCTAssertEqual(backgroundTasks.ended, backgroundTasks.begun) + try await Task.sleep(for: .seconds(0.3)) + XCTAssertEqual(log.count("service.disconnect"), 0) + XCTAssertNotNil(sut.connected) + + sut.onAppBackgrounded() + let released = await waitUntil { self.backgroundTasks.ended.count == 2 } + + XCTAssertTrue(released) + XCTAssertEqual(backgroundTasks.ended, backgroundTasks.begun) + XCTAssertEqual(log.count("service.disconnect"), 1) + XCTAssertTrue(log.contains("transport.disconnect:\(JadeFixtures.blePath)")) + XCTAssertNil(sut.connected) + } + + func testRunningOutOfBackgroundTimeReleasesTheLinkImmediately() async throws { + let sut = try await connectedManager(timing: JadeManager.Timing(backgroundRelease: 60)) + sut.onAppBackgrounded() + + backgroundTasks.expire() + + XCTAssertEqual(log.count("transport.releaseAll"), 1) + XCTAssertNil(sut.connected) + XCTAssertFalse(sut.isSessionActive) + XCTAssertEqual(backgroundTasks.ended, backgroundTasks.begun) + let coreClosed = await waitUntil { self.log.contains("service.disconnect.done") } + XCTAssertTrue(coreClosed) + } + + func testWithoutBackgroundTimeTheLinkIsReleasedAtOnce() async throws { + let sut = try await connectedManager(timing: JadeManager.Timing(backgroundRelease: 60)) + backgroundTasks.grantsTasks = false + + sut.onAppBackgrounded() + let released = await waitUntil { self.log.contains("service.disconnect.done") } + + XCTAssertTrue(released) + XCTAssertNil(sut.connected) + XCTAssertTrue(backgroundTasks.ended.isEmpty, "an invalid task is never ended") + } + + func testAutoReconnectRunsAgainAfterABackgroundRelease() async throws { + let sut = try await connectedManager(timing: Self.fastTiming) + sut.onAppBackgrounded() + let released = await waitUntil { self.backgroundTasks.ended.count == 1 } + XCTAssertTrue(released) + XCTAssertNil(sut.connected) + + sut.onAppBecameActive() + sut.startAutoReconnect() + let reconnected = await waitUntil { sut.connected != nil } + + XCTAssertTrue(reconnected) + XCTAssertEqual(service.calls.connectPaths.count, 2) + XCTAssertEqual(sut.connected?.id, JadeFixtures.deviceId) + } + + func testAPendingReconnectIsCancelledOnBackground() async throws { + store.devices = [JadeFixtures.knownEntry()] + service.stubs.scanned = [JadeFixtures.device()] + let sut = makeManager(timing: JadeManager.Timing(backgroundRelease: 0.05, expirationMargin: 0, reconnectBackoff: 0.1)) + sut.startAutoReconnect() + XCTAssertTrue(sut.isSessionActive) + + sut.onAppBackgrounded() + + XCTAssertFalse(sut.isSessionActive) + XCTAssertEqual(backgroundTasks.begun.count, 1, "a pending reconnect may already have dialled, so a release is still scheduled") + let released = await waitUntil { self.backgroundTasks.ended.count == 1 } + XCTAssertTrue(released) + try await Task.sleep(for: .seconds(0.3)) + XCTAssertTrue(service.calls.connectPaths.isEmpty) + XCTAssertFalse(log.contains("service.scan")) + } + + func testForegroundReconnectReadsTheSavedJadeBeforeTheEntriesAreLoaded() async { + store.devices = [JadeFixtures.knownEntry()] + service.stubs.scanned = [JadeFixtures.device()] + service.stubs.connectResult = .success(JadeFixtures.version(.locked)) + let sut = makeManager(timing: Self.fastTiming) + XCTAssertTrue(sut.knownDevices.isEmpty) + + sut.startAutoReconnect() + XCTAssertTrue(sut.isSessionActive) + let reconnected = await waitUntil { sut.connected != nil } + + XCTAssertTrue(reconnected) + XCTAssertEqual(sut.connected?.id, JadeFixtures.deviceId) + XCTAssertTrue(service.calls.unlockNetworks.isEmpty) + } + + // MARK: - External disconnects (Android cases 23 and 24) + + func testAnExternalDisconnectClearsTheSessionAndTellsCore() async throws { + let sut = try await connectedManager() + + transport.externalDisconnectSubject.send(JadeFixtures.blePath) + let notified = await waitUntil { self.service.calls.notifiedPaths == [JadeFixtures.blePath] } + + XCTAssertTrue(notified) + XCTAssertNil(sut.connected) + } + + /// Core matches a pending disconnect notice against the connected path, so a notice still in + /// flight when a reconnect completes would tear the new session down instead of the old one. + func testAReconnectWaitsForTheDisconnectNoticeToReachCore() async throws { + let sut = try await connectedManager() + let notice = service.gate(.notifyDisconnected) + transport.externalDisconnectSubject.send(JadeFixtures.blePath) + let notifying = await waitUntil { self.log.contains("service.notifyDisconnected:\(JadeFixtures.blePath)") } + XCTAssertTrue(notifying) + + let reconnect = Task { try await sut.connectKnownDevice(deviceId: JadeFixtures.deviceId) } + let scanned = await waitUntil { self.log.count("service.scan") == 2 } + XCTAssertTrue(scanned) + try await Task.sleep(for: .seconds(0.1)) + XCTAssertEqual(service.calls.connectPaths.count, 1) + + notice.open() + _ = try await reconnect.value + + XCTAssertEqual(service.calls.connectPaths.count, 2) + let noticeDone = try XCTUnwrap(log.entries.firstIndex(of: "service.notifyDisconnected.done")) + let reconnectStart = try XCTUnwrap(log.entries.lastIndex(of: "service.connect:\(JadeFixtures.blePath)")) + XCTAssertLessThan(noticeDone, reconnectStart) + } + + func testAnExternalDisconnectOfAnotherPathIsIgnored() async throws { + let sut = try await connectedManager() + + sut.handleExternalDisconnect(path: JadeFixtures.stalePath) + + XCTAssertNotNil(sut.connected) + try await Task.sleep(for: .seconds(0.05)) + XCTAssertTrue(service.calls.notifiedPaths.isEmpty) + } + + func testAnExternalDisconnectWhileConnectingTellsCore() async throws { + store.devices = [JadeFixtures.knownEntry()] + service.stubs.scanned = [JadeFixtures.device()] + service.stubs.connectResults = [.failure(JadeError.DeviceDisconnected)] + let connectGate = service.gate(.connect) + let sut = makeManager() + let attempt = Task { try await sut.connectKnownDevice(deviceId: JadeFixtures.deviceId) } + let dialling = await waitUntil { self.service.calls.connectPaths.count == 1 } + XCTAssertTrue(dialling) + + sut.handleExternalDisconnect(path: JadeFixtures.blePath) + let notified = await waitUntil { self.service.calls.notifiedPaths == [JadeFixtures.blePath] } + + XCTAssertTrue(notified) + connectGate.open() + _ = try? await attempt.value + XCTAssertNil(sut.connected) + } + + // MARK: - Maintenance and device operations (Android cases 25-27) + + func testForgettingTheConnectedJadeClosesItsSessionAndDropsTheEntry() async throws { + let sut = try await connectedManager() + + await sut.forgetWallet(walletId: JadeFixtures.walletId, pendingName: nil) + + XCTAssertEqual(log.count("service.disconnect"), 1) + XCTAssertEqual(store.saves.last, []) + XCTAssertNil(sut.connected) + XCTAssertTrue(sut.knownDevices.isEmpty) + } + + func testSignPsbtCompletesTheSignedPsbtIntoATransaction() async throws { + let sut = makeManager() + + let completed = try await sut.signPsbt("psbt") + + XCTAssertEqual(completed, CompletedTransaction(serializedTx: "rawtx", txid: "txid")) + XCTAssertEqual(service.calls.signings, ["psbt"]) + XCTAssertEqual(service.calls.finalizations.map(\.original), ["psbt"]) + XCTAssertEqual(service.calls.finalizations.map(\.signed), ["signed"]) + } + + func testVerifyAddressAsksTheDeviceForTheNativeSegwitVariant() async throws { + let sut = makeManager() + + try await sut.verifyAddress(addressType: .nativeSegwit, derivationPath: "m/84'/1'/0'/0/0", expectedAddress: "bcrt1q") + + XCTAssertEqual( + service.calls.verifications, + [.init(network: .regtest, variant: .wpkh, derivationPath: "m/84'/1'/0'/0/0", expectedAddress: "bcrt1q")] + ) + } + + func testAJadeThatLockedSinceConnectingIsUnlockedAndVerifiesAgain() async throws { + let sut = try await connectedManager() + service.stubs.isConnected = true + service.stubs.verifyErrors = [JadeError.DeviceLocked] + + try await sut.verifyAddress(addressType: .nativeSegwit, derivationPath: "m/84'/1'/0'/0/0", expectedAddress: "bcrt1q") + + XCTAssertEqual(service.calls.verifications.count, 2) + XCTAssertEqual(service.calls.unlockNetworks, [.regtest]) + XCTAssertEqual(service.calls.connectPaths.count, 1) + XCTAssertEqual(sut.connected?.isLocked, false) + } + + func testAJadeThatLockedSinceConnectingIsUnlockedAndSignsAgain() async throws { + let sut = try await connectedManager() + service.stubs.isConnected = true + service.stubs.signErrors = [Bitkit.AppError(error: JadeError.DeviceLocked)] + + let completed = try await sut.signPsbt("psbt") + + XCTAssertEqual(completed.serializedTx, "rawtx") + XCTAssertEqual(service.calls.signings, ["psbt", "psbt"]) + XCTAssertEqual(service.calls.finalizations.count, 1) + XCTAssertEqual(service.calls.unlockNetworks, [.regtest]) + } + + func testRenamingAJadeWalletLabelsItsEntries() async throws { + let sut = try await connectedManager() + + sut.renameWallet(walletId: JadeFixtures.walletId, newName: " Cold storage ") + + XCTAssertEqual(store.devices.first?.customLabel, "Cold storage") + XCTAssertEqual(sut.knownDevices.first?.customLabel, "Cold storage") + } + + func testPairedPathsArePushedToTheTransport() { + store.devices = [JadeFixtures.knownEntry()] + let sut = makeManager() + + sut.loadKnownDevices() + + XCTAssertEqual(transport.pairedPathUpdates.last, [JadeFixtures.blePath]) + } + + // MARK: - Teardown ordering and the connect epoch + + /// Core's disconnect cancels any connect that started before it arrives, so a release still on + /// its way must be waited out before the next connect dials. + func testALateReleaseDoesNotCancelTheNextConnect() async throws { + let sut = try await connectedManager() + let coreGate = service.gate(.disconnect) + let release = Task { await sut.releaseSession() } + let releasing = await waitUntil { self.log.contains("service.disconnect") } + XCTAssertTrue(releasing) + XCTAssertNil(sut.connected) + XCTAssertFalse(sut.isSessionActive) + + let reconnect = Task { try await sut.connectKnownDevice(deviceId: JadeFixtures.deviceId) } + let scanned = await waitUntil { self.log.count("service.scan") == 2 } + XCTAssertTrue(scanned) + try await Task.sleep(for: .seconds(0.1)) + XCTAssertEqual(service.calls.connectPaths.count, 1, "the connect waits for the release to reach core") + + coreGate.open() + await release.value + let session = try await reconnect.value + + XCTAssertEqual(sut.connected, session) + let releaseDone = try XCTUnwrap(log.entries.firstIndex(of: "service.disconnect.done")) + let reconnectStart = try XCTUnwrap(log.entries.lastIndex(of: "service.connect:\(JadeFixtures.blePath)")) + XCTAssertLessThan(releaseDone, reconnectStart) + } + + func testEveryTeardownClearsTheSessionBeforeReachingTheDevice() async throws { + let teardowns: [(name: String, run: (JadeManager) async -> Void)] = [ + ("disconnect", { await $0.disconnect() }), + ("disconnectStaleSession", { await $0.disconnectStaleSession(deviceId: JadeFixtures.deviceId) }), + ("cancelPendingConnection", { await $0.cancelPendingConnection(deviceId: JadeFixtures.deviceId) }), + ("releaseSession", { await $0.releaseSession() }), + ("resetForWipe", { await $0.resetForWipe() }), + ] + store.devices = [JadeFixtures.knownEntry()] + service.stubs.scanned = [JadeFixtures.device()] + let sut = makeManager() + + for teardown in teardowns { + try await sut.connectKnownDevice(deviceId: JadeFixtures.deviceId) + XCTAssertNotNil(sut.connected, teardown.name) + let disconnectsBefore = log.count("service.disconnect") + let coreGate = service.gate(.disconnect) + let linkGate = transport.gateDisconnects() + + let running = Task { await teardown.run(sut) } + let reachedCore = await waitUntil { self.log.count("service.disconnect") > disconnectsBefore } + + XCTAssertTrue(reachedCore, teardown.name) + XCTAssertNil(sut.connected, teardown.name) + XCTAssertFalse(sut.isSessionActive, teardown.name) + coreGate.open() + linkGate.open() + await running.value + } + } + + func testACancelledAttemptUnwindingLateLeavesTheNewerAttemptAlone() async throws { + store.devices = [JadeFixtures.knownEntry()] + service.stubs.scanned = [JadeFixtures.device()] + service.stubs.connectResults = [ + .failure(JadeError.UserCancelled), + .success(JadeFixtures.version(.locked)), + ] + let connectGate = service.gate(.connect) + let unlockGate = service.gate(.unlock) + let sut = makeManager() + + let cancelled = Task { try await sut.connectKnownDevice(deviceId: JadeFixtures.deviceId) } + let firstDialled = await waitUntil { self.service.calls.connectPaths.count == 1 } + XCTAssertTrue(firstDialled) + await sut.cancelPendingConnection(deviceId: JadeFixtures.deviceId) + let newer = Task { try await sut.connectKnownDevice(deviceId: JadeFixtures.deviceId) } + let secondDialled = await waitUntil { self.service.calls.connectPaths.count == 2 } + XCTAssertTrue(secondDialled) + let teardownsBefore = (log.count("service.disconnect"), log.count("transport.disconnect:\(JadeFixtures.blePath)")) + + connectGate.open() + do { + _ = try await cancelled.value + XCTFail("the cancelled attempt must fail") + } catch {} + + XCTAssertEqual(log.count("service.disconnect"), teardownsBefore.0, "the cancelled attempt closed nothing") + XCTAssertEqual(log.count("transport.disconnect:\(JadeFixtures.blePath)"), teardownsBefore.1) + XCTAssertTrue(sut.isConnecting, "the newer attempt keeps its flag") + let unlocking = await waitUntil { sut.isUnlocking } + XCTAssertTrue(unlocking) + + unlockGate.open() + let session = try await newer.value + XCTAssertEqual(sut.connected, session) + XCTAssertFalse(sut.isConnecting) + XCTAssertFalse(sut.isUnlocking) + } + + func testNoFlagSticksAfterCancellingAConnect() async throws { + store.devices = [JadeFixtures.knownEntry()] + service.stubs.scanned = [JadeFixtures.device()] + service.stubs.connectResults = [.failure(JadeError.UserCancelled)] + let connectGate = service.gate(.connect) + let sut = makeManager() + let attempt = Task { try await sut.connectKnownDevice(deviceId: JadeFixtures.deviceId) } + let dialling = await waitUntil { self.service.calls.connectPaths.count == 1 } + XCTAssertTrue(dialling) + XCTAssertTrue(sut.isConnecting) + + await sut.cancelPendingConnection(deviceId: JadeFixtures.deviceId) + + XCTAssertFalse(sut.isConnecting) + XCTAssertFalse(sut.isSessionActive) + connectGate.open() + _ = try? await attempt.value + XCTAssertFalse(sut.isConnecting) + try await sut.connectKnownDevice(deviceId: JadeFixtures.deviceId) + XCTAssertNotNil(sut.connected, "a new connect is not refused as already running") + } + + func testNoFlagSticksAfterReleasingDuringThePinPrompt() async throws { + store.devices = [JadeFixtures.knownEntry()] + service.stubs.scanned = [JadeFixtures.device()] + service.stubs.connectResult = .success(JadeFixtures.version(.locked)) + let unlockGate = service.gate(.unlock) + let sut = makeManager() + let ensure = Task { try await sut.ensureConnected(deviceId: JadeFixtures.deviceId) } + let unlocking = await waitUntil { sut.isUnlocking } + XCTAssertTrue(unlocking) + + await sut.releaseSession() + + XCTAssertFalse(sut.isUnlocking) + XCTAssertFalse(sut.isConnecting) + XCTAssertFalse(sut.isSessionActive) + service.stubs.unlockError = JadeError.UserCancelled + unlockGate.open() + _ = try? await ensure.value + XCTAssertFalse(sut.isUnlocking) + XCTAssertFalse(sut.isConnecting) + XCTAssertNil(sut.connected) + } + + func testNoFlagSticksAfterABackgroundRelease() async throws { + store.devices = [JadeFixtures.knownEntry()] + service.stubs.scanned = [JadeFixtures.device()] + let connectGate = service.gate(.connect) + let sut = makeManager(timing: JadeManager.Timing(backgroundRelease: 0.01, expirationMargin: 0, reconnectBackoff: 0.01)) + sut.startAutoReconnect() + let dialling = await waitUntil { self.service.calls.connectPaths.count == 1 } + XCTAssertTrue(dialling) + XCTAssertTrue(sut.isAutoReconnecting) + XCTAssertTrue(sut.isConnecting) + + sut.onAppBackgrounded() + let released = await waitUntil { self.backgroundTasks.ended.count == 1 } + + XCTAssertTrue(released) + XCTAssertTrue(log.contains("service.cancel")) + XCTAssertFalse(sut.isAutoReconnecting) + XCTAssertFalse(sut.isConnecting) + XCTAssertFalse(sut.isSessionActive) + connectGate.open() + try await Task.sleep(for: .seconds(0.2)) + XCTAssertNil(sut.connected, "the released attempt does not revive the session") + XCTAssertFalse(sut.isAutoReconnecting) + XCTAssertFalse(sut.isConnecting) + XCTAssertEqual(service.calls.connectPaths.count, 1, "the released reconnect does not retry") + } + + // MARK: - Reconnect loop and session upkeep + + func testAConnectKnownDeviceIsRefusedWhileAnotherConnectIsRunning() async throws { + store.devices = [JadeFixtures.knownEntry()] + service.stubs.scanned = [JadeFixtures.device()] + let connectGate = service.gate(.connect) + let sut = makeManager() + let first = Task { try await sut.connectKnownDevice(deviceId: JadeFixtures.deviceId) } + let dialling = await waitUntil { self.service.calls.connectPaths.count == 1 } + XCTAssertTrue(dialling) + + do { + try await sut.connectKnownDevice(deviceId: JadeFixtures.deviceId) + XCTFail("a second connect must be refused") + } catch { + XCTAssertEqual((error as? Bitkit.AppError)?.message, "Connection already in progress") + } + + connectGate.open() + _ = try await first.value + XCTAssertEqual(service.calls.connectPaths.count, 1) + } + + func testIsUnlockingOnlyWhileTheJadeWaitsForItsPin() async throws { + service.stubs.scanned = [JadeFixtures.device()] + service.stubs.connectResult = .success(JadeFixtures.version(.locked)) + let unlockGate = service.gate(.unlock) + let sut = makeManager() + _ = try await sut.scan() + XCTAssertFalse(sut.isUnlocking) + + let pairing = Task { try await sut.connect(path: JadeFixtures.blePath) } + let unlocking = await waitUntil { sut.isUnlocking } + XCTAssertTrue(unlocking) + XCTAssertTrue(sut.isConnecting) + + unlockGate.open() + _ = try await pairing.value + XCTAssertFalse(sut.isUnlocking) + XCTAssertFalse(sut.isConnecting) + } + + func testReleasingTheSessionCancelsAPendingReconnect() async throws { + store.devices = [JadeFixtures.knownEntry()] + service.stubs.scanned = [JadeFixtures.device()] + let sut = makeManager(timing: JadeManager.Timing(reconnectBackoff: 0.1)) + sut.startAutoReconnect() + XCTAssertTrue(sut.isSessionActive) + + await sut.releaseSession() + + XCTAssertFalse(sut.isSessionActive) + try await Task.sleep(for: .seconds(0.3)) + XCTAssertTrue(service.calls.connectPaths.isEmpty) + } + + func testEnsureConnectedDropsAPendingReconnectInsteadOfWaitingItOut() async throws { + store.devices = [JadeFixtures.knownEntry()] + service.stubs.scanned = [JadeFixtures.device()] + let sut = makeManager(timing: JadeManager.Timing(reconnectBackoff: 60)) + sut.startAutoReconnect() + let started = Date() + + try await sut.ensureConnected(deviceId: JadeFixtures.deviceId) + + XCTAssertLessThan(Date().timeIntervalSince(started), 5) + XCTAssertNotNil(sut.connected) + XCTAssertEqual(service.calls.connectPaths.count, 1) + } + + func testPairingCancelsABackgroundReconnectThatIsDialling() async throws { + store.devices = [JadeFixtures.knownEntry()] + service.stubs.scanned = [JadeFixtures.device()] + service.stubs.connectResults = [.failure(JadeError.UserCancelled)] + let connectGate = service.gate(.connect) + let sut = makeManager(timing: JadeManager.Timing(reconnectBackoff: 0.01)) + sut.startAutoReconnect() + let dialling = await waitUntil { self.service.calls.connectPaths.count == 1 } + XCTAssertTrue(dialling) + + let pairing = Task { try await sut.connect(path: JadeFixtures.blePath) } + let cancelled = await waitUntil { self.log.contains("service.cancel") } + XCTAssertTrue(cancelled) + connectGate.open() + let session = try await pairing.value + + XCTAssertEqual(sut.connected, session) + XCTAssertEqual(service.calls.connectPaths.count, 2) + let cancelIndex = try XCTUnwrap(log.entries.firstIndex(of: "service.cancel")) + let pairingStart = try XCTUnwrap(log.entries.lastIndex(of: "service.connect:\(JadeFixtures.blePath)")) + XCTAssertLessThan(cancelIndex, pairingStart) + } + + func testACancelledPairingNeverDials() async { + service.stubs.scanned = [JadeFixtures.device()] + let sut = makeManager() + + let pairing = Task { try await sut.connect(path: JadeFixtures.blePath) } + pairing.cancel() + + do { + _ = try await pairing.value + XCTFail("a cancelled pairing must not connect") + } catch { + XCTAssertTrue(error is CancellationError, "\(error)") + } + XCTAssertTrue(service.calls.connectPaths.isEmpty) + XCTAssertFalse(sut.isConnecting) + } + + /// Stopping the reconnect in flight can take seconds, and leaving the pairing screen meanwhile + /// must not dial once it is stopped. + func testAPairingCancelledWhileStoppingAReconnectNeverDials() async { + store.devices = [JadeFixtures.knownEntry()] + service.stubs.scanned = [JadeFixtures.device()] + service.stubs.connectResults = [.failure(JadeError.UserCancelled)] + let connectGate = service.gate(.connect) + let cancelGate = service.gate(.cancel) + let sut = makeManager(timing: JadeManager.Timing(reconnectBackoff: 0.01)) + sut.startAutoReconnect() + let dialling = await waitUntil { self.service.calls.connectPaths.count == 1 } + XCTAssertTrue(dialling) + + let pairing = Task { try await sut.connect(path: JadeFixtures.blePath) } + let stopping = await waitUntil { self.log.contains("service.cancel") } + XCTAssertTrue(stopping) + pairing.cancel() + cancelGate.open() + connectGate.open() + + do { + _ = try await pairing.value + XCTFail("a cancelled pairing must not connect") + } catch { + XCTAssertTrue(error is CancellationError, "\(error)") + } + XCTAssertEqual(service.calls.connectPaths.count, 1, "only the stopped reconnect dialled") + XCTAssertFalse(sut.isConnecting) + } + + func testAFailedReconnectAttemptIsRetried() async { + store.devices = [JadeFixtures.knownEntry()] + service.stubs.scanned = [JadeFixtures.device()] + service.stubs.connectResults = [.failure(JadeError.Timeout), .success(JadeFixtures.version(.locked))] + let sut = makeManager(timing: Self.fastTiming) + + sut.startAutoReconnect() + let reconnected = await waitUntil { sut.connected != nil } + + XCTAssertTrue(reconnected) + XCTAssertEqual(service.calls.connectPaths.count, 2) + XCTAssertEqual(sut.connected?.id, JadeFixtures.deviceId) + XCTAssertTrue(service.calls.unlockNetworks.isEmpty) + } + + /// A busy Jade is waiting on the user, so dialling it again would only interrupt them. + func testTheReconnectLoopStopsWhenTheJadeIsBusy() async { + store.devices = [JadeFixtures.knownEntry()] + service.stubs.scanned = [JadeFixtures.device()] + service.stubs.connectResult = .failure(JadeError.DeviceBusy) + let sut = makeManager(timing: Self.fastTiming) + + sut.startAutoReconnect() + let stopped = await waitUntil { !sut.isSessionActive } + + XCTAssertTrue(stopped) + XCTAssertEqual(service.calls.connectPaths.count, 1) + XCTAssertNil(sut.connected) + } + + /// A loop cancelled by a release unwinds after the next loop has started, and must not clear it. + func testACancelledReconnectLoopLeavesTheNewerLoopRunning() async { + store.devices = [JadeFixtures.knownEntry()] + service.stubs.scanned = [JadeFixtures.device()] + service.stubs.connectResult = .success(JadeFixtures.version(.locked)) + let sut = makeManager(timing: JadeManager.Timing(reconnectBackoff: 0.2)) + + sut.startAutoReconnect() + await sut.releaseSession() + sut.startAutoReconnect() + var stayedActive = true + let dialled = await waitUntil { + stayedActive = stayedActive && sut.isSessionActive + return !self.service.calls.connectPaths.isEmpty + } + + XCTAssertTrue(dialled) + XCTAssertTrue(stayedActive, "the session stays active until the newer loop dials") + let reconnected = await waitUntil { sut.connected != nil } + XCTAssertTrue(reconnected) + XCTAssertEqual(service.calls.connectPaths.count, 1) + } + + func testResetForWipeDropsTheSessionAndItsBackgroundWork() async throws { + let sut = try await connectedManager(timing: JadeManager.Timing(backgroundRelease: 60)) + sut.onAppBackgrounded() + XCTAssertEqual(backgroundTasks.begun.count, 1) + + await sut.resetForWipe() + + XCTAssertNil(sut.connected) + XCTAssertTrue(sut.knownDevices.isEmpty) + XCTAssertFalse(sut.isSessionActive) + XCTAssertTrue(log.contains("transport.closeAll")) + XCTAssertEqual(log.count("service.disconnect"), 1) + XCTAssertEqual(backgroundTasks.ended, backgroundTasks.begun) + _ = try await sut.scan() + XCTAssertEqual(log.count("service.initialize"), 2, "a wiped manager sets itself up again") + } + + // MARK: - App lifecycle without a paired Jade + + /// Starting the Jade Bluetooth central is what shows the iOS Bluetooth prompt, so the calls the app + /// makes at launch, on scene changes and on a wipe must leave it alone until a Jade is paired. + func testAppLifecycleWithoutAPairedJadeNeverStartsBluetooth() async { + let bluetooth = JadeBLEManager() + let sut = JadeManager( + service: service, + transport: JadeTransport(driver: bluetooth, isTrezorBridgeEnabled: { false }), + store: store, + backgroundTasks: backgroundTasks, + timing: JadeManagerTests.fastTiming, + network: { .regtest } + ) + + sut.loadKnownDevices() + sut.onAppBecameActive() + sut.startAutoReconnect() + sut.onAppBackgrounded() + sut.onAppBecameActive() + await sut.resetForWipe() + + XCTAssertFalse(bluetooth.hasCentral) + XCTAssertFalse(sut.isConnectInProgress) + XCTAssertTrue(backgroundTasks.begun.isEmpty) + } + + // MARK: - Helpers + + private func makeManager(timing: JadeManager.Timing = JadeManagerTests.fastTiming) -> JadeManager { + JadeManager( + service: service, + transport: transport, + store: store, + backgroundTasks: backgroundTasks, + timing: timing, + now: { Date(timeIntervalSince1970: 1000) }, + network: { .regtest } + ) + } + + /// A manager holding an unlocked session of the paired Jade. + private func connectedManager(timing: JadeManager.Timing = JadeManagerTests.fastTiming) async throws -> JadeManager { + store.devices = [JadeFixtures.knownEntry()] + service.stubs.scanned = [JadeFixtures.device()] + let sut = makeManager(timing: timing) + try await sut.connectKnownDevice(deviceId: JadeFixtures.deviceId) + return sut + } + + private func waitUntil(timeout: TimeInterval = 2, _ condition: () -> Bool) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while !condition() { + if Date() >= deadline { + return false + } + try? await Task.sleep(nanoseconds: 5_000_000) + } + return true + } +} diff --git a/BitkitTests/JadeMocks.swift b/BitkitTests/JadeMocks.swift new file mode 100644 index 000000000..f15165e81 --- /dev/null +++ b/BitkitTests/JadeMocks.swift @@ -0,0 +1,537 @@ +@testable import Bitkit +import BitkitCore +import Combine +import Foundation +import UIKit + +/// A `JadeBLEDriving` that answers from stubs and records every call, for testing `JadeTransport` +/// without CoreBluetooth. Calls can arrive on any thread, so both are guarded by a lock. +final class FakeBLEDriver: JadeBLEDriving, @unchecked Sendable { + struct Stubs { + var discoveries: [JadeBLEDiscovery] = [] + var openError: JadeBLEError? + var writeError: JadeBLEError? + var readResult: Result = .success(Data()) + var chunkSizes: [String: UInt32] = [:] + } + + struct Calls { + var scanDurations: [TimeInterval] = [] + var openedPaths: [String] = [] + var closedPaths: [String] = [] + var closedOnMainThread: [Bool] = [] + var writes: [(path: String, data: Data)] = [] + var readTimeouts: [TimeInterval] = [] + var closeAllCount = 0 + var releaseAllCount = 0 + var pairedPaths: Set? + } + + let externalDisconnectSubject = PassthroughSubject() + let poweredOnSubject = PassthroughSubject() + + private let lock = NSLock() + private var storedStubs = Stubs() + private var recordedCalls = Calls() + + var stubs: Stubs { + get { lock.withLock { storedStubs } } + set { lock.withLock { storedStubs = newValue } } + } + + var calls: Calls { + lock.withLock { recordedCalls } + } + + var externalDisconnects: AnyPublisher { + externalDisconnectSubject.eraseToAnyPublisher() + } + + var bluetoothPoweredOn: AnyPublisher { + poweredOnSubject.eraseToAnyPublisher() + } + + func scan(duration: TimeInterval) -> [JadeBLEDiscovery] { + lock.withLock { + recordedCalls.scanDurations.append(duration) + return storedStubs.discoveries + } + } + + func open(path: String) throws { + let error: JadeBLEError? = lock.withLock { + recordedCalls.openedPaths.append(path) + return storedStubs.openError + } + if let error { + throw error + } + } + + func close(path: String) { + let isMainThread = Thread.isMainThread + lock.withLock { + recordedCalls.closedPaths.append(path) + recordedCalls.closedOnMainThread.append(isMainThread) + } + } + + func write(path: String, data: Data) throws { + let error: JadeBLEError? = lock.withLock { + recordedCalls.writes.append((path, data)) + return storedStubs.writeError + } + if let error { + throw error + } + } + + func read(path _: String, timeout: TimeInterval) throws -> Data { + let result: Result = lock.withLock { + recordedCalls.readTimeouts.append(timeout) + return storedStubs.readResult + } + return try result.get() + } + + func chunkSize(path: String) -> UInt32 { + lock.withLock { storedStubs.chunkSizes[path] ?? JadeBLEManager.defaultChunkSize } + } + + func closeAll() { + lock.withLock { recordedCalls.closeAllCount += 1 } + } + + func releaseAllImmediately() { + lock.withLock { recordedCalls.releaseAllCount += 1 } + } + + func setPairedPaths(_ paths: Set) { + lock.withLock { recordedCalls.pairedPaths = paths } + } +} + +// MARK: - Session manager fakes + +/// One ordered record of the calls the Jade fakes receive, shared by the service and the transport so +/// a test can assert the order across both. Calls arrive on any thread, so it is guarded by a lock. +final class JadeCallLog: @unchecked Sendable { + private let lock = NSLock() + private var recorded: [String] = [] + + var entries: [String] { + lock.withLock { recorded } + } + + func record(_ entry: String) { + lock.withLock { recorded.append(entry) } + } + + func count(_ entry: String) -> Int { + entries.filter { $0 == entry }.count + } + + func contains(_ entry: String) -> Bool { + entries.contains(entry) + } +} + +/// Holds the calls that wait on it until a test opens it; once open it stays open. +final class AsyncGate: @unchecked Sendable { + private let lock = NSLock() + private var isOpen = false + private var waiters: [CheckedContinuation] = [] + + func wait() async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + let resumeNow = lock.withLock { + if isOpen { + return true + } + waiters.append(continuation) + return false + } + if resumeNow { + continuation.resume() + } + } + } + + func open() { + let pending = lock.withLock { + isOpen = true + defer { waiters.removeAll() } + return waiters + } + pending.forEach { $0.resume() } + } +} + +enum JadeFixtures { + static let efuseMac = "246F288F6B64" + static let deviceId = "jade:bluetooth:\(efuseMac)" + static let advertisedName = "Jade 8F6B64" + static let walletId = "jade:wallet" + static let blePath = "ble:6B7A9B16-C81C-4A4B-9B11-000000000001" + static let stalePath = "ble:6B7A9B16-C81C-4A4B-9B11-000000000002" + static let readvertisedPath = "ble:56C4BFB3-9E75-4A4B-9B11-000000000003" + static let xpub = "zpubNS" + + static func version(_ state: JadeState, efuseMac: String? = efuseMac) -> JadeVersionInfo { + JadeVersionInfo( + jadeVersion: "1.0.41", + jadeState: state, + jadeNetworks: "ALL", + jadeHasPin: true, + boardType: "JADE_V1_1", + jadeConfig: nil, + jadeFeatures: nil, + idfVersion: nil, + chipFeatures: nil, + efuseMac: efuseMac, + batteryStatus: nil, + jadeOtaMaxChunk: nil + ) + } + + static func accountExport() -> JadeAccountExport { + JadeAccountExport( + masterFingerprint: "deadbeef", + accountIndex: 0, + accounts: [JadeAccount(variant: .wpkh, xpub: xpub, derivationPath: "m/84'/1'/0'")] + ) + } + + static func device(path: String = blePath, name: String? = advertisedName) -> JadeDeviceInfo { + JadeDeviceInfo(path: path, transport: .bluetooth, name: name, serialNumber: nil) + } + + static func knownEntry(path: String = blePath, lastConnectedAt: Date = Date(timeIntervalSince1970: 0)) -> HwKnownDevice { + HwKnownDevice( + id: deviceId, + name: advertisedName, + path: path, + transportType: "bluetooth", + model: "Jade", + lastConnectedAt: lastConnectedAt, + xpubs: ["nativeSegwit": xpub], + walletId: walletId, + vendor: .blockstream, + jadeDeviceId: efuseMac + ) + } +} + +/// A `JadeServicing` that answers from stubs and writes every call to a shared `JadeCallLog`. A call +/// with a gate waits for the gate to open, so a test can hold core mid-request. +final class FakeJadeService: JadeServicing, @unchecked Sendable { + enum GatedCall: Hashable { + case connect + case unlock + case disconnect + case cancel + case notifyDisconnected + } + + struct Stubs { + var isConnected = false + var scanned: [JadeDeviceInfo] = [] + var scanError: Error? + var listed: [JadeDeviceInfo] = [] + /// Answers to successive connects; once used up, `connectResult` answers every later one. + var connectResults: [Result] = [] + var connectResult: Result = .success(JadeFixtures.version(.ready)) + var unlockError: Error? + var refreshedVersion = JadeFixtures.version(.ready) + var exportHandler: ([AccountType]) throws -> JadeAccountExport = { _ in JadeFixtures.accountExport() } + var fingerprint = "deadbeef" + /// Errors thrown by successive verifications; once used up, a verification succeeds. + var verifyErrors: [Error] = [] + var signErrors: [Error] = [] + var signedPsbt = "signed" + var completed = CompletedTransaction(serializedTx: "rawtx", txid: "txid") + } + + struct Verification: Equatable { + let network: JadeNetwork + let variant: JadeAddressVariant + let derivationPath: String + let expectedAddress: String + } + + struct Calls { + var connectPaths: [String] = [] + var unlockNetworks: [JadeNetwork] = [] + var exportTypes: [[AccountType]] = [] + var verifications: [Verification] = [] + var signings: [String] = [] + var finalizations: [(original: String, signed: String)] = [] + var notifiedPaths: [String] = [] + } + + let log: JadeCallLog + + private let lock = NSLock() + private var storedStubs = Stubs() + private var recordedCalls = Calls() + private var gates: [GatedCall: AsyncGate] = [:] + + init(log: JadeCallLog) { + self.log = log + } + + var stubs: Stubs { + get { lock.withLock { storedStubs } } + set { lock.withLock { storedStubs = newValue } } + } + + var calls: Calls { + lock.withLock { recordedCalls } + } + + /// Makes every later `call` wait until the returned gate is opened. + @discardableResult + func gate(_ call: GatedCall) -> AsyncGate { + lock.withLock { + let gate = AsyncGate() + gates[call] = gate + return gate + } + } + + func initialize() async throws { + log.record("service.initialize") + } + + func scan(timeoutMs _: UInt32) async throws -> [JadeDeviceInfo] { + log.record("service.scan") + let stubs = stubs + if let scanError = stubs.scanError { + throw scanError + } + return stubs.scanned + } + + func listDevices() async -> [JadeDeviceInfo] { + log.record("service.list") + return stubs.listed + } + + func connect(path: String) async throws -> JadeVersionInfo { + log.record("service.connect:\(path)") + let result = lock.withLock { + recordedCalls.connectPaths.append(path) + return storedStubs.connectResults.isEmpty ? storedStubs.connectResult : storedStubs.connectResults.removeFirst() + } + await waitAtGate(.connect) + return try result.get() + } + + func disconnect() async throws { + log.record("service.disconnect") + await waitAtGate(.disconnect) + log.record("service.disconnect.done") + } + + func cancel() async throws { + log.record("service.cancel") + await waitAtGate(.cancel) + } + + func notifyDisconnected(path: String) async { + log.record("service.notifyDisconnected:\(path)") + lock.withLock { recordedCalls.notifiedPaths.append(path) } + await waitAtGate(.notifyDisconnected) + log.record("service.notifyDisconnected.done") + } + + func isConnected() -> Bool { + stubs.isConnected + } + + func refreshVersionInfo() async throws -> JadeVersionInfo { + log.record("service.refresh") + return stubs.refreshedVersion + } + + func unlock(network: JadeNetwork) async throws { + log.record("service.unlock") + lock.withLock { recordedCalls.unlockNetworks.append(network) } + await waitAtGate(.unlock) + if let unlockError = stubs.unlockError { + throw unlockError + } + } + + func getMasterFingerprint(network _: JadeNetwork) async throws -> String { + log.record("service.fingerprint") + return stubs.fingerprint + } + + func getAccountExport(network _: JadeNetwork, accountTypes: [AccountType], accountIndex _: UInt32) async throws -> JadeAccountExport { + log.record("service.export") + lock.withLock { recordedCalls.exportTypes.append(accountTypes) } + return try stubs.exportHandler(accountTypes) + } + + func verifyAddress(network: JadeNetwork, variant: JadeAddressVariant, derivationPath: String, expectedAddress: String) async throws { + log.record("service.verify") + let error: Error? = lock.withLock { + recordedCalls.verifications.append( + Verification(network: network, variant: variant, derivationPath: derivationPath, expectedAddress: expectedAddress) + ) + return storedStubs.verifyErrors.isEmpty ? nil : storedStubs.verifyErrors.removeFirst() + } + if let error { + throw error + } + } + + func signPsbt(network _: JadeNetwork, psbtBase64: String) async throws -> String { + log.record("service.sign") + let error: Error? = lock.withLock { + recordedCalls.signings.append(psbtBase64) + return storedStubs.signErrors.isEmpty ? nil : storedStubs.signErrors.removeFirst() + } + if let error { + throw error + } + return stubs.signedPsbt + } + + func finalizePsbt(originalPsbt: String, signedPsbt: String) async throws -> CompletedTransaction { + log.record("service.finalize") + lock.withLock { recordedCalls.finalizations.append((originalPsbt, signedPsbt)) } + return stubs.completed + } + + private func waitAtGate(_ call: GatedCall) async { + let gate = lock.withLock { gates[call] } + await gate?.wait() + } +} + +/// A `JadeTransportControlling` that records every call to the shared log, with a gate for holding a +/// link close open. +final class FakeJadeTransportControl: JadeTransportControlling, @unchecked Sendable { + let log: JadeCallLog + let externalDisconnectSubject = PassthroughSubject() + let poweredOnSubject = PassthroughSubject() + + private let lock = NSLock() + private var disconnectGate: AsyncGate? + private var recordedPairedPaths: [Set] = [] + + init(log: JadeCallLog) { + self.log = log + } + + var externalDisconnects: AnyPublisher { + externalDisconnectSubject.eraseToAnyPublisher() + } + + var bluetoothPoweredOn: AnyPublisher { + poweredOnSubject.eraseToAnyPublisher() + } + + /// Every set of paired paths pushed, oldest first. + var pairedPathUpdates: [Set] { + lock.withLock { recordedPairedPaths } + } + + /// Makes every later link close wait until the returned gate is opened. + @discardableResult + func gateDisconnects() -> AsyncGate { + lock.withLock { + let gate = AsyncGate() + disconnectGate = gate + return gate + } + } + + func disconnectDevice(path: String) async { + log.record("transport.disconnect:\(path)") + let gate = lock.withLock { disconnectGate } + await gate?.wait() + log.record("transport.disconnect.done:\(path)") + } + + func closeAllConnections() async { + log.record("transport.closeAll") + } + + func releaseAllImmediately() { + log.record("transport.releaseAll") + } + + func setPairedPaths(_ paths: Set) { + lock.withLock { recordedPairedPaths.append(paths) } + } +} + +/// The Jade slice of the paired-device store, in memory. +final class InMemoryJadeKnownDeviceStore: JadeKnownDeviceStoring { + var devices: [HwKnownDevice] + var pendingNames: [String: String] = [:] + private(set) var saves: [[HwKnownDevice]] = [] + private(set) var pendingNameUpdates: [PendingHwWalletName?] = [] + + init(devices: [HwKnownDevice] = []) { + self.devices = devices + } + + func loadAll() -> [HwKnownDevice] { + devices.sorted { $0.lastConnectedAt > $1.lastConnectedAt } + } + + func saveAll(_ devices: [HwKnownDevice], pendingName: PendingHwWalletName?) { + if let pendingName { + setPendingName(walletId: pendingName.walletId, name: pendingName.name) + } + pendingNameUpdates.append(pendingName) + saves.append(devices) + self.devices = devices + } + + func loadPendingNames() -> [String: String] { + let named = Set(devices.filter { $0.customLabel?.isEmpty == false }.compactMap(\.resolvedWalletId)) + return pendingNames.filter { !named.contains($0.key) } + } + + func setPendingName(walletId: String, name: String?) { + pendingNames[walletId] = name.flatMap { $0.isEmpty ? nil : $0 } + } +} + +/// A `BackgroundTaskScheduling` that hands out identifiers and keeps the expiration handler, so a test +/// can run out the background time on demand. +@MainActor +final class FakeBackgroundTasks: BackgroundTaskScheduling { + var backgroundTimeRemaining: TimeInterval = 100 + var grantsTasks = true + private(set) var begun: [UIBackgroundTaskIdentifier] = [] + private(set) var ended: [UIBackgroundTaskIdentifier] = [] + private var expirationHandlers: [UIBackgroundTaskIdentifier: @MainActor () -> Void] = [:] + private var nextIdentifier = 1 + + func beginBackgroundTask(named _: String, expiration: @escaping @MainActor () -> Void) -> UIBackgroundTaskIdentifier { + guard grantsTasks else { return .invalid } + let identifier = UIBackgroundTaskIdentifier(rawValue: nextIdentifier) + nextIdentifier += 1 + begun.append(identifier) + expirationHandlers[identifier] = expiration + return identifier + } + + func endBackgroundTask(_ identifier: UIBackgroundTaskIdentifier) { + ended.append(identifier) + expirationHandlers[identifier] = nil + } + + /// Runs the expiration handler of every task still running, as the system does when time is up. + func expire() { + for handler in expirationHandlers.values { + handler() + } + } +} diff --git a/BitkitTests/JadeServiceTests.swift b/BitkitTests/JadeServiceTests.swift new file mode 100644 index 000000000..674dc482f --- /dev/null +++ b/BitkitTests/JadeServiceTests.swift @@ -0,0 +1,18 @@ +@testable import Bitkit +import BitkitCore +import XCTest + +final class JadeServiceTests: XCTestCase { + /// `JadeService.finalizePsbt` shares its name with the core function it wraps. Called unqualified, + /// it would call itself until the stack overflows instead of reaching core. + func testFinalizePsbtReachesTheCoreFunctionInsteadOfRecursing() async { + let sut = JadeService(transport: JadeTransport(driver: FakeBLEDriver())) + + do { + _ = try await sut.finalizePsbt(originalPsbt: "not a psbt", signedPsbt: "not a psbt") + XCTFail("core must reject an invalid psbt") + } catch { + XCTAssertTrue((error as? Bitkit.AppError)?.underlyingError is PsbtCompletionError, "error=\(error)") + } + } +} diff --git a/BitkitTests/JadeTransportTests.swift b/BitkitTests/JadeTransportTests.swift new file mode 100644 index 000000000..b78c6f152 --- /dev/null +++ b/BitkitTests/JadeTransportTests.swift @@ -0,0 +1,318 @@ +@testable import Bitkit +import BitkitCore +import Combine +import CoreBluetooth +import XCTest + +final class JadeTransportTests: XCTestCase { + private let path = "ble:2F8C7A10-4B3E-4D1A-9E6C-5A7B8C9D0E1F" + private let staleBondText = "Bluetooth pairing is no longer valid: forget the Jade in the iOS Bluetooth settings and pair it again." + + private var driver: FakeBLEDriver! + private var cancellables: Set = [] + + override func setUp() { + super.setUp() + driver = FakeBLEDriver() + } + + override func tearDown() { + cancellables.removeAll() + driver = nil + super.tearDown() + } + + private func makeTransport(isTrezorBridgeEnabled: Bool = false) -> JadeTransport { + JadeTransport(driver: driver, isTrezorBridgeEnabled: { isTrezorBridgeEnabled }) + } + + // MARK: - Chunk size + + func testChunkSizeClampsMaximumWriteLength() { + XCTAssertEqual(JadeBLEManager.chunkSize(maximumWriteLength: -1), 1) + XCTAssertEqual(JadeBLEManager.chunkSize(maximumWriteLength: 0), 1) + XCTAssertEqual(JadeBLEManager.chunkSize(maximumWriteLength: 20), 20) + XCTAssertEqual(JadeBLEManager.chunkSize(maximumWriteLength: 244), 244) + XCTAssertEqual(JadeBLEManager.chunkSize(maximumWriteLength: 509), 509) + XCTAssertEqual(JadeBLEManager.chunkSize(maximumWriteLength: 512), 509) + } + + func testChunkSizeDefaultsTo20WhenNotOpen() { + let transport = JadeTransport(driver: JadeBLEManager(), isTrezorBridgeEnabled: { false }) + + XCTAssertEqual(transport.getChunkSize(path: path), 20) + } + + func testChunkSizeComesFromTheDriver() { + driver.stubs.chunkSizes = [path: 182] + + XCTAssertEqual(makeTransport().getChunkSize(path: path), 182) + } + + // MARK: - Unopened paths + + func testOperationsOnUnopenedDeviceReportNotConnected() { + let transport = JadeTransport(driver: JadeBLEManager(), isTrezorBridgeEnabled: { false }) + + let read = transport.readChunk(path: path, timeoutMs: 50) + XCTAssertFalse(read.success) + XCTAssertTrue(read.data.isEmpty) + XCTAssertEqual(read.errorCode, .notConnected) + XCTAssertEqual(read.error, "Jade is not connected.") + + let write = transport.writeChunk(path: path, data: Data([0x01])) + XCTAssertFalse(write.success) + XCTAssertEqual(write.errorCode, .notConnected) + + XCTAssertTrue(transport.closeDevice(path: path).success) + } + + func testOpeningAnInvalidPathFailsWithoutStartingBluetooth() { + let manager = JadeBLEManager() + let transport = JadeTransport(driver: manager, isTrezorBridgeEnabled: { false }) + + let result = transport.openDevice(path: "usb:jade") + + XCTAssertFalse(result.success) + XCTAssertEqual(result.error, "Invalid Jade Bluetooth path: usb:jade") + XCTAssertNil(result.errorCode) + XCTAssertFalse(manager.hasCentral) + } + + func testCentralIsOnlyCreatedByScanOrOpen() { + let manager = JadeBLEManager() + let transport = JadeTransport(driver: manager, isTrezorBridgeEnabled: { false }) + + manager.setPairedPaths([path]) + manager.releaseAllImmediately() + manager.closeAll() + _ = manager.chunkSize(path: path) + manager.externalDisconnects.sink { _ in }.store(in: &cancellables) + manager.bluetoothPoweredOn.sink { _ in }.store(in: &cancellables) + _ = transport.readChunk(path: path, timeoutMs: 10) + _ = transport.writeChunk(path: path, data: Data([0x01])) + _ = transport.closeDevice(path: path) + transport.releaseAllImmediately() + transport.setPairedPaths([]) + + XCTAssertFalse(manager.hasCentral) + } + + // MARK: - Reads + + func testReadTimeoutIsEmptySuccess() { + driver.stubs.readResult = .success(Data()) + + let result = makeTransport().readChunk(path: path, timeoutMs: 250) + + XCTAssertEqual(result, JadeTransportReadResult(success: true, data: Data(), error: "", errorCode: nil)) + XCTAssertEqual(driver.calls.readTimeouts, [0.25]) + } + + func testReadReturnsArrivedBytes() { + driver.stubs.readResult = .success(Data([0xA1, 0xB2, 0xC3])) + + let result = makeTransport().readChunk(path: path, timeoutMs: 100) + + XCTAssertTrue(result.success) + XCTAssertEqual(result.data, Data([0xA1, 0xB2, 0xC3])) + XCTAssertNil(result.errorCode) + } + + func testReadAfterLinkDropReportsDisconnected() { + driver.stubs.readResult = .failure(.disconnected) + + let result = makeTransport().readChunk(path: path, timeoutMs: 250) + + XCTAssertFalse(result.success) + XCTAssertTrue(result.data.isEmpty) + XCTAssertEqual(result.errorCode, .disconnected) + XCTAssertEqual(result.error, "Your Jade disconnected.") + } + + // MARK: - Writes + + func testWriteSendsTheChunkToTheDriver() { + let result = makeTransport().writeChunk(path: path, data: Data([0x01, 0x02])) + + XCTAssertEqual(result, JadeTransportResult(success: true, error: "", errorCode: nil)) + XCTAssertEqual(driver.calls.writes.map(\.path), [path]) + XCTAssertEqual(driver.calls.writes.map(\.data), [Data([0x01, 0x02])]) + } + + func testWriteTimeoutReportsTimeoutCode() { + driver.stubs.writeError = .writeTimeout + + let result = makeTransport().writeChunk(path: path, data: Data([0x01])) + + XCTAssertFalse(result.success) + XCTAssertEqual(result.errorCode, .timeout) + XCTAssertEqual(result.error, "Timed out sending data to your Jade.") + } + + func testStaleBondHasNoErrorCodeAndVerbatimText() { + driver.stubs.writeError = .staleBond + + let result = makeTransport().writeChunk(path: path, data: Data([0x01])) + + XCTAssertFalse(result.success) + XCTAssertNil(result.errorCode) + XCTAssertEqual(result.error, staleBondText) + XCTAssertNil(JadeBLEError.staleBond.transportErrorCode) + XCTAssertEqual(JadeBLEError.staleBond.localizedDescription, staleBondText) + } + + func testErrorCodesFollowTheFailure() { + XCTAssertEqual(JadeTransport.errorCode(for: JadeBLEError.notOpen), .notConnected) + XCTAssertEqual(JadeTransport.errorCode(for: JadeBLEError.deviceNotFound), .notConnected) + XCTAssertEqual(JadeTransport.errorCode(for: JadeBLEError.connectTimeout), .timeout) + XCTAssertEqual(JadeTransport.errorCode(for: JadeBLEError.closed), .disconnected) + XCTAssertNil(JadeTransport.errorCode(for: JadeBLEError.pairingNotConfirmed)) + XCTAssertNil(JadeTransport.errorCode(for: JadeBLEError.writeFailed("busy"))) + XCTAssertNil(JadeTransport.errorCode(for: CancellationError())) + } + + // MARK: - Opening and closing + + func testOpenReportsTheFailureText() { + driver.stubs.openError = .pairingNotConfirmed + + let result = makeTransport().openDevice(path: path) + + XCTAssertFalse(result.success) + XCTAssertEqual(result.error, "Bluetooth pairing with your Jade was not confirmed. Try again and accept the pairing request.") + XCTAssertEqual(driver.calls.openedPaths, [path]) + } + + func testOpenSucceedsWhenTheDriverOpens() { + XCTAssertEqual(makeTransport().openDevice(path: path), JadeTransportResult(success: true, error: "", errorCode: nil)) + } + + func testCloseAlwaysSucceeds() { + let transport = makeTransport() + + XCTAssertTrue(transport.closeDevice(path: path).success) + XCTAssertTrue(transport.closeDevice(path: path).success) + XCTAssertEqual(driver.calls.closedPaths, [path, path]) + } + + @MainActor + func testDisconnectDeviceClosesOffTheMainThread() async { + let transport = makeTransport() + + await transport.disconnectDevice(path: path) + await transport.closeAllConnections() + + XCTAssertEqual(driver.calls.closedPaths, [path]) + XCTAssertEqual(driver.calls.closedOnMainThread, [false]) + XCTAssertEqual(driver.calls.closeAllCount, 1) + } + + func testControlCallsReachTheDriver() { + let transport = makeTransport() + + transport.setPairedPaths([path]) + transport.releaseAllImmediately() + + XCTAssertEqual(driver.calls.pairedPaths, [path]) + XCTAssertEqual(driver.calls.releaseAllCount, 1) + } + + func testPublishersForwardDriverEvents() { + let transport = makeTransport() + var disconnectedPaths: [String] = [] + var poweredOnCount = 0 + transport.externalDisconnects.sink { disconnectedPaths.append($0) }.store(in: &cancellables) + transport.bluetoothPoweredOn.sink { poweredOnCount += 1 }.store(in: &cancellables) + + driver.externalDisconnectSubject.send(path) + driver.poweredOnSubject.send(()) + + XCTAssertEqual(disconnectedPaths, [path]) + XCTAssertEqual(poweredOnCount, 1) + } + + // MARK: - Scanning + + func testScanDurationIsClamped() { + XCTAssertEqual(JadeTransport.scanDuration(timeoutMs: 0), 0.5) + XCTAssertEqual(JadeTransport.scanDuration(timeoutMs: 499), 0.5) + XCTAssertEqual(JadeTransport.scanDuration(timeoutMs: 3000), 3) + XCTAssertEqual(JadeTransport.scanDuration(timeoutMs: 15000), 15) + XCTAssertEqual(JadeTransport.scanDuration(timeoutMs: 60000), 15) + } + + func testScanMapsDiscoveriesToBluetoothDevices() { + driver.stubs.discoveries = [ + JadeBLEDiscovery(path: path, name: "Jade 8F6B64"), + JadeBLEDiscovery(path: "ble:9A1B2C3D-4E5F-4061-8293-A4B5C6D7E8F9", name: "Jade"), + ] + + let devices = makeTransport().scanDevices(timeoutMs: 3000) + + XCTAssertEqual(devices, [ + JadeNativeDevice(path: path, transport: .bluetooth, name: "Jade 8F6B64", serialNumber: nil), + JadeNativeDevice(path: "ble:9A1B2C3D-4E5F-4061-8293-A4B5C6D7E8F9", transport: .bluetooth, name: "Jade", serialNumber: nil), + ]) + XCTAssertEqual(driver.calls.scanDurations, [3]) + } + + func testScanIsSkippedWhileTrezorBridgeIsEnabled() { + driver.stubs.discoveries = [JadeBLEDiscovery(path: path, name: "Jade 8F6B64")] + + let devices = makeTransport(isTrezorBridgeEnabled: true).scanDevices(timeoutMs: 3000) + + XCTAssertTrue(devices.isEmpty) + XCTAssertTrue(driver.calls.scanDurations.isEmpty) + } + + func testOnlyJadeNamesPassTheScanFilter() { + XCTAssertTrue(JadeBLEManager.isJadeName(nil)) + XCTAssertTrue(JadeBLEManager.isJadeName("Jade")) + XCTAssertTrue(JadeBLEManager.isJadeName("Jade 8F6B64")) + XCTAssertTrue(JadeBLEManager.isJadeName("jade plus")) + XCTAssertFalse(JadeBLEManager.isJadeName("Nordic UART")) + XCTAssertFalse(JadeBLEManager.isJadeName("My Jade")) + XCTAssertFalse(JadeBLEManager.isJadeName("")) + } + + // MARK: - Error mapping + + func testCoreBluetoothPairingErrorsMapToPairingAdvice() { + let peerRemoved = NSError(domain: CBErrorDomain, code: CBError.Code.peerRemovedPairingInformation.rawValue) + let encryptionTimedOut = NSError(domain: CBErrorDomain, code: CBError.Code.encryptionTimedOut.rawValue) + let insufficientEncryption = NSError(domain: CBATTErrorDomain, code: CBATTError.Code.insufficientEncryption.rawValue) + let insufficientAuthentication = NSError(domain: CBATTErrorDomain, code: CBATTError.Code.insufficientAuthentication.rawValue) + let connectionTimeout = NSError(domain: CBErrorDomain, code: CBError.Code.connectionTimeout.rawValue) + + XCTAssertEqual(JadeBLEManager.pairingError(for: peerRemoved, isPaired: false), .staleBond) + XCTAssertEqual(JadeBLEManager.pairingError(for: encryptionTimedOut, isPaired: true), .pairingNotConfirmed) + XCTAssertEqual(JadeBLEManager.pairingError(for: insufficientEncryption, isPaired: true), .staleBond) + XCTAssertEqual(JadeBLEManager.pairingError(for: insufficientEncryption, isPaired: false), .pairingNotConfirmed) + XCTAssertEqual(JadeBLEManager.pairingError(for: insufficientAuthentication, isPaired: true), .staleBond) + XCTAssertEqual(JadeBLEManager.pairingError(for: insufficientAuthentication, isPaired: false), .pairingNotConfirmed) + XCTAssertNil(JadeBLEManager.pairingError(for: connectionTimeout, isPaired: true)) + XCTAssertNil(JadeBLEManager.pairingError(for: nil, isPaired: true)) + } + + func testFailureDetailsReadAsOneSentence() { + XCTAssertEqual( + JadeBLEError.writeFailed("The operation was cancelled.").localizedDescription, + "Sending data to your Jade failed (The operation was cancelled)." + ) + XCTAssertEqual(JadeBLEError.connectFailed(" ").localizedDescription, "Could not connect to your Jade over Bluetooth (unknown error).") + } + + // MARK: - Paths + + func testDevicePathRoundTrip() throws { + let identifier = try XCTUnwrap(UUID(uuidString: "2F8C7A10-4B3E-4D1A-9E6C-5A7B8C9D0E1F")) + + XCTAssertEqual(HwDevicePath.ble(identifier), path) + XCTAssertTrue(HwDevicePath.isBle(path)) + XCTAssertEqual(HwDevicePath.bleIdentifier(path), identifier) + XCTAssertFalse(HwDevicePath.isBle("usb:jade")) + XCTAssertNil(HwDevicePath.bleIdentifier("usb:jade")) + XCTAssertNil(HwDevicePath.bleIdentifier("ble:not-a-uuid")) + } +} diff --git a/BitkitTests/TransferViewModelHwTests.swift b/BitkitTests/TransferViewModelHwTests.swift index edf24f5b6..31a5e025c 100644 --- a/BitkitTests/TransferViewModelHwTests.swift +++ b/BitkitTests/TransferViewModelHwTests.swift @@ -11,7 +11,7 @@ final class TransferViewModelHwTests: XCTestCase { funding: MockHwFunding, connecting: MockHwConnecting, feeRate: UInt64? = 2, - timeouts: (reconnect: Double, compose: Double, sign: Double, broadcast: Double) = (reconnect: 5, compose: 5, sign: 5, broadcast: 5) + timeouts: (compose: Double, sign: Double, broadcast: Double) = (compose: 5, sign: 5, broadcast: 5) ) -> TransferViewModel { TransferViewModel( hwFunding: funding, @@ -478,7 +478,47 @@ final class TransferViewModelHwTests: XCTestCase { vm.onTransferToSpendingHwConfirm(order: .mock(), walletId: "trezor:wallet") await awaitSigningComplete(vm) - XCTAssertEqual(vm.hwTransferError, .deviceBusy) + XCTAssertEqual(vm.hwTransferError, .deviceBusy(.trezor)) + } + + func testJadeBusyShowsTheJadeCopy() async { + let funding = MockHwFunding() + funding.signError = Bitkit.AppError(error: JadeError.DeviceLocked) + let connecting = MockHwConnecting() + let vm = makeViewModel(funding: funding, connecting: connecting) + + vm.onTransferToSpendingHwConfirm(order: .mock(), walletId: "jade:wallet") + await awaitSigningComplete(vm) + + XCTAssertEqual(vm.hwTransferError, .deviceBusy(.blockstream)) + XCTAssertEqual(HwErrorPresenter.deviceBusyMessage(for: .blockstream), t("hardware__jade_device_busy")) + XCTAssertTrue(connecting.staleDisconnects.isEmpty, "a busy device keeps its session") + } + + func testJadeCancellationOnDeviceIsSilent() async { + let funding = MockHwFunding() + funding.signError = Bitkit.AppError(error: JadeError.UserCancelled) + let connecting = MockHwConnecting() + let vm = makeViewModel(funding: funding, connecting: connecting) + + vm.onTransferToSpendingHwConfirm(order: .mock(), walletId: "jade:wallet") + await awaitSigningComplete(vm) + + XCTAssertNil(vm.hwTransferError, "a cancel on the Jade must not surface a toast") + XCTAssertFalse(vm.hwSpending.isSigning) + XCTAssertEqual(vm.hwSignedEvent, 0, "a cancelled transfer must not advance the flow") + XCTAssertTrue(connecting.staleDisconnects.isEmpty, "a device cancel must not tear down the session") + } + + func testJadeSigningFailureShowsTheJadeCopy() async { + let funding = MockHwFunding() + funding.signError = Bitkit.AppError(error: JadeError.PsbtTooLarge(size: 20000, max: 16384)) + let vm = makeViewModel(funding: funding, connecting: MockHwConnecting()) + + vm.onTransferToSpendingHwConfirm(order: .mock(), walletId: "jade:wallet") + await awaitSigningComplete(vm) + + XCTAssertEqual(vm.hwTransferError, .generic(t("hardware__jade_psbt_too_large"))) } func testFirmwareErrorMapsToFirmwareReconnectError() async { diff --git a/BitkitTests/TrezorKnownDeviceMatchingTests.swift b/BitkitTests/TrezorKnownDeviceMatchingTests.swift deleted file mode 100644 index 94a7da740..000000000 --- a/BitkitTests/TrezorKnownDeviceMatchingTests.swift +++ /dev/null @@ -1,228 +0,0 @@ -@testable import Bitkit -import XCTest - -/// Covers how a connect resolves which stored entry it refreshes and which entries it supersedes, -/// now that one physical device can hold a standard wallet plus its passphrase (hidden) wallets. -final class TrezorKnownDeviceMatchingTests: XCTestCase { - // MARK: - previous(in:deviceId:fetchedXpubs:) - - func testRefreshesTheEntrySharingKeyMaterial() { - let standard = makeDevice(xpubs: ["nativeSegwit": "zStandard"]) - let hidden = makeDevice(xpubs: ["nativeSegwit": "zHidden"], walletId: "trezor:hidden") - - let previous = TrezorKnownDeviceMatching.previous( - in: [standard, hidden], - deviceId: "dev1", - fetchedXpubs: ["nativeSegwit": "zHidden", "taproot": "zHiddenTR"] - ) - - XCTAssertEqual(previous?.walletId, "trezor:hidden") - } - - /// A passphrase wallet read for the first time overlaps nothing, so it must not adopt the - /// standard wallet's entry — that would blend two seeds' xpubs into one record. - func testTreatsUnseenKeyMaterialAsANewIdentity() { - let standard = makeDevice(xpubs: ["nativeSegwit": "zStandard"]) - - let previous = TrezorKnownDeviceMatching.previous( - in: [standard], - deviceId: "dev1", - fetchedXpubs: ["nativeSegwit": "zHidden"] - ) - - XCTAssertNil(previous) - } - - func testAdoptsALoneEntryStoredBeforeAnyXpubWasCaptured() { - let bare = makeDevice(xpubs: [:], customLabel: "My Trezor") - - let previous = TrezorKnownDeviceMatching.previous( - in: [bare], - deviceId: "dev1", - fetchedXpubs: ["nativeSegwit": "zStandard"] - ) - - XCTAssertEqual(previous?.customLabel, "My Trezor") - } - - func testIgnoresEntriesOfAnotherDevice() { - let other = makeDevice(id: "dev2", xpubs: ["nativeSegwit": "zStandard"]) - - let previous = TrezorKnownDeviceMatching.previous( - in: [other], - deviceId: "dev1", - fetchedXpubs: ["nativeSegwit": "zStandard"] - ) - - XCTAssertNil(previous) - } - - // MARK: - named(in:previous:walletKey:) - - /// The wallet reappears on a fresh transport path, so nothing matches by device id — but it is - /// the same key material, and the user's label belongs to the wallet, not to the path. - func testInheritsTheLabelOfTheSameWalletOnAnotherPath() { - let previouslyPaired = makeDevice(id: "old-path", xpubs: ["nativeSegwit": "zStandard"], customLabel: "Savings") - - let named = TrezorKnownDeviceMatching.named( - in: [previouslyPaired], - previous: nil, - walletKey: TrezorKnownDevice.walletKey(for: ["nativeSegwit": "zStandard"], fallback: "dev1") - ) - - XCTAssertEqual(named?.customLabel, "Savings") - } - - func testPrefersTheRefreshedEntryForTheLabel() { - let refreshed = makeDevice(xpubs: ["nativeSegwit": "zStandard"], customLabel: "Refreshed") - let sameKey = makeDevice(id: "old-path", xpubs: ["nativeSegwit": "zStandard"], customLabel: "Stale") - - let named = TrezorKnownDeviceMatching.named( - in: [sameKey, refreshed], - previous: refreshed, - walletKey: refreshed.walletKey - ) - - XCTAssertEqual(named?.customLabel, "Refreshed") - } - - // MARK: - merged(_:with:refreshed:) - - func testKeepsTheStandardWalletWhenAPassphraseWalletIsAdded() { - let standard = makeDevice(xpubs: ["nativeSegwit": "zStandard"], walletId: "trezor:standard") - let hidden = makeDevice(xpubs: ["nativeSegwit": "zHidden"], walletId: "trezor:hidden", passphraseProtected: true) - - let merged = TrezorKnownDeviceMatching.merged([standard], with: hidden, refreshed: nil) - - XCTAssertEqual(merged.map(\.walletId), ["trezor:standard", "trezor:hidden"]) - } - - func testReplacesTheEntryHoldingTheSameIdentity() { - let stored = makeDevice(xpubs: ["nativeSegwit": "zStandard"], customLabel: "Old") - let known = makeDevice(xpubs: ["nativeSegwit": "zStandard"], customLabel: "New") - - let merged = TrezorKnownDeviceMatching.merged([stored], with: known, refreshed: stored) - - XCTAssertEqual(merged.map(\.customLabel), ["New"]) - } - - /// Reading a previously rejected address type changes the wallet key, so matching on the new - /// key alone would leave the entry this connect refreshed behind as a duplicate. - func testReplacesTheRefreshedEntryWhenReadingMoreAccountsChangesItsKey() { - let partial = makeDevice(xpubs: ["nativeSegwit": "zStandard"]) - let complete = makeDevice(xpubs: ["nativeSegwit": "zStandard", "taproot": "zTaproot"]) - - let merged = TrezorKnownDeviceMatching.merged([partial], with: complete, refreshed: partial) - - XCTAssertEqual(merged.count, 1) - XCTAssertEqual(merged[0].xpubs.count, 2) - } - - func testSupersedesWalletsOfASeedTheDeviceNoLongerCarries() { - let wiped = makeDevice(xpubs: ["nativeSegwit": "zOldSeed"], trezorDeviceId: "trezor-before-wipe") - let known = makeDevice(xpubs: ["nativeSegwit": "zNewSeed"], trezorDeviceId: "trezor-after-wipe") - - let merged = TrezorKnownDeviceMatching.merged([wiped], with: known, refreshed: nil) - - XCTAssertEqual(merged.map(\.xpubs), [["nativeSegwit": "zNewSeed"]]) - } - - /// Two identities of one device report the same Trezor device id, so the wipe rule must not - /// sweep away the sibling wallet. - func testKeepsAnotherIdentityOfTheSameDevice() { - let standard = makeDevice( - xpubs: ["nativeSegwit": "zStandard"], - walletId: "trezor:standard", - trezorDeviceId: "trezor-id" - ) - let hidden = makeDevice( - xpubs: ["nativeSegwit": "zHidden"], - walletId: "trezor:hidden", - passphraseProtected: true, - trezorDeviceId: "trezor-id" - ) - - let merged = TrezorKnownDeviceMatching.merged([standard], with: hidden, refreshed: nil) - - XCTAssertEqual(merged.map(\.walletId), ["trezor:standard", "trezor:hidden"]) - } - - func testLeavesEntriesOfAnotherDeviceAlone() { - let other = makeDevice(id: "dev2", xpubs: ["nativeSegwit": "zOther"], trezorDeviceId: "other-trezor") - let known = makeDevice(xpubs: ["nativeSegwit": "zStandard"], trezorDeviceId: "trezor-id") - - let merged = TrezorKnownDeviceMatching.merged([other], with: known, refreshed: nil) - - XCTAssertEqual(merged.count, 2) - } - - // MARK: - Identity helpers - - func testWalletKeyIsIndependentOfAddressTypeKeys() { - let a = makeDevice(xpubs: ["nativeSegwit": "zA", "taproot": "zB"]) - let b = makeDevice(xpubs: ["taproot": "zA", "nativeSegwit": "zB"]) - - XCTAssertEqual(a.walletKey, b.walletKey) - } - - func testWalletKeyFallsBackToTheTransportIdWithoutXpubs() { - XCTAssertEqual(makeDevice(xpubs: [:]).walletKey, "dev1") - } - - func testEntryIdSeparatesTwoIdentitiesOfOneDevice() { - let standard = makeDevice(xpubs: ["nativeSegwit": "zStandard"]) - let hidden = makeDevice(xpubs: ["nativeSegwit": "zHidden"]) - - XCTAssertNotEqual(standard.entryId, hidden.entryId) - } - - func testResolvedWalletIdPrefersTheStoredValue() { - let device = makeDevice(xpubs: ["nativeSegwit": "zStandard"], walletId: "trezor:stored") - - XCTAssertEqual(device.resolvedWalletId, "trezor:stored") - } - - // MARK: - Decoding entries stored before hidden wallets existed - - func testDecodesLegacyEntriesAndDerivesTheirWalletId() throws { - let legacy = """ - { - "id": "dev1", - "name": "Trezor", - "path": "ble://dev1", - "transportType": "bluetooth", - "lastConnectedAt": 0, - "xpubs": { "nativeSegwit": "zStandard" } - } - """ - - let decoded = try JSONDecoder().decode(TrezorKnownDevice.self, from: Data(legacy.utf8)) - - XCTAssertNil(decoded.walletId) - XCTAssertFalse(decoded.passphraseProtected) - XCTAssertNil(decoded.trezorDeviceId) - XCTAssertEqual(decoded.resolvedWalletId, try HwWalletId.derive(xpubs: ["nativeSegwit": "zStandard"])) - } - - private func makeDevice( - id: String = "dev1", - xpubs: [String: String], - customLabel: String? = nil, - walletId: String? = nil, - passphraseProtected: Bool = false, - trezorDeviceId: String? = nil - ) -> TrezorKnownDevice { - TrezorKnownDevice( - id: id, - name: "Trezor", - path: "ble://\(id)", - transportType: "bluetooth", - lastConnectedAt: Date(timeIntervalSince1970: 0), - xpubs: xpubs, - customLabel: customLabel, - walletId: walletId, - passphraseProtected: passphraseProtected, - trezorDeviceId: trezorDeviceId - ) - } -} diff --git a/BitkitTests/TrezorKnownDeviceStorageTests.swift b/BitkitTests/TrezorKnownDeviceStorageTests.swift deleted file mode 100644 index 9e0f1eb00..000000000 --- a/BitkitTests/TrezorKnownDeviceStorageTests.swift +++ /dev/null @@ -1,225 +0,0 @@ -@testable import Bitkit -import Combine -import XCTest - -/// Covers identity-scoped reads and writes: one physical device can hold a standard wallet plus its -/// passphrase wallets, so `id` no longer identifies a stored entry on its own. -final class TrezorKnownDeviceStorageTests: XCTestCase { - private static let storageKey = "trezor.knownDevices" - private static let pendingNamesKey = "trezor.pendingWalletNames" - private var savedDefaults: Data? - private var savedPendingNames: [String: String]? - private var cancellables: Set = [] - - override func setUp() { - super.setUp() - savedDefaults = UserDefaults.standard.data(forKey: Self.storageKey) - savedPendingNames = UserDefaults.standard.dictionary(forKey: Self.pendingNamesKey) as? [String: String] - cancellables = [] - TrezorKnownDeviceStorage.removeAll() - } - - override func tearDown() { - cancellables = [] - TrezorKnownDeviceStorage.removeAll() - if let savedDefaults { - UserDefaults.standard.set(savedDefaults, forKey: Self.storageKey) - } - if let savedPendingNames { - UserDefaults.standard.set(savedPendingNames, forKey: Self.pendingNamesKey) - } - super.tearDown() - } - - func testSavingAPassphraseWalletKeepsTheStandardWalletOfTheSameDevice() { - TrezorKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zStandard"], walletId: "trezor:standard")) - TrezorKnownDeviceStorage.save(makeDevice( - xpubs: ["nativeSegwit": "zHidden"], - walletId: "trezor:hidden", - passphraseProtected: true - )) - - let stored = TrezorKnownDeviceStorage.loadAll() - XCTAssertEqual(Set(stored.compactMap(\.walletId)), ["trezor:standard", "trezor:hidden"]) - XCTAssertEqual(stored.filter(\.passphraseProtected).count, 1) - } - - func testSavingTheSameIdentityAgainReplacesIt() { - TrezorKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zStandard"], customLabel: "Old")) - TrezorKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zStandard"], customLabel: "New")) - - XCTAssertEqual(TrezorKnownDeviceStorage.loadAll().map(\.customLabel), ["New"]) - } - - func testRemovingOneWalletLeavesTheDevicesOtherWalletsPaired() { - TrezorKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zStandard"], walletId: "trezor:standard")) - TrezorKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zHidden"], walletId: "trezor:hidden")) - - TrezorKnownDeviceStorage.remove(walletId: "trezor:hidden") - - XCTAssertEqual(TrezorKnownDeviceStorage.loadAll().compactMap(\.walletId), ["trezor:standard"]) - XCTAssertTrue(TrezorKnownDeviceStorage.isKnown(id: "dev1"), "the device itself stays paired") - } - - /// Entries written before the wallet id was persisted resolve it from their xpubs. - func testRemovingAWalletMatchesEntriesWithoutAStoredWalletId() throws { - let legacy = makeDevice(xpubs: ["nativeSegwit": "zStandard"], walletId: nil) - TrezorKnownDeviceStorage.save(legacy) - - try TrezorKnownDeviceStorage.remove(walletId: HwWalletId.derive(xpubs: legacy.xpubs)) - - XCTAssertTrue(TrezorKnownDeviceStorage.loadAll().isEmpty) - } - - func testRemovingByDeviceIdForgetsEveryWalletItHolds() { - TrezorKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zStandard"], walletId: "trezor:standard")) - TrezorKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zHidden"], walletId: "trezor:hidden")) - TrezorKnownDeviceStorage.save(makeDevice(id: "dev2", xpubs: ["nativeSegwit": "zOther"], walletId: "trezor:other")) - - TrezorKnownDeviceStorage.remove(id: "dev1") - - XCTAssertEqual(TrezorKnownDeviceStorage.loadAll().compactMap(\.walletId), ["trezor:other"]) - } - - func testLoadingByWalletIdReturnsOnlyThatIdentitysEntries() { - TrezorKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zStandard"], walletId: "trezor:standard")) - TrezorKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zHidden"], walletId: "trezor:hidden")) - - let entries = TrezorKnownDeviceStorage.loadAll(walletId: "trezor:hidden") - - XCTAssertEqual(entries.count, 1) - XCTAssertEqual(entries.first?.xpubs, ["nativeSegwit": "zHidden"]) - } - - func testNewFieldsSurviveAStorageRoundTrip() { - TrezorKnownDeviceStorage.save(makeDevice( - xpubs: ["nativeSegwit": "zHidden"], - walletId: "trezor:hidden", - passphraseProtected: true, - trezorDeviceId: "trezor-id" - )) - - let stored = TrezorKnownDeviceStorage.loadAll().first - XCTAssertEqual(stored?.walletId, "trezor:hidden") - XCTAssertTrue(stored?.passphraseProtected == true) - XCTAssertEqual(stored?.trezorDeviceId, "trezor-id") - } - - // MARK: - Hardware wallet names - - func testAPendingNameAndTheDeviceListAreWrittenTogether() { - let device = makeDevice(xpubs: ["nativeSegwit": "zStandard"], customLabel: "Cold", walletId: "trezor:standard") - TrezorKnownDeviceStorage.save(device) - - TrezorKnownDeviceStorage.saveAll([], pendingName: PendingHwWalletName(walletId: "trezor:standard", name: "Cold")) - - XCTAssertTrue(TrezorKnownDeviceStorage.loadAll().isEmpty) - XCTAssertEqual(TrezorKnownDeviceStorage.loadPendingNames(), ["trezor:standard": "Cold"]) - } - - /// Adoption on pairing consumes a pending name by masking rather than by a second write, so a - /// wallet the device list already names must not report one. - func testAPendingNameIsMaskedOnceTheWalletIsPairedAndNamed() { - TrezorKnownDeviceStorage.setPendingName(walletId: "trezor:standard", name: "Cold") - TrezorKnownDeviceStorage.save( - makeDevice(xpubs: ["nativeSegwit": "zStandard"], customLabel: "Cold", walletId: "trezor:standard") - ) - - XCTAssertTrue(TrezorKnownDeviceStorage.loadPendingNames().isEmpty) - XCTAssertEqual(TrezorKnownDeviceStorage.backupSnapshot(), ["trezor:standard": "Cold"]) - } - - func testTheNameOfAPairedWalletWinsOverAPendingOne() { - TrezorKnownDeviceStorage.setPendingName(walletId: "trezor:standard", name: "Restored") - TrezorKnownDeviceStorage.save( - makeDevice(xpubs: ["nativeSegwit": "zStandard"], customLabel: "Renamed", walletId: "trezor:standard") - ) - - XCTAssertEqual(TrezorKnownDeviceStorage.backupSnapshot(), ["trezor:standard": "Renamed"]) - } - - func testSettingAPendingNameToNilDropsIt() { - TrezorKnownDeviceStorage.setPendingName(walletId: "trezor:standard", name: "Cold") - TrezorKnownDeviceStorage.setPendingName(walletId: "trezor:standard", name: nil) - - XCTAssertTrue(TrezorKnownDeviceStorage.backupSnapshot().isEmpty) - } - - func testRestoringNamesLetsALocalNameWinAndNeverClears() { - TrezorKnownDeviceStorage.setPendingName(walletId: "trezor:standard", name: "Local") - - TrezorKnownDeviceStorage.restoreNames(["trezor:standard": "Backed up", "trezor:hidden": "Hidden"]) - XCTAssertEqual( - TrezorKnownDeviceStorage.backupSnapshot(), - ["trezor:standard": "Local", "trezor:hidden": "Hidden"] - ) - - // An envelope written before the field carries no names, and must not drop what is stored. - TrezorKnownDeviceStorage.restoreNames([:]) - XCTAssertEqual( - TrezorKnownDeviceStorage.backupSnapshot(), - ["trezor:standard": "Local", "trezor:hidden": "Hidden"] - ) - } - - func testForgettingAWalletDropsTheNameKeptForIt() { - TrezorKnownDeviceStorage.setPendingName(walletId: "trezor:hidden", name: "Hidden") - TrezorKnownDeviceStorage.save(makeDevice(xpubs: ["nativeSegwit": "zHidden"], walletId: "trezor:hidden")) - - TrezorKnownDeviceStorage.remove(walletId: "trezor:hidden") - - XCTAssertTrue(TrezorKnownDeviceStorage.backupSnapshot().isEmpty) - } - - func testRemoveAllClearsPendingNamesToo() { - TrezorKnownDeviceStorage.setPendingName(walletId: "trezor:standard", name: "Cold") - - TrezorKnownDeviceStorage.removeAll() - - XCTAssertTrue(TrezorKnownDeviceStorage.backupSnapshot().isEmpty) - } - - /// Every connect rewrites the device list to refresh `lastConnectedAt`; only a name change may - /// mark the metadata backup stale. - func testTheNameSignalFiresOnARenameButNotOnAReconnect() { - let device = makeDevice(xpubs: ["nativeSegwit": "zStandard"], customLabel: "Cold", walletId: "trezor:standard") - TrezorKnownDeviceStorage.save(device) - - var fires = 0 - TrezorKnownDeviceStorage.namesChangedPublisher - .sink { fires += 1 } - .store(in: &cancellables) - - var reconnected = device - reconnected.lastConnectedAt = Date(timeIntervalSince1970: 5000) - TrezorKnownDeviceStorage.saveAll([reconnected]) - XCTAssertEqual(fires, 0, "a reconnect must not re-upload the metadata envelope") - - var renamed = reconnected - renamed.customLabel = "Vault" - TrezorKnownDeviceStorage.saveAll([renamed]) - XCTAssertEqual(fires, 1) - } - - private func makeDevice( - id: String = "dev1", - xpubs: [String: String], - customLabel: String? = nil, - walletId: String? = nil, - passphraseProtected: Bool = false, - trezorDeviceId: String? = nil - ) -> TrezorKnownDevice { - TrezorKnownDevice( - id: id, - name: "Trezor", - path: "ble://\(id)", - transportType: "bluetooth", - lastConnectedAt: Date(timeIntervalSince1970: 0), - xpubs: xpubs, - customLabel: customLabel, - walletId: walletId, - passphraseProtected: passphraseProtected, - trezorDeviceId: trezorDeviceId - ) - } -} diff --git a/BitkitTests/TrezorManagerSessionTests.swift b/BitkitTests/TrezorManagerSessionTests.swift new file mode 100644 index 000000000..941809f78 --- /dev/null +++ b/BitkitTests/TrezorManagerSessionTests.swift @@ -0,0 +1,48 @@ +@testable import Bitkit +import XCTest + +/// How a Trezor session makes way for another vendor: a foreground reconnect reads as active from the +/// moment it is started, and releasing the session or wiping the wallet cancels it before it runs. +@MainActor +final class TrezorManagerSessionTests: XCTestCase { + func testAForegroundReconnectReadsAsActiveBeforeItFirstRuns() { + let manager = TrezorManager() + + manager.startAutoReconnect() + + XCTAssertTrue(manager.isSessionActive) + } + + func testReleasingCancelsAPendingForegroundReconnect() async { + let manager = TrezorManager() + manager.startAutoReconnect() + + await manager.releaseSession() + + XCTAssertFalse(manager.isSessionActive) + } + + func testWipeCancelsAPendingForegroundReconnectAndDropsTheLoadedDevices() async { + let manager = TrezorManager() + manager.knownDevices = [makeTrezor()] + manager.startAutoReconnect() + + await manager.resetForWipe() + + XCTAssertFalse(manager.isSessionActive) + XCTAssertTrue(manager.knownDevices.isEmpty) + } + + private func makeTrezor() -> HwKnownDevice { + HwKnownDevice( + id: "trezor-dev", + name: "Trezor", + path: "ble:trezor", + transportType: "bluetooth", + model: "Safe 7", + lastConnectedAt: Date(timeIntervalSince1970: 0), + xpubs: ["nativeSegwit": "zTrezor"], + walletId: "trezor:standard" + ) + } +} diff --git a/BitkitTests/TrezorManagerVendorIsolationTests.swift b/BitkitTests/TrezorManagerVendorIsolationTests.swift new file mode 100644 index 000000000..f9e54849c --- /dev/null +++ b/BitkitTests/TrezorManagerVendorIsolationTests.swift @@ -0,0 +1,140 @@ +@testable import Bitkit +import XCTest + +/// Trezor reads and writes only its own slice of the paired-device store, so nothing it loads, renames +/// or forgets can reach a paired Jade, not even one holding the same seed. +@MainActor +final class TrezorManagerVendorIsolationTests: XCTestCase { + private static let storageKey = "trezor.knownDevices" + private static let pendingNamesKey = "trezor.pendingWalletNames" + private static let sharedSeed = ["nativeSegwit": "zShared"] + + private var savedDefaults: Data? + private var savedPendingNames: [String: String]? + + override func setUp() { + super.setUp() + savedDefaults = UserDefaults.standard.data(forKey: Self.storageKey) + savedPendingNames = UserDefaults.standard.dictionary(forKey: Self.pendingNamesKey) as? [String: String] + HwKnownDeviceStorage.removeAll() + } + + override func tearDown() { + HwKnownDeviceStorage.removeAll() + if let savedDefaults { + UserDefaults.standard.set(savedDefaults, forKey: Self.storageKey) + } + if let savedPendingNames { + UserDefaults.standard.set(savedPendingNames, forKey: Self.pendingNamesKey) + } + super.tearDown() + } + + func testLoadingKnownDevicesSeesOnlyTrezorEntries() { + HwKnownDeviceStorage.save(makeTrezor(walletId: "trezor:standard")) + HwKnownDeviceStorage.save(makeJade()) + let manager = TrezorManager() + + manager.loadKnownDevices() + + XCTAssertEqual(manager.knownDevices.map(\.walletId), ["trezor:standard"]) + XCTAssertEqual(manager.storedDevices.map(\.vendor), [.trezor]) + } + + func testRenamingATrezorWalletLeavesTheJadeUntouched() { + HwKnownDeviceStorage.save(makeTrezor(walletId: "trezor:standard")) + let jade = makeJade() + HwKnownDeviceStorage.save(jade) + + TrezorManager().renameWallet(walletId: "trezor:standard", newName: "Vault") + + XCTAssertEqual(HwKnownDeviceStorage.loadAll(vendor: .trezor).map(\.customLabel), ["Vault"]) + XCTAssertEqual(HwKnownDeviceStorage.loadAll(vendor: .blockstream), [jade]) + } + + /// Renaming a device also renames every entry holding its xpubs, which must stop at the Trezor + /// slice: a Jade restored from the same seed is a different wallet. + func testRenamingATrezorDeviceLeavesAJadeWithTheSameSeedUntouched() { + HwKnownDeviceStorage.save(makeTrezor(walletId: "trezor:standard")) + let jade = makeJade() + HwKnownDeviceStorage.save(jade) + + TrezorManager().renameDevice(id: "trezor-dev", newName: "Vault") + + XCTAssertEqual(HwKnownDeviceStorage.loadAll(vendor: .trezor).map(\.customLabel), ["Vault"]) + XCTAssertEqual(HwKnownDeviceStorage.loadAll(vendor: .blockstream), [jade]) + } + + func testRenamingAJadeWalletThroughTrezorChangesNothing() { + let jade = makeJade() + HwKnownDeviceStorage.save(jade) + + TrezorManager().renameWallet(walletId: "jade:wallet", newName: "Vault") + + XCTAssertEqual(HwKnownDeviceStorage.loadAll(vendor: .blockstream), [jade]) + } + + /// The hidden wallet's sibling stays paired, so no transport credential is cleared. + func testForgettingATrezorWalletKeepsTheJade() async { + HwKnownDeviceStorage.save(makeTrezor(walletId: "trezor:standard")) + HwKnownDeviceStorage.save(makeTrezor(xpubs: ["nativeSegwit": "zHidden"], walletId: "trezor:hidden")) + let jade = makeJade() + HwKnownDeviceStorage.save(jade) + let manager = TrezorManager() + manager.loadKnownDevices() + + await manager.forgetWallet(walletId: "trezor:hidden") + + XCTAssertEqual(HwKnownDeviceStorage.loadAll(vendor: .trezor).compactMap(\.walletId), ["trezor:standard"]) + XCTAssertEqual(HwKnownDeviceStorage.loadAll(vendor: .blockstream), [jade]) + } + + func testForgettingAJadeWalletThroughTrezorChangesNothing() async { + let jade = makeJade() + HwKnownDeviceStorage.save(jade) + + await TrezorManager().forgetWallet(walletId: "jade:wallet") + + XCTAssertEqual(HwKnownDeviceStorage.loadAll(vendor: .blockstream), [jade]) + } + + func testForgettingADeviceIdOnlyAJadeHoldsChangesNothing() async { + let jade = makeJade() + HwKnownDeviceStorage.save(jade) + let manager = TrezorManager() + manager.loadKnownDevices() + + await manager.forgetDevice(id: jade.id) + + XCTAssertEqual(HwKnownDeviceStorage.loadAll(vendor: .blockstream), [jade]) + } + + private func makeTrezor(xpubs: [String: String] = ["nativeSegwit": "zShared"], walletId: String) -> HwKnownDevice { + HwKnownDevice( + id: "trezor-dev", + name: "Trezor", + path: "ble:trezor", + transportType: "bluetooth", + model: "Safe 7", + lastConnectedAt: Date(timeIntervalSince1970: 0), + xpubs: xpubs, + walletId: walletId + ) + } + + private func makeJade() -> HwKnownDevice { + HwKnownDevice( + id: "jade:bluetooth:aabbcc", + name: "Jade AABBCC", + path: "ble:jade", + transportType: "bluetooth", + model: "Jade", + lastConnectedAt: Date(timeIntervalSince1970: 10), + xpubs: Self.sharedSeed, + customLabel: "Travel", + walletId: "jade:wallet", + vendor: .blockstream, + jadeDeviceId: "aabbcc" + ) + } +} diff --git a/changelog.d/next/765.added.md b/changelog.d/next/765.added.md new file mode 100644 index 000000000..edf8e5a96 --- /dev/null +++ b/changelog.d/next/765.added.md @@ -0,0 +1 @@ +Added support for pairing Blockstream Jade and Jade Plus hardware wallets over Bluetooth, including watch-only balances, address verification and on-device transaction signing. diff --git a/journeys/README.md b/journeys/README.md index 67db5dee3..824d5885e 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -126,6 +126,7 @@ Known naming differences: | Send max | `SendAmountMax` | *(no button — tap `AvailableAmount`)* | | External amount available | — | `ExternalAmountAvailable` | | Payment Request details screen | `PaymentRequestDetailsScreen` | `PaymentRequestDetailScreen` | +| Hardware wallet receive tab | `Tab-hardware` (after bitkit-android#1231) | `Tab-trezor` for a Trezor wallet, `Tab-jade` for a Jade | `SubscriptionRow-` matches Android exactly. `PaymentRequestRow` keeps that prefix but appends the billing period (`-one-time` for a one-off), because every recurring payment of one diff --git a/journeys/hardware-wallet/README.md b/journeys/hardware-wallet/README.md index 8f1363d58..b70bd3d23 100644 --- a/journeys/hardware-wallet/README.md +++ b/journeys/hardware-wallet/README.md @@ -132,3 +132,15 @@ Walked as far as a simulator allows, against a wallet with two Trezor identities Tag button `ActivityTag` (labelled "Tag"), tag field `TagInput`, submit `ActivityTagsSubmit`, detail chip list `ActivityTags` (only rendered once a tag exists), All Activity tag filter `TagsPrompt` (not `ActivityTags` — that one is detail-screen only), explorer `ActivityTxDetails`. + +## Blockstream Jade + +There is no Jade emulator in `bitkit-docker`, and iOS reaches a Jade over Bluetooth only, which the +simulator does not have. The Jade flows (Connect Hardware, receive-address verification and on-device +signing) are therefore covered by unit tests (`JadeManagerTests.swift`, `JadeTransportTests.swift`, +`JadeBLELinkStateTests.swift`, `HwWalletManagerVendorTests.swift`, `HwConnectViewModelTests.swift`, +`HwFundingSignerTests.swift`, `HwEngagedSessionTests.swift`) and by manual runs against a physical Jade +paired with a physical iPhone. The journeys in this folder stay Trezor-only. + +While a Jade waits for its PIN, the Found step shows `HwFoundUnlockHint`. The receive tab of a hardware +wallet is labelled with its vendor: a Trezor wallet keeps `Tab-trezor` and a Jade reads `Tab-jade`.