diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 9519ceb31..fff6b6c4a 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1201,7 +1201,7 @@ repositoryURL = "https://github.com/synonymdev/bitkit-core"; requirement = { kind = exactVersion; - version = 0.1.64; + version = 0.1.66; }; }; 96E20CD22CB6D91A00C24149 /* XCRemoteSwiftPackageReference "CodeScanner" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 1347ccc14..6a8a87a87 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" : "a7577cc4572d581a0ab1d84f2792a1e6198110ef", - "version" : "0.1.64" + "revision" : "99ffc3b610bdb199cbe3a35d9c9dc9435f769b85", + "version" : "0.1.66" } }, { diff --git a/Bitkit/Components/Trezor/TrezorAccountTypeSelector.swift b/Bitkit/Components/Trezor/TrezorAccountTypeSelector.swift new file mode 100644 index 000000000..0b293f0f7 --- /dev/null +++ b/Bitkit/Components/Trezor/TrezorAccountTypeSelector.swift @@ -0,0 +1,18 @@ +import SwiftUI + +struct TrezorAccountTypeSelector: View { + @Binding var selection: TrezorAccountTypeSelection + var title: String = "Account Type" + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + CaptionMText(title) + + SegmentedControl(selectedTab: $selection, tabs: TrezorAccountTypeSelection.allCases) + + FootnoteText(selection.subtitle) + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityIdentifier("TrezorAccountTypeSelector") + } +} diff --git a/Bitkit/Components/Trezor/TrezorPinPad.swift b/Bitkit/Components/Trezor/TrezorPinPad.swift index 17952b70e..161938107 100644 --- a/Bitkit/Components/Trezor/TrezorPinPad.swift +++ b/Bitkit/Components/Trezor/TrezorPinPad.swift @@ -6,8 +6,11 @@ struct TrezorPinPad: View { /// Current PIN being entered @Binding var pin: String - /// Maximum PIN length - var maxLength: Int = 9 + /// Maximum PIN length. Trezor PINs can be up to 50 digits. + var maxLength: Int = 50 + + /// Number of entered-digit dots to render per row before wrapping. + private let dotsPerRow = 9 /// PIN pad layout (positions map to device keypad) /// The Trezor shows scrambled numbers, we show only position dots @@ -19,12 +22,30 @@ struct TrezorPinPad: View { var body: some View { VStack(spacing: 16) { - // PIN display - HStack(spacing: 12) { - ForEach(0 ..< maxLength, id: \.self) { index in - Circle() - .fill(index < pin.count ? Color.white : Color.white.opacity(0.3)) - .frame(width: 12, height: 12) + // PIN display — one dot per entered digit, wrapping across rows so long + // PINs (Trezor allows up to 50 digits) don't overflow a single line. + VStack(spacing: 8) { + if pin.isEmpty { + // Placeholder row so the layout doesn't collapse before entry. + HStack(spacing: 12) { + ForEach(0 ..< dotsPerRow, id: \.self) { _ in + Circle() + .fill(Color.white.opacity(0.3)) + .frame(width: 12, height: 12) + } + } + } else { + let rowCount = (pin.count + dotsPerRow - 1) / dotsPerRow + ForEach(0 ..< rowCount, id: \.self) { row in + let dotsInRow = min(dotsPerRow, pin.count - row * dotsPerRow) + HStack(spacing: 12) { + ForEach(0 ..< dotsInRow, id: \.self) { _ in + Circle() + .fill(Color.white) + .frame(width: 12, height: 12) + } + } + } } } .padding(.bottom, 24) diff --git a/Bitkit/Services/Trezor/TrezorEventListener.swift b/Bitkit/Services/Trezor/TrezorEventListener.swift new file mode 100644 index 000000000..803f409b4 --- /dev/null +++ b/Bitkit/Services/Trezor/TrezorEventListener.swift @@ -0,0 +1,39 @@ +import BitkitCore +import Foundation + +/// Bridges bitkit-core's `EventListener` callback (invoked on a background thread by the +/// Rust watcher loop) onto the main actor so the ViewModel can update `@Observable` state. +/// +/// Mirrors bitkit-android's `eventBridge` in `TrezorRepo`. +final class TrezorEventListener: EventListener, @unchecked Sendable { + /// Forwards `(watcherId, event)` to a consumer on the main actor. + private let onEventHandler: @MainActor (String, WatcherEvent) -> Void + + init(onEvent: @escaping @MainActor (String, WatcherEvent) -> Void) { + onEventHandler = onEvent + } + + func onEvent(watcherId: String, event: WatcherEvent) { + let handler = onEventHandler + Task { @MainActor in + TrezorDebugLog.shared.log("[WATCHER] [\(watcherId)] \(event.logLabel)") + handler(watcherId, event) + } + } +} + +extension WatcherEvent { + /// Short label for the debug log. + var logLabel: String { + switch self { + case .transactionsChanged: + return "transactionsChanged" + case let .error(message): + return "error: \(message)" + case let .disconnected(message): + return "disconnected: \(message)" + case .reconnected: + return "reconnected" + } + } +} diff --git a/Bitkit/Services/Trezor/TrezorService.swift b/Bitkit/Services/Trezor/TrezorService.swift index d5dbf74b9..a31d4052f 100644 --- a/Bitkit/Services/Trezor/TrezorService.swift +++ b/Bitkit/Services/Trezor/TrezorService.swift @@ -1,6 +1,16 @@ import BitkitCore import Foundation +/// Watcher-related service calls, extracted as a protocol so unit tests can +/// substitute a mock (mirrors bitkit-android's mocked TrezorRepo in TrezorViewModelTest). +protocol TrezorWatcherServicing { + func startWatcher(params: WatcherParams, listener: EventListener) async throws + func stopWatcher(watcherId: String) throws + func stopAllWatchers() +} + +extension TrezorService: TrezorWatcherServicing {} + /// Service layer wrapper for Trezor FFI functions /// All operations run on ServiceQueue.background(.core) to ensure thread safety class TrezorService { @@ -65,13 +75,17 @@ class TrezorService { // MARK: - Connection Management - /// Connect to a Trezor device by its ID - /// - Parameter deviceId: The device identifier (path) + /// Connect to a Trezor device by its ID, opening the wallet given by `selection`. + /// On THP devices (Safe 5/7) the passphrase is bound to the session at creation, so + /// it is supplied per-connect rather than cached between calls. + /// - Parameters: + /// - deviceId: The device identifier (path) + /// - selection: Which wallet to open (standard / hidden / on-device passphrase) /// - Returns: Device features after successful connection - func connect(deviceId: String) async throws -> TrezorFeatures { + func connect(deviceId: String, selection: WalletSelection) async throws -> TrezorFeatures { try await ServiceQueue.background(.core) { [self] in ensureCallbacksRegistered() - return try await trezorConnect(deviceId: deviceId, selection: .standard) + return try await trezorConnect(deviceId: deviceId, selection: selection) } } @@ -268,6 +282,27 @@ class TrezorService { } } + // MARK: - Event Watcher (No Device Required) + + /// Start watching an extended public key for on-chain transaction activity. + /// Events are delivered to `listener` until the watcher is stopped. + /// Does NOT require a connected Trezor device — it subscribes to Electrum directly. + func startWatcher(params: WatcherParams, listener: EventListener) async throws { + try await ServiceQueue.background(.core) { + try await onchainStartWatcher(params: params, listener: listener) + } + } + + /// Stop a specific watcher by its id. + func stopWatcher(watcherId: String) throws { + try onchainStopWatcher(watcherId: watcherId) + } + + /// Stop all active watchers. + func stopAllWatchers() { + onchainStopAllWatchers() + } + // MARK: - Helpers /// Convert TrezorCoinType to the Network enum used by onchain FFI functions diff --git a/Bitkit/Services/Trezor/TrezorUiHandler.swift b/Bitkit/Services/Trezor/TrezorUiHandler.swift index a698e79c6..5bfc3ec86 100644 --- a/Bitkit/Services/Trezor/TrezorUiHandler.swift +++ b/Bitkit/Services/Trezor/TrezorUiHandler.swift @@ -2,9 +2,27 @@ import BitkitCore import Combine import Foundation +/// Which wallet to open when the device asks for a passphrase. +/// +/// - `standard`: no passphrase — the default wallet. +/// - `passphraseHost`: a hidden wallet, passphrase typed on the phone. +/// - `passphraseDevice`: a hidden wallet, passphrase typed on the Trezor. +enum TrezorWalletMode { + case standard + case passphraseHost + case passphraseDevice +} + /// Implementation of TrezorUiCallback protocol for PIN and passphrase handling. -/// Blocks the Rust calling thread until the user responds via the UI, -/// following the same semaphore pattern as TrezorTransport.getPairingCode(). +/// +/// PIN entry still blocks the Rust calling thread until the user responds via the +/// UI (the semaphore pattern shared with TrezorTransport.getPairingCode()). +/// +/// Passphrase handling follows the bitkit-android model: the user selects a wallet +/// mode up front (Standard / hidden-on-phone / hidden-on-device) and that selection +/// is bound to the THP session at connect time via `currentSelection()`. The device +/// callback `onPassphraseRequest` is answered silently from the stored mode — this is +/// what non-THP (legacy) devices use when they re-request the passphrase mid-operation. final class TrezorUiHandler: TrezorUiCallback { static let shared = TrezorUiHandler() @@ -17,24 +35,19 @@ final class TrezorUiHandler: TrezorUiCallback { private let pinLock = NSLock() private let pinSemaphore = DispatchSemaphore(value: 0) - // MARK: - Passphrase Handling - - /// Publisher to notify UI when passphrase entry is needed. - /// Bool parameter: true if passphrase should be entered on the device itself. - let needsPassphrasePublisher = PassthroughSubject() + /// Timeout for PIN entry (2 minutes) + private static let timeoutSeconds: TimeInterval = 120 - private var submittedPassphrase: String = "" - private var didCancelPassphrase = false - private let passphraseLock = NSLock() - private let passphraseSemaphore = DispatchSemaphore(value: 0) + // MARK: - Wallet Mode / Passphrase Selection - /// Tracks whether a passphrase request is actively blocking, - /// to prevent stale semaphore signals from dismissConfirmOnDevice(). - private var isAwaitingPassphrase = false - private let awaitingLock = NSLock() + private let modeLock = NSLock() + private var walletMode: TrezorWalletMode = .standard - /// Timeout for PIN/passphrase entry (2 minutes) - private static let timeoutSeconds: TimeInterval = 120 + /// Host passphrase captured when `.passphraseHost` is selected. Mirrors the value + /// bound to the THP session so legacy (non-THP) devices — which re-request the + /// passphrase mid-operation via `onPassphraseRequest` — can be answered from the + /// value the user already entered up front. Nil when not in host-passphrase mode. + private var hostPassphrase: String? private init() {} @@ -45,6 +58,44 @@ final class TrezorUiHandler: TrezorUiCallback { TrezorDebugLog.shared.log("[UI] \(message)") } + // MARK: - Wallet Mode API + + /// Set which wallet to open. The caller is responsible for resetting the device + /// session (disconnect/reconnect) so the new mode takes effect — the Trezor caches + /// the passphrase for the lifetime of a session. + /// + /// `hostPassphrase` is only meaningful for `.passphraseHost` — it is the passphrase + /// the user entered on the phone up front. + func setWalletMode(_ mode: TrezorWalletMode, hostPassphrase: String = "") { + modeLock.lock() + walletMode = mode + self.hostPassphrase = mode == .passphraseHost ? hostPassphrase : nil + modeLock.unlock() + debugLog("Wallet mode set to \(mode)") + } + + /// The wallet the current mode/passphrase selects, for binding to a THP session when + /// `connect` runs. Mirrors `onPassphraseRequest` so THP (bound at session creation) + /// and legacy devices (answered mid-operation) stay in lockstep from one source of + /// truth. Reconnects derive their wallet from here, so it reflects the selection until + /// the next `setWalletMode` or disconnect. + func currentSelection() -> WalletSelection { + modeLock.lock() + defer { modeLock.unlock() } + + switch walletMode { + case .standard: + return .standard + case .passphraseDevice: + return .onDevice + case .passphraseHost: + if let cached = hostPassphrase, !cached.isEmpty { + return .hidden(passphrase: cached) + } + return .standard + } + } + // MARK: - TrezorUiCallback Implementation func onPinRequest() -> String { @@ -77,55 +128,37 @@ final class TrezorUiHandler: TrezorUiCallback { } func onPassphraseRequest(onDevice: Bool) -> PassphraseResponse { - debugLog("onPassphraseRequest: onDevice=\(onDevice), waiting for user input...") - - passphraseLock.lock() - submittedPassphrase = "" - didCancelPassphrase = false - passphraseLock.unlock() - - awaitingLock.lock() - isAwaitingPassphrase = true - awaitingLock.unlock() - - // Notify UI - DispatchQueue.main.async { - self.needsPassphrasePublisher.send(onDevice) - } - - // Block and wait for user response - let timeout = DispatchTime.now() + Self.timeoutSeconds - let result = passphraseSemaphore.wait(timeout: timeout) - - awaitingLock.lock() - isAwaitingPassphrase = false - awaitingLock.unlock() - - if result == .timedOut { - debugLog("onPassphraseRequest: timed out") - return .cancel - } - + // Device-mandated on-device entry always wins, regardless of mode. if onDevice { - debugLog("onPassphraseRequest(onDevice): acknowledged") + debugLog("onPassphraseRequest: on-device (device-mandated), deferring to Trezor") return .onDevice } - passphraseLock.lock() - let passphrase = submittedPassphrase - let wasCancelled = didCancelPassphrase - passphraseLock.unlock() - - if wasCancelled { - debugLog("onPassphraseRequest: cancelled") - return .cancel + modeLock.lock() + let mode = walletMode + let cached = hostPassphrase + modeLock.unlock() + + switch mode { + case .standard: + debugLog("onPassphraseRequest: standard wallet") + return .standard + case .passphraseDevice: + debugLog("onPassphraseRequest: passphrase wallet (on-device entry), deferring to Trezor") + return .onDevice + case .passphraseHost: + // Answer from the passphrase entered up front (the same value bound to the + // THP session). Empty/absent == the standard wallet. + if let cached, !cached.isEmpty { + debugLog("onPassphraseRequest: host passphrase wallet, answering with pre-entered passphrase") + return .hidden(value: cached) + } + debugLog("onPassphraseRequest: host passphrase empty, answering with standard") + return .standard } - - debugLog("onPassphraseRequest: \(passphrase.isEmpty ? "standard wallet" : "received")") - return passphrase.isEmpty ? .standard : .hidden(value: passphrase) } - // MARK: - UI Submit/Cancel Methods + // MARK: - PIN Submit/Cancel Methods /// Called by ViewModel when user submits PIN func submitPin(_ pin: String) { @@ -144,36 +177,4 @@ final class TrezorUiHandler: TrezorUiCallback { pinLock.unlock() pinSemaphore.signal() } - - /// Called by ViewModel when user submits passphrase - func submitPassphrase(_ passphrase: String) { - debugLog("submitPassphrase") - passphraseLock.lock() - submittedPassphrase = passphrase - passphraseLock.unlock() - passphraseSemaphore.signal() - } - - /// Called by ViewModel when user cancels passphrase entry - func cancelPassphrase() { - debugLog("cancelPassphrase") - passphraseLock.lock() - submittedPassphrase = "" - didCancelPassphrase = true - passphraseLock.unlock() - passphraseSemaphore.signal() - } - - /// Called by ViewModel when user acknowledges on-device passphrase entry. - /// Only signals if a passphrase request is actually pending. - func acknowledgeOnDevicePassphrase() { - awaitingLock.lock() - let awaiting = isAwaitingPassphrase - awaitingLock.unlock() - - guard awaiting else { return } - - debugLog("acknowledgeOnDevicePassphrase") - passphraseSemaphore.signal() - } } diff --git a/Bitkit/ViewModels/Trezor/TrezorViewModel.swift b/Bitkit/ViewModels/Trezor/TrezorViewModel.swift index 3f47ea5bb..b077bf9d1 100644 --- a/Bitkit/ViewModels/Trezor/TrezorViewModel.swift +++ b/Bitkit/ViewModels/Trezor/TrezorViewModel.swift @@ -10,6 +10,55 @@ enum SendStep { case signed } +/// Account-type override for on-chain xpub tools. `automatic` preserves the +/// bitkit-core prefix detector; explicit values cover ambiguous xpub/tpub keys. +enum TrezorAccountTypeSelection: String, CaseIterable, Identifiable, CustomStringConvertible { + case automatic + case legacy + case wrappedSegwit + case nativeSegwit + case taproot + + var id: String { + rawValue + } + + /// Segment label when rendered by `SegmentedControl` + var description: String { + title + } + + var accountType: AccountType? { + switch self { + case .automatic: nil + case .legacy: .legacy + case .wrappedSegwit: .wrappedSegwit + case .nativeSegwit: .nativeSegwit + case .taproot: .taproot + } + } + + var title: String { + switch self { + case .automatic: "Auto" + case .legacy: "Legacy" + case .wrappedSegwit: "Wrapped" + case .nativeSegwit: "Native" + case .taproot: "Taproot" + } + } + + var subtitle: String { + switch self { + case .automatic: "Prefix" + case .legacy: "BIP44" + case .wrappedSegwit: "BIP49" + case .nativeSegwit: "BIP84" + case .taproot: "BIP86" + } + } +} + /// ViewModel for Trezor hardware wallet integration @Observable @MainActor @@ -82,6 +131,22 @@ class TrezorViewModel { /// Message for confirm on device overlay var confirmMessage: String = "" + /// Show the "where to enter the passphrase" chooser (phone vs Trezor). + /// Only presented for devices that report on-device passphrase entry capability. + var showWalletModeChooser: Bool = false + + // MARK: - Wallet Mode State + + /// The currently selected wallet mode (standard / hidden-on-phone / hidden-on-device). + /// Drives the wallet-mode selector UI; the binding to the device session is applied + /// via setWalletMode (disconnect/reconnect). + var walletMode: TrezorWalletMode = .standard + + /// Whether the connected device supports entering the passphrase on the Trezor itself. + var passphraseEntryCapable: Bool { + deviceFeatures?.passphraseEntryCapable == true + } + // MARK: - Address Generation State /// Current derivation path @@ -220,6 +285,77 @@ class TrezorViewModel { /// Error specific to the send flow var sendError: String? + // MARK: - Event Watcher State + + /// Connection status of the active watcher + enum WatcherConnectionStatus { + case idle + case starting + case connected + case disconnected + case error + } + + /// Extended public key to watch + var watcherExtendedKey: String = "" + + /// Gap limit input (string for the text field) + var watcherGapLimit: String = "20" + + /// Optional account-type override shared by the on-chain xpub tools. + /// Changing it restarts a running (or starting) watcher so the subscription uses the new type. + var onchainAccountTypeSelection: TrezorAccountTypeSelection = .automatic { + didSet { + guard oldValue != onchainAccountTypeSelection else { return } + restartWatcherIfRunning() + } + } + + /// Identifier of the active watcher, nil when not watching + var activeWatcherId: String? + + /// Current connection status of the active watcher + var watcherConnectionStatus: WatcherConnectionStatus = .idle + + /// Latest balance reported by the watcher + var watcherBalance: WalletBalance? + + /// Latest block height reported by the watcher + var watcherBlockHeight: UInt32 = 0 + + /// Account type reported by the watcher + var watcherAccountType: AccountType? + + /// Transaction count reported by the watcher + var watcherTransactionCount: UInt32 = 0 + + /// Latest transactions reported by the watcher + var watcherTransactions: [HistoryTransaction] = [] + + /// Rolling event log (most recent last, capped) + var watcherEvents: [String] = [] + + /// Error scoped to the watcher section. + var watcherError: String? + + /// Whether a watcher is in the process of starting + var isStartingWatcher: Bool = false + + /// Identifier of a watcher whose native start call is still in flight. + private var startingWatcherId: String? + + /// Strong reference to the active listener so it stays alive while watching + private var watcherListener: TrezorEventListener? + + /// Whether the watcher section has status or output worth keeping visible. + var hasVisibleWatcherStatus: Bool { + activeWatcherId != nil || + isStartingWatcher || + watcherConnectionStatus == .error || + watcherBalance != nil || + !watcherEvents.isEmpty + } + // MARK: - Bluetooth State /// Current Bluetooth state — reads directly from BLEManager (@Observable chaining) @@ -234,6 +370,7 @@ class TrezorViewModel { // MARK: - Private Properties private let trezorService = TrezorService.shared + private let watcherService: TrezorWatcherServicing private let transport = TrezorTransport.shared private let uiHandler = TrezorUiHandler.shared private var cancellables = Set() @@ -241,7 +378,8 @@ class TrezorViewModel { // MARK: - Initialization - init() { + init(watcherService: TrezorWatcherServicing = TrezorService.shared) { + self.watcherService = watcherService selectedNetwork = Self.appDefaultCoinType // Callback subscriptions are deferred to initialize() to avoid // triggering BLE stack and Combine overhead at app launch. @@ -265,18 +403,10 @@ class TrezorViewModel { } .store(in: &cancellables) - // Passphrase request from device - uiHandler.needsPassphrasePublisher - .receive(on: DispatchQueue.main) - .sink { [weak self] onDevice in - if onDevice { - self?.showConfirmOnDevice = true - self?.confirmMessage = "Enter passphrase on your Trezor" - } else { - self?.showPassphraseEntry = true - } - } - .store(in: &cancellables) + // Passphrase entry is now driven proactively by the wallet-mode selector + // (see setWalletMode / requestPassphraseWallet). The device callback + // `onPassphraseRequest` is answered silently from the selected mode, so there + // is no reactive passphrase prompt to subscribe to here. } // MARK: - Debug Log Helper @@ -294,6 +424,29 @@ class TrezorViewModel { TrezorDebugLog.shared.log(message) } + // MARK: - State Reset Helpers + + func clearWalletDerivedState() { + deviceFingerprint = nil + generatedAddress = nil + signedMessage = nil + xpub = nil + publicKeyHex = nil + } + + func clearDisconnectedDeviceState(errorMessage: String? = nil) { + connectedDevice = nil + deviceFeatures = nil + clearWalletDerivedState() + error = errorMessage + showPinEntry = false + showPassphraseEntry = false + showConfirmOnDevice = false + showWalletModeChooser = false + uiHandler.setWalletMode(.standard) + walletMode = .standard + } + // MARK: - Manager Setup /// Set up subscriptions and start BLE stack (synchronous, non-blocking). @@ -392,10 +545,16 @@ class TrezorViewModel { error = nil suppressNextAutoReconnect = false + // Explicit user-initiated connect always opens the standard wallet — a + // passphrase/on-device selection left over from a previously connected device + // must not silently apply to a newly selected one. + uiHandler.setWalletMode(.standard) + walletMode = .standard + trezorLog("=== Connecting to device: \(device.path) ===") do { - let features = try await trezorService.connect(deviceId: device.path) + let features = try await trezorService.connect(deviceId: device.path, selection: uiHandler.currentSelection()) connectedDevice = device deviceFeatures = features showConfirmOnDevice = false @@ -415,27 +574,20 @@ class TrezorViewModel { guard connectedDevice != nil else { return } suppressNextAutoReconnect = true + // NOTE: the event watcher is intentionally NOT stopped here. It subscribes to + // Electrum directly and does not require a connected device, so it survives a + // disconnect and remains controllable from the device-list screen. It is only + // torn down on a network switch (different Electrum server) or via stopWatcher(). + do { try await trezorService.disconnect() // Clear connection state but preserve device list for quick reconnection - connectedDevice = nil - deviceFeatures = nil - deviceFingerprint = nil - generatedAddress = nil - signedMessage = nil - xpub = nil - publicKeyHex = nil - error = nil - showPinEntry = false - showPassphraseEntry = false - showConfirmOnDevice = false + clearDisconnectedDeviceState() trezorLog("Disconnected from Trezor") } catch { // Even if disconnect fails, clear local state - connectedDevice = nil - deviceFeatures = nil - self.error = errorMessage(from: error) + clearDisconnectedDeviceState(errorMessage: errorMessage(from: error)) trezorLog("Disconnect failed: \(error)", level: "error") } } @@ -567,18 +719,97 @@ class TrezorViewModel { uiHandler.cancelPin() } - /// Submit passphrase from UI - func submitPassphrase(_ passphrase: String) { + /// Submit a host-entered passphrase from the UI — opens the corresponding hidden + /// wallet (or the standard wallet when empty) by resetting the session. + func submitPassphrase(_ passphrase: String) async { showPassphraseEntry = false showConfirmOnDevice = false - uiHandler.submitPassphrase(passphrase) + await setWalletMode(passphrase.isEmpty ? .standard : .passphraseHost, passphrase: passphrase) } /// Cancel passphrase entry func cancelPassphrase() { showPassphraseEntry = false showConfirmOnDevice = false - uiHandler.cancelPassphrase() + showWalletModeChooser = false + } + + // MARK: - Wallet Mode Selection + + /// User tapped the "Standard" wallet option in the selector. + func selectStandardWallet() async { + guard walletMode != .standard else { return } + await setWalletMode(.standard) + } + + /// User tapped the "Passphrase" wallet option. On a capable device this offers a + /// choice of where to enter the passphrase; otherwise it goes straight to host entry. + func requestPassphraseWallet() { + if passphraseEntryCapable { + showWalletModeChooser = true + } else { + showPassphraseEntry = true + } + } + + /// Wallet-mode chooser: user chose to enter the passphrase on this phone. + func choosePhonePassphraseEntry() { + showWalletModeChooser = false + showPassphraseEntry = true + } + + /// Wallet-mode chooser: user chose to enter the passphrase on the Trezor. + func chooseDevicePassphraseEntry() async { + showWalletModeChooser = false + await setWalletMode(.passphraseDevice) + } + + /// Switch between wallet modes. The Trezor caches the passphrase for the whole + /// session, so switching requires a fresh session: this records the desired mode, + /// then disconnects and reconnects by path. Mirrors bitkit-android's setWalletMode. + func setWalletMode(_ mode: TrezorWalletMode, passphrase: String = "") async { + guard let device = connectedDevice else { + error = "Not connected to a Trezor" + return + } + + isOperating = true + error = nil + trezorLog("=== Switching wallet mode to \(mode); resetting session ===") + + // Reset the session. We call the service directly (not the VM's disconnect()) + // so connectedDevice/deviceFeatures stay populated for the reconnect. + do { + try await trezorService.disconnect() + } catch { + trezorLog("Disconnect before wallet-mode switch failed: \(error)", level: "warn") + } + + // Results derived from the previous wallet are no longer valid once the + // session has been reset for a different wallet mode. + clearWalletDerivedState() + + // Brief settle delay before reconnecting (matches Android's reconnect delay). + try? await Task.sleep(nanoseconds: 300_000_000) + + // Record the selection AFTER the disconnect so it survives into the new session. + // THP reads it via currentSelection() to bind the passphrase at session creation; + // non-THP devices re-request it mid-operation and are answered from the same value. + uiHandler.setWalletMode(mode, hostPassphrase: passphrase) + walletMode = mode + + do { + let features = try await trezorService.connect(deviceId: device.path, selection: uiHandler.currentSelection()) + connectedDevice = device + deviceFeatures = features + showConfirmOnDevice = false + trezorLog("Reconnected with wallet mode \(mode)") + } catch { + clearDisconnectedDeviceState(errorMessage: errorMessage(from: error)) + trezorLog("Reconnect after wallet-mode switch failed: \(error)", level: "error") + } + + isOperating = false } /// Submit pairing code from UI @@ -597,7 +828,6 @@ class TrezorViewModel { func dismissConfirmOnDevice() { showConfirmOnDevice = false confirmMessage = "" - uiHandler.acknowledgeOnDevicePassphrase() } // MARK: - Known Devices @@ -845,6 +1075,9 @@ class TrezorViewModel { guard network != selectedNetwork else { return } selectedNetwork = network + // A running watcher is bound to the previous network's Electrum server. + stopWatcher() + // Reset derivation paths with the new coin type derivationPath = "m/84'/\(coinTypeComponent)/0'/0/0" publicKeyPath = "m/84'/\(coinTypeComponent)/0'" @@ -911,7 +1144,8 @@ class TrezorViewModel { accountResult = try await trezorService.getAccountInfo( extendedKey: trimmedInput, electrumUrl: electrumUrl, - network: selectedNetwork + network: selectedNetwork, + scriptType: onchainAccountTypeSelection.accountType ) case .address: addressResult = try await trezorService.getAddressInfo( @@ -951,6 +1185,8 @@ class TrezorViewModel { return "Invalid transaction ID: \(errorDetails)" case let .TransactionNotFound(errorDetails): return "Transaction not found: \(errorDetails)" + case let .WatcherError(errorDetails): + return "Watcher error: \(errorDetails)" } } if let appError = error as? AppError, @@ -978,7 +1214,8 @@ class TrezorViewModel { txHistoryResult = try await trezorService.getTransactionHistory( extendedKey: trimmedKey, electrumUrl: electrumUrl, - network: selectedNetwork + network: selectedNetwork, + scriptType: onchainAccountTypeSelection.accountType ) } catch { txHistoryError = formatLookupError(error) @@ -1006,7 +1243,8 @@ class TrezorViewModel { extendedKey: trimmedKey, electrumUrl: electrumUrl, txid: trimmedTxid, - network: selectedNetwork + network: selectedNetwork, + scriptType: onchainAccountTypeSelection.accountType ) } catch { txDetailError = formatLookupError(error) @@ -1338,6 +1576,253 @@ class TrezorViewModel { } } + // MARK: - Event Watcher Operations + + /// Copy the most recently retrieved xpub into the watcher's extended-key field. + func populateWatcherFromXpub() { + if let xpub { + watcherExtendedKey = xpub + } + } + + /// Start watching the entered extended key for on-chain activity. + func startWatcher() async { + let key = watcherExtendedKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { + watcherError = "Enter an extended public key to watch" + return + } + guard !isStartingWatcher, activeWatcherId == nil else { return } + + guard let gapLimit = UInt32(watcherGapLimit.trimmingCharacters(in: .whitespacesAndNewlines)), gapLimit > 0 else { + watcherError = "Gap limit must be a positive integer" + return + } + + let watcherId = UUID().uuidString + let network = selectedNetwork + let accountType = onchainAccountTypeSelection.accountType + + let params = WatcherParams( + watcherId: watcherId, + extendedKey: key, + electrumUrl: Self.electrumUrlForNetwork(network), + network: toNetwork(network), + accountType: accountType, + gapLimit: gapLimit + ) + + let listener = TrezorEventListener { [weak self] id, event in + self?.handleWatcherEvent(watcherId: id, event: event) + } + watcherListener = listener + + isStartingWatcher = true + startingWatcherId = watcherId + watcherConnectionStatus = .starting + watcherTransactions = [] + watcherEvents = ["starting: \(watcherId)"] + watcherBalance = nil + watcherTransactionCount = 0 + watcherBlockHeight = 0 + watcherAccountType = nil + watcherError = nil + trezorLog("Starting watcher \(watcherId) for \(key.prefix(12))...") + + do { + try await watcherService.startWatcher(params: params, listener: listener) + guard startingWatcherId == watcherId else { + try? watcherService.stopWatcher(watcherId: watcherId) + trezorLog("Stopped stale watcher start: \(watcherId)", level: "warn") + return + } + + if selectedNetwork != network { + try? watcherService.stopWatcher(watcherId: watcherId) + finishStoppedWatcherStartup(watcherId: watcherId) + return + } + + if onchainAccountTypeSelection.accountType != accountType { + try? watcherService.stopWatcher(watcherId: watcherId) + finishStoppedWatcherStartup(watcherId: watcherId) + trezorLog("Account type changed during watcher startup, restarting: \(watcherId)") + scheduleWatcherRestart() + return + } + + activeWatcherId = watcherId + startingWatcherId = nil + isStartingWatcher = false + appendWatcherEvent("started") + trezorLog("Watcher started: \(watcherId)") + } catch { + guard startingWatcherId == watcherId else { + trezorLog("Superseded watcher start failed: \(watcherId)", level: "warn") + return + } + + // A native-side stop that aborts the Rust startup surfaces here as a + // thrown error rather than a return. A Swift-side stop is already handled + // by the quarantine in stopWatcher() and the stale guard above; this covers + // the core stopping the watcher directly. It's a cancellation, not a + // failure the user caused. + if Self.isWatcherStartupCancellation(error) { + finishStoppedWatcherStartup(watcherId: watcherId) + return + } + + let message = errorMessage(from: error) + watcherError = message + watcherConnectionStatus = .error + activeWatcherId = nil + startingWatcherId = nil + watcherListener = nil + appendWatcherEvent("start failed: \(message)") + trezorLog("Watcher start failed: \(error)", level: "error") + isStartingWatcher = false + } + } + + /// Stop the active watcher, if any. A watcher whose native start call is still + /// in flight is stopped too: its id is quarantined so handleWatcherEvent drops + /// any events that arrive before the native call returns. + func stopWatcher() { + if let startingId = startingWatcherId { + // Abort the in-flight native startup and quarantine the id; if the + // native call still returns success, startWatcher's stale-start check + // stops the watcher then. + try? watcherService.stopWatcher(watcherId: startingId) + finishStoppedWatcherStartup(watcherId: startingId) + } + + guard let watcherId = activeWatcherId else { return } + do { + try watcherService.stopWatcher(watcherId: watcherId) + } catch { + trezorLog("Watcher stop failed: \(error)", level: "warn") + } + activeWatcherId = nil + watcherConnectionStatus = .idle + watcherListener = nil + watcherBalance = nil + watcherTransactions = [] + watcherTransactionCount = 0 + watcherBlockHeight = 0 + watcherAccountType = nil + watcherEvents = [] + watcherError = nil + trezorLog("Watcher stopped: \(watcherId)") + } + + /// Tear down all watchers when the Trezor dashboard is dismissed. On Android this + /// happens in the ViewModel's onCleared, but this ViewModel is app-lifetime, so the + /// root view calls it from onDisappear. + func stopAllWatchers() { + stopWatcher() + watcherService.stopAllWatchers() + } + + /// Full teardown when the Trezor dashboard is dismissed: stop all watchers and + /// reset the watcher input state so the next visit starts fresh. + func handleDashboardDismiss() { + stopAllWatchers() + watcherExtendedKey = "" + watcherGapLimit = "20" + onchainAccountTypeSelection = .automatic + } + + /// Restart a running watcher (e.g. after the account-type override changes) + /// so the Electrum subscription reflects the new settings. A change that lands + /// while a start is still in flight is handled by startWatcher itself, which + /// re-checks the selection once the native call returns. + private func restartWatcherIfRunning() { + guard activeWatcherId != nil else { return } + stopWatcher() + scheduleWatcherRestart() + } + + /// Start a replacement watcher on the next main-actor turn. Bails if the input + /// state was cleared in the meantime (dashboard dismissed) so an unsolicited + /// restart never revives a watcher or surfaces a validation error. + private func scheduleWatcherRestart() { + Task { + guard !watcherExtendedKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + await startWatcher() + } + } + + /// Handle a watcher event on the main actor. Filters out events from stale watchers. + private func handleWatcherEvent(watcherId: String, event: WatcherEvent) { + guard watcherId == activeWatcherId || watcherId == startingWatcherId else { return } + + switch event { + case let .transactionsChanged(transactions, balance, txCount, blockHeight, accountType): + watcherConnectionStatus = .connected + watcherError = nil + watcherTransactions = transactions + watcherBalance = balance + watcherTransactionCount = txCount + watcherBlockHeight = blockHeight + watcherAccountType = accountType + appendWatcherEvent("transactionsChanged: \(txCount) txs, balance \(balance.total) sats") + case let .error(message): + watcherConnectionStatus = .error + watcherError = message + appendWatcherEvent("error: \(message)") + case let .disconnected(message): + watcherConnectionStatus = .disconnected + appendWatcherEvent("disconnected: \(message)") + case .reconnected: + watcherConnectionStatus = .connected + appendWatcherEvent("reconnected") + } + } + + /// Append to the rolling event log, capping at the most recent 50 entries. + private func appendWatcherEvent(_ message: String) { + watcherEvents.append(message) + if watcherEvents.count > 50 { + watcherEvents.removeFirst(watcherEvents.count - 50) + } + } + + /// Message the Rust core throws when a stop aborts a watcher whose startup is + /// still in flight — a cancellation, not a genuine failure. + private static let watcherStartupCancelledMessage = "Watcher stopped during startup" + + /// True when a thrown startup error is the Rust core reporting that the watcher + /// was deliberately stopped mid-startup. ServiceQueue wraps core errors in + /// AppError, so check the wrapped debug message as well as the typed error. + private static func isWatcherStartupCancellation(_ error: Error) -> Bool { + if let accountInfoError = error as? AccountInfoError, + case let .WatcherError(errorDetails) = accountInfoError + { + return errorDetails.contains(watcherStartupCancelledMessage) + } + if let appError = error as? AppError, let debugMessage = appError.debugMessage { + return debugMessage.contains(watcherStartupCancelledMessage) + } + return false + } + + private func finishStoppedWatcherStartup(watcherId: String) { + guard startingWatcherId == watcherId else { return } + activeWatcherId = nil + startingWatcherId = nil + isStartingWatcher = false + watcherConnectionStatus = .idle + watcherListener = nil + watcherBalance = nil + watcherTransactions = [] + watcherTransactionCount = 0 + watcherBlockHeight = 0 + watcherAccountType = nil + watcherEvents = [] + watcherError = nil + trezorLog("Watcher startup stopped before activation: \(watcherId)") + } + // MARK: - AI Test Hooks func testShowPinPrompt() { diff --git a/Bitkit/Views/Trezor/TrezorBalanceLookupView.swift b/Bitkit/Views/Trezor/TrezorBalanceLookupView.swift index 2e551f544..af533557c 100644 --- a/Bitkit/Views/Trezor/TrezorBalanceLookupView.swift +++ b/Bitkit/Views/Trezor/TrezorBalanceLookupView.swift @@ -4,11 +4,15 @@ import SwiftUI /// Inline content for balance lookup, used by expandable section. struct TrezorBalanceLookupContent: View { @State private var input: String = "" + @Environment(TrezorViewModel.self) private var trezor var body: some View { + @Bindable var trezor = trezor VStack(spacing: 24) { InputSection(input: $input) + TrezorAccountTypeSelector(selection: $trezor.onchainAccountTypeSelection) + LookupButtonWrapper(input: input) BalanceLookupResultsSection(input: input) diff --git a/Bitkit/Views/Trezor/TrezorConnectedView.swift b/Bitkit/Views/Trezor/TrezorConnectedView.swift index 94c805698..ace5faab2 100644 --- a/Bitkit/Views/Trezor/TrezorConnectedView.swift +++ b/Bitkit/Views/Trezor/TrezorConnectedView.swift @@ -11,6 +11,7 @@ struct TrezorConnectedView: View { @State private var isBalanceLookupExpanded = false @State private var isTxHistoryExpanded = false @State private var isTxDetailExpanded = false + @State private var isWatcherExpanded = false @State private var isDeviceInfoExpanded = false var body: some View { @@ -22,6 +23,9 @@ struct TrezorConnectedView: View { features: trezor.deviceFeatures ) + // Wallet mode selector (standard vs hidden/passphrase wallet) + WalletModeSelectorRow() + // Expandable sections VStack(spacing: 12) { TrezorExpandableSection( @@ -84,6 +88,16 @@ struct TrezorConnectedView: View { TrezorTransactionDetailContent() } + TrezorExpandableSection( + title: "Event Watcher", + icon: "dot.radiowaves.left.and.right", + description: "Watch an xpub for live on-chain activity", + accessibilityIdentifier: "TrezorSection-Watcher", + isExpanded: $isWatcherExpanded + ) { + TrezorWatcherContent() + } + TrezorExpandableSection( title: "Device Info", icon: "info.circle", @@ -137,6 +151,53 @@ struct TrezorConnectedView: View { } } +// MARK: - Wallet Mode Selector + +/// Lets the user switch between the standard wallet and a hidden (passphrase) wallet. +/// Switching resets the device session (handled by the ViewModel). +private struct WalletModeSelectorRow: View { + @Environment(TrezorViewModel.self) private var trezor + + private enum WalletModeTab: CaseIterable, CustomStringConvertible { + case standard + case passphrase + + var description: String { + switch self { + case .standard: "Standard" + case .passphrase: "Passphrase" + } + } + } + + /// Selecting a tab kicks off the wallet switch; the underline only moves + /// once the ViewModel actually changes `walletMode` (e.g. after the + /// passphrase flow completes). + private var selectedTab: Binding { + Binding( + get: { trezor.walletMode == .standard ? .standard : .passphrase }, + set: { newValue in + switch newValue { + case .standard: + Task { await trezor.selectStandardWallet() } + case .passphrase: + trezor.requestPassphraseWallet() + } + } + ) + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + CaptionMText("Wallet") + + SegmentedControl(selectedTab: selectedTab, tabs: WalletModeTab.allCases) + } + .frame(maxWidth: .infinity, alignment: .leading) + .disabled(trezor.isOperating) + } +} + // MARK: - Device Info Card private struct DeviceInfoCard: View { diff --git a/Bitkit/Views/Trezor/TrezorDeviceListView.swift b/Bitkit/Views/Trezor/TrezorDeviceListView.swift index 7eeafad35..358560798 100644 --- a/Bitkit/Views/Trezor/TrezorDeviceListView.swift +++ b/Bitkit/Views/Trezor/TrezorDeviceListView.swift @@ -6,6 +6,7 @@ import SwiftUI struct TrezorDeviceListView: View { @Environment(TrezorViewModel.self) private var trezor @State private var connectingDevicePath: String? + @State private var isWatcherExpanded = false /// Scanned devices that are NOT already in the known devices list private var nearbyDevices: [TrezorDeviceInfo] { @@ -84,6 +85,19 @@ struct TrezorDeviceListView: View { if let error = trezor.error { ErrorCard(message: error) } + + // Event watcher — works without a connected device (subscribes to + // Electrum directly), so it is available from the device-list screen + // and keeps running across connects/disconnects. + TrezorExpandableSection( + title: "Event Watcher", + icon: "dot.radiowaves.left.and.right", + description: "Watch an xpub for live on-chain activity (no device required)", + accessibilityIdentifier: "TrezorSection-Watcher", + isExpanded: $isWatcherExpanded + ) { + TrezorWatcherContent() + } } .padding(16) } diff --git a/Bitkit/Views/Trezor/TrezorRootView.swift b/Bitkit/Views/Trezor/TrezorRootView.swift index 0a661ee66..1f3dc7971 100644 --- a/Bitkit/Views/Trezor/TrezorRootView.swift +++ b/Bitkit/Views/Trezor/TrezorRootView.swift @@ -28,6 +28,7 @@ struct TrezorRootView: View { } } } + .modifier(TrezorLifecycleModifier()) .modifier(TrezorDialogsModifier()) } } @@ -67,9 +68,31 @@ private struct TrezorContentSwitcher: View { } } .animation(.easeInOut(duration: 0.25), value: trezor.isConnected) - .task { - trezor.setup() - } + } +} + +// MARK: - Lifecycle Modifier + +/// Setup/teardown tied to the whole dashboard's lifetime. This must live on +/// TrezorRootView's stable root: Group distributes modifiers to the active +/// branch of TrezorContentSwitcher's conditional, so an onDisappear there fires +/// on every connect/disconnect and would tear down watchers mid-session. +/// The ViewModel is only accessed inside closures, so the root body still +/// establishes no observation dependencies. +private struct TrezorLifecycleModifier: ViewModifier { + @Environment(TrezorViewModel.self) private var trezor + + func body(content: Content) -> some View { + content + .task { + trezor.setup() + } + .onDisappear { + // The ViewModel outlives this screen (app-lifetime), so watchers and + // their input state are torn down when the dashboard is dismissed — + // the iOS counterpart of Android's onCleared. + trezor.handleDashboardDismiss() + } } } @@ -106,6 +129,27 @@ private struct TrezorDialogsModifier: ViewModifier { .sheet(isPresented: $trezor.showPassphraseEntry) { TrezorPassphraseSheet() } + .confirmationDialog( + "Passphrase Entry", + isPresented: $trezor.showWalletModeChooser, + titleVisibility: .visible + ) { + Button("On this phone") { + trezor.choosePhonePassphraseEntry() + } + .accessibilityIdentifier("TrezorWalletModeOnPhone") + + Button("On the Trezor") { + Task { await trezor.chooseDevicePassphraseEntry() } + } + .accessibilityIdentifier("TrezorWalletModeOnTrezor") + + Button("Cancel", role: .cancel) { + trezor.showWalletModeChooser = false + } + } message: { + Text("Where do you want to enter the passphrase for your hidden wallet?") + } .overlay { if trezor.showConfirmOnDevice { TrezorConfirmOnDeviceOverlay( @@ -381,6 +425,16 @@ struct TrezorPassphraseSheet: View { .font(.system(size: 14)) .foregroundColor(.red) } + + // Offer on-device entry when the connected Trezor supports it + if trezor.passphraseEntryCapable { + CustomButton(title: "Enter on Trezor instead", variant: .tertiary) { + dismiss() + await trezor.chooseDevicePassphraseEntry() + } + .padding(.top, 4) + .accessibilityIdentifier("TrezorPassphraseUseDevice") + } } .padding(.horizontal, 16) @@ -403,8 +457,9 @@ struct TrezorPassphraseSheet: View { .accessibilityIdentifier("TrezorPassphraseCancel") Button(action: { - trezor.submitPassphrase(passphrase) + let entered = passphrase dismiss() + Task { await trezor.submitPassphrase(entered) } }) { Text("Confirm") .font(.system(size: 16, weight: .semibold)) diff --git a/Bitkit/Views/Trezor/TrezorTransactionDetailView.swift b/Bitkit/Views/Trezor/TrezorTransactionDetailView.swift index 7e89419fb..937cb9be0 100644 --- a/Bitkit/Views/Trezor/TrezorTransactionDetailView.swift +++ b/Bitkit/Views/Trezor/TrezorTransactionDetailView.swift @@ -5,11 +5,15 @@ import SwiftUI struct TrezorTransactionDetailContent: View { @State private var xpubInput: String = "" @State private var txidInput: String = "" + @Environment(TrezorViewModel.self) private var trezor var body: some View { + @Bindable var trezor = trezor VStack(spacing: 24) { TxDetailInputSection(xpubInput: $xpubInput, txidInput: $txidInput) + TrezorAccountTypeSelector(selection: $trezor.onchainAccountTypeSelection) + TxDetailButtonWrapper(xpubInput: xpubInput, txidInput: txidInput) TxDetailResultsSection() diff --git a/Bitkit/Views/Trezor/TrezorTransactionHistoryView.swift b/Bitkit/Views/Trezor/TrezorTransactionHistoryView.swift index a43067baf..9be5933f7 100644 --- a/Bitkit/Views/Trezor/TrezorTransactionHistoryView.swift +++ b/Bitkit/Views/Trezor/TrezorTransactionHistoryView.swift @@ -4,11 +4,15 @@ import SwiftUI /// Inline content for transaction history lookup, used by expandable section. struct TrezorTransactionHistoryContent: View { @State private var input: String = "" + @Environment(TrezorViewModel.self) private var trezor var body: some View { + @Bindable var trezor = trezor VStack(spacing: 24) { TxHistoryInputSection(input: $input) + TrezorAccountTypeSelector(selection: $trezor.onchainAccountTypeSelection) + TxHistoryButtonWrapper(input: input) TxHistoryResultsSection() diff --git a/Bitkit/Views/Trezor/TrezorWatcherView.swift b/Bitkit/Views/Trezor/TrezorWatcherView.swift new file mode 100644 index 000000000..bac2a939c --- /dev/null +++ b/Bitkit/Views/Trezor/TrezorWatcherView.swift @@ -0,0 +1,231 @@ +import BitkitCore +import SwiftUI + +/// Inline content for the on-chain event watcher, used by an expandable section. +/// Subscribes an extended public key to live Electrum updates (no device required). +struct TrezorWatcherContent: View { + @Environment(TrezorViewModel.self) private var trezor + + var body: some View { + @Bindable var trezor = trezor + let isStartDisabled = trezor.isStartingWatcher || trezor.watcherExtendedKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + + VStack(spacing: 20) { + // Extended key input + VStack(alignment: .leading, spacing: 8) { + CaptionMText("Extended Key (xpub/tpub/...)") + + TextField( + "xpub...", + text: $trezor.watcherExtendedKey, + font: .system(size: 13, design: .monospaced), + axis: .vertical, + testIdentifier: "TrezorWatcherExtendedKey" + ) + .lineLimit(1 ... 3) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + } + .frame(maxWidth: .infinity, alignment: .leading) + + // Use xpub from device shortcut + if trezor.xpub != nil { + CustomButton(title: "Use xpub from device", variant: .secondary, size: .small) { + trezor.populateWatcherFromXpub() + } + .accessibilityIdentifier("TrezorWatcherUseXpub") + } + + TrezorAccountTypeSelector(selection: $trezor.onchainAccountTypeSelection) + + // Gap limit + VStack(alignment: .leading, spacing: 8) { + CaptionMText("Gap Limit") + + TextField( + "20", + text: $trezor.watcherGapLimit, + font: .system(size: 14, design: .monospaced), + testIdentifier: "TrezorWatcherGapLimit" + ) + .keyboardType(.numberPad) + } + .frame(maxWidth: .infinity, alignment: .leading) + + // Start / Stop button + if trezor.activeWatcherId != nil { + CustomButton( + title: "Stop Watching", + variant: .secondary, + isDisabled: trezor.isStartingWatcher + ) { + trezor.stopWatcher() + } + .accessibilityIdentifier("TrezorWatcherStop") + } else { + CustomButton( + title: "Start Watching", + icon: Image(systemName: "dot.radiowaves.left.and.right") + .foregroundColor(.textPrimary), + isDisabled: isStartDisabled, + isLoading: trezor.isStartingWatcher + ) { + await trezor.startWatcher() + } + .accessibilityIdentifier("TrezorWatcherStart") + } + + if let error = trezor.watcherError { + TrezorErrorBanner(message: error) + } + + // Live status + if trezor.hasVisibleWatcherStatus { + WatcherStatusView(trezor: trezor) + } + } + } +} + +// MARK: - Status + +private struct WatcherStatusView: View { + let trezor: TrezorViewModel + + private var statusLabel: String { + switch trezor.watcherConnectionStatus { + case .idle: return "IDLE" + case .starting: return "STARTING" + case .connected: return "CONNECTED" + case .disconnected: return "DISCONNECTED" + case .error: return "ERROR" + } + } + + private var statusColor: Color { + switch trezor.watcherConnectionStatus { + case .idle: return .white64 + case .starting: return .yellowAccent + case .connected: return .greenAccent + case .disconnected: return .yellowAccent + case .error: return .redAccent + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + // Status badge + HStack(spacing: 6) { + Circle() + .fill(statusColor) + .frame(width: 8, height: 8) + CaptionBText(statusLabel, textColor: statusColor) + } + .accessibilityIdentifier("TrezorWatcherStatus") + + // Balance card + if let balance = trezor.watcherBalance { + VStack(spacing: 8) { + InfoRow(label: "Confirmed", value: "\(balance.confirmed) sats") + InfoRow(label: "Pending", value: "\(balance.trustedPending + balance.untrustedPending) sats") + InfoRow(label: "Total", value: "\(balance.total) sats") + InfoRow(label: "Block Height", value: "\(trezor.watcherBlockHeight)") + InfoRow(label: "Account Type", value: accountTypeLabel(trezor.watcherAccountType)) + InfoRow(label: "Transactions", value: "\(trezor.watcherTransactionCount)") + } + .padding(16) + .background(Color.white.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + // Transactions + if !trezor.watcherTransactions.isEmpty { + CaptionMText("Transactions (\(trezor.watcherTransactions.count))") + + VStack(spacing: 4) { + ForEach(trezor.watcherTransactions, id: \.txid) { tx in + WatcherTransactionRow(tx: tx) + } + } + } + + // Event log + if !trezor.watcherEvents.isEmpty { + CaptionMText("Event Log") + + VStack(alignment: .leading, spacing: 2) { + ForEach(Array(trezor.watcherEvents.enumerated()), id: \.offset) { _, event in + Text(event) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(.white80) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.black.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func accountTypeLabel(_ type: AccountType?) -> String { + guard let type else { return "-" } + switch type { + case .legacy: return "legacy" + case .wrappedSegwit: return "wrapped-segwit" + case .nativeSegwit: return "native-segwit" + case .taproot: return "taproot" + } + } +} + +private struct WatcherTransactionRow: View { + let tx: HistoryTransaction + + private var directionLabel: String { + switch tx.direction { + case .sent: return "Sent" + case .received: return "Recv" + case .selfTransfer: return "Self" + } + } + + private var directionColor: Color { + switch tx.direction { + case .sent: return .redAccent + case .received: return .greenAccent + case .selfTransfer: return .white64 + } + } + + private var shortTxid: String { + guard tx.txid.count > 16 else { return tx.txid } + return "\(tx.txid.prefix(8))...\(tx.txid.suffix(8))" + } + + var body: some View { + HStack { + CaptionText("\(directionLabel) \(tx.amount) sats", textColor: directionColor) + Spacer() + Text(shortTxid) + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(.white50) + } + .padding(.vertical, 2) + } +} + +private struct InfoRow: View { + let label: String + let value: String + + var body: some View { + HStack { + CaptionText(label) + Spacer() + CaptionBText(value, textColor: .textPrimary) + } + } +} diff --git a/BitkitTests/TrezorViewModelWatcherTests.swift b/BitkitTests/TrezorViewModelWatcherTests.swift new file mode 100644 index 000000000..1abda22b3 --- /dev/null +++ b/BitkitTests/TrezorViewModelWatcherTests.swift @@ -0,0 +1,506 @@ +@testable import Bitkit +import BitkitCore +import XCTest + +/// Watcher tests for TrezorViewModel, ported from bitkit-android's `TrezorViewModelTest.kt`. +final class TrezorViewModelWatcherTests: XCTestCase { + // MARK: - Mock + + /// Mock watcher service, standing in for Android's mocked `TrezorRepo`. + /// `holdStart` mirrors the `CompletableDeferred`-backed mock used to keep + /// the native start call in flight until the test resolves it. + private final class MockWatcherService: TrezorWatcherServicing, @unchecked Sendable { + private let lock = NSLock() + + private(set) var startedParams: [WatcherParams] = [] + private(set) var startedListeners: [EventListener] = [] + private(set) var stoppedWatcherIds: [String] = [] + private(set) var stopAllWatchersCallCount = 0 + + var holdStart = false + + private var startContinuation: CheckedContinuation? + private var pendingStartResult: Result? + + func startWatcher(params: WatcherParams, listener: EventListener) async throws { + lock.lock() + startedParams.append(params) + startedListeners.append(listener) + let shouldHold = holdStart + lock.unlock() + + guard shouldHold else { return } + try await withCheckedThrowingContinuation { continuation in + lock.lock() + defer { lock.unlock() } + if let result = pendingStartResult { + pendingStartResult = nil + continuation.resume(with: result) + } else { + startContinuation = continuation + } + } + } + + func completeStart(with result: Result = .success(())) { + lock.lock() + defer { lock.unlock() } + if let continuation = startContinuation { + startContinuation = nil + continuation.resume(with: result) + } else { + pendingStartResult = result + } + } + + func stopWatcher(watcherId: String) throws { + lock.lock() + defer { lock.unlock() } + stoppedWatcherIds.append(watcherId) + } + + func stopAllWatchers() { + lock.lock() + defer { lock.unlock() } + stopAllWatchersCallCount += 1 + } + } + + // MARK: - Fixtures + + private static let sampleBalance = WalletBalance( + confirmed: 150_000, + immature: 0, + trustedPending: 5000, + untrustedPending: 1000, + spendable: 155_000, + total: 156_000 + ) + + private static let sampleTransactions: [HistoryTransaction] = [ + HistoryTransaction( + txid: "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + received: 50000, + sent: 0, + net: 50000, + fee: nil, + amount: 50000, + direction: .received, + blockHeight: 849_990, + timestamp: 1_700_000_000, + confirmations: 11 + ), + HistoryTransaction( + txid: "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + received: 0, + sent: 20000, + net: -20000, + fee: 500, + amount: 19500, + direction: .sent, + blockHeight: 849_995, + timestamp: 1_700_001_000, + confirmations: 6 + ), + HistoryTransaction( + txid: "6f7cf9580f1c2dfb3c4d5d043cdbb128c640e3f20161245aa7372e9666168516", + received: 10000, + sent: 10500, + net: -500, + fee: 500, + amount: 500, + direction: .selfTransfer, + blockHeight: nil, + timestamp: nil, + confirmations: 0 + ), + ] + + private static func sampleTransactionsChangedEvent() -> WatcherEvent { + .transactionsChanged( + transactions: sampleTransactions, + balance: sampleBalance, + txCount: 3, + blockHeight: 850_000, + accountType: .nativeSegwit + ) + } + + // MARK: - Helpers + + @MainActor + private func makeViewModel(service: MockWatcherService) -> TrezorViewModel { + let viewModel = TrezorViewModel(watcherService: service) + viewModel.watcherExtendedKey = "xpub6test123" + return viewModel + } + + /// Poll until `condition` is true or the timeout elapses, yielding the main + /// actor between checks so listener Tasks can run (Android: advanceUntilIdle). + @MainActor + 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: - Tests + + @MainActor + func testStartWatcherDoesNotExposeActiveWatcherUntilStartCompletes() async { + let service = MockWatcherService() + service.holdStart = true + let viewModel = makeViewModel(service: service) + + let startTask = Task { await viewModel.startWatcher() } + await waitUntil { service.startedParams.count == 1 } + + XCTAssertEqual(service.startedParams.count, 1) + XCTAssertTrue(viewModel.isStartingWatcher) + XCTAssertNil(viewModel.activeWatcherId) + XCTAssertEqual(viewModel.watcherConnectionStatus, .starting) + + service.completeStart() + await startTask.value + + XCTAssertFalse(viewModel.isStartingWatcher) + XCTAssertEqual(viewModel.activeWatcherId, service.startedParams[0].watcherId) + XCTAssertEqual(viewModel.watcherConnectionStatus, .starting) + } + + @MainActor + func testStartWatcherRejectsZeroGapLimit() async { + let service = MockWatcherService() + let viewModel = makeViewModel(service: service) + viewModel.watcherGapLimit = "0" + + await viewModel.startWatcher() + + XCTAssertTrue(service.startedParams.isEmpty) + XCTAssertNil(viewModel.activeWatcherId) + XCTAssertNotNil(viewModel.watcherError) + } + + @MainActor + func testDisconnectedStateResetClearsSensitiveWalletState() { + let service = MockWatcherService() + let viewModel = makeViewModel(service: service) + + TrezorUiHandler.shared.setWalletMode(.passphraseHost, hostPassphrase: "secret") + viewModel.walletMode = .passphraseHost + viewModel.deviceFingerprint = "73c5da0a" + viewModel.generatedAddress = "bcrt1qexample" + viewModel.xpub = "xpub6previous" + viewModel.publicKeyHex = "02abcdef" + viewModel.showPinEntry = true + viewModel.showPassphraseEntry = true + viewModel.showConfirmOnDevice = true + viewModel.showWalletModeChooser = true + + viewModel.clearDisconnectedDeviceState(errorMessage: "disconnect failed") + + XCTAssertNil(viewModel.deviceFingerprint) + XCTAssertNil(viewModel.generatedAddress) + XCTAssertNil(viewModel.xpub) + XCTAssertNil(viewModel.publicKeyHex) + XCTAssertEqual(viewModel.error, "disconnect failed") + XCTAssertFalse(viewModel.showPinEntry) + XCTAssertFalse(viewModel.showPassphraseEntry) + XCTAssertFalse(viewModel.showConfirmOnDevice) + XCTAssertFalse(viewModel.showWalletModeChooser) + XCTAssertEqual(viewModel.walletMode, .standard) + + switch TrezorUiHandler.shared.currentSelection() { + case .standard: + break + default: + XCTFail("Expected cached Trezor wallet selection to reset to standard") + } + } + + @MainActor + func testWatcherTransactionEventMarksWatcherConnected() async throws { + let service = MockWatcherService() + let viewModel = makeViewModel(service: service) + + await viewModel.startWatcher() + let watcherId = try XCTUnwrap(viewModel.activeWatcherId) + let listener = try XCTUnwrap(service.startedListeners.first) + + listener.onEvent(watcherId: watcherId, event: Self.sampleTransactionsChangedEvent()) + await waitUntil { viewModel.watcherConnectionStatus == .connected } + + XCTAssertEqual(viewModel.watcherConnectionStatus, .connected) + XCTAssertEqual(viewModel.watcherBalance?.total, Self.sampleBalance.total) + XCTAssertEqual(viewModel.watcherTransactionCount, 3) + } + + @MainActor + func testWatcherEventIsHandledWhileStartIsInFlight() async throws { + let service = MockWatcherService() + service.holdStart = true + let viewModel = makeViewModel(service: service) + + let startTask = Task { await viewModel.startWatcher() } + await waitUntil { service.startedParams.count == 1 } + let watcherId = try XCTUnwrap(service.startedParams.first?.watcherId) + let listener = try XCTUnwrap(service.startedListeners.first) + + listener.onEvent(watcherId: watcherId, event: Self.sampleTransactionsChangedEvent()) + await waitUntil { viewModel.watcherConnectionStatus == .connected } + + XCTAssertTrue(viewModel.isStartingWatcher) + XCTAssertNil(viewModel.activeWatcherId) + XCTAssertEqual(viewModel.watcherConnectionStatus, .connected) + + service.completeStart() + await startTask.value + + XCTAssertFalse(viewModel.isStartingWatcher) + XCTAssertEqual(viewModel.activeWatcherId, watcherId) + XCTAssertEqual(viewModel.watcherConnectionStatus, .connected) + } + + @MainActor + func testStopWatcherStopsServiceWatcherAndClearsWatcherState() async throws { + let service = MockWatcherService() + let viewModel = makeViewModel(service: service) + + await viewModel.startWatcher() + let watcherId = try XCTUnwrap(viewModel.activeWatcherId) + let listener = try XCTUnwrap(service.startedListeners.first) + listener.onEvent(watcherId: watcherId, event: Self.sampleTransactionsChangedEvent()) + await waitUntil { viewModel.watcherConnectionStatus == .connected } + + viewModel.stopWatcher() + + XCTAssertEqual(service.stoppedWatcherIds, [watcherId]) + XCTAssertNil(viewModel.activeWatcherId) + XCTAssertEqual(viewModel.watcherConnectionStatus, .idle) + XCTAssertNil(viewModel.watcherBalance) + XCTAssertTrue(viewModel.watcherTransactions.isEmpty) + } + + /// iOS-specific: stopping while the native start call is still in flight + /// quarantines the starting watcher — its events are dropped immediately + /// instead of repopulating balance/transaction state until the call returns. + @MainActor + func testStopWatcherDuringInFlightStartQuarantinesStartingWatcher() async throws { + let service = MockWatcherService() + service.holdStart = true + let viewModel = makeViewModel(service: service) + + let startTask = Task { await viewModel.startWatcher() } + await waitUntil { service.startedParams.count == 1 } + let watcherId = try XCTUnwrap(service.startedParams.first?.watcherId) + let listener = try XCTUnwrap(service.startedListeners.first) + + viewModel.stopWatcher() + + XCTAssertEqual(service.stoppedWatcherIds, [watcherId]) + XCTAssertFalse(viewModel.isStartingWatcher) + XCTAssertEqual(viewModel.watcherConnectionStatus, .idle) + + // Events from the canceled startup must not repopulate watcher state. + listener.onEvent(watcherId: watcherId, event: Self.sampleTransactionsChangedEvent()) + await waitUntil(timeout: 0.2) { viewModel.watcherBalance != nil } + + XCTAssertNil(viewModel.watcherBalance) + XCTAssertTrue(viewModel.watcherTransactions.isEmpty) + XCTAssertEqual(viewModel.watcherConnectionStatus, .idle) + + // The held native call returning success must not activate the watcher. + service.completeStart() + await startTask.value + + XCTAssertNil(viewModel.activeWatcherId) + XCTAssertFalse(viewModel.isStartingWatcher) + XCTAssertEqual(viewModel.watcherConnectionStatus, .idle) + } + + /// iOS-specific: the root view calls stopAllWatchers from onDisappear since the + /// ViewModel is app-lifetime (no onCleared equivalent). + @MainActor + func testStopAllWatchersStopsActiveWatcherAndService() async throws { + let service = MockWatcherService() + let viewModel = makeViewModel(service: service) + + await viewModel.startWatcher() + let watcherId = try XCTUnwrap(viewModel.activeWatcherId) + + viewModel.stopAllWatchers() + + XCTAssertEqual(service.stoppedWatcherIds, [watcherId]) + XCTAssertEqual(service.stopAllWatchersCallCount, 1) + XCTAssertNil(viewModel.activeWatcherId) + XCTAssertEqual(viewModel.watcherConnectionStatus, .idle) + } + + /// iOS-specific: dashboard dismissal also resets the watcher input fields. + @MainActor + func testHandleDashboardDismissStopsWatchersAndClearsInputState() async throws { + let service = MockWatcherService() + let viewModel = makeViewModel(service: service) + viewModel.watcherGapLimit = "30" + viewModel.onchainAccountTypeSelection = .legacy + + await viewModel.startWatcher() + let watcherId = try XCTUnwrap(viewModel.activeWatcherId) + + viewModel.handleDashboardDismiss() + + XCTAssertEqual(service.stoppedWatcherIds, [watcherId]) + XCTAssertEqual(service.stopAllWatchersCallCount, 1) + XCTAssertNil(viewModel.activeWatcherId) + XCTAssertEqual(viewModel.watcherExtendedKey, "") + XCTAssertEqual(viewModel.watcherGapLimit, "20") + XCTAssertEqual(viewModel.onchainAccountTypeSelection, .automatic) + } + + /// iOS-specific: changing the account-type override restarts a running watcher + /// so the Electrum subscription reflects the new type. + @MainActor + func testAccountTypeChangeRestartsRunningWatcher() async throws { + let service = MockWatcherService() + let viewModel = makeViewModel(service: service) + + await viewModel.startWatcher() + let firstWatcherId = try XCTUnwrap(viewModel.activeWatcherId) + + viewModel.onchainAccountTypeSelection = .taproot + await waitUntil { service.startedParams.count == 2 && viewModel.activeWatcherId != nil } + + XCTAssertEqual(service.stoppedWatcherIds, [firstWatcherId]) + XCTAssertEqual(service.startedParams.count, 2) + XCTAssertEqual(service.startedParams.last?.accountType, .taproot) + let secondWatcherId = try XCTUnwrap(viewModel.activeWatcherId) + XCTAssertNotEqual(secondWatcherId, firstWatcherId) + } + + /// iOS-specific: an account-type change that lands while the start call is still + /// in flight is picked up once the call returns — the stale watcher is stopped + /// and a replacement starts with the new type. + @MainActor + func testAccountTypeChangeDuringStartRestartsWithNewType() async throws { + let service = MockWatcherService() + service.holdStart = true + let viewModel = makeViewModel(service: service) + + let startTask = Task { await viewModel.startWatcher() } + await waitUntil { service.startedParams.count == 1 } + let firstWatcherId = try XCTUnwrap(service.startedParams.first?.watcherId) + + viewModel.onchainAccountTypeSelection = .taproot + service.holdStart = false + service.completeStart() + await startTask.value + await waitUntil { service.startedParams.count == 2 && viewModel.activeWatcherId != nil } + + XCTAssertEqual(service.stoppedWatcherIds, [firstWatcherId]) + XCTAssertEqual(service.startedParams.count, 2) + XCTAssertEqual(service.startedParams.last?.accountType, .taproot) + XCTAssertEqual(viewModel.activeWatcherId, service.startedParams.last?.watcherId) + } + + /// iOS-specific: dismissing the dashboard right after an account-type change + /// cancels the pending restart instead of reviving a watcher or surfacing a + /// validation error for the cleared key. + @MainActor + func testDismissAfterAccountTypeChangeCancelsPendingRestart() async throws { + let service = MockWatcherService() + let viewModel = makeViewModel(service: service) + + await viewModel.startWatcher() + let firstWatcherId = try XCTUnwrap(viewModel.activeWatcherId) + + viewModel.onchainAccountTypeSelection = .taproot + viewModel.handleDashboardDismiss() + await waitUntil(timeout: 0.2) { service.startedParams.count > 1 } + + XCTAssertEqual(service.startedParams.count, 1) + XCTAssertEqual(service.stoppedWatcherIds, [firstWatcherId]) + XCTAssertNil(viewModel.activeWatcherId) + XCTAssertNil(viewModel.watcherError) + } + + /// iOS-specific: dismissing the dashboard while the native start call is in flight + /// aborts the Rust-side startup, which surfaces as a thrown + /// "Watcher stopped during startup" (wrapped in AppError by ServiceQueue). + /// That is a cancellation, not a failure — no error is shown to the user. + @MainActor + func testDismissDuringInFlightStartTreatsAbortedStartupAsCancellation() async { + let service = MockWatcherService() + service.holdStart = true + let viewModel = makeViewModel(service: service) + + let startTask = Task { await viewModel.startWatcher() } + await waitUntil { service.startedParams.count == 1 } + + viewModel.handleDashboardDismiss() + let nativeError = AccountInfoError.WatcherError(errorDetails: "Watcher stopped during startup") + service.completeStart(with: .failure(AppError(error: nativeError))) + await startTask.value + + XCTAssertNil(viewModel.watcherError) + XCTAssertEqual(viewModel.watcherConnectionStatus, .idle) + XCTAssertFalse(viewModel.isStartingWatcher) + XCTAssertNil(viewModel.activeWatcherId) + XCTAssertEqual(viewModel.watcherExtendedKey, "") + } + + /// iOS-specific: the native cancellation error is treated as graceful even when + /// no stop was requested on the Swift side (e.g. the core stopped the watcher + /// directly), based on the typed error alone. + @MainActor + func testNativeStartupCancellationWithoutStopRequestFinishesGracefully() async { + let service = MockWatcherService() + service.holdStart = true + let viewModel = makeViewModel(service: service) + + let startTask = Task { await viewModel.startWatcher() } + await waitUntil { service.startedParams.count == 1 } + + service.completeStart(with: .failure(AccountInfoError.WatcherError(errorDetails: "Watcher stopped during startup"))) + await startTask.value + + XCTAssertNil(viewModel.watcherError) + XCTAssertEqual(viewModel.watcherConnectionStatus, .idle) + XCTAssertFalse(viewModel.isStartingWatcher) + XCTAssertNil(viewModel.activeWatcherId) + } + + /// iOS-specific: a genuine native failure (not a cancellation) still surfaces + /// to the user as a watcher error. + @MainActor + func testGenuineStartFailureStillSurfacesError() async { + let service = MockWatcherService() + service.holdStart = true + let viewModel = makeViewModel(service: service) + + let startTask = Task { await viewModel.startWatcher() } + await waitUntil { service.startedParams.count == 1 } + + let nativeError = AccountInfoError.ElectrumError(errorDetails: "connection refused") + service.completeStart(with: .failure(AppError(error: nativeError))) + await startTask.value + + XCTAssertNotNil(viewModel.watcherError) + XCTAssertEqual(viewModel.watcherConnectionStatus, .error) + XCTAssertFalse(viewModel.isStartingWatcher) + XCTAssertNil(viewModel.activeWatcherId) + } + + /// iOS-specific: changing the account type while no watcher runs starts nothing. + @MainActor + func testAccountTypeChangeDoesNotStartWatcherWhenIdle() async { + let service = MockWatcherService() + let viewModel = makeViewModel(service: service) + + viewModel.onchainAccountTypeSelection = .taproot + await waitUntil(timeout: 0.2) { !service.startedParams.isEmpty } + + XCTAssertTrue(service.startedParams.isEmpty) + XCTAssertNil(viewModel.activeWatcherId) + } +} diff --git a/changelog.d/next/574.added.md b/changelog.d/next/574.added.md new file mode 100644 index 000000000..ab2ad0aab --- /dev/null +++ b/changelog.d/next/574.added.md @@ -0,0 +1 @@ +Added Trezor hidden-wallet passphrase selection and an on-chain event watcher for live xpub activity.