diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index cdb9a558d..35b222c27 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -851,7 +851,8 @@ struct AppScene: View { do { try await app.handleScannedData( paymentTarget, - claimedContactPaymentContext: contactPaymentContext + claimedContactPaymentContext: contactPaymentContext, + alternativeOnchainBalanceSats: hwWalletManager.maximumFundingBalanceSats ) guard paykitPaymentRequestManager.isCurrentPresentation(request), app.ownsContactPaymentContext(contactPaymentContext), @@ -896,8 +897,12 @@ struct AppScene: View { continue } - let route: SendRoute = app.lnurlPayData == nil ? .confirm : .lnurlPayConfirm - guard paykitPaymentRequestManager.isCurrentPresentation(request) else { + guard let route = PaymentNavigationHelper.contactPaymentRoute( + app: app, + currency: currency, + settings: settings + ), paykitPaymentRequestManager.isCurrentPresentation(request) + else { app.resetSendState() wallet.resetSendState(speed: settings.defaultTransactionSpeed) return diff --git a/Bitkit/Components/TabBar/TabBar.swift b/Bitkit/Components/TabBar/TabBar.swift index e112ba082..f1774a987 100644 --- a/Bitkit/Components/TabBar/TabBar.swift +++ b/Bitkit/Components/TabBar/TabBar.swift @@ -47,7 +47,11 @@ struct TabBar: View { } private func onSendPress() { - sheets.showSheet(.send) + if case let .hardwareWallet(walletId) = navigation.currentRoute { + sheets.showSheet(.send, data: SendConfig(hardwareWalletId: walletId)) + } else { + sheets.showSheet(.send) + } } private func onReceivePress() { diff --git a/Bitkit/Extensions/TrezorError+Cancellation.swift b/Bitkit/Extensions/TrezorError+Cancellation.swift index 1ec03d0e3..873b50f81 100644 --- a/Bitkit/Extensions/TrezorError+Cancellation.swift +++ b/Bitkit/Extensions/TrezorError+Cancellation.swift @@ -56,4 +56,29 @@ extension Error { let message = localizedDescription return message.contains("Device error (code \(firmwareErrorCode))") && message.contains("Firmware error") } + + /// Whether the current Trezor channel can no longer be used and must be re-established. + func isTrezorSessionFailure() -> Bool { + if let trezorError = self as? TrezorError { + switch trezorError { + case .TransportError, .DeviceDisconnected, .ConnectionError, .Timeout, .NotConnected, .SessionError, .IoError: + return true + case let .ProtocolError(errorDetails): + let details = errorDetails.lowercased() + return details.contains("thp decryption") + || details.contains("thp encryption") + || details.contains("thp ack") + || details.contains("thp invalid sync") + || details.contains("thp state missing") + default: + return false + } + } + + if let appError = self as? AppError, let underlyingError = appError.underlyingError { + return underlyingError.isTrezorSessionFailure() + } + + return false + } } diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index cf45f67b6..f5d4f4e89 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -234,6 +234,9 @@ struct MainNavView: View { // surface the app-wide Pair Device sheet. Hidden again once submitted/cancelled. if needsCode { guard !sheets.hardwareConnectHandlesPairing else { return } + if sheets.activeSheetConfiguration?.id == .send { + return + } sheets.showSheet(.hardwarePairing) } else { sheets.hideSheetIfActive(.hardwarePairing, reason: "Pairing code resolved") @@ -354,7 +357,10 @@ struct MainNavView: View { } do { - try await app.handleScannedData(url.absoluteString) + try await app.handleScannedData( + url.absoluteString, + alternativeOnchainBalanceSats: hwWalletManager.maximumFundingBalanceSats + ) if shouldOpenPaymentSheet(for: url.absoluteString) { PaymentNavigationHelper.openPaymentSheet( app: app, @@ -658,7 +664,10 @@ struct MainNavView: View { await wallet.waitForNodeToRun() try await Task.sleep(nanoseconds: Self.nodeReadyDelayNanoseconds) - try await app.handleScannedData(uri) + try await app.handleScannedData( + uri, + alternativeOnchainBalanceSats: hwWalletManager.maximumFundingBalanceSats + ) try await Task.sleep(nanoseconds: Self.statePropagationDelayNanoseconds) if shouldOpenPaymentSheet(for: uri) { diff --git a/Bitkit/Managers/HwWalletManager.swift b/Bitkit/Managers/HwWalletManager.swift index 1e6e012f1..af90ed6db 100644 --- a/Bitkit/Managers/HwWalletManager.swift +++ b/Bitkit/Managers/HwWalletManager.swift @@ -16,9 +16,6 @@ import Foundation /// injected `HwDeviceSessioning` seam, and read the stored entries fresh from it: a connect that /// just wrote one lands there before the push does. Never references `TrezorManager` concretely. /// -/// Adapts bitkit-android's `HwWalletRepo`. iOS supports Bluetooth only, so the cross-transport -/// (BLE+USB) dedup is reduced to a plain xpub-based identity and USB-specific reconnect handling -/// is omitted. @Observable @MainActor final class HwWalletManager { @@ -36,6 +33,12 @@ final class HwWalletManager { /// Sum of every paired wallet's balance. private(set) var totalSats: UInt64 = 0 + /// Largest funding-account balance held by one paired hardware wallet. Hardware and software + /// balances are separate funding sources; fee-adjusted availability is resolved in the send flow. + var maximumFundingBalanceSats: UInt64 { + wallets.map(\.fundingBalanceSats).max() ?? 0 + } + /// bitkit-core wallet ids for the paired hardware wallets — the activity list queries these. private(set) var hwWalletIds: Set = [] @@ -106,6 +109,7 @@ final class HwWalletManager { private var emittedReceivedTxIds: Set = [] private var listeners: [String: TrezorEventListener] = [:] + private var staleSessionCleanupTasks: [String: Task] = [:] init( session: HwDeviceSessioning? = nil, @@ -312,6 +316,7 @@ final class HwWalletManager { guard let session else { throw AppError(message: "Unavailable", debugMessage: "No device session to open a passphrase wallet with") } + await waitForStaleSessionCleanup(deviceId: deviceId) // Absent features mean there is nothing to read the setting from — a session that dropped // between pairing and this call — which is a reconnect problem and not a device that refuses // hidden wallets. @@ -350,6 +355,7 @@ final class HwWalletManager { throw AppError(message: "Unavailable", debugMessage: "No device session for wallet '\(walletId)'") } let deviceId = try requireTransportDeviceId(for: walletId) + await waitForStaleSessionCleanup(deviceId: deviceId) try await session.ensureConnected(deviceId: deviceId) if session.connectedWalletId == walletId { return } @@ -373,6 +379,31 @@ final class HwWalletManager { func disconnectStaleSession(walletId: String) async { guard let deviceId = transportDeviceId(for: walletId) else { return } + if let cleanup = staleSessionCleanupTasks[deviceId] { + await cleanup.value + return + } + await performStaleSessionCleanup(deviceId: deviceId) + } + + /// Starts timeout recovery without blocking the current UI operation. Any subsequent connect + /// for the same physical device waits for this task before opening a new session. + func scheduleStaleSessionCleanup(walletId: String) { + guard let deviceId = transportDeviceId(for: walletId) else { return } + guard staleSessionCleanupTasks[deviceId] == nil else { return } + + staleSessionCleanupTasks[deviceId] = Task { @MainActor [weak self] in + guard let self else { return } + await performStaleSessionCleanup(deviceId: deviceId) + staleSessionCleanupTasks[deviceId] = nil + } + } + + private func waitForStaleSessionCleanup(deviceId: String) async { + await staleSessionCleanupTasks[deviceId]?.value + } + + private func performStaleSessionCleanup(deviceId: String) async { await session?.disconnectStaleSession(deviceId: deviceId) } @@ -388,6 +419,7 @@ final class HwWalletManager { // about to need. The prompt reopens it properly a moment later. guard !needsPassphrase(walletId: walletId) else { return } guard let deviceId = transportDeviceId(for: walletId) else { return } + guard staleSessionCleanupTasks[deviceId] == nil else { return } session?.warmUpConnection(deviceId: deviceId) } @@ -400,6 +432,7 @@ final class HwWalletManager { throw AppError(message: "Unavailable", debugMessage: "No device session for wallet '\(walletId)'") } let deviceId = try requireTransportDeviceId(for: walletId) + await waitForStaleSessionCleanup(deviceId: deviceId) let watchedBefore = watchedWalletIds() // Not `ensureConnected`: the session this reopens is usually already gone, either because the // app restarted or because a wrong passphrase closed it. diff --git a/Bitkit/Managers/ScannerManager.swift b/Bitkit/Managers/ScannerManager.swift index 9599ddd60..17dd716ae 100644 --- a/Bitkit/Managers/ScannerManager.swift +++ b/Bitkit/Managers/ScannerManager.swift @@ -19,6 +19,7 @@ class ScannerManager: ObservableObject { private var pubkyProfile: PubkyProfileManager? private var sheets: SheetViewModel? private var wallet: WalletViewModel? + private weak var hwWalletManager: HwWalletManager? func configure( app: AppViewModel, @@ -28,7 +29,8 @@ class ScannerManager: ObservableObject { navigation: NavigationViewModel? = nil, pubkyProfile: PubkyProfileManager? = nil, sheets: SheetViewModel? = nil, - wallet: WalletViewModel? = nil + wallet: WalletViewModel? = nil, + hwWalletManager: HwWalletManager? = nil ) { self.app = app self.contactsManager = contactsManager @@ -38,6 +40,7 @@ class ScannerManager: ObservableObject { self.pubkyProfile = pubkyProfile self.sheets = sheets self.wallet = wallet + self.hwWalletManager = hwWalletManager } func handleScan(_ uri: String, context: ScannerContext) async { @@ -76,7 +79,10 @@ class ScannerManager: ObservableObject { return } - try await app.handleScannedData(uri) + try await app.handleScannedData( + uri, + alternativeOnchainBalanceSats: hwWalletManager?.maximumFundingBalanceSats ?? 0 + ) guard shouldOpenPaymentFlow(for: uri) else { return } if let currency, let settings, let sheets { @@ -123,7 +129,11 @@ class ScannerManager: ObservableObject { return true } - func handleSendScan(_ uri: String, completion: @escaping (SendRoute?) -> Void) async { + func handleSendScan( + _ uri: String, + scope: ScanHandlingScope = .unrestricted, + completion: @escaping (SendRoute?) -> Void + ) async { guard let app, let currency, let settings else { completion(nil) return @@ -147,7 +157,11 @@ class ScannerManager: ObservableObject { return } - try await app.handleScannedData(uri) + try await app.handleScannedData( + uri, + scope: scope, + alternativeOnchainBalanceSats: hwWalletManager?.maximumFundingBalanceSats ?? 0 + ) guard shouldOpenPaymentFlow(for: uri) else { completion(nil) return @@ -220,7 +234,12 @@ class ScannerManager: ObservableObject { await handleScan(uri.trimmingCharacters(in: .whitespacesAndNewlines), context: context) } - func handleImageSelection(_ item: PhotosPickerItem?, context: ScannerContext, completion: @escaping (SendRoute?) -> Void = { _ in }) async { + func handleImageSelection( + _ item: PhotosPickerItem?, + context: ScannerContext, + scope: ScanHandlingScope = .unrestricted, + completion: @escaping (SendRoute?) -> Void = { _ in } + ) async { guard let app, let item else { return } do { @@ -287,7 +306,7 @@ class ScannerManager: ObservableObject { DispatchQueue.main.async { if context == .send { Task { - await self?.handleSendScan(payload, completion: completion) + await self?.handleSendScan(payload, scope: scope, completion: completion) } } else { Task { diff --git a/Bitkit/Managers/TrezorManager.swift b/Bitkit/Managers/TrezorManager.swift index fc3ddde97..93c4ce531 100644 --- a/Bitkit/Managers/TrezorManager.swift +++ b/Bitkit/Managers/TrezorManager.swift @@ -114,6 +114,8 @@ final class TrezorManager { private let uiHandler = TrezorUiHandler.shared private var cancellables = Set() private var hasSetupSubscriptions = false + private var isConnectionOperationActive = false + private var connectionOperationWaiters: [CheckedContinuation] = [] // MARK: - Initialization @@ -156,6 +158,35 @@ final class TrezorManager { // is no reactive passphrase prompt to subscribe to here. } + private func withConnectionOperation( + _ operation: @escaping @MainActor () async throws -> T + ) async throws -> T { + await acquireConnectionOperation() + defer { releaseConnectionOperation() } + try Task.checkCancellation() + return try await operation() + } + + private func acquireConnectionOperation() async { + guard isConnectionOperationActive else { + isConnectionOperationActive = true + return + } + + await withCheckedContinuation { continuation in + connectionOperationWaiters.append(continuation) + } + } + + private func releaseConnectionOperation() { + guard !connectionOperationWaiters.isEmpty else { + isConnectionOperationActive = false + return + } + + connectionOperationWaiters.removeFirst().resume() + } + // MARK: - Debug Log Helper private func trezorLog(_ message: String, level: String = "info") { @@ -175,7 +206,7 @@ final class TrezorManager { deviceFingerprint = nil } - func clearDisconnectedDeviceState(errorMessage: String? = nil) { + func clearDisconnectedDeviceState(errorMessage: String? = nil, preserveWalletMode: Bool = false) { connectedDevice = nil connectedWalletId = nil deviceFeatures = nil @@ -186,8 +217,10 @@ final class TrezorManager { showConfirmOnDevice = false showPairingCode = false showWalletModeChooser = false - uiHandler.setWalletMode(.standard) - walletMode = .standard + if !preserveWalletMode { + uiHandler.setWalletMode(.standard) + walletMode = .standard + } } // MARK: - Manager Setup @@ -224,11 +257,28 @@ final class TrezorManager { // MARK: - Device Scanning func startScan(clearExisting: Bool = true) async { + do { + try await withConnectionOperation { + try await self.performScan(clearExisting: clearExisting) + } + } catch is CancellationError { + // The caller no longer needs scan results. + } catch { + self.error = errorMessage(from: error) + trezorLog("Scan failed: \(error)", level: "error") + } + } + + private func performScan(clearExisting: Bool) async throws { if !isInitialized { await initialize() } isScanning = true + defer { + transport.stopBLEScanning() + isScanning = false + } error = nil if clearExisting { @@ -238,11 +288,7 @@ final class TrezorManager { if !transport.isBridgeEnabled { transport.startBLEScanning() - // Wait for BLE to discover devices (like Android's 3-second scan) before - // calling the FFI enumerate, then stop scanning to prevent race conditions. - try? await Task.sleep(nanoseconds: 3_000_000_000) - - transport.stopBLEScanning() + try await Task.sleep(nanoseconds: 3_000_000_000) } do { @@ -261,11 +307,10 @@ final class TrezorManager { devices = uniqueDevices trezorLog("Found \(uniqueDevices.count) Trezor devices (filtered from \(foundDevices.count))") } catch { + if error is CancellationError { throw error } self.error = errorMessage(from: error) trezorLog("Scan failed: \(error)", level: "error") } - - isScanning = false } func stopScan() { @@ -280,6 +325,19 @@ final class TrezorManager { /// reopened. Every other caller passes `.standard` explicitly, because a passphrase selection /// left over from a previously connected device must never silently apply to a newly picked one. func connect(device: TrezorDeviceInfo, mode: TrezorWalletMode? = .standard) async { + do { + try await withConnectionOperation { + try await self.connectThrowing(device: device, mode: mode) + } + } catch { + let errorMsg = errorMessage(from: error) + self.error = errorMsg + showConfirmOnDevice = false + trezorLog("Connection failed: \(error)", level: "error") + } + } + + private func connectThrowing(device: TrezorDeviceInfo, mode: TrezorWalletMode?) async throws { error = nil suppressNextAutoReconnect = false showPairingCode = false @@ -291,33 +349,55 @@ final class TrezorManager { trezorLog("=== Connecting to device: \(device.path) ===") + let features = try await connectWithSessionRetry(device: device) + + if Task.isCancelled { + try? await trezorService.disconnect() + trezorLog("Connect cancelled before pairing; disconnected \(device.path)") + throw CancellationError() + } + + connectedDevice = device + deviceFeatures = features + showConfirmOnDevice = false + // Unresolved until this session's accounts are read: reporting the previous session's + // identity would mark the wrong wallet as the one that can sign. + connectedWalletId = nil + + let savedComplete = await saveCurrentDeviceAsKnown() + if savedComplete { + trezorLog("Connected to Trezor: \(device.path)") + } else { + trezorLog("Connected to Trezor: \(device.path) with incomplete account-key capture", level: "warn") + } + } + + private func connectWithSessionRetry(device: TrezorDeviceInfo) async throws -> TrezorFeatures { + let selection = uiHandler.currentSelection() do { - let features = try await trezorService.connect(deviceId: device.path, selection: uiHandler.currentSelection()) + return try await trezorService.connect(deviceId: device.path, selection: selection) + } catch { + guard error.isTrezorSessionFailure() else { throw error } - if Task.isCancelled { - try? await trezorService.disconnect() - trezorLog("Connect cancelled before pairing; disconnected \(device.path)") - return - } + trezorLog("Resetting stale session before reconnecting to \(device.path)", level: "warn") + await resetStaleSession( + deviceId: device.id, + transportPath: device.path, + preserveWalletMode: true + ) - connectedDevice = device - deviceFeatures = features - showConfirmOnDevice = false - // Unresolved until this session's accounts are read: reporting the previous session's - // identity would mark the wrong wallet as the one that can sign. - connectedWalletId = nil - - let savedComplete = await saveCurrentDeviceAsKnown() - if savedComplete { - trezorLog("Connected to Trezor: \(device.path)") - } else { - trezorLog("Connected to Trezor: \(device.path) with incomplete account-key capture", level: "warn") + do { + return try await trezorService.connect(deviceId: device.path, selection: selection) + } catch { + if error.isTrezorSessionFailure() { + await resetStaleSession( + deviceId: device.id, + transportPath: device.path, + preserveWalletMode: false + ) + } + throw error } - } catch { - let errorMsg = errorMessage(from: error) - self.error = errorMsg - showConfirmOnDevice = false - trezorLog("Connection failed: \(error)", level: "error") } } @@ -425,6 +505,16 @@ final class TrezorManager { deviceId: String, mode: TrezorWalletMode, passphrase: String = "" + ) async throws -> TrezorFeatures { + try await withConnectionOperation { + try await self.openWalletSession(deviceId: deviceId, mode: mode, passphrase: passphrase) + } + } + + private func openWalletSession( + deviceId: String, + mode: TrezorWalletMode, + passphrase: String ) async throws -> TrezorFeatures { isOpeningSession = true defer { isOpeningSession = false } @@ -460,14 +550,11 @@ final class TrezorManager { // Any other device has no such handle, so it takes the known-device path with its scan and // bluetooth fallback. if hadSession, let reopening = connectedDevice, reopening.id == deviceId { - await connect(device: reopening, mode: nil) + try await connectThrowing(device: reopening, mode: nil) } else { try await reconnectKnownDevice(deviceId: deviceId, mode: nil) } - // `connect(device:)` reports failure on `error` and leaves the previous session's device and - // features in place, so identity alone would accept a failed reopen and let the caller read - // the wallet the old session had opened. The live session is the only proof. let isLive = await trezorService.isConnected() guard connectedDevice?.id == deviceId, isLive, let features = deviceFeatures else { let message = error ?? "Failed to open wallet on '\(deviceId)'" @@ -770,6 +857,19 @@ final class TrezorManager { // MARK: - Auto-Reconnect func autoReconnect() async { + do { + try await withConnectionOperation { + try await self.performAutoReconnect() + } + } catch is CancellationError { + // Foreground reconnect is best effort. + } catch { + self.error = errorMessage(from: error) + trezorLog("Auto-reconnect failed: \(error)", level: "error") + } + } + + private func performAutoReconnect() async throws { guard !knownDevices.isEmpty else { return } guard !isAutoReconnecting else { return } // A deliberate wallet-mode open is mid-flight; reconnecting now would race it and open the @@ -786,23 +886,23 @@ final class TrezorManager { } isAutoReconnecting = true + defer { + isAutoReconnecting = false + autoReconnectStatus = nil + } autoReconnectStatus = "Scanning for known devices..." trezorLog("Auto-reconnect: starting scan") - await startScan(clearExisting: true) + try await performScan(clearExisting: true) let knownIds = Set(knownDevices.map(\.id)) if let match = devices.first(where: { knownIds.contains($0.id) }) { autoReconnectStatus = "Connecting to \(match.label ?? match.name ?? "Trezor")..." trezorLog("Auto-reconnect: found known device \(match.path)") - await connect(device: match) + try await connectThrowing(device: match, mode: .standard) } else { - autoReconnectStatus = nil trezorLog("Auto-reconnect: no known devices found nearby") } - - isAutoReconnecting = false - autoReconnectStatus = nil } // MARK: - Reconnect for on-device signing @@ -811,17 +911,19 @@ final class TrezorManager { /// connection when it already matches the requested device; otherwise reconnects it. Throws on /// failure so the transfer flow can surface a reconnect error. func ensureConnected(deviceId: String) async throws { - if connectedDevice?.id == deviceId, await trezorService.isConnected() { - let features = try await refreshedFeaturesIfLocked() - try requireUnlocked(features: features) - return - } - // A stale or mismatched session blocks a clean reconnect — clear it first. - if connectedDevice != nil { - await disconnectStaleSession(deviceId: connectedDevice?.id ?? deviceId) + try await withConnectionOperation { + if self.connectedDevice?.id == deviceId, await self.trezorService.isConnected() { + let features = try await self.refreshedFeaturesIfLocked() + try self.requireUnlocked(features: features) + return + } + // A stale or mismatched session blocks a clean reconnect — clear it first. + if self.connectedDevice != nil { + await self.disconnectStaleSession(deviceId: self.connectedDevice?.id ?? deviceId) + } + try await self.reconnectKnownDevice(deviceId: deviceId) + try self.requireUnlocked(features: self.deviceFeatures) } - try await reconnectKnownDevice(deviceId: deviceId) - try requireUnlocked(features: deviceFeatures) } private func refreshedFeaturesIfLocked() async throws -> TrezorFeatures { @@ -846,7 +948,8 @@ final class TrezorManager { } private func reconnectKnownDevice(deviceId: String, mode: TrezorWalletMode? = .standard) async throws { - await startScan(clearExisting: true) + try await performScan(clearExisting: true) + try Task.checkCancellation() let target: TrezorDeviceInfo if let scanned = devices.first(where: { $0.id == deviceId }) { @@ -859,7 +962,7 @@ final class TrezorManager { throw AppError(message: "Reconnect Hardware Device", debugMessage: "Device '\(deviceId)' not found nearby") } - await connect(device: target, mode: mode) + try await connectThrowing(device: target, mode: mode) guard connectedDevice?.id == deviceId, await trezorService.isConnected() else { throw AppError(message: "Reconnect Hardware Device", debugMessage: error ?? "Failed to reconnect '\(deviceId)'") @@ -873,17 +976,17 @@ final class TrezorManager { } /// Best-effort pre-connect of a known BLE Trezor before the sign screen asks for it, so tapping - /// Open Trezor Connect is less likely to hit a cold reconnect. Fire-and-forget; no-op when already - /// connected to it, a connect is in flight, or it isn't a known BLE device. + /// Open Trezor Connect is less likely to hit a cold reconnect. Fire-and-forget; serialized with + /// other connection work and skipped when already connected or the device is not known over BLE. func warmUpConnection(deviceId: String) { - guard connectedDevice?.id != deviceId else { return } - guard !isScanning else { return } - // Would race a deliberate wallet-mode open and land on the standard wallet. - guard !isOpeningSession else { return } guard isKnownBluetoothDevice(deviceId: deviceId) else { return } Task { do { - try await reconnectKnownDevice(deviceId: deviceId) + try await withConnectionOperation { + guard self.connectedDevice?.id != deviceId else { return } + guard !self.isOpeningSession else { return } + try await self.reconnectKnownDevice(deviceId: deviceId) + } } catch { trezorLog("Warm-up connect failed for '\(deviceId)': \(error)", level: "debug") } @@ -893,14 +996,27 @@ final class TrezorManager { /// Tear down the current device session so the next connect establishes a fresh one. Used after /// a signing failure or timeout, where the transport session may be left in a bad state. func disconnectStaleSession(deviceId: String) async { + await resetStaleSession(deviceId: deviceId, preserveWalletMode: false) + } + + private func resetStaleSession( + deviceId: String, + transportPath: String? = nil, + preserveWalletMode: Bool + ) async { await Task.detached { @MainActor [weak self] in guard let self else { return } + let path = transportPath ?? knownDevices.first(where: { $0.id == deviceId })?.path ?? deviceId do { try await trezorService.disconnect() } catch { trezorLog("Failed to disconnect stale session for '\(deviceId)': \(error)", level: "warn") } - clearDisconnectedDeviceState() + let closeResult = transport.closeDevice(path: path) + if !closeResult.success { + trezorLog("Failed to close stale transport for '\(deviceId)': \(closeResult.error)", level: "warn") + } + clearDisconnectedDeviceState(preserveWalletMode: preserveWalletMode) }.value } diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 271d3b42d..8444bbee5 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -67,7 +67,7 @@ "hardware__passphrase_text" = "If you have funds protected by a passphrase, enter it below to add these funds to your wallet balance as well."; "hardware__passphrase_paired_header" = "Passphrase funds found"; "hardware__passphrase_paired_text" = "Bitkit found funds behind a passphrase, and added these to your wallet balance."; -"hardware__passphrase_sign_text" = "Enter the passphrase of this wallet so your hardware device can sign the transfer."; +"hardware__passphrase_sign_text" = "Enter the passphrase of this wallet so your hardware device can sign the transaction."; "hardware__passphrase_disabled" = "Passphrase protection is turned off on this hardware device. Enable it in Trezor Suite, then try again."; "hardware__passphrase_duplicate" = "You are already watching this passphrase wallet."; "hardware__passphrase_error" = "Could not open the passphrase wallet. Make sure your hardware device is unlocked and try again."; @@ -80,6 +80,9 @@ "hardware__remove_dialog_keep" = "Back up name and tags"; "hardware__remove_error" = "Could not remove the hardware wallet. Please try again."; "hardware__remove_keep_error" = "Could not keep this wallet's tags in your backup. Try again, or remove it without keeping them."; +"hardware__send_confirm_address" = "To address (confirm on device)"; +"hardware__send_open_connect" = "Open Trezor Connect"; +"hardware__send_sign_title" = "Sign With Device"; "cards__buyBitcoin__title" = "Buy"; "cards__buyBitcoin__description" = "Buy some bitcoin"; "cards__btFailed__title" = "Failed"; diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index cd0b10bc8..28f3bc764 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -411,11 +411,12 @@ class ActivityService { // `markOnchainActivityAsTransfer` could interleave with it. Anything needing `await` runs // after this returns. let removedActivities = try await ServiceQueue.background(.core) { - try Self.applyHwSnapshot( + return try Self.applyHwSnapshot( walletId: walletId, activities: activities, transactionDetails: transactionDetails, pruneMissing: pruneMissing, + currentTimestamp: UInt64(Date().timeIntervalSince1970), transferChannelIdsByFundingTxId: transferChannelIdsByFundingTxId ) } @@ -445,12 +446,14 @@ class ActivityService { activities: [Activity], transactionDetails: [BitkitCore.TransactionDetails], pruneMissing: Bool, + currentTimestamp: UInt64, transferChannelIdsByFundingTxId: [String: String] ) throws -> Bool { let plan = try HwSnapshotMerge.plan( existing: storedOnchainActivities(walletId: walletId), incoming: activities, pruneMissing: pruneMissing, + currentTimestamp: currentTimestamp, transferChannelIdsByFundingTxId: transferChannelIdsByFundingTxId ) @@ -1239,14 +1242,18 @@ class ActivityService { contact: String? = nil, walletId: String = WalletScope.default ) async { + let normalizedContact = contact.map { PubkyPublicKeyFormat.normalized($0) ?? $0 } do { try await ServiceQueue.background(.core) { if let existing = try? BitkitCore.getActivityByTxId(walletId: walletId, txId: txid) { - // The watcher can persist a hardware transaction before this call lands, so the - // transfer flag still has to be applied to the row it already created. - if isTransfer, !existing.isTransfer { - var updated = existing + var updated = existing + if isTransfer { updated.isTransfer = true + } + if let normalizedContact { + updated.contact = normalizedContact + } + if updated != existing { try updateActivity(activityId: existing.id, activity: .onchain(updated)) self.activitiesChangedSubject.send() } @@ -1272,7 +1279,7 @@ class ActivityService { confirmTimestamp: nil, channelId: nil, transferTxId: nil, - contact: contact.map { PubkyPublicKeyFormat.normalized($0) ?? $0 }, + contact: normalizedContact, createdAt: now, updatedAt: now, seenAt: now diff --git a/Bitkit/Services/HwSnapshotMerge.swift b/Bitkit/Services/HwSnapshotMerge.swift index d07375d7d..dadc3ff9a 100644 --- a/Bitkit/Services/HwSnapshotMerge.swift +++ b/Bitkit/Services/HwSnapshotMerge.swift @@ -21,8 +21,10 @@ enum ActivityScope { /// Plans how a hardware-wallet watcher snapshot should replace what bitkit-core already stores for /// that wallet. Kept pure and free of the core FFI so the reconciliation rules can be unit tested; -/// `ActivityService.replaceHwSnapshot` applies the plan. Adapts bitkit-android's `mergeHwSnapshot`. +/// `ActivityService.replaceHwSnapshot` applies the plan. enum HwSnapshotMerge { + private static let pendingSendGracePeriod: UInt64 = 24 * 60 * 60 + struct Plan { let toDelete: [OnchainActivity] let toUpsert: [Activity] @@ -39,15 +41,19 @@ enum HwSnapshotMerge { existing: [OnchainActivity], incoming: [Activity], pruneMissing: Bool, + currentTimestamp: UInt64, transferChannelIdsByFundingTxId: [String: String] = [:] ) -> Plan { let incomingIds = Set(incoming.map(ActivityScope.id(of:))) - // Transfers are never dropped: the pending Transfer To Spending row is written when the - // funding tx is broadcast, before any watcher poll can report it, so a snapshot that does - // not mention it yet must not delete it. + // A broadcast is persisted before the watcher may report it. Keep recent pending sends + // through that eventual-consistency window; `createdAt` survives process restarts. let toDelete = pruneMissing - ? existing.filter { !$0.isTransfer && !incomingIds.contains($0.id) } + ? existing.filter { + !$0.isTransfer && + !isRecentPendingSend($0, currentTimestamp: currentTimestamp) && + !incomingIds.contains($0.id) + } : [] let storedByTxId = Dictionary(existing.map { ($0.txId, $0) }, uniquingKeysWith: { first, _ in first }) @@ -55,11 +61,12 @@ enum HwSnapshotMerge { guard case var .onchain(onchain) = activity else { return activity } if let stored = storedByTxId[onchain.txId] { - // The watcher only knows what is on chain, so transfer metadata the app wrote locally - // would otherwise be erased on every snapshot. + // The watcher only knows what is on chain, so app-owned metadata would otherwise be + // erased on every snapshot. onchain.isTransfer = onchain.isTransfer || stored.isTransfer onchain.channelId = onchain.channelId ?? stored.channelId onchain.transferTxId = onchain.transferTxId ?? stored.transferTxId + onchain.contact = onchain.contact ?? stored.contact } // Re-pairing a wallet that was removed leaves nothing to carry forward — removal deleted @@ -77,4 +84,18 @@ enum HwSnapshotMerge { return Plan(toDelete: toDelete, toUpsert: toUpsert) } + + private static func isRecentPendingSend( + _ activity: OnchainActivity, + currentTimestamp: UInt64 + ) -> Bool { + guard activity.txType == .sent, + !activity.confirmed, + activity.doesExist, + let createdAt = activity.createdAt + else { return false } + + let age = currentTimestamp >= createdAt ? currentTimestamp - createdAt : 0 + return age <= pendingSendGracePeriod + } } diff --git a/Bitkit/Utilities/PaymentNavigationHelper.swift b/Bitkit/Utilities/PaymentNavigationHelper.swift index d617962f5..60e038638 100644 --- a/Bitkit/Utilities/PaymentNavigationHelper.swift +++ b/Bitkit/Utilities/PaymentNavigationHelper.swift @@ -213,6 +213,13 @@ struct PaymentNavigationHelper { return invoice.amountSatoshis == 0 ? .amount : .confirm } return route + case .amount: + if app.contactPaymentContext?.incomingPaymentRequest != nil, + let invoice = app.scannedOnchainInvoice + { + return invoice.amountSatoshis == 0 ? .amount : .confirm + } + return route default: return route } diff --git a/Bitkit/Utilities/ShopPaymentRequest.swift b/Bitkit/Utilities/ShopPaymentRequest.swift index 37411fbd2..5c014bdce 100644 --- a/Bitkit/Utilities/ShopPaymentRequest.swift +++ b/Bitkit/Utilities/ShopPaymentRequest.swift @@ -4,6 +4,7 @@ import Foundation enum ScanHandlingScope { case unrestricted case paymentRequests + case onchainPayments } enum ShopPaymentRequest { @@ -15,9 +16,14 @@ enum ShopPaymentRequest { return false } } + + static func isOnchainPayment(_ data: BitkitCore.Scanner) -> Bool { + if case .onChain = data { return true } + return false + } } -enum ShopPaymentRequestError: LocalizedError { +enum ScanHandlingError: LocalizedError { case unsupportedRequest var errorDescription: String? { diff --git a/Bitkit/ViewModels/ActivityListViewModel.swift b/Bitkit/ViewModels/ActivityListViewModel.swift index be5cec21d..1c3be7263 100644 --- a/Bitkit/ViewModels/ActivityListViewModel.swift +++ b/Bitkit/ViewModels/ActivityListViewModel.swift @@ -250,12 +250,15 @@ class ActivityListViewModel: ObservableObject { } /// Find activity by payment hash or transaction ID - func findActivity(byPaymentId paymentId: String) async throws -> Activity { + func findActivity( + byPaymentId paymentId: String, + walletId: String = WalletScope.default + ) async throws -> Activity { guard !paymentId.isEmpty else { throw AppError(message: "Payment ID is empty", debugMessage: nil) } - let activities = try await coreService.activity.get(filter: .all, limit: 50) + let activities = try await coreService.activity.get(filter: .all, limit: 50, walletId: walletId) let activity = activities.first { activity in switch activity { case let .lightning(ln): diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index f92abf766..6e53d2a8f 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -420,7 +420,8 @@ extension AppViewModel { func handleScannedData( _ uri: String, claimedContactPaymentContext: ContactPaymentContext? = nil, - scope: ScanHandlingScope = .unrestricted + scope: ScanHandlingScope = .unrestricted, + alternativeOnchainBalanceSats: UInt64 = 0 ) async throws { let handlingId = claimedContactPaymentContext?.id ?? UUID() if let claimedContactPaymentContext { @@ -441,7 +442,7 @@ extension AppViewModel { guard SamRockSetupRequest.parse(uri) == nil, !SamRockSetupRequest.isProtocolURL(uri) else { - throw ShopPaymentRequestError.unsupportedRequest + throw ScanHandlingError.unsupportedRequest } if Bip21Utils.isDuplicatedBip21(uri) { toast( @@ -454,7 +455,7 @@ extension AppViewModel { } let data = try await decode(invoice: uri) try ensureScannedDataHandlingOwnership(handlingId, claimedContactPaymentContext: claimedContactPaymentContext) - guard ShopPaymentRequest.isSupported(data) else { throw ShopPaymentRequestError.unsupportedRequest } + guard ShopPaymentRequest.isSupported(data) else { throw ScanHandlingError.unsupportedRequest } prevalidatedPaymentRequest = data } else { prevalidatedPaymentRequest = nil @@ -492,6 +493,10 @@ extension AppViewModel { try ensureScannedDataHandlingOwnership(handlingId, claimedContactPaymentContext: claimedContactPaymentContext) } + if scope == .onchainPayments { + guard ShopPaymentRequest.isOnchainPayment(data) else { throw ScanHandlingError.unsupportedRequest } + } + switch data { // BIP21 (Unified) invoice handling case let .onChain(invoice): @@ -508,7 +513,7 @@ extension AppViewModel { return } - if let lnInvoice = invoice.params?["lightning"] { + if scope != .onchainPayments, let lnInvoice = invoice.params?["lightning"] { // Lightning invoice param found, prefer lightning payment if invoice is valid let lightningData = try await decode(invoice: lnInvoice) try ensureScannedDataHandlingOwnership(handlingId, claimedContactPaymentContext: claimedContactPaymentContext) @@ -542,7 +547,10 @@ extension AppViewModel { // Lightning insufficient for any other reason (no channels at all, or // usable channels without capacity). // Fall back to onchain and validate onchain balance immediately. - let onchainBalance = lightningService.balances?.spendableOnchainBalanceSats ?? 0 + let onchainBalance = max( + lightningService.balances?.spendableOnchainBalanceSats ?? 0, + alternativeOnchainBalanceSats + ) guard validateOnchainBalance(invoiceAmount: invoice.amountSatoshis, onchainBalance: onchainBalance) else { return } @@ -566,7 +574,10 @@ extension AppViewModel { // If node is running, validate balance immediately if lightningService.status?.isRunning == true { - let onchainBalance = lightningService.balances?.spendableOnchainBalanceSats ?? 0 + let onchainBalance = max( + lightningService.balances?.spendableOnchainBalanceSats ?? 0, + alternativeOnchainBalanceSats + ) guard validateOnchainBalance(invoiceAmount: invoice.amountSatoshis, onchainBalance: onchainBalance) else { return } diff --git a/Bitkit/ViewModels/HwFundingSigner.swift b/Bitkit/ViewModels/HwFundingSigner.swift index 27a1a2938..878147d48 100644 --- a/Bitkit/ViewModels/HwFundingSigner.swift +++ b/Bitkit/ViewModels/HwFundingSigner.swift @@ -1,13 +1,11 @@ import BitkitCore +import Observation -/// Orchestrates funding a Lightning channel from a hardware wallet: reconnect the device, compose -/// the exact on-chain payment, sign it on-device, and broadcast. Owns the per-phase timeouts and the -/// fee-reserve math. +/// Orchestrates an on-chain payment from a hardware wallet: reconnect the device, compose the exact +/// payment, sign it on-device, and broadcast. Owns the per-phase timeouts and fee-reserve math. /// /// Pure orchestration over the injected `HwTransferFunding` / `HwTransferConnecting` capabilities — -/// it holds no UI state and doesn't touch `TransferViewModel`, so the device-signing flow can be -/// tested in isolation. `TransferViewModel` keeps the coordination that genuinely reuses the transfer -/// machinery (spending limits, order watching, published state). +/// it holds no UI state, so device signing can be tested independently of its callers. @MainActor struct HwFundingSigner { /// Device balance and the amount available to fund after holding back an on-chain fee reserve. @@ -74,9 +72,26 @@ struct HwFundingSigner { address: String, onComposed: (HwFundingTransaction) -> Void = { _ in } ) async throws -> HwFundingSignedTx { - try await ensureConnected(walletId: walletId) let satsPerVByte = await resolvedSatsPerVByte() - let tx = try await compose(walletId: walletId, address: address, sats: order.feeSat, satsPerVByte: satsPerVByte) + return try await prepareSignedPayment( + walletId: walletId, + address: address, + sats: order.feeSat, + satsPerVByte: satsPerVByte, + onComposed: onComposed + ) + } + + /// Reconnects, composes and signs a normal on-chain payment without broadcasting it. + func prepareSignedPayment( + walletId: String, + address: String, + sats: UInt64, + satsPerVByte: UInt64, + onComposed: (HwFundingTransaction) -> Void = { _ in } + ) async throws -> HwFundingSignedTx { + try await ensureConnected(walletId: walletId) + let tx = try await compose(walletId: walletId, address: address, sats: sats, satsPerVByte: satsPerVByte) onComposed(tx) return try await signStep(walletId: walletId, funding: tx) } @@ -98,7 +113,7 @@ struct HwFundingSigner { connecting.warmUpConnection(walletId: walletId) } - /// Offline compose for the exact order amount; does not require a connected device. + /// Offline compose for the exact payment amount; does not require a connected device. func estimateOfflineFundingMiningFee(walletId: String, address: String, sats: UInt64) async throws -> UInt64 { let satsPerVByte = await resolvedSatsPerVByte() return try await funding.estimateOfflineFundingMiningFee( @@ -117,6 +132,9 @@ struct HwFundingSigner { } } catch is CancellationError { throw CancellationError() + } catch is Timeout { + disconnectAfterTimeout(walletId: walletId) + throw HwTransferError.reconnect(isBluetooth: connecting.isKnownBluetoothDevice(walletId: walletId)) } catch { if error.isTrezorUserCancellation() { throw error } // Swift has no cause chain, so this must be rethrown explicitly: the catch-all below @@ -147,7 +165,7 @@ struct HwFundingSigner { } catch is CancellationError { throw CancellationError() } catch is Timeout { - await connecting.disconnectStaleSession(walletId: walletId) + disconnectAfterTimeout(walletId: walletId) throw HwTransferError.signingTimeout } catch { let message = (error as? AppError)?.debugMessage ?? (error as? AppError)?.message ?? error.localizedDescription @@ -157,16 +175,36 @@ struct HwFundingSigner { private func signStep(walletId: String, funding tx: HwFundingTransaction) async throws -> HwFundingSignedTx { do { - return try await withTimeout(timeouts.sign) { - try await funding.signFunding(walletId: walletId, funding: tx) - } + return try await signOnce(walletId: walletId, funding: tx) } catch is CancellationError { throw CancellationError() } catch is Timeout { - await connecting.disconnectStaleSession(walletId: walletId) + disconnectAfterTimeout(walletId: walletId) throw HwTransferError.signingTimeout + } catch { + guard error.isTrezorSessionFailure() else { throw error } + + await connecting.disconnectStaleSession(walletId: walletId) + try await ensureConnected(walletId: walletId) + + do { + return try await signOnce(walletId: walletId, funding: tx) + } catch is Timeout { + disconnectAfterTimeout(walletId: walletId) + throw HwTransferError.signingTimeout + } catch { + if error.isTrezorSessionFailure() { + await connecting.disconnectStaleSession(walletId: walletId) + } + throw error + } + } + } + + private func signOnce(walletId: String, funding tx: HwFundingTransaction) async throws -> HwFundingSignedTx { + try await withTimeout(timeouts.sign) { + try await funding.signFunding(walletId: walletId, funding: tx) } - // Any other (real signing) error propagates to the caller's generic handler. } /// Broadcast the signed tx under its own timeout, separate from signing. A broadcast that has @@ -190,6 +228,12 @@ struct HwFundingSigner { await feeRateProvider() ?? fallbackSatsPerVByte } + /// Start recovery without making the timed-out UI operation wait on a transport that may also + /// be stuck. The next connect remains serialized behind this cleanup by the device manager. + private func disconnectAfterTimeout(walletId: String) { + connecting.scheduleStaleSessionCleanup(walletId: walletId) + } + /// Pure fee-reserve computation. With a known fee rate: `rate × vbytes`. Without one (estimates /// unavailable): `max(minReserve, balance × fallbackPercent)`. static func feeReserve( @@ -209,21 +253,295 @@ struct HwFundingSigner { private struct Timeout: Error {} - /// Race an async operation against a timeout. Cancellation (user dismiss) propagates as - /// `CancellationError`; the deadline throws `Timeout`. + /// Race an async operation against a timeout without structurally waiting for the losing task. + /// This matters for blocking service calls that cannot observe Swift task cancellation. private func withTimeout( _ seconds: Double, _ operation: @escaping @Sendable () async throws -> T ) async throws -> T { - try await withThrowingTaskGroup(of: T.self) { group in - group.addTask { try await operation() } - group.addTask { + let (stream, continuation) = AsyncStream>.makeStream( + bufferingPolicy: .bufferingOldest(1) + ) + let operationTask = Task { + do { + try await continuation.yield(.success(operation())) + } catch { + continuation.yield(.failure(error)) + } + } + let timeoutTask = Task { + do { try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) - throw Timeout() + continuation.yield(.failure(Timeout())) + } catch is CancellationError { + // The operation completed or its caller was cancelled. + } catch { + continuation.yield(.failure(error)) + } + } + + return try await withTaskCancellationHandler { + defer { + operationTask.cancel() + timeoutTask.cancel() + continuation.finish() + } + for await result in stream { + try Task.checkCancellation() + return try result.get() } - defer { group.cancelAll() } - guard let result = try await group.next() else { throw Timeout() } - return result + throw CancellationError() + } onCancel: { + operationTask.cancel() + timeoutTask.cancel() + continuation.finish() } } } + +/// Send-sheet state for a normal on-chain payment funded and signed by a paired hardware wallet. +@Observable +@MainActor +final class HwSendCoordinator { + private(set) var walletId: String? + + private(set) var availableSats: UInt64 = 0 + private(set) var previewFeeSats: UInt64 = 0 + private(set) var isSigning = false + private(set) var isBroadcastUnresolved = false + private(set) var isPassphraseRequired = false + private(set) var isVerifyingPassphrase = false + + private var pendingPayment: PendingPayment? + private var operationTask: Task? + private var operationRequest: PaymentRequest? + private var availabilityRequestId = 0 + private var previewRequestId = 0 + private let signerFactory: @MainActor (HwWalletManager, String, UInt64) -> HwFundingSigner + + var isActive: Bool { + walletId != nil + } + + var hasPendingBroadcast: Bool { + pendingPayment != nil + } + + init( + walletId: String? = nil, + signerFactory: @escaping @MainActor (HwWalletManager, String, UInt64) -> HwFundingSigner = { manager, address, satsPerVByte in + HwSendCoordinator.signer( + manager: manager, + address: address, + satsPerVByte: satsPerVByte + ) + } + ) { + self.walletId = walletId + self.signerFactory = signerFactory + } + + func seedAvailable(walletId: String, availableSats: UInt64) { + guard self.walletId == walletId else { return } + self.availableSats = availableSats + } + + func selectWallet(_ walletId: String?, initialAvailableSats: UInt64 = 0) { + guard self.walletId != walletId else { return } + guard operationTask == nil, !isBroadcastUnresolved else { return } + + availabilityRequestId += 1 + previewRequestId += 1 + self.walletId = walletId + pendingPayment = nil + availableSats = walletId == nil ? 0 : initialAvailableSats + previewFeeSats = 0 + isSigning = false + isBroadcastUnresolved = false + isPassphraseRequired = false + isVerifyingPassphrase = false + } + + func refreshAvailable( + manager: HwWalletManager, + destinationAddress: String, + satsPerVByte: UInt64 + ) async { + guard let walletId else { return } + + guard !destinationAddress.isEmpty else { + if self.walletId == walletId { + availableSats = manager.fundingBalance(walletId: walletId) + } + return + } + + availabilityRequestId += 1 + let requestId = availabilityRequestId + + func apply(_ available: UInt64) { + guard self.walletId == walletId, availabilityRequestId == requestId else { return } + availableSats = available + } + + do { + let signer = signerFactory(manager, destinationAddress, satsPerVByte) + let available = try await signer.availability(walletId: walletId).available + apply(available) + } catch { + let balance = manager.fundingBalance(walletId: walletId) + let reserve = HwFundingSigner.feeReserve( + balanceSats: balance, + satsPerVByte: satsPerVByte + ) + apply(balance > reserve ? balance - reserve : 0) + } + } + + func preparePreview( + manager: HwWalletManager, + address: String, + sats: UInt64, + satsPerVByte: UInt64 + ) async throws -> UInt64? { + guard let walletId else { return nil } + previewRequestId += 1 + let requestId = previewRequestId + let request = PaymentRequest(address: address, sats: sats, satsPerVByte: satsPerVByte) + if pendingPayment?.request != request { + pendingPayment = nil + } + let fee = try await manager.estimateOfflineFundingMiningFee( + walletId: walletId, + address: address, + sats: sats, + satsPerVByte: satsPerVByte + ) + guard self.walletId == walletId, previewRequestId == requestId else { return nil } + previewFeeSats = fee + return fee + } + + func signAndBroadcast( + manager: HwWalletManager, + address: String, + sats: UInt64, + satsPerVByte: UInt64, + beforeBroadcast: @escaping () async throws -> Void = {} + ) async throws -> HwFundingBroadcastResult { + guard let walletId else { + throw AppError(message: "Unknown hardware wallet", debugMessage: "The send flow has no wallet id") + } + let request = PaymentRequest(address: address, sats: sats, satsPerVByte: satsPerVByte) + if let operationTask { + guard operationRequest == request else { throw HwTransferError.deviceBusy } + return try await operationTask.value + } + + let task = Task { @MainActor in + isSigning = true + defer { isSigning = false } + + let signer = signerFactory(manager, address, satsPerVByte) + let signed: HwFundingSignedTx + if let pendingPayment, pendingPayment.request == request { + signed = pendingPayment.signedTx + } else { + signed = try await signer.prepareSignedPayment( + walletId: walletId, + address: address, + sats: sats, + satsPerVByte: satsPerVByte, + onComposed: { [weak self] in self?.previewFeeSats = $0.miningFeeSats } + ) + pendingPayment = PendingPayment(request: request, signedTx: signed) + } + + if pendingPayment?.isPreparedForBroadcast != true { + try await beforeBroadcast() + pendingPayment?.isPreparedForBroadcast = true + } + + isBroadcastUnresolved = true + do { + return try await signer.broadcastSignedFunding(signed) + } catch { + let outcomeIsUncertain = (error as? HwTransferError) == .broadcastUncertain + if !outcomeIsUncertain, !error.isBroadcastConnectivityFailure() { + pendingPayment = nil + isBroadcastUnresolved = false + } + throw error + } + } + operationRequest = request + operationTask = task + defer { + operationRequest = nil + operationTask = nil + } + return try await task.value + } + + func reconnectWithPassphrase( + _ passphrase: String, + manager: HwWalletManager + ) async throws { + guard let walletId else { return } + isVerifyingPassphrase = true + defer { isVerifyingPassphrase = false } + try await manager.reconnectWithPassphrase(walletId: walletId, passphrase: passphrase) + guard isPassphraseRequired else { throw CancellationError() } + isPassphraseRequired = false + } + + func requestPassphrase() { + isPassphraseRequired = true + } + + func dismissPassphrase() { + isPassphraseRequired = false + } + + func completeBroadcast() { + pendingPayment = nil + isBroadcastUnresolved = false + } + + func cancel() { + isVerifyingPassphrase = false + isPassphraseRequired = false + guard !isBroadcastUnresolved else { return } + operationTask?.cancel() + operationTask = nil + operationRequest = nil + pendingPayment = nil + isSigning = false + } + + private static func signer( + manager: HwWalletManager, + address: String, + satsPerVByte: UInt64 + ) -> HwFundingSigner { + HwFundingSigner( + funding: manager, + connecting: manager, + feeRateProvider: { satsPerVByte }, + addressProvider: { address }, + timeouts: (reconnect: 30, compose: 45, sign: 120, broadcast: 120) + ) + } + + private struct PaymentRequest: Equatable { + let address: String + let sats: UInt64 + let satsPerVByte: UInt64 + } + + private struct PendingPayment { + let request: PaymentRequest + let signedTx: HwFundingSignedTx + var isPreparedForBroadcast = false + } +} diff --git a/Bitkit/ViewModels/SheetViewModel.swift b/Bitkit/ViewModels/SheetViewModel.swift index daf8581ec..613498bfa 100644 --- a/Bitkit/ViewModels/SheetViewModel.swift +++ b/Bitkit/ViewModels/SheetViewModel.swift @@ -41,6 +41,10 @@ class SheetViewModel: ObservableObject { @Published private(set) var isReplacingSheet = false func showSheet(_ id: SheetID, data: Any? = nil) { + if activeSheetConfiguration?.id == .send, id == .receivedTx { + Logger.debug("Skipping received-transaction sheet while send is active", context: "SheetViewModel") + return + } if isAnySheetOpen { // If any other sheet is open, close it and delay before showing the new sheet // to prevent the new sheet from closing immediately (bug) @@ -419,7 +423,7 @@ class SheetViewModel: ObservableObject { guard let config = activeSheetConfiguration, config.id == .send else { return nil } let sendConfig = config.data as? SendConfig let initialRoute = sendConfig?.initialRoute ?? .options - return SendSheetItem(initialRoute: initialRoute) + return SendSheetItem(initialRoute: initialRoute, hardwareWalletId: sendConfig?.hardwareWalletId) } set { if newValue == nil { diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 57c23588e..67f2a0693 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -101,6 +101,7 @@ protocol HwTransferFunding: Sendable { protocol HwTransferConnecting: Sendable { func ensureConnected(walletId: String) async throws func disconnectStaleSession(walletId: String) async + func scheduleStaleSessionCleanup(walletId: String) /// Whether the wallet is reachable over a known Bluetooth device, so a reconnect failure can show /// the softer BLE "check that it is unlocked and try again" toast instead of the generic error. func isKnownBluetoothDevice(walletId: String) -> Bool diff --git a/Bitkit/Views/Contacts/AddContactView.swift b/Bitkit/Views/Contacts/AddContactView.swift index c7d2272f1..07eca48ee 100644 --- a/Bitkit/Views/Contacts/AddContactView.swift +++ b/Bitkit/Views/Contacts/AddContactView.swift @@ -9,6 +9,7 @@ struct AddContactView: View { @EnvironmentObject var settings: SettingsViewModel @EnvironmentObject var sheets: SheetViewModel @EnvironmentObject var wallet: WalletViewModel + @Environment(HwWalletManager.self) private var hwWalletManager let publicKey: String @@ -292,7 +293,8 @@ struct AddContactView: View { do { try await app.handleScannedData( paymentRequest, - claimedContactPaymentContext: contactPaymentContext + claimedContactPaymentContext: contactPaymentContext, + alternativeOnchainBalanceSats: hwWalletManager.maximumFundingBalanceSats ) } catch is CancellationError { if app.ownsContactPaymentContext(contactPaymentContext) { @@ -334,6 +336,7 @@ struct AddContactView: View { .environmentObject(SettingsViewModel.shared) .environmentObject(SheetViewModel()) .environmentObject(WalletViewModel()) + .environment(HwWalletManager()) } .preferredColorScheme(.dark) } diff --git a/Bitkit/Views/Contacts/ContactDetailView.swift b/Bitkit/Views/Contacts/ContactDetailView.swift index ae84a9890..283821cb4 100644 --- a/Bitkit/Views/Contacts/ContactDetailView.swift +++ b/Bitkit/Views/Contacts/ContactDetailView.swift @@ -8,6 +8,7 @@ struct ContactDetailView: View { @EnvironmentObject var settings: SettingsViewModel @EnvironmentObject var sheets: SheetViewModel @EnvironmentObject var wallet: WalletViewModel + @Environment(HwWalletManager.self) private var hwWalletManager let publicKey: String var showsDeleteAction = false @@ -340,7 +341,8 @@ struct ContactDetailView: View { do { try await app.handleScannedData( paymentRequest, - claimedContactPaymentContext: contactPaymentContext + claimedContactPaymentContext: contactPaymentContext, + alternativeOnchainBalanceSats: hwWalletManager.maximumFundingBalanceSats ) } catch is CancellationError { if app.ownsContactPaymentContext(contactPaymentContext) { @@ -380,6 +382,7 @@ struct ContactDetailView: View { .environmentObject(SettingsViewModel.shared) .environmentObject(SheetViewModel()) .environmentObject(WalletViewModel()) + .environment(HwWalletManager()) } .preferredColorScheme(.dark) } diff --git a/Bitkit/Views/Scanner/ScannerScreen.swift b/Bitkit/Views/Scanner/ScannerScreen.swift index da353190b..1b2a4e121 100644 --- a/Bitkit/Views/Scanner/ScannerScreen.swift +++ b/Bitkit/Views/Scanner/ScannerScreen.swift @@ -10,6 +10,7 @@ struct ScannerScreen: View { @EnvironmentObject private var settings: SettingsViewModel @EnvironmentObject private var sheets: SheetViewModel @EnvironmentObject private var wallet: WalletViewModel + @Environment(HwWalletManager.self) private var hwWalletManager @State private var isManualEntryPresented = false @State private var manualEntry = "" @@ -74,7 +75,8 @@ struct ScannerScreen: View { navigation: navigation, pubkyProfile: pubkyProfile, sheets: sheets, - wallet: wallet + wallet: wallet, + hwWalletManager: hwWalletManager ) } .sheet(isPresented: $isManualEntryPresented) { diff --git a/Bitkit/Views/Scanner/ScannerSheet.swift b/Bitkit/Views/Scanner/ScannerSheet.swift index be4ac5b5c..14731bea4 100644 --- a/Bitkit/Views/Scanner/ScannerSheet.swift +++ b/Bitkit/Views/Scanner/ScannerSheet.swift @@ -15,6 +15,7 @@ struct ScannerSheet: View { @EnvironmentObject private var settings: SettingsViewModel @EnvironmentObject private var sheets: SheetViewModel @EnvironmentObject private var wallet: WalletViewModel + @Environment(HwWalletManager.self) private var hwWalletManager @State private var isManualEntryPresented = false @State private var manualEntry = "" @@ -72,7 +73,8 @@ struct ScannerSheet: View { navigation: navigation, pubkyProfile: pubkyProfile, sheets: sheets, - wallet: wallet + wallet: wallet, + hwWalletManager: hwWalletManager ) } .sheet(isPresented: $isManualEntryPresented) { diff --git a/Bitkit/Views/Shop/ShopMain.swift b/Bitkit/Views/Shop/ShopMain.swift index 5c4ac4fe3..815ed30b0 100644 --- a/Bitkit/Views/Shop/ShopMain.swift +++ b/Bitkit/Views/Shop/ShopMain.swift @@ -7,6 +7,7 @@ struct ShopMain: View { @EnvironmentObject private var navigation: NavigationViewModel @EnvironmentObject private var sheets: SheetViewModel @EnvironmentObject private var settings: SettingsViewModel + @Environment(HwWalletManager.self) private var hwWalletManager let page: String @@ -54,7 +55,11 @@ struct ShopMain: View { Task { @MainActor in do { - try await app.handleScannedData(paymentUri, scope: .paymentRequests) + try await app.handleScannedData( + paymentUri, + scope: .paymentRequests, + alternativeOnchainBalanceSats: hwWalletManager.maximumFundingBalanceSats + ) PaymentNavigationHelper.openPaymentSheet( app: app, diff --git a/Bitkit/Views/Wallets/Activity/ActivityIcon.swift b/Bitkit/Views/Wallets/Activity/ActivityIcon.swift index dcd97388b..c905a757c 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityIcon.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityIcon.swift @@ -66,8 +66,8 @@ struct ActivityIcon: View { } else if confirmed == false, txType == .sent, context == .row { CircularIcon( icon: "hourglass-simple", - iconColor: .brandAccent, - backgroundColor: .brand16, + iconColor: isHwWallet ? .blueAccent : .brandAccent, + backgroundColor: isHwWallet ? .blue16 : .brand16, size: size ) } else { diff --git a/Bitkit/Views/Wallets/HardwareWalletScreen.swift b/Bitkit/Views/Wallets/HardwareWalletScreen.swift index 987315b96..008c27480 100644 --- a/Bitkit/Views/Wallets/HardwareWalletScreen.swift +++ b/Bitkit/Views/Wallets/HardwareWalletScreen.swift @@ -3,8 +3,8 @@ import SwiftUI /// Detail overview of a paired hardware wallet, tracked as a watch-only balance. Mirrors the /// Savings/Spending screens: device name + blue Bitcoin icon in the top bar, balance header, the -/// device's on-chain activity grouped by date (blue hardware icons), a Transfer-To-Spending -/// placeholder on funded devices, and a Remove action. Ports bitkit-android's `HardwareWalletScreen`. +/// device's on-chain activity grouped by date (blue hardware icons), a Transfer-To-Spending action +/// on funded devices, and a Remove action. Ports bitkit-android's `HardwareWalletScreen`. struct HardwareWalletScreen: View { let walletId: String diff --git a/Bitkit/Views/Wallets/Send/HwSendSignView.swift b/Bitkit/Views/Wallets/Send/HwSendSignView.swift new file mode 100644 index 000000000..4f5976579 --- /dev/null +++ b/Bitkit/Views/Wallets/Send/HwSendSignView.swift @@ -0,0 +1,214 @@ +import BitkitCore +import SwiftUI + +struct HwSendSignView: View { + @EnvironmentObject private var app: AppViewModel + @EnvironmentObject private var tagManager: TagManager + @EnvironmentObject private var wallet: WalletViewModel + @Environment(HwWalletManager.self) private var hwWalletManager + + @Binding var navigationPath: [SendRoute] + let hwSend: HwSendCoordinator + let prepareContactPayment: () async throws -> Void + @State private var signingTask: Task? + @State private var passphraseTask: Task? + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + SheetHeader( + title: t("hardware__send_sign_title"), + showBackButton: !hwSend.isSigning && !hwSend.isBroadcastUnresolved + ) + + if let invoice = app.scannedOnchainInvoice { + MoneyStack( + sats: Int(wallet.sendAmountSats ?? invoice.amountSatoshis), + showSymbol: true, + testIdPrefix: "HardwareSendSignAmount" + ) + + CaptionMText(t("hardware__send_confirm_address")) + .padding(.top, 40) + + BodySSBText(invoice.address) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 8) + + Divider() + .padding(.top, 16) + + Spacer(minLength: 16) + + Image("trezor-card") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 256, height: 256) + .frame(maxWidth: .infinity) + .offset(y: 54) + .accessibilityHidden(true) + + Spacer(minLength: 0) + + CustomButton( + title: t(hwSend.hasPendingBroadcast ? "common__retry" : "hardware__send_open_connect"), + isDisabled: hwSend.isSigning, + isLoading: hwSend.isSigning + ) { + startSigning() + } + .accessibilityIdentifier("HardwareSendOpenTrezorConnect") + } + } + .navigationBarHidden(true) + .allowSwipeBack(false) + .padding(.horizontal, 16) + .sheetBackground() + .sheet(isPresented: passphrasePromptBinding) { + HwPassphrasePromptSheet( + isVerifying: hwSend.isVerifyingPassphrase, + onSubmit: reconnectWithPassphrase, + onCancel: dismissPassphrase + ) + } + .onDisappear { + guard !hwSend.isBroadcastUnresolved else { return } + signingTask?.cancel() + signingTask = nil + passphraseTask?.cancel() + passphraseTask = nil + hwSend.cancel() + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("HardwareSendSign") + } + + private var passphrasePromptBinding: Binding { + Binding( + get: { hwSend.isPassphraseRequired }, + set: { if !$0 { dismissPassphrase() } } + ) + } + + private func startSigning() { + guard signingTask == nil else { return } + signingTask = Task { @MainActor in + defer { signingTask = nil } + guard let invoice = app.scannedOnchainInvoice, + let amount = wallet.sendAmountSats, + let feeRate = wallet.selectedFeeRateSatsPerVByte, + let walletId = hwSend.walletId + else { + app.toast(type: .error, title: t("common__error"), description: t("other__try_again")) + return + } + let contactPublicKey = app.contactPaymentContext?.publicKey + + do { + let result = try await hwSend.signAndBroadcast( + manager: hwWalletManager, + address: invoice.address, + sats: amount, + satsPerVByte: UInt64(feeRate), + beforeBroadcast: prepareContactPayment + ) + await recordSentPayment( + result, + walletId: walletId, + address: invoice.address, + amount: amount, + contactPublicKey: contactPublicKey + ) + hwSend.completeBroadcast() + navigationPath.append(.success(paymentId: result.txId, walletId: walletId)) + } catch is CancellationError { + return + } catch is HwPassphraseError { + hwSend.requestPassphrase() + } catch let error as HwTransferError { + app.toast(error) + } catch { + showHardwareError(error) + } + } + } + + private func reconnectWithPassphrase(_ passphrase: String) { + guard passphraseTask == nil else { return } + passphraseTask = Task { @MainActor in + defer { passphraseTask = nil } + do { + try await hwSend.reconnectWithPassphrase(passphrase, manager: hwWalletManager) + startSigning() + } catch is CancellationError { + return + } catch HwPassphraseError.mismatch { + app.toast(HwTransferError.passphraseMismatch) + } catch { + showHardwareError(error) + } + } + } + + private func dismissPassphrase() { + passphraseTask?.cancel() + passphraseTask = nil + hwSend.dismissPassphrase() + } + + private func showHardwareError(_ error: Error) { + if error.isTrezorUserCancellation() { + return + } + if error.isTrezorDeviceBusy() { + app.toast(HwTransferError.deviceBusy) + } else if error.isTrezorFirmwareError() { + app.toast(HwTransferError.firmwareReconnect) + } else if hwSend.hasPendingBroadcast, error.isBroadcastConnectivityFailure() { + app.toast(HwTransferError.broadcastConnectivity) + } else { + app.toast(error) + } + } + + private func recordSentPayment( + _ result: HwFundingBroadcastResult, + walletId: String, + address: String, + amount: UInt64, + contactPublicKey: String? + ) async { + let metadata = PreActivityMetadata( + walletId: walletId, + paymentId: result.txId, + tags: tagManager.selectedTagsArray, + paymentHash: nil, + txId: result.txId, + address: address, + isReceive: false, + feeRate: result.feeRate, + isTransfer: false, + channelId: nil, + createdAt: UInt64(Date().timeIntervalSince1970) + ) + try? await CoreService.shared.activity.addPreActivityMetadata(metadata) + + await CoreService.shared.activity.createSentOnchainActivityFromSendResult( + txid: result.txId, + address: address, + amount: amount, + fee: result.miningFeeSats, + feeRate: UInt32(clamping: result.feeRate), + contact: contactPublicKey, + walletId: walletId + ) + if !tagManager.selectedTagsArray.isEmpty { + try? await CoreService.shared.activity.appendTags( + toActivity: result.txId, + tagManager.selectedTagsArray, + walletId: walletId + ) + } + + Logger.info("Hardware onchain send result txid: \(result.txId)") + } +} diff --git a/Bitkit/Views/Wallets/Send/SendAmountView.swift b/Bitkit/Views/Wallets/Send/SendAmountView.swift index 01311dedf..017c11ef8 100644 --- a/Bitkit/Views/Wallets/Send/SendAmountView.swift +++ b/Bitkit/Views/Wallets/Send/SendAmountView.swift @@ -1,37 +1,96 @@ import SwiftUI +enum SendFundingSource: Equatable { + case spending + case savings + case hardware(walletId: String) +} + struct SendAmountView: View { @EnvironmentObject var app: AppViewModel @EnvironmentObject var currency: CurrencyViewModel @EnvironmentObject var settings: SettingsViewModel @EnvironmentObject var wallet: WalletViewModel + @Environment(HwWalletManager.self) private var hwWalletManager @Binding var navigationPath: [SendRoute] + let hwSend: HwSendCoordinator @State private var amountViewModel = AmountInputViewModel() @State private var maxSendableAmount: UInt64? @State private var routingFee: UInt64 = 0 + @State private var isContinuing = false var amountSats: UInt64 { amountViewModel.amountSats } - var canSwitchWallet: Bool { - app.scannedOnchainInvoice != nil && app.scannedLightningInvoice != nil + private var fundingSources: [SendFundingSource] { + var sources: [SendFundingSource] = [] + if app.scannedLightningInvoice != nil { + sources.append(.spending) + } + if app.scannedOnchainInvoice != nil { + sources.append(.savings) + sources.append(contentsOf: hwWalletManager.wallets.compactMap { hardwareWallet in + guard hardwareWallet.fundingBalanceSats > 0 || hardwareWallet.walletId == hwSend.walletId else { + return nil + } + return .hardware(walletId: hardwareWallet.walletId) + }) + } + return sources + } + + private var selectedFundingSource: SendFundingSource { + if let walletId = hwSend.walletId { + return .hardware(walletId: walletId) + } + return app.selectedWalletToPayFrom == .lightning ? .spending : .savings + } + + private var canSwitchFundingSource: Bool { + fundingSources.count > 1 + } + + private var selectedSourceLabel: String { + return switch selectedFundingSource { + case .spending: + t("wallet__spending__title") + case .savings: + t("wallet__savings__title") + case let .hardware(walletId): + hwWalletManager.wallets.first(where: { $0.id == walletId })?.name + ?? t("hardware__device_model_trezor") + } + } + + private var selectedSourceColor: Color { + switch selectedFundingSource { + case .spending: .purpleAccent + case .savings: .brandAccent + case .hardware: .blueAccent + } } private var assetButtonTestIdentifier: String { - if canSwitchWallet { + if canSwitchFundingSource { return "switch" } - return app.selectedWalletToPayFrom == .lightning ? "spending" : "savings" + return switch selectedFundingSource { + case .spending: "spending" + case .savings: "savings" + case .hardware: "trezor" + } } /// The amount to display in the available balance section /// For onchain transactions, this shows the max sendable amount (balance minus fees) /// For lightning transactions, this shows the max sendable lightning amount minus routing fees var availableAmount: UInt64 { - if app.selectedWalletToPayFrom == .lightning { + if hwSend.isActive { + return hwSend.availableSats + } else if app.selectedWalletToPayFrom == .lightning { let maxSendLightning = UInt64(wallet.maxSendLightningSats) return maxSendLightning >= routingFee ? maxSendLightning - routingFee : 0 } else { @@ -89,19 +148,14 @@ struct SendAmountView: View { Spacer() - // No specific invoice, show toggle button based on selected wallet type NumberPadActionButton( - text: app.selectedWalletToPayFrom == .lightning - ? t("wallet__spending__title") - : t("wallet__savings__title"), - imageName: canSwitchWallet ? "arrow-up-down" : nil, - color: app.selectedWalletToPayFrom == .lightning ? .purpleAccent : .brandAccent, - variant: canSwitchWallet ? .primary : .secondary, - disabled: !canSwitchWallet + text: selectedSourceLabel, + imageName: canSwitchFundingSource ? "arrow-up-down" : nil, + color: selectedSourceColor, + variant: canSwitchFundingSource ? .primary : .secondary, + disabled: !canSwitchFundingSource || isContinuing ) { - if canSwitchWallet { - app.selectedWalletToPayFrom.toggle() - } + selectNextFundingSource() } .accessibilityIdentifier("AssetButton-\(assetButtonTestIdentifier)") @@ -127,10 +181,12 @@ struct SendAmountView: View { amountViewModel.handleNumberPadInput(key, currency: currency) } - CustomButton(title: t("common__continue"), isDisabled: !isValidAmount) { - Task { - await onContinue() - } + CustomButton( + title: t("common__continue"), + isDisabled: !isValidAmount, + isLoading: isContinuing + ) { + await onContinue() } .accessibilityIdentifier("ContinueAmount") } @@ -139,6 +195,10 @@ struct SendAmountView: View { .padding(.horizontal, 16) .sheetBackground() .onAppear { + if !fundingSources.contains(selectedFundingSource), let firstSource = fundingSources.first { + selectFundingSource(firstSource) + } + if let invoice = app.scannedOnchainInvoice, invoice.amountSatoshis > 0 { // Set the amount to the scanned onchain invoice amount if it exists amountViewModel.updateFromSats(invoice.amountSatoshis, currency: currency) @@ -154,7 +214,7 @@ struct SendAmountView: View { } // Calculate max sendable amount for onchain transactions - if app.selectedWalletToPayFrom == .onchain { + if hwSend.isActive || app.selectedWalletToPayFrom == .onchain { Task { await calculateMaxSendableAmount() } @@ -166,7 +226,7 @@ struct SendAmountView: View { } .onChange(of: app.selectedWalletToPayFrom) { _, newValue in // Recalculate max sendable amount when switching wallet types - if newValue == .onchain { + if hwSend.isActive || newValue == .onchain { Task { await calculateMaxSendableAmount() } @@ -178,9 +238,14 @@ struct SendAmountView: View { maxSendableAmount = nil } } + .onChange(of: hwSend.walletId) { + Task { + await calculateMaxSendableAmount() + } + } .onChange(of: wallet.selectedFeeRateSatsPerVByte) { // Recalculate max sendable amount when fee rate becomes available or changes - if app.selectedWalletToPayFrom == .onchain { + if hwSend.isActive || app.selectedWalletToPayFrom == .onchain { Task { await calculateMaxSendableAmount() } @@ -191,10 +256,30 @@ struct SendAmountView: View { } private func onContinue() async { + guard !isContinuing else { return } + isContinuing = true + defer { isContinuing = false } + do { wallet.sendAmountSats = amountSats wallet.isMaxAmountSend = isMaxAmountSend + if hwSend.isActive { + guard let address = app.scannedOnchainInvoice?.address, + let feeRate = wallet.selectedFeeRateSatsPerVByte + else { + throw AppError(message: t("other__try_again"), debugMessage: "Missing hardware send address or fee rate") + } + guard try await hwSend.preparePreview( + manager: hwWalletManager, + address: address, + sats: amountSats, + satsPerVByte: UInt64(feeRate) + ) != nil else { return } + navigationPath.append(.confirm) + return + } + // Lightning payment if app.selectedWalletToPayFrom == .lightning { if UInt64(wallet.maxSendLightningSats) < amountSats { @@ -259,6 +344,34 @@ struct SendAmountView: View { amountViewModel.maxAmountOverride = availableAmount > 0 ? availableAmount : nil } + private func selectNextFundingSource() { + guard canSwitchFundingSource else { return } + let currentIndex = fundingSources.firstIndex(of: selectedFundingSource) ?? -1 + selectFundingSource(fundingSources[(currentIndex + 1) % fundingSources.count]) + } + + private func selectFundingSource(_ source: SendFundingSource) { + switch source { + case .spending: + hwSend.selectWallet(nil) + app.selectedWalletToPayFrom = .lightning + case .savings: + hwSend.selectWallet(nil) + app.selectedWalletToPayFrom = .onchain + case let .hardware(walletId): + let balance = hwWalletManager.fundingBalance(walletId: walletId) + let reserve = HwFundingSigner.feeReserve( + balanceSats: balance, + satsPerVByte: wallet.selectedFeeRateSatsPerVByte.map(UInt64.init) + ) + hwSend.selectWallet( + walletId, + initialAvailableSats: balance > reserve ? balance - reserve : 0 + ) + app.selectedWalletToPayFrom = .onchain + } + } + private func showMaxExceededToast() { app.toast( type: .warning, @@ -271,10 +384,19 @@ struct SendAmountView: View { private func calculateMaxSendableAmount() async { // Make sure we have everything we need to calculate the max sendable amount - guard app.selectedWalletToPayFrom == .onchain else { return } + guard hwSend.isActive || app.selectedWalletToPayFrom == .onchain else { return } guard let address = app.scannedOnchainInvoice?.address else { return } guard let feeRate = wallet.selectedFeeRateSatsPerVByte else { return } + if hwSend.isActive { + await hwSend.refreshAvailable( + manager: hwWalletManager, + destinationAddress: address, + satsPerVByte: UInt64(feeRate) + ) + return + } + do { let maxAmount = try await wallet.calculateMaxSendableAmount( address: address, @@ -329,11 +451,15 @@ struct SendAmountView: View { isPresented: .constant(true), content: { NavigationStack { - SendAmountView(navigationPath: .constant([])) - .environmentObject(AppViewModel()) - .environmentObject(WalletViewModel()) - .environmentObject(CurrencyViewModel()) - .environmentObject(SettingsViewModel.shared) + SendAmountView( + navigationPath: .constant([]), + hwSend: HwSendCoordinator(walletId: nil) + ) + .environmentObject(AppViewModel()) + .environmentObject(WalletViewModel()) + .environmentObject(CurrencyViewModel()) + .environmentObject(SettingsViewModel.shared) + .environment(HwWalletManager()) } .presentationDetents([.height(UIScreen.screenHeight - 120)]) } diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 17590e18e..934b94c4c 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -12,8 +12,10 @@ struct SendConfirmationView: View { @EnvironmentObject var sheets: SheetViewModel @EnvironmentObject var wallet: WalletViewModel @EnvironmentObject var tagManager: TagManager + @Environment(HwWalletManager.self) private var hwWalletManager @Binding var navigationPath: [SendRoute] + let hwSend: HwSendCoordinator let requestPinCheck: () async -> Bool let prepareIncomingPaymentRequest: () async throws -> Void let routingCacheResetAttempted: Bool @@ -22,21 +24,58 @@ struct SendConfirmationView: View { @State private var showingBiometricError = false @State private var biometricErrorMessage = "" @State private var transactionFee: Int = 0 + @State private var feeCalculationId = 0 @State private var currentWarning: WarningType? @State private var pendingWarnings: [WarningType] = [] @State private var warningContinuation: CheckedContinuation? @State private var swipeProgress: CGFloat = 0 var accentColor: Color { - app.selectedWalletToPayFrom == .lightning ? .purpleAccent : .brandAccent + if hwSend.isActive { return .blueAccent } + return app.selectedWalletToPayFrom == .lightning ? .purpleAccent : .brandAccent + } + + private var fundingSources: [SendFundingSource] { + var sources: [SendFundingSource] = [] + if app.scannedLightningInvoice != nil { + sources.append(.spending) + } + if app.scannedOnchainInvoice != nil { + sources.append(.savings) + sources.append(contentsOf: hwWalletManager.wallets.compactMap { hardwareWallet in + guard hardwareWallet.fundingBalanceSats > 0 || hardwareWallet.walletId == hwSend.walletId else { + return nil + } + return .hardware(walletId: hardwareWallet.walletId) + }) + } + return sources + } + + private var selectedFundingSource: SendFundingSource { + if let walletId = hwSend.walletId { + return .hardware(walletId: walletId) + } + return app.selectedWalletToPayFrom == .lightning ? .spending : .savings + } + + private var canSwitchFundingSource: Bool { + fundingSources.count > 1 } var canSwitchWallet: Bool { + guard !hwSend.isActive else { return false } guard app.scannedOnchainInvoice != nil, app.scannedLightningInvoice != nil else { return false } let amount = wallet.sendAmountSats ?? app.scannedOnchainInvoice?.amountSatoshis ?? 0 return wallet.canSwitchWalletForUnifiedInvoice(amountSats: amount) } + private var hardwareWalletName: String? { + guard let walletId = hwSend.walletId else { return nil } + return hwWalletManager.wallets.first(where: { $0.id == walletId })?.name + ?? t("hardware__device_model_trezor") + } + /// `.instant` is only valid when paying from Lightning; align `selectedSpeed` with the current sat/vB on savings. private func reconcileInstantSpeedWhenSwitchingToOnChain() async { guard wallet.selectedSpeed == .instant else { return } @@ -184,12 +223,15 @@ struct SendConfirmationView: View { .onChange(of: app.selectedWalletToPayFrom) { Task { if app.selectedWalletToPayFrom == .lightning { - await MainActor.run { transactionFee = 0 } + await calculateTransactionFee() } else { await onSwitchToOnchainWallet() } } } + .onChange(of: hwSend.walletId) { + Task { await calculateTransactionFee() } + } .alert( t("security__bio_error_title"), isPresented: $showingBiometricError @@ -226,15 +268,13 @@ struct SendConfirmationView: View { HStack(alignment: .top, spacing: 16) { SendSectionView(t("wallet__send_from")) { NumberPadActionButton( - text: t("wallet__savings__title"), - imageName: canSwitchWallet ? "arrow-up-down" : nil, - color: app.selectedWalletToPayFrom == .lightning ? .purpleAccent : .brandAccent, - variant: canSwitchWallet ? .primary : .secondary, - disabled: !canSwitchWallet + text: hardwareWalletName ?? t("wallet__savings__title"), + imageName: canSwitchFundingSource ? "arrow-up-down" : nil, + color: hwSend.isActive ? .blueAccent : .brandAccent, + variant: canSwitchFundingSource ? .primary : .secondary, + disabled: !canSwitchFundingSource ) { - if canSwitchWallet { - app.selectedWalletToPayFrom.toggle() - } + selectNextFundingSource() } .accessibilityIdentifier("SendConfirmAssetButton") } @@ -332,14 +372,12 @@ struct SendConfirmationView: View { SendSectionView(t("wallet__send_from")) { NumberPadActionButton( text: t("wallet__spending__title"), - imageName: canSwitchWallet ? "arrow-up-down" : nil, + imageName: canSwitchFundingSource ? "arrow-up-down" : nil, color: app.selectedWalletToPayFrom == .lightning ? .purpleAccent : .brandAccent, - variant: canSwitchWallet ? .primary : .secondary, - disabled: !canSwitchWallet + variant: canSwitchFundingSource ? .primary : .secondary, + disabled: !canSwitchFundingSource ) { - if canSwitchWallet { - app.selectedWalletToPayFrom.toggle() - } + selectNextFundingSource() } .accessibilityIdentifier("SendConfirmAssetButton") } @@ -443,6 +481,35 @@ struct SendConfirmationView: View { } } + private func selectNextFundingSource() { + guard canSwitchFundingSource else { return } + let currentIndex = fundingSources.firstIndex(of: selectedFundingSource) + let nextIndex = currentIndex.map { ($0 + 1) % fundingSources.count } ?? 0 + selectFundingSource(fundingSources[nextIndex]) + } + + private func selectFundingSource(_ source: SendFundingSource) { + switch source { + case .spending: + hwSend.selectWallet(nil) + app.selectedWalletToPayFrom = .lightning + case .savings: + hwSend.selectWallet(nil) + app.selectedWalletToPayFrom = .onchain + case let .hardware(walletId): + let balance = hwWalletManager.fundingBalance(walletId: walletId) + let reserve = HwFundingSigner.feeReserve( + balanceSats: balance, + satsPerVByte: wallet.selectedFeeRateSatsPerVByte.map(UInt64.init) + ) + hwSend.selectWallet( + walletId, + initialAvailableSats: balance > reserve ? balance - reserve : 0 + ) + app.selectedWalletToPayFrom = .onchain + } + } + private func submitPayment() async throws { // Validate payment and show warnings if needed let warnings = await validatePayment() @@ -453,6 +520,23 @@ struct SendConfirmationView: View { } } + if hwSend.isActive { + do { + let context = app.contactPaymentContext + try validateIncomingPaymentRequestContext(context) + try validateIncomingPaymentRequestAmounts(context) + } catch { + Logger.error("Failed to validate hardware payment: \(error)") + navigationPath.append(.failure(SendFailureContext( + error: error, + retryRoute: .confirm, + routingCacheResetAttempted: routingCacheResetAttempted, + paymentRequest: nil + ))) + return + } + } + // Check if authentication is required for payments if settings.requirePinForPayments && settings.pinEnabled { if settings.useBiometrics && BiometricAuth.isAvailable { @@ -475,7 +559,11 @@ struct SendConfirmationView: View { } } - try await performPayment() + if hwSend.isActive { + navigationPath.append(.hardwareSign) + } else { + try await performPayment() + } } private func contactRecipient(_ contact: PubkyContact) -> some View { @@ -495,10 +583,7 @@ struct SendConfirmationView: View { let contactPublicKey = contactPaymentContext?.publicKey do { - try validateIncomingPaymentRequestContext(contactPaymentContext) - try validateIncomingPaymentRequestAmounts(contactPaymentContext) - try await prepareIncomingPaymentRequest() - try validateIncomingPaymentRequestContext(contactPaymentContext) + try await prepareContactPaymentIfNeeded() if app.selectedWalletToPayFrom == .lightning, let invoice = app.scannedLightningInvoice { let amount = wallet.sendAmountSats ?? invoice.amountSatoshis @@ -577,6 +662,14 @@ struct SendConfirmationView: View { } } + private func prepareContactPaymentIfNeeded() async throws { + let context = app.contactPaymentContext + try validateIncomingPaymentRequestContext(context) + try validateIncomingPaymentRequestAmounts(context) + try await prepareIncomingPaymentRequest() + try validateIncomingPaymentRequestContext(context) + } + private func validateIncomingPaymentRequestContext(_ context: ContactPaymentContext?) throws { guard let context, let request = context.incomingPaymentRequest else { return } guard !request.isExpired(at: Date()) else { throw PaykitPaymentRequestError.requestExpired } @@ -639,7 +732,7 @@ struct SendConfirmationView: View { warnings.append(.balance) } } else { - let onchainBalance = wallet.totalOnchainSats + let onchainBalance = hwSend.isActive ? hwSend.availableSats : UInt64(clamping: wallet.totalOnchainSats) if amount > onchainBalance / 2 { warnings.append(.balance) } @@ -828,8 +921,18 @@ struct SendConfirmationView: View { await calculateTransactionFee() } + @MainActor private func calculateTransactionFee() async { + feeCalculationId += 1 + let requestId = feeCalculationId + + func apply(_ fee: UInt64) { + guard feeCalculationId == requestId else { return } + transactionFee = Int(fee) + } + guard app.selectedWalletToPayFrom == .onchain else { + apply(0) return } @@ -841,14 +944,23 @@ struct SendConfirmationView: View { } do { + if hwSend.isActive { + guard let fee = try await hwSend.preparePreview( + manager: hwWalletManager, + address: address, + sats: amountSats, + satsPerVByte: UInt64(feeRate) + ) else { return } + apply(fee) + return + } + if await shouldUseMaxOnchainSend(address: address, amountSats: amountSats, feeRate: feeRate) { let sendAllFee = try await wallet.estimateSendAllFee( address: address, satsPerVByte: feeRate ) - await MainActor.run { - transactionFee = Int(sendAllFee) - } + apply(sendAllFee) return } @@ -859,15 +971,12 @@ struct SendConfirmationView: View { satsPerVByte: feeRate, utxosToSpend: wallet.selectedUtxos ) - await MainActor.run { - transactionFee = Int(normalFee) - } + apply(normalFee) } catch { + guard feeCalculationId == requestId else { return } Logger.error("Failed to calculate actual fee: \(error)") - await MainActor.run { - transactionFee = 0 - app.toast(type: .error, title: t("other__try_again")) - } + transactionFee = 0 + app.toast(type: .error, title: t("other__try_again")) } } diff --git a/Bitkit/Views/Wallets/Send/SendContactSelectView.swift b/Bitkit/Views/Wallets/Send/SendContactSelectView.swift index 11c571f7b..3e7c03df3 100644 --- a/Bitkit/Views/Wallets/Send/SendContactSelectView.swift +++ b/Bitkit/Views/Wallets/Send/SendContactSelectView.swift @@ -7,8 +7,10 @@ struct SendContactSelectView: View { @EnvironmentObject private var settings: SettingsViewModel @EnvironmentObject private var sheets: SheetViewModel @EnvironmentObject private var wallet: WalletViewModel + @Environment(HwWalletManager.self) private var hwWalletManager @Binding var navigationPath: [SendRoute] + let hwSend: HwSendCoordinator @State private var selectedContactKey: String? private var contacts: [PubkyContact] { @@ -97,7 +99,9 @@ struct SendContactSelectView: View { do { try await app.handleScannedData( paymentRequest, - claimedContactPaymentContext: contactPaymentContext + claimedContactPaymentContext: contactPaymentContext, + scope: hwSend.isActive ? .onchainPayments : .unrestricted, + alternativeOnchainBalanceSats: hwWalletManager.maximumFundingBalanceSats ) } catch is CancellationError { if app.ownsContactPaymentContext(contactPaymentContext) { diff --git a/Bitkit/Views/Wallets/Send/SendEnterManuallyView.swift b/Bitkit/Views/Wallets/Send/SendEnterManuallyView.swift index aa69a289b..67a419573 100644 --- a/Bitkit/Views/Wallets/Send/SendEnterManuallyView.swift +++ b/Bitkit/Views/Wallets/Send/SendEnterManuallyView.swift @@ -9,8 +9,10 @@ struct SendEnterManuallyView: View { @EnvironmentObject var navigation: NavigationViewModel @EnvironmentObject var pubkyProfile: PubkyProfileManager @EnvironmentObject var sheets: SheetViewModel + @Environment(HwWalletManager.self) private var hwWalletManager @Binding var navigationPath: [SendRoute] + let hwSend: HwSendCoordinator @FocusState private var isTextEditorFocused: Bool private var manualEntryBinding: Binding { @@ -20,7 +22,10 @@ struct SendEnterManuallyView: View { app.manualEntryInput = newValue app.validateManualEntryInput( newValue, - savingsBalanceSats: wallet.spendableOnchainBalanceSats, + savingsBalanceSats: max( + wallet.spendableOnchainBalanceSats, + Int(clamping: hwWalletManager.maximumFundingBalanceSats) + ), spendingBalanceSats: wallet.maxSendLightningSats ) } @@ -100,7 +105,11 @@ struct SendEnterManuallyView: View { Logger.error("Failed to set default fee rate: \(error)") } - try await app.handleScannedData(uri) + try await app.handleScannedData( + uri, + scope: hwSend.isActive ? .onchainPayments : .unrestricted, + alternativeOnchainBalanceSats: hwWalletManager.maximumFundingBalanceSats + ) if let route = PaymentNavigationHelper.appropriateSendRoute( app: app, @@ -117,7 +126,7 @@ struct SendEnterManuallyView: View { } #Preview { - SendEnterManuallyView(navigationPath: .constant([])) + SendEnterManuallyView(navigationPath: .constant([]), hwSend: HwSendCoordinator()) .environmentObject(AppViewModel()) .environmentObject(WalletViewModel()) .environmentObject(CurrencyViewModel()) @@ -126,5 +135,6 @@ struct SendEnterManuallyView: View { .environmentObject(NavigationViewModel()) .environmentObject(PubkyProfileManager()) .environmentObject(SheetViewModel()) + .environment(HwWalletManager()) .preferredColorScheme(.dark) } diff --git a/Bitkit/Views/Wallets/Send/SendFeeCustom.swift b/Bitkit/Views/Wallets/Send/SendFeeCustom.swift index dc037b282..a9c3543f7 100644 --- a/Bitkit/Views/Wallets/Send/SendFeeCustom.swift +++ b/Bitkit/Views/Wallets/Send/SendFeeCustom.swift @@ -6,8 +6,10 @@ struct SendFeeCustom: View { @EnvironmentObject var feeEstimatesManager: FeeEstimatesManager @EnvironmentObject var settings: SettingsViewModel @EnvironmentObject var wallet: WalletViewModel + @Environment(HwWalletManager.self) private var hwWalletManager @Binding var navigationPath: [SendRoute] + let hwSend: HwSendCoordinator @State private var feeRate: UInt32 = 1 @State private var maxFee: UInt32 = 999 @@ -103,12 +105,21 @@ struct SendFeeCustom: View { let amountSats = wallet.sendAmountSats! do { - let fee = try await wallet.calculateTotalFee( - address: address, - amountSats: amountSats, - satsPerVByte: feeRate, - utxosToSpend: wallet.selectedUtxos - ) + let fee = if let walletId = hwSend.walletId { + try await hwWalletManager.estimateOfflineFundingMiningFee( + walletId: walletId, + address: address, + sats: amountSats, + satsPerVByte: UInt64(feeRate) + ) + } else { + try await wallet.calculateTotalFee( + address: address, + amountSats: amountSats, + satsPerVByte: feeRate, + utxosToSpend: wallet.selectedUtxos + ) + } await MainActor.run { transactionFee = fee @@ -187,6 +198,7 @@ struct SendFeeCustom: View { do { try await wallet.setFeeRate(speed: .custom(satsPerVByte: feeRate)) app.selectedWalletToPayFrom = .onchain + await refreshHardwareMaxIfNeeded() navigationPath.removeLast() } catch { Logger.error("Failed to set custom fee rate: \(error)") @@ -198,4 +210,21 @@ struct SendFeeCustom: View { } } } + + private func refreshHardwareMaxIfNeeded() async { + guard hwSend.isActive, + let address = app.scannedOnchainInvoice?.address, + let selectedFeeRate = wallet.selectedFeeRateSatsPerVByte + else { + return + } + await hwSend.refreshAvailable( + manager: hwWalletManager, + destinationAddress: address, + satsPerVByte: UInt64(selectedFeeRate) + ) + if wallet.isMaxAmountSend { + wallet.sendAmountSats = hwSend.availableSats + } + } } diff --git a/Bitkit/Views/Wallets/Send/SendFeeRate.swift b/Bitkit/Views/Wallets/Send/SendFeeRate.swift index 358a95986..249a9cf46 100644 --- a/Bitkit/Views/Wallets/Send/SendFeeRate.swift +++ b/Bitkit/Views/Wallets/Send/SendFeeRate.swift @@ -7,13 +7,16 @@ struct SendFeeRate: View { @EnvironmentObject var feeEstimatesManager: FeeEstimatesManager @EnvironmentObject var settings: SettingsViewModel @EnvironmentObject var wallet: WalletViewModel + @Environment(HwWalletManager.self) private var hwWalletManager @Binding var navigationPath: [SendRoute] + let hwSend: HwSendCoordinator @State private var transactionFees: [TransactionSpeed: UInt64] = [:] /// Both on-chain and Lightning options exist and the user can pay from either (BIP21 / unified invoice). private var canSwitchWallet: Bool { + guard !hwSend.isActive else { return false } guard app.scannedOnchainInvoice != nil, app.scannedLightningInvoice != nil else { return false } let amount = wallet.sendAmountSats ?? app.scannedOnchainInvoice?.amountSatoshis ?? 0 return wallet.canSwitchWalletForUnifiedInvoice(amountSats: amount) @@ -34,7 +37,9 @@ struct SendFeeRate: View { guard let amount = wallet.sendAmountSats else { return true } let fee = getFee(for: speed) - return wallet.totalOnchainSats < amount + fee && wallet.selectedSpeed != speed + let totalSats = hwSend.walletId.map { hwWalletManager.fundingBalance(walletId: $0) } + ?? UInt64(clamping: wallet.totalOnchainSats) + return totalSats < amount + fee && wallet.selectedSpeed != speed } private func selectFee(_ speed: TransactionSpeed) { @@ -48,6 +53,7 @@ struct SendFeeRate: View { } else { try await wallet.setFeeRate(speed: speed) app.selectedWalletToPayFrom = .onchain + await refreshHardwareMaxIfNeeded() navigationPath.removeLast() } } catch { @@ -85,12 +91,21 @@ struct SendFeeRate: View { let feeRate = speed.getFeeRate(from: estimates) do { - let fee = try await wallet.calculateTotalFee( - address: address, - amountSats: amountSats, - satsPerVByte: feeRate, - utxosToSpend: wallet.selectedUtxos - ) + let fee = if let walletId = hwSend.walletId { + try await hwWalletManager.estimateOfflineFundingMiningFee( + walletId: walletId, + address: address, + sats: amountSats, + satsPerVByte: UInt64(feeRate) + ) + } else { + try await wallet.calculateTotalFee( + address: address, + amountSats: amountSats, + satsPerVByte: feeRate, + utxosToSpend: wallet.selectedUtxos + ) + } newFees[speed] = fee } catch { Logger.error("Error calculating fee for \(speed): \(error)", context: "SendFeeRate") @@ -105,6 +120,23 @@ struct SendFeeRate: View { } } + private func refreshHardwareMaxIfNeeded() async { + guard hwSend.isActive, + let address = app.scannedOnchainInvoice?.address, + let feeRate = wallet.selectedFeeRateSatsPerVByte + else { + return + } + await hwSend.refreshAvailable( + manager: hwWalletManager, + destinationAddress: address, + satsPerVByte: UInt64(feeRate) + ) + if wallet.isMaxAmountSend { + wallet.sendAmountSats = hwSend.availableSats + } + } + var body: some View { VStack(alignment: .leading, spacing: 0) { SheetHeader(title: t("wallet__send_fee_speed"), showBackButton: true) diff --git a/Bitkit/Views/Wallets/Send/SendOptionsView.swift b/Bitkit/Views/Wallets/Send/SendOptionsView.swift index 65ddf1c55..a73687f8e 100644 --- a/Bitkit/Views/Wallets/Send/SendOptionsView.swift +++ b/Bitkit/Views/Wallets/Send/SendOptionsView.swift @@ -13,10 +13,16 @@ struct SendOptionsView: View { @EnvironmentObject var settings: SettingsViewModel @EnvironmentObject var sheets: SheetViewModel @EnvironmentObject var wallet: WalletViewModel + @Environment(HwWalletManager.self) private var hwWalletManager @Binding var navigationPath: [SendRoute] + let hwSend: HwSendCoordinator @State private var selectedItem: PhotosPickerItem? + private var scanScope: ScanHandlingScope { + hwSend.isActive ? .onchainPayments : .unrestricted + } + private var isPaykitUIActive: Bool { PaykitFeatureFlags.isUIAvailable && isPaykitUIEnabled } @@ -29,16 +35,20 @@ struct SendOptionsView: View { VStack(spacing: 8) { Scanner( onScan: { uri in - await scanner.handleSendScan(uri) { route in + await scanner.handleSendScan(uri, scope: scanScope) { route in if let route { - navigationPath.append(route) + handleRoute(route) } } }, onImageSelection: { item in - await scanner.handleImageSelection(item, context: .send) { route in + await scanner.handleImageSelection( + item, + context: .send, + scope: scanScope + ) { route in if let route { - navigationPath.append(route) + handleRoute(route) } } } @@ -86,7 +96,8 @@ struct SendOptionsView: View { navigation: navigation, pubkyProfile: pubkyProfile, sheets: sheets, - wallet: wallet + wallet: wallet, + hwWalletManager: hwWalletManager ) } } @@ -102,9 +113,9 @@ struct SendOptionsView: View { } Task { - await scanner.handleSendScan(uri) { route in + await scanner.handleSendScan(uri, scope: scanScope) { route in if let route { - navigationPath.append(route) + handleRoute(route) } } } @@ -113,6 +124,10 @@ struct SendOptionsView: View { func handleContact() { navigationPath.append(isPaykitUIActive ? .contact : .comingSoon) } + + private func handleRoute(_ route: SendRoute) { + navigationPath.append(route) + } } #Preview { @@ -121,7 +136,7 @@ struct SendOptionsView: View { isPresented: .constant(true), content: { NavigationStack { - SendOptionsView(navigationPath: .constant([])) + SendOptionsView(navigationPath: .constant([]), hwSend: HwSendCoordinator()) .environmentObject(AppViewModel()) .environmentObject(ContactsManager()) .environmentObject(CurrencyViewModel()) @@ -131,6 +146,7 @@ struct SendOptionsView: View { .environmentObject(SettingsViewModel.shared) .environmentObject(SheetViewModel()) .environmentObject(WalletViewModel()) + .environment(HwWalletManager()) } .presentationDetents([.height(UIScreen.screenHeight - 120)]) } diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index 4576866c2..e4254fe07 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -42,13 +42,14 @@ enum SendRoute: Hashable { case amount case utxoSelection case confirm + case hardwareSign case feeRate case feeCustom case tag case quickpay case pin case pending(paymentHash: String, retryRoute: SendRetryRoute, paymentRequest: String?) - case success(paymentId: String) + case success(paymentId: String, walletId: String = WalletScope.default) case failure(SendFailureContext) case lnurlPayAmount case lnurlPayConfirm @@ -59,9 +60,11 @@ enum SendRoute: Hashable { struct SendConfig { let initialRoute: SendRoute + let hardwareWalletId: String? - init(view: SendRoute = .options) { + init(view: SendRoute = .options, hardwareWalletId: String? = nil) { initialRoute = view + self.hardwareWalletId = hardwareWalletId } } @@ -69,9 +72,11 @@ struct SendSheetItem: SheetItem { let id: SheetID = .send let size: SheetSize = .large let initialRoute: SendRoute + let hardwareWalletId: String? - init(initialRoute: SendRoute = .options) { + init(initialRoute: SendRoute = .options, hardwareWalletId: String? = nil) { self.initialRoute = initialRoute + self.hardwareWalletId = hardwareWalletId } } @@ -83,6 +88,8 @@ struct SendSheet: View { @EnvironmentObject private var tagManager: TagManager @EnvironmentObject private var wallet: WalletViewModel @Environment(PaykitPaymentRequestManager.self) private var paykitPaymentRequestManager + @Environment(HwWalletManager.self) private var hwWalletManager + @Environment(TrezorManager.self) private var trezorManager let config: SendSheetItem @@ -94,6 +101,13 @@ struct SendSheet: View { @State private var routingCacheResetAttempted = false @State private var syncTimedOut = false @State private var pinCheckContinuations: [CheckedContinuation] = [] + @State private var hwSend: HwSendCoordinator + @State private var setupTask: Task? + + init(config: SendSheetItem) { + self.config = config + _hwSend = State(initialValue: HwSendCoordinator(walletId: config.hardwareWalletId)) + } private var currentRoot: SendRoute { rootOverride ?? config.initialRoute @@ -107,6 +121,10 @@ struct SendSheet: View { /// If there are no channels at all, we should NOT wait behind the sync UI – that's a capacity issue, not a sync issue. /// For onchain: only need node running. private var shouldShowSyncOverlay: Bool { + if hwSend.isActive { + return false + } + // Node must be running guard wallet.nodeLifecycleState == .running else { return true } @@ -161,6 +179,10 @@ struct SendSheet: View { } } .animation(.easeInOut(duration: 0.3), value: shouldShowSyncOverlay) + .interactiveDismissDisabled(hwSend.isSigning || hwSend.isBroadcastUnresolved) + .sheet(isPresented: reconnectPairingBinding) { + HardwarePairingSheet(config: HardwarePairingSheetItem()) + } .offlineSheetOverlay(title: t("wallet__send_bitcoin"), forceShow: syncTimedOut) .onChange(of: shouldShowSyncOverlay, initial: true) { _, isShowing in Logger.debug("shouldShowSyncOverlay: \(isShowing) (node: \(wallet.nodeLifecycleState))", context: "SendSheet") @@ -168,6 +190,17 @@ struct SendSheet: View { .onAppear { tagManager.clearSelectedTags() wallet.resetSendState(speed: settings.defaultTransactionSpeed) + if let walletId = config.hardwareWalletId { + let balance = hwWalletManager.fundingBalance(walletId: walletId) + let reserve = HwFundingSigner.feeReserve( + balanceSats: balance, + satsPerVByte: wallet.selectedFeeRateSatsPerVByte.map(UInt64.init) + ) + hwSend.seedAvailable( + walletId: walletId, + availableSats: balance > reserve ? balance - reserve : 0 + ) + } if let request = app.contactPaymentContext?.incomingPaymentRequest { incomingPaymentRequest = request guard paykitPaymentRequestManager.markPresentedIfPending(request) else { @@ -180,28 +213,43 @@ struct SendSheet: View { hasValidatedAfterSync = false syncTimedOut = false - if app.contactPaymentContext?.incomingPaymentRequest != nil, !shouldShowSyncOverlay { - validatePaymentAfterSync() - } - // A plain Send open (TabBar) must not inherit invoice state from an earlier scan, // e.g. one abandoned behind the sync overlay. Invoice-carrying opens use other routes. if config.initialRoute == .options { app.resetSendState() } - Task { + setupTask?.cancel() + setupTask = Task { do { try await wallet.setFeeRate(speed: settings.defaultTransactionSpeed) + } catch is CancellationError { + return } catch { Logger.error("Failed to set default fee rate: \(error)") } + + if let request = incomingPaymentRequest { + guard isCurrentIncomingRequest(request.id) else { return } + guard await selectHardwareFundingSourceIfNeeded( + amountSats: request.amountSats, + requestId: request.id + ) else { return } + guard isCurrentIncomingRequest(request.id) else { return } + if !shouldShowSyncOverlay { + validatePaymentAfterSync() + } + } } } .onDisappear { + setupTask?.cancel() + setupTask = nil + hwSend.cancel() if let incomingPaymentRequest { paykitPaymentRequestManager.finishPayment(incomingPaymentRequest) } + incomingPaymentRequest = nil app.contactPaymentContext = nil app.resetQuickPay() QuickPayPaymentCoordinator.shared.detach() @@ -388,7 +436,10 @@ struct SendSheet: View { app.scannedLightningInvoice = nil // Validate onchain balance BEFORE navigating - let onchainBalance = LightningService.shared.balances?.spendableOnchainBalanceSats ?? 0 + let onchainBalance = max( + LightningService.shared.balances?.spendableOnchainBalanceSats ?? 0, + hwWalletManager.maximumFundingBalanceSats + ) guard validateOnchainBalanceAndDismissIfInsufficient( invoiceAmount: requestedAmount ?? onchainInvoice.amountSatoshis, onchainBalance: onchainBalance @@ -421,7 +472,10 @@ struct SendSheet: View { // Validate onchain payment balance (for pure onchain invoices) if let onchainInvoice = app.scannedOnchainInvoice { - let onchainBalance = LightningService.shared.balances?.spendableOnchainBalanceSats ?? 0 + let onchainBalance = max( + LightningService.shared.balances?.spendableOnchainBalanceSats ?? 0, + hwWalletManager.maximumFundingBalanceSats + ) guard validateOnchainBalanceAndDismissIfInsufficient( invoiceAmount: requestedAmount ?? onchainInvoice.amountSatoshis, onchainBalance: onchainBalance @@ -455,32 +509,111 @@ struct SendSheet: View { } } + private func isCurrentIncomingRequest(_ requestId: PaykitPaymentRequest.ID) -> Bool { + !Task.isCancelled + && sheets.activeSheetConfiguration?.id == .send + && incomingPaymentRequest?.id == requestId + && app.contactPaymentContext?.incomingPaymentRequest?.id == requestId + } + + private func selectHardwareFundingSourceIfNeeded( + amountSats: UInt64, + requestId: PaykitPaymentRequest.ID + ) async -> Bool { + guard isCurrentIncomingRequest(requestId) else { return false } + guard app.selectedWalletToPayFrom == .onchain, + let invoice = app.scannedOnchainInvoice, + let satsPerVByte = wallet.selectedFeeRateSatsPerVByte + else { return true } + + let savingsAvailable: UInt64? + do { + savingsAvailable = try await wallet.calculateMaxSendableAmount( + address: invoice.address, + satsPerVByte: satsPerVByte + ) + } catch is CancellationError { + return false + } catch { + savingsAvailable = nil + Logger.error(error, context: "SendSheet failed to estimate Savings availability") + } + guard isCurrentIncomingRequest(requestId) else { return false } + if let savingsAvailable, savingsAvailable >= amountSats { return true } + + var hardwareSources: [(wallet: HwWallet, available: UInt64)] = [] + var hasUnavailableSource = savingsAvailable == nil + for hardwareWallet in hwWalletManager.wallets { + do { + let available = try await hwWalletManager.maxSpendableFunding( + walletId: hardwareWallet.walletId, + destinationAddress: invoice.address, + satsPerVByte: UInt64(satsPerVByte) + ) + hardwareSources.append((wallet: hardwareWallet, available: available)) + } catch is CancellationError { + return false + } catch { + hasUnavailableSource = true + Logger.error(error, context: "SendSheet failed to estimate hardware availability") + } + guard isCurrentIncomingRequest(requestId) else { return false } + } + if let source = hardwareSources + .filter({ $0.available >= amountSats }) + .max(by: { $0.available < $1.available }) + { + hwSend.selectWallet( + source.wallet.walletId, + initialAvailableSats: source.available + ) + return true + } + + // A failed estimate is not proof of insufficient funds. Keep the sheet open so the normal + // confirmation path can retry instead of rejecting a payable request. + guard !hasUnavailableSource else { return true } + let maximumAvailable = max(savingsAvailable ?? 0, hardwareSources.map(\.available).max() ?? 0) + _ = validateOnchainBalanceAndDismissIfInsufficient( + invoiceAmount: amountSats, + onchainBalance: maximumAvailable + ) + return false + } + @ViewBuilder private func viewForRoute(_ route: SendRoute) -> some View { switch route { case .options: - SendOptionsView(navigationPath: $navigationPath) + SendOptionsView(navigationPath: $navigationPath, hwSend: hwSend) case .contact: - SendContactSelectView(navigationPath: $navigationPath) + SendContactSelectView(navigationPath: $navigationPath, hwSend: hwSend) case .comingSoon: SendComingSoonView() case .manual: - SendEnterManuallyView(navigationPath: $navigationPath) + SendEnterManuallyView(navigationPath: $navigationPath, hwSend: hwSend) case .amount: - SendAmountView(navigationPath: $navigationPath) + SendAmountView(navigationPath: $navigationPath, hwSend: hwSend) case .utxoSelection: SendUtxoSelectionView(navigationPath: $navigationPath) case .confirm: SendConfirmationView( navigationPath: $navigationPath, + hwSend: hwSend, requestPinCheck: requestPinCheck, prepareIncomingPaymentRequest: prepareIncomingPaymentRequest, routingCacheResetAttempted: routingCacheResetAttempted ) + case .hardwareSign: + HwSendSignView( + navigationPath: $navigationPath, + hwSend: hwSend, + prepareContactPayment: prepareIncomingPaymentRequest + ) case .feeRate: - SendFeeRate(navigationPath: $navigationPath) + SendFeeRate(navigationPath: $navigationPath, hwSend: hwSend) case .feeCustom: - SendFeeCustom(navigationPath: $navigationPath) + SendFeeCustom(navigationPath: $navigationPath, hwSend: hwSend) case .tag: SendTagScreen(navigationPath: $navigationPath) case .quickpay: @@ -500,8 +633,8 @@ struct SendSheet: View { routingCacheResetAttempted: routingCacheResetAttempted, navigationPath: $navigationPath ) - case let .success(paymentId): - SendSuccess(paymentId: paymentId) + case let .success(paymentId, walletId): + SendSuccess(paymentId: paymentId, walletId: walletId) case let .failure(context): SendFailure( context: context, @@ -538,6 +671,7 @@ struct SendSheet: View { guard let context = app.contactPaymentContext, let request = context.incomingPaymentRequest else { return } + guard !paykitPaymentRequestManager.isApprovedForPayment(request) else { return } try await paykitPaymentRequestManager.prepareForPayment(request) { guard let privatePaymentContext = context.privatePaymentContext else { return } @@ -555,6 +689,17 @@ struct SendSheet: View { navigationPath = next.path } + private var reconnectPairingBinding: Binding { + Binding( + get: { trezorManager.showPairingCode }, + set: { isPresented in + if !isPresented, trezorManager.showPairingCode { + trezorManager.cancelPairingCode() + } + } + ) + } + private func resetNavigationForRetry(_ retryRoute: SendRetryRoute) { let route = retryRoute.sendRoute if retryRoute == .quickpay { diff --git a/Bitkit/Views/Wallets/Send/SendSuccess.swift b/Bitkit/Views/Wallets/Send/SendSuccess.swift index c2170f0f6..8c161e607 100644 --- a/Bitkit/Views/Wallets/Send/SendSuccess.swift +++ b/Bitkit/Views/Wallets/Send/SendSuccess.swift @@ -10,6 +10,12 @@ struct SendSuccess: View { @EnvironmentObject var wallet: WalletViewModel let paymentId: String // The payment hash or txid from the successful payment + let walletId: String + + init(paymentId: String, walletId: String = WalletScope.default) { + self.paymentId = paymentId + self.walletId = walletId + } @State private var foundActivity: Activity? @@ -107,14 +113,17 @@ struct SendSuccess: View { do { let activity = try await tryNTimes( toTry: { - try await activityListViewModel.findActivity(byPaymentId: paymentId) + try await activityListViewModel.findActivity(byPaymentId: paymentId, walletId: walletId) }, times: 12, interval: 5 ) await applyPendingContactContextIfNeeded() - let updatedActivity = try? await activityListViewModel.findActivity(byPaymentId: paymentId) + let updatedActivity = try? await activityListViewModel.findActivity( + byPaymentId: paymentId, + walletId: walletId + ) foundActivity = updatedActivity ?? activity } catch { Logger.warn("Could not find activity for payment ID: \(paymentId) after 12 attempts") @@ -127,7 +136,12 @@ struct SendSuccess: View { } do { - try await activityListViewModel.setContact(contactPublicKey, forPaymentId: paymentId, syncLdkPayments: false) + try await activityListViewModel.setContact( + contactPublicKey, + forPaymentId: paymentId, + walletId: walletId, + syncLdkPayments: false + ) app.consumeContactPaymentContext(forPendingPaymentHash: paymentId) } catch { Logger.warn("Failed to set pending contact for payment \(paymentId): \(error)", context: "SendSuccess") diff --git a/Bitkit/Views/Wallets/Send/SendUtxoSelectionView.swift b/Bitkit/Views/Wallets/Send/SendUtxoSelectionView.swift index 4b1f3d020..0975c112f 100644 --- a/Bitkit/Views/Wallets/Send/SendUtxoSelectionView.swift +++ b/Bitkit/Views/Wallets/Send/SendUtxoSelectionView.swift @@ -196,11 +196,15 @@ extension Array { isPresented: .constant(true), content: { NavigationStack { - SendAmountView(navigationPath: .constant([])) - .environmentObject(AppViewModel()) - .environmentObject(WalletViewModel()) - .environmentObject(CurrencyViewModel()) - .environmentObject(SettingsViewModel.shared) + SendAmountView( + navigationPath: .constant([]), + hwSend: HwSendCoordinator(walletId: nil) + ) + .environmentObject(AppViewModel()) + .environmentObject(WalletViewModel()) + .environmentObject(CurrencyViewModel()) + .environmentObject(SettingsViewModel.shared) + .environment(HwWalletManager()) } .presentationDetents([.height(UIScreen.screenHeight - 120)]) } diff --git a/BitkitTests/HwFundingSignerTests.swift b/BitkitTests/HwFundingSignerTests.swift index 5cb939934..cff215de9 100644 --- a/BitkitTests/HwFundingSignerTests.swift +++ b/BitkitTests/HwFundingSignerTests.swift @@ -2,8 +2,7 @@ import BitkitCore import XCTest -/// Device-signing orchestration coverage for `HwFundingSigner`, adapting the sign/compose/reconnect -/// cases from bitkit-android's `TransferViewModelTest`. Exercised in isolation from +/// Device-signing orchestration coverage for `HwFundingSigner`, exercised in isolation from /// `TransferViewModel` via the `HwTransferFunding` / `HwTransferConnecting` mocks. @MainActor final class HwFundingSignerTests: XCTestCase { @@ -39,6 +38,89 @@ final class HwFundingSignerTests: XCTestCase { XCTAssertEqual(HwFundingSigner.feeReserve(balanceSats: 5000, satsPerVByte: nil), 3600) } + func testCoordinatorChangesFundingWalletAndClearsDevicePrompt() { + let coordinator = HwSendCoordinator() + coordinator.requestPassphrase() + + coordinator.selectWallet("trezor:wallet", initialAvailableSats: 42000) + + XCTAssertEqual(coordinator.walletId, "trezor:wallet") + XCTAssertEqual(coordinator.availableSats, 42000) + XCTAssertTrue(coordinator.isActive) + XCTAssertFalse(coordinator.isPassphraseRequired) + + coordinator.selectWallet(nil) + + XCTAssertFalse(coordinator.isActive) + } + + func testCoordinatorSeedsAvailableForSelectedWalletOnly() { + let coordinator = HwSendCoordinator(walletId: "trezor:selected") + + coordinator.seedAvailable(walletId: "trezor:other", availableSats: 10000) + XCTAssertEqual(coordinator.availableSats, 0) + + coordinator.seedAvailable(walletId: "trezor:selected", availableSats: 42000) + XCTAssertEqual(coordinator.availableSats, 42000) + } + + func testCoordinatorRetryReusesSignedPaymentAfterUncertainBroadcast() async throws { + try await assertCoordinatorRetryReusesSignedPayment(error: HwTransferError.broadcastUncertain) + } + + func testCoordinatorRetryReusesSignedPaymentAfterConnectivityFailure() async throws { + try await assertCoordinatorRetryReusesSignedPayment( + error: BroadcastError.ElectrumError(errorDetails: "offline") + ) + } + + private func assertCoordinatorRetryReusesSignedPayment(error: Error) async throws { + let funding = MockHwFunding() + let connecting = MockHwConnecting() + let manager = HwWalletManager() + let coordinator = HwSendCoordinator( + walletId: "trezor:wallet", + signerFactory: { [self] _, address, satsPerVByte in + makeSigner( + funding: funding, + connecting: connecting, + feeRate: satsPerVByte, + address: address + ) + } + ) + var beforeBroadcastCalls = 0 + funding.broadcastError = error + + await assertThrowsAsync { + _ = try await coordinator.signAndBroadcast( + manager: manager, + address: "bc1qtest", + sats: 42000, + satsPerVByte: 2, + beforeBroadcast: { beforeBroadcastCalls += 1 } + ) + } + + XCTAssertTrue(coordinator.hasPendingBroadcast) + XCTAssertTrue(coordinator.isBroadcastUnresolved) + + funding.broadcastError = nil + _ = try await coordinator.signAndBroadcast( + manager: manager, + address: "bc1qtest", + sats: 42000, + satsPerVByte: 2, + beforeBroadcast: { beforeBroadcastCalls += 1 } + ) + + XCTAssertEqual(funding.composeCalls.count, 1) + XCTAssertEqual(funding.signCalls, 1) + XCTAssertEqual(funding.broadcastCalls, 2) + XCTAssertEqual(funding.broadcastTransactions, [funding.signedTx.serializedTx, funding.signedTx.serializedTx]) + XCTAssertEqual(beforeBroadcastCalls, 1) + } + // MARK: - Availability func testAvailabilityUsesRealMaxSpendable() async throws { @@ -151,10 +233,33 @@ final class HwFundingSignerTests: XCTestCase { } _: { error in XCTAssertEqual(error as? HwTransferError, .signingTimeout) } + await Task.yield() XCTAssertEqual(connecting.staleDisconnects, ["trezor:wallet"]) XCTAssertEqual(funding.signCalls, 1) } + func testSigningTimeoutDoesNotWaitForCancellationIgnoringOperation() async { + let funding = MockHwFunding() + funding.cancellationIgnoringSignDelay = 0.5 + let connecting = MockHwConnecting() + let signer = makeSigner( + funding: funding, + connecting: connecting, + timeouts: (reconnect: 5, compose: 5, sign: 0.05, broadcast: 5) + ) + let start = ContinuousClock.now + + await assertThrowsAsync { + _ = try await signer.prepareSignedFunding(order: .mock(), walletId: "trezor:wallet", address: "bc1q...") + } _: { error in + XCTAssertEqual(error as? HwTransferError, .signingTimeout) + } + + XCTAssertLessThan(start.duration(to: .now), .milliseconds(250)) + await Task.yield() + XCTAssertEqual(connecting.staleDisconnects, ["trezor:wallet"]) + } + func testBroadcastTimeoutThrowsBroadcastUncertainWithoutClearingSession() async { let funding = MockHwFunding() funding.broadcastDelay = 0.4 @@ -213,6 +318,7 @@ final class HwFundingSignerTests: XCTestCase { } _: { error in XCTAssertEqual(error as? HwTransferError, .signingTimeout) } + await Task.yield() XCTAssertEqual(connecting.staleDisconnects, ["trezor:wallet"], "a compose timeout must tear down the stale session") XCTAssertEqual(funding.signCalls, 0, "signing must not run after a compose timeout") } @@ -231,6 +337,26 @@ final class HwFundingSignerTests: XCTestCase { } XCTAssertTrue(connecting.staleDisconnects.isEmpty, "a non-timeout error must not clear the session") } + + func testBrokenThpSessionReconnectsAndRetriesSigningOnce() async throws { + let funding = MockHwFunding() + funding.signErrors = [ + Bitkit.AppError(error: TrezorError.ProtocolError(errorDetails: "THP decryption error: aead::Error")), + ] + let connecting = MockHwConnecting() + let signer = makeSigner(funding: funding, connecting: connecting) + + let result = try await signer.prepareSignedFunding( + order: .mock(), + walletId: "trezor:wallet", + address: "bc1q..." + ) + + XCTAssertEqual(result, funding.signedTx) + XCTAssertEqual(funding.signCalls, 2) + XCTAssertEqual(connecting.staleDisconnects, ["trezor:wallet"]) + XCTAssertEqual(connecting.ensureCalls, 2) + } } /// Async variant of `XCTAssertThrowsError` using a plain (non-autoclosure) operation closure, so the diff --git a/BitkitTests/HwSnapshotMergeTests.swift b/BitkitTests/HwSnapshotMergeTests.swift index b19fea2ed..061e1c4da 100644 --- a/BitkitTests/HwSnapshotMergeTests.swift +++ b/BitkitTests/HwSnapshotMergeTests.swift @@ -13,12 +13,15 @@ final class HwSnapshotMergeTests: XCTestCase { isTransfer: Bool = false, channelId: String? = nil, transferTxId: String? = nil, - confirmed: Bool = true + contact: String? = nil, + confirmed: Bool = true, + txType: PaymentType = .received, + createdAt: UInt64? = nil ) -> OnchainActivity { OnchainActivity( walletId: walletId, id: id, - txType: .received, + txType: txType, txId: txId ?? id, value: 1000, fee: 100, @@ -33,8 +36,8 @@ final class HwSnapshotMergeTests: XCTestCase { confirmTimestamp: nil, channelId: channelId, transferTxId: transferTxId, - contact: nil, - createdAt: nil, + contact: contact, + createdAt: createdAt, updatedAt: nil, seenAt: nil ) @@ -47,12 +50,14 @@ final class HwSnapshotMergeTests: XCTestCase { existing: [OnchainActivity], incoming: [Activity], pruneMissing: Bool = true, + currentTimestamp: UInt64 = 100_000, transferChannelIdsByFundingTxId: [String: String] = [:] ) -> HwSnapshotMerge.Plan { HwSnapshotMerge.plan( existing: existing, incoming: incoming, pruneMissing: pruneMissing, + currentTimestamp: currentTimestamp, transferChannelIdsByFundingTxId: transferChannelIdsByFundingTxId ) } @@ -85,6 +90,24 @@ final class HwSnapshotMergeTests: XCTestCase { XCTAssertTrue(plan.toUpsert.isEmpty) } + func testKeepsLocallyCreatedPendingSendUntilWatcherReportsIt() { + let plan = makePlan( + existing: [onchain(id: "pendingSend", confirmed: false, txType: .sent, createdAt: 100_000)], + incoming: [] + ) + + XCTAssertTrue(plan.toDelete.isEmpty) + } + + func testDeletesExpiredPendingSendMissingFromCompleteSnapshot() { + let plan = makePlan( + existing: [onchain(id: "pendingSend", confirmed: false, txType: .sent, createdAt: 1)], + incoming: [] + ) + + XCTAssertEqual(plan.toDelete.map(\.id), ["pendingSend"]) + } + func testCarriesTransferMetadataForwardOntoIncomingActivity() { let stored = onchain(id: "tx1", isTransfer: true, channelId: "channel-1", transferTxId: "transfer-1") let plan = makePlan( @@ -110,6 +133,15 @@ final class HwSnapshotMergeTests: XCTestCase { XCTAssertEqual(merged?.transferTxId, "transfer-2") } + func testCarriesStoredContactForwardOntoWatcherActivity() { + let plan = makePlan( + existing: [onchain(id: "tx1", contact: "pubky-contact")], + incoming: [.onchain(onchain(id: "tx1"))] + ) + + XCTAssertEqual(upserted(plan, id: "tx1")?.contact, "pubky-contact") + } + func testMatchesStoredMetadataByTxIdNotActivityId() { // Core can re-key an activity (e.g. a boost) while the txid stays the same. let stored = onchain(id: "oldId", txId: "tx1", isTransfer: true, channelId: "channel-1") diff --git a/BitkitTests/HwTransferMocks.swift b/BitkitTests/HwTransferMocks.swift index f06160013..88dd6da05 100644 --- a/BitkitTests/HwTransferMocks.swift +++ b/BitkitTests/HwTransferMocks.swift @@ -1,5 +1,6 @@ @testable import Bitkit import BitkitCore +import Foundation /// Shared mocks for the hardware-wallet transfer tests (`HwFundingSignerTests`, /// `TransferViewModelHwTests`). @@ -14,7 +15,9 @@ final class MockHwFunding: HwTransferFunding { var composeError: Error? var composeDelay: Double = 0 var signError: Error? + var signErrors: [Error] = [] var signDelay: Double = 0 + var cancellationIgnoringSignDelay: Double = 0 var broadcastError: Error? var broadcastDelay: Double = 0 var funding = HwFundingTransaction(psbt: "psbt", miningFeeSats: 141, feeRate: 1, totalSpent: 43186, satsPerVByte: 1) @@ -26,6 +29,7 @@ final class MockHwFunding: HwTransferFunding { private(set) var maxSpendableCalls: [(address: String, satsPerVByte: UInt64)] = [] private(set) var signCalls = 0 private(set) var broadcastCalls = 0 + private(set) var broadcastTransactions: [String] = [] func getFundingAccount(walletId _: String, addressType _: AddressScriptType) throws -> HwFundingAccount { if let accountError { throw accountError } @@ -71,12 +75,21 @@ final class MockHwFunding: HwTransferFunding { func signFunding(walletId _: String, funding _: HwFundingTransaction) async throws -> HwFundingSignedTx { signCalls += 1 if signDelay > 0 { try await Task.sleep(nanoseconds: UInt64(signDelay * 1_000_000_000)) } + if cancellationIgnoringSignDelay > 0 { + await withCheckedContinuation { continuation in + DispatchQueue.global().asyncAfter(deadline: .now() + cancellationIgnoringSignDelay) { + continuation.resume() + } + } + } + if !signErrors.isEmpty { throw signErrors.removeFirst() } if let signError { throw signError } return signedTx } - func broadcastFunding(serializedTx _: String) async throws -> String { + func broadcastFunding(serializedTx: String) async throws -> String { broadcastCalls += 1 + broadcastTransactions.append(serializedTx) if broadcastDelay > 0 { try await Task.sleep(nanoseconds: UInt64(broadcastDelay * 1_000_000_000)) } if let broadcastError { throw broadcastError } return broadcastTxId @@ -121,4 +134,8 @@ final class MockHwConnecting: HwTransferConnecting { func disconnectStaleSession(walletId: String) async { staleDisconnects.append(walletId) } + + func scheduleStaleSessionCleanup(walletId: String) { + staleDisconnects.append(walletId) + } } diff --git a/BitkitTests/HwWalletManagerPassphraseTests.swift b/BitkitTests/HwWalletManagerPassphraseTests.swift index 7d126355c..29e44d689 100644 --- a/BitkitTests/HwWalletManagerPassphraseTests.swift +++ b/BitkitTests/HwWalletManagerPassphraseTests.swift @@ -26,6 +26,9 @@ final class HwWalletManagerPassphraseTests: XCTestCase { var ensureConnectedError: Error? var connectWithWalletModeError: Error? + var blocksStaleDisconnect = false + var onStaleDisconnect: (() -> Void)? + private var staleDisconnectContinuation: CheckedContinuation? private(set) var ensureCalls: [String] = [] private(set) var openCalls: [(deviceId: String, mode: TrezorWalletMode, passphrase: String)] = [] @@ -64,6 +67,16 @@ final class HwWalletManagerPassphraseTests: XCTestCase { func disconnectStaleSession(deviceId: String) async { staleDisconnects.append(deviceId) + onStaleDisconnect?() + if blocksStaleDisconnect { + await withCheckedContinuation { staleDisconnectContinuation = $0 } + } + } + + func finishStaleDisconnect() { + blocksStaleDisconnect = false + staleDisconnectContinuation?.resume() + staleDisconnectContinuation = nil } func isKnownBluetoothDevice(deviceId _: String) -> Bool { @@ -176,6 +189,32 @@ final class HwWalletManagerPassphraseTests: XCTestCase { XCTAssertTrue(session.openCalls.isEmpty, "no reopen is needed") } + func testRetryWaitsForScheduledStaleSessionCleanup() async throws { + session.storedDevices = [makeDevice(walletId: standardWalletId)] + session.connectedDeviceId = "dev1" + session.connectedWalletId = standardWalletId + session.blocksStaleDisconnect = true + let manager = makeManager() + let cleanupStarted = expectation(description: "stale cleanup started") + session.onStaleDisconnect = { cleanupStarted.fulfill() } + + manager.scheduleStaleSessionCleanup(walletId: standardWalletId) + await fulfillment(of: [cleanupStarted], timeout: 1) + + let retry = Task { @MainActor in + try await manager.ensureConnected(walletId: standardWalletId) + } + await Task.yield() + + XCTAssertEqual(session.staleDisconnects, ["dev1"]) + XCTAssertTrue(session.ensureCalls.isEmpty) + + session.finishStaleDisconnect() + try await retry.value + + XCTAssertEqual(session.ensureCalls, ["dev1"]) + } + /// A session reporting no identity may be holding any seed the device has open, so it is not /// accepted on trust. A wallet that needs no secret can simply be reopened, which re-reads the /// accounts that failed to resolve. diff --git a/BitkitTests/QuickPayPaymentCoordinatorTests.swift b/BitkitTests/QuickPayPaymentCoordinatorTests.swift index 26c4f0866..f79d28cc6 100644 --- a/BitkitTests/QuickPayPaymentCoordinatorTests.swift +++ b/BitkitTests/QuickPayPaymentCoordinatorTests.swift @@ -167,7 +167,7 @@ final class QuickPayPaymentCoordinatorTests: XCTestCase { } ) - guard case let .success(paymentId) = route else { + guard case let .success(paymentId, _) = route else { return XCTFail("Expected success, got \(String(describing: route))") } XCTAssertEqual(paymentId, invoiceHash) @@ -349,7 +349,7 @@ final class QuickPayPaymentCoordinatorTests: XCTestCase { } ) - guard case let .success(paymentId) = route else { + guard case let .success(paymentId, _) = route else { return XCTFail("Expected success, got \(String(describing: route))") } XCTAssertEqual(paymentId, invoiceHash) @@ -678,7 +678,7 @@ final class QuickPayPaymentCoordinatorTests: XCTestCase { ) XCTAssertFalse(sent) - guard case let .success(paymentId) = route else { + guard case let .success(paymentId, _) = route else { return XCTFail("Expected success, got \(String(describing: route))") } XCTAssertEqual(paymentId, invoiceHash) diff --git a/BitkitTests/ShopPaymentRequestTests.swift b/BitkitTests/ShopPaymentRequestTests.swift index b2f17d3b5..a3304d82f 100644 --- a/BitkitTests/ShopPaymentRequestTests.swift +++ b/BitkitTests/ShopPaymentRequestTests.swift @@ -13,6 +13,11 @@ final class ShopPaymentRequestTests: XCTestCase { XCTAssertFalse(ShopPaymentRequest.isSupported(.pubkyAuth(data: "pubkyauth://example"))) } + func testOnchainPaymentScopeRejectsLightning() { + XCTAssertTrue(ShopPaymentRequest.isOnchainPayment(.onChain(invoice: onchainInvoice))) + XCTAssertFalse(ShopPaymentRequest.isOnchainPayment(.lightning(invoice: lightningInvoice))) + } + func testNonPaymentRequestDoesNotClearExistingPaymentState() async { let app = AppViewModel() app.scannedLightningInvoice = lightningInvoice @@ -24,7 +29,7 @@ final class ShopPaymentRequestTests: XCTestCase { ) XCTFail("Expected the shop payment scope to reject a setup request") } catch { - XCTAssertTrue(error is ShopPaymentRequestError) + XCTAssertTrue(error is ScanHandlingError) } XCTAssertNotNil(app.scannedLightningInvoice) @@ -43,4 +48,14 @@ final class ShopPaymentRequestTests: XCTestCase { payeeNodeId: nil ) } + + private var onchainInvoice: OnChainInvoice { + OnChainInvoice( + address: "bcrt1qexample", + amountSatoshis: 1000, + label: nil, + message: nil, + params: ["lightning": "test-invoice"] + ) + } } diff --git a/BitkitTests/TrezorSessionFailureTests.swift b/BitkitTests/TrezorSessionFailureTests.swift new file mode 100644 index 000000000..b9e69088c --- /dev/null +++ b/BitkitTests/TrezorSessionFailureTests.swift @@ -0,0 +1,19 @@ +@testable import Bitkit +import BitkitCore +import XCTest + +final class TrezorSessionFailureTests: XCTestCase { + func testRecognizesWrappedChannelMismatch() { + let error = Bitkit.AppError( + error: TrezorError.ProtocolError( + errorDetails: "THP decryption error: Channel mismatch: expected [73, cb], got [73, ca]" + ) + ) + + XCTAssertTrue(error.isTrezorSessionFailure()) + } + + func testRejectsUnrelatedProtocolFailure() { + XCTAssertFalse(TrezorError.ProtocolError(errorDetails: "Invalid PSBT").isTrezorSessionFailure()) + } +} diff --git a/changelog.d/next/688.added.md b/changelog.d/next/688.added.md new file mode 100644 index 000000000..07e0e0dc6 --- /dev/null +++ b/changelog.d/next/688.added.md @@ -0,0 +1 @@ +Added on-chain send support for paired Trezor wallets, including transaction approval on the device.