From 46bb276bbaf0804d93f3194f633b8eb7bddd1580 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Mon, 1 Jun 2026 12:39:35 -0400 Subject: [PATCH 1/2] feat(trezor): add hidden-wallet passphrase selection and on-chain watcher --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- .../xcschemes/BitkitAITests.xcscheme | 3 +- Bitkit/Components/Trezor/TrezorPinPad.swift | 37 ++- .../Services/Trezor/TrezorEventListener.swift | 39 +++ Bitkit/Services/Trezor/TrezorService.swift | 33 +- Bitkit/Services/Trezor/TrezorUiHandler.swift | 183 +++++------ .../ViewModels/Trezor/TrezorViewModel.swift | 298 ++++++++++++++++-- Bitkit/Views/Trezor/TrezorConnectedView.swift | 60 ++++ Bitkit/Views/Trezor/TrezorRootView.swift | 38 ++- Bitkit/Views/Trezor/TrezorWatcherView.swift | 258 +++++++++++++++ 11 files changed, 828 insertions(+), 127 deletions(-) create mode 100644 Bitkit/Services/Trezor/TrezorEventListener.swift create mode 100644 Bitkit/Views/Trezor/TrezorWatcherView.swift diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 085103027..176dea9d0 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1200,7 +1200,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 dafce08c6..86d9139d5 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.xcodeproj/xcshareddata/xcschemes/BitkitAITests.xcscheme b/Bitkit.xcodeproj/xcshareddata/xcschemes/BitkitAITests.xcscheme index 1db566a33..cfb07f7e6 100644 --- a/Bitkit.xcodeproj/xcshareddata/xcschemes/BitkitAITests.xcscheme +++ b/Bitkit.xcodeproj/xcshareddata/xcschemes/BitkitAITests.xcscheme @@ -31,8 +31,7 @@ shouldAutocreateTestPlan = "YES"> + skipped = "NO"> 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..cd6285df9 100644 --- a/Bitkit/Services/Trezor/TrezorService.swift +++ b/Bitkit/Services/Trezor/TrezorService.swift @@ -65,13 +65,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 +272,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..f64019541 100644 --- a/Bitkit/ViewModels/Trezor/TrezorViewModel.swift +++ b/Bitkit/ViewModels/Trezor/TrezorViewModel.swift @@ -82,6 +82,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 +236,53 @@ 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" + + /// 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] = [] + + /// Whether a watcher is in the process of starting + var isStartingWatcher: Bool = false + + /// Strong reference to the active listener so it stays alive while watching + private var watcherListener: TrezorEventListener? + // MARK: - Bluetooth State /// Current Bluetooth state — reads directly from BLEManager (@Observable chaining) @@ -265,18 +328,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 @@ -392,10 +447,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,6 +476,8 @@ class TrezorViewModel { guard connectedDevice != nil else { return } suppressNextAutoReconnect = true + stopWatcher() + do { try await trezorService.disconnect() // Clear connection state but preserve device list for quick reconnection @@ -429,6 +492,9 @@ class TrezorViewModel { showPinEntry = false showPassphraseEntry = false showConfirmOnDevice = false + showWalletModeChooser = false + uiHandler.setWalletMode(.standard) + walletMode = .standard trezorLog("Disconnected from Trezor") } catch { @@ -567,18 +633,103 @@ 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") + } + + // 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 + // Results derived from the previous wallet are no longer valid. + deviceFingerprint = nil + generatedAddress = nil + xpub = nil + publicKeyHex = nil + signedMessage = nil + trezorLog("Reconnected with wallet mode \(mode)") + } catch { + self.error = errorMessage(from: error) + connectedDevice = nil + deviceFeatures = nil + walletMode = .standard + uiHandler.setWalletMode(.standard) + trezorLog("Reconnect after wallet-mode switch failed: \(error)", level: "error") + } + + isOperating = false } /// Submit pairing code from UI @@ -597,7 +748,6 @@ class TrezorViewModel { func dismissConfirmOnDevice() { showConfirmOnDevice = false confirmMessage = "" - uiHandler.acknowledgeOnDevicePassphrase() } // MARK: - Known Devices @@ -845,6 +995,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'" @@ -951,6 +1104,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, @@ -1338,6 +1493,113 @@ 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 { + error = "Enter an extended public key to watch" + return + } + guard activeWatcherId == nil else { return } + + let gapLimit = UInt32(watcherGapLimit.trimmingCharacters(in: .whitespacesAndNewlines)) + let watcherId = UUID().uuidString + + let params = WatcherParams( + watcherId: watcherId, + extendedKey: key, + electrumUrl: Self.electrumUrlForNetwork(selectedNetwork), + network: toNetwork(selectedNetwork), + accountType: nil, + gapLimit: gapLimit + ) + + let listener = TrezorEventListener { [weak self] id, event in + self?.handleWatcherEvent(watcherId: id, event: event) + } + watcherListener = listener + + isStartingWatcher = true + activeWatcherId = watcherId + watcherConnectionStatus = .starting + watcherTransactions = [] + watcherEvents = [] + watcherBalance = nil + watcherTransactionCount = 0 + watcherBlockHeight = 0 + watcherAccountType = nil + trezorLog("Starting watcher \(watcherId) for \(key.prefix(12))...") + + do { + try await trezorService.startWatcher(params: params, listener: listener) + trezorLog("Watcher started: \(watcherId)") + } catch { + self.error = errorMessage(from: error) + watcherConnectionStatus = .error + activeWatcherId = nil + watcherListener = nil + trezorLog("Watcher start failed: \(error)", level: "error") + } + + isStartingWatcher = false + } + + /// Stop the active watcher, if any. + func stopWatcher() { + guard let watcherId = activeWatcherId else { return } + do { + try trezorService.stopWatcher(watcherId: watcherId) + } catch { + trezorLog("Watcher stop failed: \(error)", level: "warn") + } + activeWatcherId = nil + watcherConnectionStatus = .idle + watcherListener = nil + trezorLog("Watcher stopped: \(watcherId)") + } + + /// Handle a watcher event on the main actor. Filters out events from stale watchers. + private func handleWatcherEvent(watcherId: String, event: WatcherEvent) { + guard watcherId == activeWatcherId else { return } + + switch event { + case let .transactionsChanged(transactions, balance, txCount, blockHeight, accountType): + watcherConnectionStatus = .connected + watcherTransactions = transactions + watcherBalance = balance + watcherTransactionCount = txCount + watcherBlockHeight = blockHeight + watcherAccountType = accountType + appendWatcherEvent("transactionsChanged: \(txCount) txs, balance \(balance.total) sats") + case let .error(message): + watcherConnectionStatus = .error + 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) + } + } + // MARK: - AI Test Hooks func testShowPinPrompt() { diff --git a/Bitkit/Views/Trezor/TrezorConnectedView.swift b/Bitkit/Views/Trezor/TrezorConnectedView.swift index 94c805698..d39d2e5b1 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,52 @@ 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 var isPassphraseMode: Bool { + trezor.walletMode == .passphraseHost || trezor.walletMode == .passphraseDevice + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Wallet") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.white.opacity(0.4)) + + HStack(spacing: 8) { + modeButton(title: "Standard", selected: trezor.walletMode == .standard) { + Task { await trezor.selectStandardWallet() } + } + .accessibilityIdentifier("TrezorWalletModeStandard") + + modeButton(title: "Passphrase", selected: isPassphraseMode) { + trezor.requestPassphraseWallet() + } + .accessibilityIdentifier("TrezorWalletModePassphrase") + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .disabled(trezor.isOperating) + } + + private func modeButton(title: String, selected: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(title) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(selected ? .white : .white.opacity(0.5)) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(selected ? Color.white.opacity(0.2) : Color.white.opacity(0.05)) + .clipShape(Capsule()) + } + } +} + // MARK: - Device Info Card private struct DeviceInfoCard: View { diff --git a/Bitkit/Views/Trezor/TrezorRootView.swift b/Bitkit/Views/Trezor/TrezorRootView.swift index 0a661ee66..27b12bc5c 100644 --- a/Bitkit/Views/Trezor/TrezorRootView.swift +++ b/Bitkit/Views/Trezor/TrezorRootView.swift @@ -106,6 +106,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 +402,20 @@ struct TrezorPassphraseSheet: View { .font(.system(size: 14)) .foregroundColor(.red) } + + // Offer on-device entry when the connected Trezor supports it + if trezor.passphraseEntryCapable { + Button(action: { + dismiss() + Task { await trezor.chooseDevicePassphraseEntry() } + }) { + Text("Enter on Trezor instead") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white.opacity(0.7)) + } + .padding(.top, 4) + .accessibilityIdentifier("TrezorPassphraseUseDevice") + } } .padding(.horizontal, 16) @@ -403,8 +438,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/TrezorWatcherView.swift b/Bitkit/Views/Trezor/TrezorWatcherView.swift new file mode 100644 index 000000000..60a6ce022 --- /dev/null +++ b/Bitkit/Views/Trezor/TrezorWatcherView.swift @@ -0,0 +1,258 @@ +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 + VStack(spacing: 20) { + // Extended key input + VStack(alignment: .leading, spacing: 8) { + Text("Extended Key (xpub/tpub/...)") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white.opacity(0.6)) + + SwiftUI.TextField("xpub...", text: $trezor.watcherExtendedKey, axis: .vertical) + .font(.system(size: 13, design: .monospaced)) + .foregroundColor(.white) + .lineLimit(1 ... 3) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .padding(12) + .background(Color.white.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .accessibilityIdentifier("TrezorWatcherExtendedKey") + } + + // Use xpub from device shortcut + if trezor.xpub != nil { + Button(action: { trezor.populateWatcherFromXpub() }) { + Text("Use xpub from device") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(Color.white.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .accessibilityIdentifier("TrezorWatcherUseXpub") + } + + // Gap limit + VStack(alignment: .leading, spacing: 8) { + Text("Gap Limit") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white.opacity(0.6)) + + SwiftUI.TextField("20", text: $trezor.watcherGapLimit) + .font(.system(size: 14, design: .monospaced)) + .foregroundColor(.white) + .keyboardType(.numberPad) + .padding(12) + .background(Color.white.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .accessibilityIdentifier("TrezorWatcherGapLimit") + } + + // Start / Stop button + if trezor.activeWatcherId != nil { + Button(action: { trezor.stopWatcher() }) { + Text("Stop Watching") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.red) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(Color.red.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .disabled(trezor.isStartingWatcher) + .opacity(trezor.isStartingWatcher ? 0.5 : 1.0) + .accessibilityIdentifier("TrezorWatcherStop") + } else { + Button(action: { Task { await trezor.startWatcher() } }) { + HStack(spacing: 8) { + if trezor.isStartingWatcher { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .black)) + } else { + Image(systemName: "dot.radiowaves.left.and.right") + } + Text(trezor.isStartingWatcher ? "Starting..." : "Start Watching") + } + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.black) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .disabled(trezor.isStartingWatcher || trezor.watcherExtendedKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .opacity(trezor.watcherExtendedKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? 0.5 : 1.0) + .accessibilityIdentifier("TrezorWatcherStart") + } + + // Live status + if trezor.activeWatcherId != nil { + 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 .white.opacity(0.5) + case .starting: return .yellow + case .connected: return .green + case .disconnected: return .yellow + case .error: return .red + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + // Status badge + HStack(spacing: 6) { + Circle() + .fill(statusColor) + .frame(width: 8, height: 8) + Text(statusLabel) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(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 { + Text("Transactions (\(trezor.watcherTransactions.count))") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.white.opacity(0.6)) + + VStack(spacing: 4) { + ForEach(Array(trezor.watcherTransactions.enumerated()), id: \.offset) { _, tx in + WatcherTransactionRow(tx: tx) + } + } + } + + // Event log + if !trezor.watcherEvents.isEmpty { + Text("Event Log") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.white.opacity(0.6)) + + VStack(alignment: .leading, spacing: 2) { + ForEach(Array(trezor.watcherEvents.enumerated()), id: \.offset) { _, event in + Text(event) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(.white.opacity(0.8)) + .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 .red + case .received: return .green + case .selfTransfer: return .white.opacity(0.6) + } + } + + 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 { + Text("\(directionLabel) \(tx.amount) sats") + .font(.system(size: 12)) + .foregroundColor(directionColor) + Spacer() + Text(shortTxid) + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(.white.opacity(0.5)) + } + .padding(.vertical, 2) + } +} + +private struct InfoRow: View { + let label: String + let value: String + + var body: some View { + HStack { + Text(label) + .font(.system(size: 13)) + .foregroundColor(.white.opacity(0.6)) + Spacer() + Text(value) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.white) + } + } +} From 5953f537b4c0e1d66bf418883a34695c19a8d687 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Mon, 1 Jun 2026 13:04:13 -0400 Subject: [PATCH 2/2] fix(trezor): make event watcher usable without a connected device --- Bitkit/ViewModels/Trezor/TrezorViewModel.swift | 5 ++++- Bitkit/Views/Trezor/TrezorDeviceListView.swift | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/Bitkit/ViewModels/Trezor/TrezorViewModel.swift b/Bitkit/ViewModels/Trezor/TrezorViewModel.swift index f64019541..f84cb22bb 100644 --- a/Bitkit/ViewModels/Trezor/TrezorViewModel.swift +++ b/Bitkit/ViewModels/Trezor/TrezorViewModel.swift @@ -476,7 +476,10 @@ class TrezorViewModel { guard connectedDevice != nil else { return } suppressNextAutoReconnect = true - stopWatcher() + // 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() 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) }