diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 83f0d2947..dc8068439 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -74,9 +74,25 @@ struct AppScene: View { _blocktank = StateObject(wrappedValue: BlocktankViewModel()) _feeEstimatesManager = StateObject(wrappedValue: feeEstimatesManager) _activity = StateObject(wrappedValue: ActivityListViewModel(transferService: transferService)) + + // Created ahead of `transfer` so the hardware-wallet transfer flow can reach the funding + // (compose/sign/broadcast) and device-session (reconnect) capabilities. + let trezorManager = TrezorManager() + let hwWalletManager = HwWalletManager() + _transfer = StateObject(wrappedValue: TransferViewModel( transferService: transferService, sheetViewModel: sheetViewModel, + hwFunding: hwWalletManager, + hwConnecting: trezorManager, + hwFeeRateProvider: { + guard let rates = await feeEstimatesManager.getEstimates() else { return nil } + return UInt64(SettingsViewModel.shared.defaultTransactionSpeed.getFeeRate(from: rates)) + }, + hwAddressProvider: { + let addressType = LDKNode.AddressType.fromStorage(UserDefaults.standard.string(forKey: "selectedAddressType")) + return try await PrivatePaykitAddressReservationStore.shared.nextNonReservedReceiveAddress(addressType: addressType) + }, onBalanceRefresh: { await walletVm.updateBalanceState() } )) _widgets = StateObject(wrappedValue: WidgetsViewModel()) @@ -84,11 +100,10 @@ struct AppScene: View { _transferTracking = StateObject(wrappedValue: TransferTrackingManager(service: transferService)) - let trezorManager = TrezorManager() let trezorViewModel = TrezorViewModel(connection: trezorManager) _trezorManager = State(initialValue: trezorManager) _trezorViewModel = State(initialValue: trezorViewModel) - _hwWalletManager = State(initialValue: HwWalletManager()) + _hwWalletManager = State(initialValue: hwWalletManager) CoreService.shared.activity.setPrivatePaykitContactResolvers( invoice: { paymentHash in diff --git a/Bitkit/Extensions/Activity+Contact.swift b/Bitkit/Extensions/Activity+Contact.swift index ba94c2148..952f9d507 100644 --- a/Bitkit/Extensions/Activity+Contact.swift +++ b/Bitkit/Extensions/Activity+Contact.swift @@ -40,3 +40,19 @@ extension Activity { } } } + +extension [Activity] { + /// Drop hardware-wallet on-chain rows whose tx already exists as a main-wallet activity, so a + /// transfer shows a single (main-wallet) row in the merged home / All Activity lists. The HW row + /// still appears on the hardware wallet detail screen (which fetches scoped to the device). + func withoutHardwareDuplicates() -> [Activity] { + let localTxIds = Set(compactMap { activity -> String? in + guard !activity.isHardwareWallet, case let .onchain(onchain) = activity else { return nil } + return onchain.txId + }) + return filter { activity in + guard activity.isHardwareWallet, case let .onchain(onchain) = activity else { return true } + return !localTxIds.contains(onchain.txId) + } + } +} diff --git a/Bitkit/Extensions/TrezorError+Cancellation.swift b/Bitkit/Extensions/TrezorError+Cancellation.swift new file mode 100644 index 000000000..328150ff2 --- /dev/null +++ b/Bitkit/Extensions/TrezorError+Cancellation.swift @@ -0,0 +1,25 @@ +import BitkitCore + +extension Error { + /// Whether this error is a Trezor on-device cancellation (user declined signing, or cancelled PIN + /// / passphrase entry). Distinct from Swift `CancellationError` (the flow/task being dismissed). + /// Callers treat it as a silent no-op so the user can retry on the same screen. + func isTrezorUserCancellation() -> Bool { + if let trezorError = self as? TrezorError { + switch trezorError { + case .UserCancelled, .PinCancelled, .PassphraseCancelled: + return true + default: + return false + } + } + + // `ServiceQueue` boxes core errors into a generic `AppError` before they reach the caller, so + // unwrap and re-check the preserved underlying error. + if let appError = self as? AppError, let underlyingError = appError.underlyingError { + return underlyingError.isTrezorUserCancellation() + } + + return false + } +} diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index e41a788f2..505d6613f 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -420,7 +420,11 @@ struct MainNavView: View { case .transferIntro: TransferIntroView() case .fundingOptions: FundingOptions() case .spendingIntro: SpendingIntroView() + case let .spendingIntroHw(deviceId): SpendingIntroView(deviceId: deviceId) case .spendingAmount: SpendingAmount() + case let .spendingAmountHw(deviceId): SpendingAmountHw(deviceId: deviceId) + case let .spendingHwSign(deviceId): SpendingHwSign(deviceId: deviceId) + case .spendingHwSigned: SpendingHwSigned() case let .spendingConfirm(order): SpendingConfirm(order: order) case let .spendingAdvanced(order): SpendingAdvancedView(order: order) case let .transferLearnMore(order): TransferLearnMoreView(order: order) diff --git a/Bitkit/Managers/BalanceManager.swift b/Bitkit/Managers/BalanceManager.swift index 06a7da988..f2d6e7c65 100644 --- a/Bitkit/Managers/BalanceManager.swift +++ b/Bitkit/Managers/BalanceManager.swift @@ -35,8 +35,12 @@ class BalanceManager { let channels = await MainActor.run { lightningService.channels } ?? [] let activeTransfers = try transferService.getActiveTransfers() - let paidOrdersSats = getOrderPaymentsSats(activeTransfers: activeTransfers) - let pendingChannelsSats = getPendingChannelsSats( + let paidOrdersSats = await getOrderPaymentsSats( + activeTransfers: activeTransfers, + channels: channels, + balances: balanceDetails + ) + let pendingChannelsSats = await getPendingChannelsSats( transfers: activeTransfers, channels: channels, balances: balanceDetails @@ -96,21 +100,34 @@ class BalanceManager { // MARK: - Private Helper Methods - /// Calculates the total amount paid for LSP orders before their channel is assigned. - /// Once channelId is set, funds are tracked by getPendingChannelsSats instead. - private func getOrderPaymentsSats(activeTransfers: [Transfer]) -> UInt64 { - // Only count orders that don't have a channel assigned yet - let paidOrders = activeTransfers.filter { - $0.type.isToSpending() && $0.lspOrderId != nil && $0.channelId == nil - } + /// Calculates the total amount paid for LSP orders whose funds are not yet reflected in a + /// Lightning channel balance. The channel is resolved dynamically (via the order's funding tx), + /// so an order is dropped here as soon as LDK exposes its pending channel — where + /// getPendingChannelsSats then tracks it — preventing a double-count during the + /// paid-order → pending-channel transition (notably for hardware-wallet transfers, where LDK can + /// expose the pending channel before the transfer's channelId is persisted). + private func getOrderPaymentsSats( + activeTransfers: [Transfer], + channels: [ChannelDetails], + balances: BalanceDetails + ) async -> UInt64 { + var amount: UInt64 = 0 + let paidOrders = activeTransfers.filter { $0.type.isToSpending() && $0.lspOrderId != nil } for transfer in paidOrders { - Logger.debug( - "Order payment transfer: id=\(transfer.id) orderId=\(transfer.lspOrderId ?? "nil") amount=\(transfer.amountSats)" - ) + let channelId = try? await transferService.resolveChannelId(for: transfer, channels: channels) + let channelBalance = channelId.flatMap { id in + balances.lightningBalances.first { $0.channelIdString == id } + } + if channelBalance == nil { + Logger.debug( + "Order payment transfer: id=\(transfer.id) orderId=\(transfer.lspOrderId ?? "nil") amount=\(transfer.amountSats)" + ) + amount += transfer.amountSats + } } - return paidOrders.reduce(0) { $0 + $1.amountSats } + return amount } /// Amount to subtract from displayed on-chain for order payments whose tx is not yet reflected in LDK. @@ -129,20 +146,24 @@ class BalanceManager { } /// Calculates the total balance in pending (not yet usable) channels. + /// + /// The channel is resolved dynamically (via the order's funding tx) rather than only from the + /// transfer's stored channelId, so a pending channel is subtracted from the Lightning total as + /// soon as LDK exposes it — even before syncTransferStates persists the channelId. private func getPendingChannelsSats( transfers: [Transfer], channels: [ChannelDetails], balances: BalanceDetails - ) -> UInt64 { + ) async -> UInt64 { var amount: UInt64 = 0 - let pendingTransfers = transfers.filter { $0.type.isToSpending() && $0.channelId != nil } + let pendingTransfers = transfers.filter { $0.type.isToSpending() } Logger.debug("Checking \(pendingTransfers.count) pending transfers to spending") Logger.debug("Available channels: \(channels.count), Lightning balances: \(balances.lightningBalances.count)") for transfer in pendingTransfers { - guard let channelId = transfer.channelId else { - Logger.debug("Transfer \(transfer.id) has no channelId") + guard let channelId = try? await transferService.resolveChannelId(for: transfer, channels: channels) else { + Logger.debug("Transfer \(transfer.id) has no resolvable channelId") continue } diff --git a/Bitkit/Managers/HwWalletManager.swift b/Bitkit/Managers/HwWalletManager.swift index ee8e8c7b9..9cea54543 100644 --- a/Bitkit/Managers/HwWalletManager.swift +++ b/Bitkit/Managers/HwWalletManager.swift @@ -45,6 +45,7 @@ final class HwWalletManager { private let networkProvider: () -> TrezorCoinType private let persistActivities: ([Activity]) -> Void private let deleteActivities: (String) -> Void + private let syncHardwareActivity: (OnchainActivity) -> Void // MARK: - Internal state @@ -89,7 +90,8 @@ final class HwWalletManager { electrumUrl: (() -> String)? = nil, network: (() -> TrezorCoinType)? = nil, persistActivities: (([Activity]) -> Void)? = nil, - deleteActivities: ((String) -> Void)? = nil + deleteActivities: ((String) -> Void)? = nil, + syncHardwareActivity: ((OnchainActivity) -> Void)? = nil ) { self.watcherService = watcherService networkProvider = network ?? { OnChainHwService.appDefaultCoinType } @@ -114,6 +116,9 @@ final class HwWalletManager { } } } + self.syncHardwareActivity = syncHardwareActivity ?? { activity in + Task { await CoreService.shared.activity.syncHardwareOnchainActivity(activity) } + } } // MARK: - Device input @@ -328,6 +333,13 @@ final class HwWalletManager { let groups = deviceGroups() recomputeDerivedState(groups: groups) persistGroupActivities(forDevice: deviceId, groups: groups) + // Bridge watcher confirmation data to any matching main-wallet transfer activity (e.g. the + // funding tx of a transfer to spending), without clobbering its transfer metadata. + for activity in activities { + if case let .onchain(onchain) = activity { + syncHardwareActivity(onchain) + } + } emitReceivedTxs(previous: previous, activities: activities) } @@ -414,6 +426,7 @@ final class HwWalletManager { model: device.model, isConnected: connectedDevice != nil, balanceSats: deviceWatchers.reduce(UInt64(0)) { $0.saturatingAdd($1.balanceSats) }, + fundingBalanceSats: fundingBalance(group: group, addressType: hwFundingDefaultAddressType), deviceIds: group.ids ) } @@ -438,6 +451,154 @@ final class HwWalletManager { } } + // MARK: - Funding (transfer to spending) + + /// The watch-only balance available to fund a transfer to spending from `deviceId`, sourced from + /// the given address-type account only (v1: native segwit). Does not require a connected device. + func fundingBalance(deviceId: String, addressType: AddressScriptType = hwFundingDefaultAddressType) -> UInt64 { + guard let group = deviceGroups().first(where: { $0.ids.contains(deviceId) }) else { return 0 } + return fundingBalance(group: group, addressType: addressType) + } + + private func fundingBalance(group: DeviceGroup, addressType: AddressScriptType) -> UInt64 { + watcherData + .filter { group.ids.contains($0.value.deviceId) && self.addressType(fromWatcherId: $0.key) == addressType.stringValue } + .reduce(UInt64(0)) { $0.saturatingAdd($1.value.balanceSats) } + } + + /// Resolve the funding account (xpub + watch-only balance) for a paired device. Does not require + /// a connected device — the xpub is read from the stored known-device record and the balance + /// from the running watchers. + func getFundingAccount( + deviceId: String, + addressType: AddressScriptType = hwFundingDefaultAddressType + ) throws -> HwFundingAccount { + guard let device = knownDevices.first(where: { $0.id == deviceId }) else { + throw AppError(message: "Unknown hardware wallet", debugMessage: "No known device '\(deviceId)'") + } + guard let xpub = device.xpubs[addressType.stringValue] else { + throw AppError( + message: "Missing account", + debugMessage: "Device '\(deviceId)' has no '\(addressType.stringValue)' account xpub" + ) + } + return HwFundingAccount( + xpub: xpub, + addressType: addressType, + balanceSats: fundingBalance(deviceId: deviceId, addressType: addressType) + ) + } + + /// The exact amount spendable from the funding account after the real coin-selection mining fee, + /// computed offline via a `sendMax` compose. No connected device is needed — `fingerprint` is only + /// required for signing — so this mirrors the software wallet's max-sendable estimate. + /// `destinationAddress` is a fee-estimation destination only (never broadcast). + func maxSpendableFunding( + deviceId: String, + destinationAddress: String, + satsPerVByte: UInt64, + addressType: AddressScriptType = hwFundingDefaultAddressType + ) async throws -> UInt64 { + let account = try getFundingAccount(deviceId: deviceId, addressType: addressType) + let params = ComposeParams( + wallet: WalletParams( + extendedKey: account.xpub, + electrumUrl: electrumUrlProvider(), + fingerprint: nil, + network: networkProvider().coreNetwork, + accountType: account.accountType + ), + outputs: [.sendMax(address: destinationAddress)], + feeRates: [Float(satsPerVByte)], + coinSelection: .branchAndBound + ) + let results = try await OnChainHwService.shared.composeTransaction(params: params) + for result in results { + if case let .success(_, fee, _, totalSpent) = result { + return totalSpent > fee ? totalSpent - fee : 0 + } + } + let composeError: String? = results.compactMap { + if case let .error(error) = $0 { return error } else { return nil } + }.first + throw AppError( + message: "Failed to estimate hardware funding amount", + debugMessage: composeError ?? "No successful compose result" + ) + } + + /// Compose the exact on-chain funding payment before prompting for the on-device signature. + /// Requires the device to be connected (the fingerprint drives the PSBT derivation paths); the + /// caller must ensure the Trezor is connected first (via `TrezorManager`). + func composeFundingTransaction( + deviceId: String, + address: String, + sats: UInt64, + satsPerVByte: UInt64, + addressType: AddressScriptType = hwFundingDefaultAddressType + ) async throws -> HwFundingTransaction { + let account = try getFundingAccount(deviceId: deviceId, addressType: addressType) + let network = networkProvider() + let fingerprint = try await TrezorService.shared.getDeviceFingerprint() + let params = ComposeParams( + wallet: WalletParams( + extendedKey: account.xpub, + electrumUrl: electrumUrlProvider(), + fingerprint: fingerprint, + network: network.coreNetwork, + accountType: account.accountType + ), + outputs: [.payment(address: address, amountSats: sats)], + feeRates: [Float(satsPerVByte)], + coinSelection: .branchAndBound + ) + let results = try await OnChainHwService.shared.composeTransaction(params: params) + for result in results { + if case let .success(psbt, fee, feeRate, totalSpent) = result { + return HwFundingTransaction( + psbt: psbt, + miningFeeSats: fee, + feeRate: feeRate, + totalSpent: totalSpent, + satsPerVByte: satsPerVByte + ) + } + } + let composeError: String? = results.compactMap { + if case let .error(error) = $0 { return error } else { return nil } + }.first + throw AppError( + message: "Failed to compose hardware transfer", + debugMessage: composeError ?? "No successful compose result" + ) + } + + /// Sign a composed funding payment on the device. Requires the device to be connected. On signing + /// failure the caller is responsible for clearing the stale session (via + /// `TrezorManager.disconnectStaleSession`). Broadcasting is a separate step so a device-signing + /// timeout is never conflated with an in-flight broadcast. + func signFunding( + deviceId _: String, + funding: HwFundingTransaction + ) async throws -> HwFundingSignedTx { + let network = networkProvider() + let signed = try await TrezorService.shared.signTxFromPsbt(psbtBase64: funding.psbt, network: network) + return HwFundingSignedTx( + serializedTx: signed.serializedTx, + miningFeeSats: funding.miningFeeSats, + feeRate: funding.feeRate, + totalSpent: funding.totalSpent + ) + } + + /// Broadcast a signed funding transaction and return its txid. Does not require a connected device. + func broadcastFunding(serializedTx: String) async throws -> String { + try await OnChainHwService.shared.broadcastRawTx( + serializedTx: serializedTx, + electrumUrl: electrumUrlProvider() + ) + } + // MARK: - Helpers private func deviceId(fromWatcherId watcherId: String) -> String { @@ -445,6 +606,11 @@ final class HwWalletManager { return String(watcherId[.. String { + guard let range = watcherId.range(of: Constants.watcherIdSeparator) else { return "" } + return String(watcherId[range.upperBound...]) + } + // MARK: - Supporting types private struct WatcherSpec { diff --git a/Bitkit/Managers/TrezorManager.swift b/Bitkit/Managers/TrezorManager.swift index 845d40975..71eadebc2 100644 --- a/Bitkit/Managers/TrezorManager.swift +++ b/Bitkit/Managers/TrezorManager.swift @@ -644,6 +644,90 @@ final class TrezorManager { autoReconnectStatus = nil } + // MARK: - Reconnect for on-device signing + + /// Ensure the given known device has a live session so it can sign. Reuses the current + /// 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() { + return + } + // A stale or mismatched session blocks a clean reconnect — clear it first. + if connectedDevice != nil { + await disconnectStaleSession(deviceId: connectedDevice?.id ?? deviceId) + } + try await reconnectKnownDevice(deviceId: deviceId) + } + + private func reconnectKnownDevice(deviceId: String) async throws { + await startScan(clearExisting: true) + + let target: TrezorDeviceInfo + if let scanned = devices.first(where: { $0.id == deviceId }) { + target = scanned + } else if let known = knownDevices.first(where: { $0.id == deviceId }), known.transportType == "bluetooth" { + // Honor the stored BLE transport — reconnect directly to a known device that didn't + // surface within the scan window instead of failing outright. + target = deviceInfo(from: known) + } else { + throw AppError(message: "Reconnect Hardware Device", debugMessage: "Device '\(deviceId)' not found nearby") + } + + await connect(device: target) + + guard connectedDevice?.id == deviceId, await trezorService.isConnected() else { + throw AppError(message: "Reconnect Hardware Device", debugMessage: error ?? "Failed to reconnect '\(deviceId)'") + } + } + + /// Whether the given device is a known Bluetooth device. iOS is BLE-only in practice, but the + /// stored transport is honored so a USB device still gets the hard reconnect error. + func isKnownBluetoothDevice(deviceId: String) -> Bool { + knownDevices.first(where: { $0.id == deviceId })?.transportType == "bluetooth" + } + + /// 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. + func warmUpConnection(deviceId: String) { + guard connectedDevice?.id != deviceId else { return } + guard !isScanning else { return } + guard isKnownBluetoothDevice(deviceId: deviceId) else { return } + Task { + do { + try await reconnectKnownDevice(deviceId: deviceId) + } catch { + trezorLog("Warm-up connect failed for '\(deviceId)': \(error)", level: "debug") + } + } + } + + /// 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 { + do { + try await trezorService.disconnect() + } catch { + trezorLog("Failed to disconnect stale session for '\(deviceId)': \(error)", level: "warn") + } + clearDisconnectedDeviceState() + } + + /// Reconstruct a `TrezorDeviceInfo` for reconnecting to a known BLE device when a fresh scan + /// hasn't surfaced it (BLE devices advertise intermittently). + private func deviceInfo(from known: TrezorKnownDevice) -> TrezorDeviceInfo { + TrezorDeviceInfo( + id: known.id, + transportType: known.transportType == "bluetooth" ? .bluetooth : .usb, + name: known.name, + path: known.path, + label: known.label, + model: known.model, + isBootloader: false + ) + } + // MARK: - Network Switching /// Switches the dashboard's network independently of the app's global network. diff --git a/Bitkit/Models/HwFunding.swift b/Bitkit/Models/HwFunding.swift new file mode 100644 index 000000000..c63f06c39 --- /dev/null +++ b/Bitkit/Models/HwFunding.swift @@ -0,0 +1,49 @@ +import BitkitCore +import LDKNode + +/// The default address type funds are sourced from when transferring from a hardware wallet to +/// spending. v1 funds from the native-segwit account only; multi-address-type spend is out of scope. +let hwFundingDefaultAddressType: AddressScriptType = .nativeSegwit + +/// A paired hardware wallet's account used to fund a transfer, resolved from the stored account +/// xpub and the watch-only balance for that address type. +struct HwFundingAccount: Equatable { + let xpub: String + let addressType: AddressScriptType + let balanceSats: UInt64 + + /// bitkit-core account type for composing/watching this account. + var accountType: AccountType { + addressType.accountType + } +} + +/// A composed (but not yet signed) hardware-wallet funding payment, produced before prompting for +/// the on-device signature so exact fees are known up front. +struct HwFundingTransaction: Equatable { + /// Base64-encoded PSBT ready for on-device signing. + let psbt: String + let miningFeeSats: UInt64 + let feeRate: Float + /// Total value spent (payment + fee, excluding change), from the composer. + let totalSpent: UInt64 + let satsPerVByte: UInt64 +} + +/// A signed (but not yet broadcast) hardware-wallet funding payment. Carries the fee metadata from +/// the composed payment so the broadcast result can be built without re-reading it. +struct HwFundingSignedTx: Equatable { + /// Signed raw transaction hex, ready to broadcast. + let serializedTx: String + let miningFeeSats: UInt64 + let feeRate: Float + let totalSpent: UInt64 +} + +/// The result of signing a composed funding payment on the device and broadcasting it. +struct HwFundingBroadcastResult: Equatable { + let txId: String + let miningFeeSats: UInt64 + let feeRate: UInt64 + let totalSpent: UInt64 +} diff --git a/Bitkit/Models/HwWallet.swift b/Bitkit/Models/HwWallet.swift index 4fe3dba4d..1cc69049b 100644 --- a/Bitkit/Models/HwWallet.swift +++ b/Bitkit/Models/HwWallet.swift @@ -13,6 +13,10 @@ struct HwWallet: Identifiable { let model: String? let isConnected: Bool let balanceSats: UInt64 + /// Balance available to fund a transfer to spending, sourced from the native-segwit account only + /// (v1 funds from native-segwit; other address types are watched but not spent). Defaults to + /// `balanceSats` when not computed separately. + let fundingBalanceSats: UInt64 let deviceIds: Set init( @@ -22,6 +26,7 @@ struct HwWallet: Identifiable { model: String?, isConnected: Bool, balanceSats: UInt64, + fundingBalanceSats: UInt64? = nil, deviceIds: Set? = nil ) { self.id = id @@ -30,6 +35,7 @@ struct HwWallet: Identifiable { self.model = model self.isConnected = isConnected self.balanceSats = balanceSats + self.fundingBalanceSats = fundingBalanceSats ?? balanceSats self.deviceIds = deviceIds ?? [id] } } diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 4eaefe66c..cf01ee0b3 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -66,7 +66,6 @@ "hardware__remove_button" = "Remove {name}"; "hardware__remove_dialog_title" = "Remove {name}"; "hardware__remove_dialog_text" = "Don't worry, your funds are safe and your coins won't be deleted. Bitkit will simply stop displaying the amounts in the wallet."; -"hardware__transfer_not_implemented" = "Transfer to spending not yet implemented."; "cards__buyBitcoin__title" = "Buy"; "cards__buyBitcoin__description" = "Buy some bitcoin"; "cards__btFailed__title" = "Failed"; @@ -90,6 +89,7 @@ "common__copy" = "Copy"; "common__edit" = "Edit"; "common__error" = "Error"; +"common__error_body" = "Unknown error"; "common__share" = "Share"; "common__search" = "Search"; "common__discard" = "Discard"; @@ -176,6 +176,13 @@ "lightning__transfer__confirm" = "Please\nconfirm"; "lightning__transfer__custom_fee" = "Custom fee"; "lightning__transfer__swipe" = "Swipe To Transfer"; +"lightning__transfer_hw__broadcast_uncertain_title" = "Transaction may have been sent"; +"lightning__transfer_hw__broadcast_uncertain_description" = "Your funding transaction may have been broadcast. Please check your activity before trying again."; +"lightning__transfer_hw__open_connect" = "Open Trezor Connect"; +"lightning__transfer_hw__reconnect_error_description" = "Please reconnect your hardware device, it appears to be disconnected from your phone."; +"lightning__transfer_hw__reconnect_error_title" = "Reconnect Hardware Device"; +"lightning__transfer_hw__sign_title" = "Sign with\nyour device"; +"lightning__transfer_hw__signed_title" = "Transaction\nsigned"; "lightning__transfer_to_savings_button" = "Transfer To Savings"; "lightning__transfer_to_spending_button" = "Transfer To Spending"; "lightning__spending_intro__title" = "Transfer\nto spending"; @@ -1262,6 +1269,7 @@ "wallet__toast_payment_success_description" = "Your instant payment was sent successfully."; "wallet__toast_payment_failed_title" = "Payment Failed"; "wallet__toast_payment_failed_description" = "Your instant payment failed. Please try again."; +"wallet__toast_payment_failed_timeout" = "Payment timed out. Please try again."; "wallet__toast_received_transaction_replaced_title" = "Received Transaction Replaced"; "wallet__toast_received_transaction_replaced_description" = "Your received transaction was replaced by a fee bump"; "wallet__toast_transaction_replaced_title" = "Transaction Replaced"; diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index 6cb6e9994..e8471815e 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -1102,6 +1102,7 @@ class ActivityService { amount: UInt64, fee: UInt64, feeRate: UInt32, + isTransfer: Bool = false, contact: String? = nil ) async { do { @@ -1124,7 +1125,7 @@ class ActivityService { timestamp: now, isBoosted: false, boostTxIds: [], - isTransfer: false, + isTransfer: isTransfer, doesExist: true, confirmTimestamp: nil, channelId: nil, @@ -1144,6 +1145,50 @@ class ActivityService { } } + /// Update an existing hardware-wallet on-chain activity from watcher data (confirmation, fee), + /// preserving any transfer metadata already set on it. No-op when no matching activity exists. + func syncHardwareOnchainActivity(_ activity: OnchainActivity) async { + do { + try await ServiceQueue.background(.core) { + guard let existing = try BitkitCore.getActivityByTxId(walletId: WalletScope.default, txId: activity.txId) else { return } + let confirmTimestamp = existing.confirmTimestamp + ?? (activity.confirmed ? (activity.confirmTimestamp ?? activity.timestamp) : nil) + var updated = existing + updated.confirmed = existing.confirmed || activity.confirmed + updated.confirmTimestamp = confirmTimestamp + updated.doesExist = activity.confirmed ? true : existing.doesExist + updated.fee = (existing.fee == 0 && activity.fee > 0) ? activity.fee : existing.fee + updated.updatedAt = max(existing.updatedAt ?? 0, activity.updatedAt ?? activity.timestamp) + guard updated != existing else { return } + try updateActivity(activityId: existing.id, activity: .onchain(updated)) + self.activitiesChangedSubject.send() + Logger.debug("Synced hardware onchain activity '\(activity.txId)'", context: "ActivityService") + } + } catch { + Logger.error("Failed to sync hardware activity '\(activity.txId)': \(error)", context: "ActivityService") + } + } + + /// Atomically mark the on-chain activity for `txId` as a transfer associated with `channelId`, + /// in a single core-queue transaction so a concurrent watcher sync can't clobber it. No-op when + /// no matching activity exists or it is already correctly tagged. + func markOnchainActivityAsTransfer(txId: String, channelId: String) async { + do { + try await ServiceQueue.background(.core) { + guard let existing = try BitkitCore.getActivityByTxId(walletId: WalletScope.default, txId: txId) else { return } + if existing.isTransfer, existing.channelId == channelId { return } + var updated = existing + updated.isTransfer = true + updated.channelId = channelId + try updateActivity(activityId: existing.id, activity: .onchain(updated)) + self.activitiesChangedSubject.send() + Logger.debug("Marked activity \(existing.id) as transfer for channel \(channelId)", context: "ActivityService") + } + } catch { + Logger.error("Failed to mark activity as transfer for \(txId): \(error)", context: "ActivityService") + } + } + func setContact(_ publicKey: String?, forActivity id: String) async throws { let normalizedContact = publicKey.map { PubkyPublicKeyFormat.normalized($0) ?? $0 } diff --git a/Bitkit/Services/TransferService.swift b/Bitkit/Services/TransferService.swift index 31a407d0e..831f4ff54 100644 --- a/Bitkit/Services/TransferService.swift +++ b/Bitkit/Services/TransferService.swift @@ -73,6 +73,24 @@ class TransferService { return id } + /// Create the pending on-chain activity for a hardware-wallet transfer to spending. The funding + /// tx is broadcast externally (on-device signing), so LDK's own activity sync won't surface it; + /// this makes it appear immediately, marked as a transfer. + func createPendingToSpendingActivity(order: IBtOrder, txId: String, fee: UInt64, feeRate: UInt64) async { + guard let address = order.payment?.onchain?.address, !address.isEmpty else { + Logger.error("Order '\(order.id)' has no on-chain payment address", context: "TransferService") + return + } + await coreService.activity.createSentOnchainActivityFromSendResult( + txid: txId, + address: address, + amount: order.feeSat, + fee: fee, + feeRate: UInt32(feeRate), + isTransfer: true + ) + } + /// Mark a transfer as settled func markSettled(id: String) async throws { let settledAt = UInt64(Date().timeIntervalSince1970) @@ -125,6 +143,12 @@ class TransferService { Logger.debug("Updated transfer \(transfer.id) with channelId: \(channelId)", context: "TransferService") } + // Associate the funding tx's on-chain activity with the resolved channel so it reads + // as a transfer (e.g. the hardware-wallet funding tx created before the channel opened). + if let fundingTxId = transfer.fundingTxId { + await coreService.activity.markOnchainActivityAsTransfer(txId: fundingTxId, channelId: channelId) + } + // Check if channel is ready (usable) if let channel = channels.first(where: { $0.channelId.description == channelId }), channel.isUsable @@ -237,7 +261,10 @@ class TransferService { orders = try await blocktankService.orders(orderIds: [orderId], filter: nil, refresh: false) } catch { Logger.error("Failed to fetch Blocktank orders for orderId \(orderId): \(error)", context: "TransferService") - return nil + // Fall back to the persisted channel id (same BOLT `channelId.description` format the + // success path returns) so a transient Blocktank failure doesn't drop a known channel + // and double-count the transfer in the balance calc. + return transfer.channelId } if let order = orders?.first { diff --git a/Bitkit/Services/Trezor/TrezorBridgeTransport.swift b/Bitkit/Services/Trezor/TrezorBridgeTransport.swift index 90138e9c8..93b1c3223 100644 --- a/Bitkit/Services/Trezor/TrezorBridgeTransport.swift +++ b/Bitkit/Services/Trezor/TrezorBridgeTransport.swift @@ -12,6 +12,11 @@ final class TrezorBridgeTransport { private static let headerSize = 6 private static let connectTimeout: TimeInterval = 5 private static let readTimeout: TimeInterval = 30 + /// Read timeout for `/call` messages. Any message in an interactive session (signing, address + /// confirmation, …) can block while the user confirms on the device, and the confirmation isn't + /// tied to a single message type, so every `/call` gets the long window. Bridge is dev/E2E-only + /// and management calls return immediately regardless. + private static let callReadTimeout: TimeInterval = 120 private let decoder = JSONDecoder() private let sessionLock = NSLock() @@ -133,22 +138,24 @@ final class TrezorBridgeTransport { do { let request = Self.encodeFrame(messageType: messageType, data: data) - let response = try post(path: "/call/\(Self.encode(session))", body: request) + // Any interactive message can block on device confirmation, so use the long window. + debugLog("callMessage type=\(messageType)") + let response = try post(path: "/call/\(Self.encode(session))", body: request, readTimeout: Self.callReadTimeout) return try Self.decodeFrame(response) } catch { - debugLog("callMessage FAILED: \(error.localizedDescription)") + debugLog("callMessage FAILED (type=\(messageType)): \(error.localizedDescription)") return TrezorCallMessageResult(success: false, messageType: 0, data: Data(), error: error.localizedDescription, errorCode: nil) } } - private func post(path: String, body: String? = nil) throws -> String { + private func post(path: String, body: String? = nil, readTimeout: TimeInterval? = nil) throws -> String { guard let url = URL(string: "\(Env.trezorBridgeUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")))\(path)") else { throw TrezorBridgeTransportError.invalidUrl } var request = URLRequest(url: url) request.httpMethod = "POST" - request.timeoutInterval = body == nil ? Self.connectTimeout : Self.readTimeout + request.timeoutInterval = body == nil ? Self.connectTimeout : (readTimeout ?? Self.readTimeout) request.cachePolicy = .reloadIgnoringLocalCacheData if let body { request.httpBody = Data(body.utf8) diff --git a/Bitkit/Utilities/Errors.swift b/Bitkit/Utilities/Errors.swift index 140bec735..5c0ecab37 100644 --- a/Bitkit/Utilities/Errors.swift +++ b/Bitkit/Utilities/Errors.swift @@ -77,6 +77,10 @@ enum BlocktankError_deprecated: Error { struct AppError: LocalizedError { let message: String let debugMessage: String? + /// The original error this was wrapped from, when known. Preserved so callers can unwrap and + /// inspect the underlying error (e.g. `isTrezorUserCancellation()`) after it has been boxed by + /// `ServiceQueue` into a generic `AppError`. + let underlyingError: Error? var errorDescription: String? { return NSLocalizedString(message, comment: "") @@ -102,15 +106,17 @@ struct AppError: LocalizedError { // EsploraError // PersistenceError - self.init(message: "App Error", debugMessage: error.localizedDescription) + self.init(message: "App Error", debugMessage: error.localizedDescription, underlyingError: error) } - init(message: String, debugMessage: String?) { + init(message: String, debugMessage: String?, underlyingError: Error? = nil) { self.message = message self.debugMessage = debugMessage + self.underlyingError = underlyingError } init(serviceError: CustomServiceError) { + underlyingError = serviceError switch serviceError { case .nodeNotSetup: message = "Node is not setup" @@ -157,6 +163,7 @@ struct AppError: LocalizedError { // } private init(ldkBuildError: BuildError) { + underlyingError = ldkBuildError switch ldkBuildError as BuildError { case let .InvalidSeedBytes(message: ldkMessage): message = "Invalid seed bytes" @@ -213,6 +220,7 @@ struct AppError: LocalizedError { } private init(ldkError: NodeError) { + underlyingError = ldkError switch ldkError as NodeError { case let .AlreadyRunning(message: ldkMessage): message = "Node is already running" diff --git a/Bitkit/ViewModels/ActivityListViewModel.swift b/Bitkit/ViewModels/ActivityListViewModel.swift index f9adec550..bfbbc866f 100644 --- a/Bitkit/ViewModels/ActivityListViewModel.swift +++ b/Bitkit/ViewModels/ActivityListViewModel.swift @@ -141,7 +141,7 @@ class ActivityListViewModel: ObservableObject { // Fetch extra to account for potential filtering of replaced transactions. // walletId nil → global: merges the Bitkit wallet with watch-only hardware wallets. let latest = try await coreService.activity.get(filter: .all, limit: limitLatest * 3, walletId: nil) - let filtered = await filterOutReplacedSentTransactions(latest) + let filtered = await filterOutReplacedSentTransactions(latest).withoutHardwareDuplicates() latestActivities = Array(filtered.prefix(Int(limitLatest))) // Fetch all activities @@ -201,8 +201,9 @@ class ActivityListViewModel: ObservableObject { walletId: nil ) - // Filter out replaced sent transactions that appear in another transaction's boostTxIds - let filteredOutReplaced = await filterOutReplacedSentTransactions(baseFilteredActivities) + // Filter out replaced sent transactions that appear in another transaction's boostTxIds, + // then collapse hardware-wallet duplicates of the same funding tx into the main-wallet row. + let filteredOutReplaced = await filterOutReplacedSentTransactions(baseFilteredActivities).withoutHardwareDuplicates() // Apply tab filtering filteredActivities = filterActivitiesByTab(filteredOutReplaced, selectedTab: selectedTab) diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 8c8f1bb42..473bb95c0 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -277,12 +277,41 @@ extension AppViewModel { } func toast(_ error: Error) { - if error is CancellationError { + if error is CancellationError || error.isTrezorUserCancellation() { return } toast(type: .error, title: "Error", description: error.localizedDescription) } + func toast(_ error: HwTransferError) { + switch error { + case let .reconnect(isBluetooth): + if isBluetooth { + // BLE devices advertise intermittently; a reconnect miss is usually a locked/asleep + // device, so surface the softer INFO guidance rather than a hard error. + toast(type: .info, title: t("hardware__connect_error")) + } else { + toast( + type: .error, + title: t("lightning__transfer_hw__reconnect_error_title"), + description: t("lightning__transfer_hw__reconnect_error_description") + ) + } + case .signingTimeout: + toast(type: .error, title: t("common__error"), description: t("wallet__toast_payment_failed_timeout")) + case .broadcastUncertain: + toast( + type: .warning, + title: t("lightning__transfer_hw__broadcast_uncertain_title"), + description: t("lightning__transfer_hw__broadcast_uncertain_description") + ) + case let .funding(message): + toast(type: .error, title: t("common__error"), description: message ?? t("common__error_body")) + case let .generic(message): + toast(type: .error, title: t("common__error"), description: message ?? t("common__error_body")) + } + } + func hideToast() { ToastWindowManager.shared.hideToast() } diff --git a/Bitkit/ViewModels/HwFundingSigner.swift b/Bitkit/ViewModels/HwFundingSigner.swift new file mode 100644 index 000000000..8463ba8d4 --- /dev/null +++ b/Bitkit/ViewModels/HwFundingSigner.swift @@ -0,0 +1,206 @@ +import BitkitCore + +/// 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. +/// +/// 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). +@MainActor +struct HwFundingSigner { + /// Device balance and the amount available to fund after holding back an on-chain fee reserve. + struct Availability: Equatable { + let balanceSats: UInt64 + let available: UInt64 + } + + let funding: HwTransferFunding + let connecting: HwTransferConnecting + let feeRateProvider: () async -> UInt64? + /// Provides a fee-estimation destination address (an app receive address); never broadcast to. + let addressProvider: () async throws -> String + let timeouts: (reconnect: Double, compose: Double, sign: Double, broadcast: Double) + + /// Conservative vbyte reserve, used only as a fallback when the real coin-selection estimate + /// (a `sendMax` compose) is unavailable. + var txVBytes: UInt64 = 1200 + /// Minimum fallback fee rate when fee estimates are temporarily unavailable. + var fallbackSatsPerVByte: UInt64 = 1 + /// Fallback fee percentage used when fee estimates are temporarily unavailable. + var fallbackFeePercent: Double = 0.1 + + /// Resolve the device balance and the amount available to fund. Prefers the real max-sendable + /// (same coin-selection fee as the software wallet), falling back to the reserve estimate. + func availability( + deviceId: String, + addressType: AddressScriptType = hwFundingDefaultAddressType + ) async throws -> Availability { + let account = try funding.getFundingAccount(deviceId: deviceId, addressType: addressType) + let available = await maxSpendable(deviceId: deviceId, balanceSats: account.balanceSats, addressType: addressType) + return Availability(balanceSats: account.balanceSats, available: available) + } + + /// The amount available to fund. Computes the exact max-sendable via a `sendMax` compose at the + /// target fee rate; when the fee rate, address, or compose is unavailable, falls back to the + /// conservative reserve clamp. + private func maxSpendable(deviceId: String, balanceSats: UInt64, addressType: AddressScriptType) async -> UInt64 { + if let satsPerVByte = await feeRateProvider(), + let address = try? await addressProvider(), + let spendable = try? await funding.maxSpendableFunding( + deviceId: deviceId, + destinationAddress: address, + satsPerVByte: satsPerVByte, + addressType: addressType + ) + { + return min(balanceSats, spendable) + } + let reserve = await Self.feeReserve( + balanceSats: balanceSats, + satsPerVByte: feeRateProvider(), + txVBytes: txVBytes, + fallbackSatsPerVByte: fallbackSatsPerVByte, + fallbackFeePercent: fallbackFeePercent + ) + return balanceSats > reserve ? balanceSats - reserve : 0 + } + + /// Reconnect → compose → sign+broadcast. Throws `HwTransferError` for recoverable failures and + /// rethrows `CancellationError` when the caller's task is cancelled (user dismissed the flow). + /// Any non-timeout sign/broadcast error propagates as-is for the caller's generic handling. + func sign(order: IBtOrder, deviceId: String, address: String) async throws -> HwFundingBroadcastResult { + try await ensureConnected(deviceId: deviceId) + let satsPerVByte = await resolvedSatsPerVByte() + let tx = try await compose(deviceId: deviceId, address: address, sats: order.feeSat, satsPerVByte: satsPerVByte) + let signed = try await signStep(deviceId: deviceId, funding: tx) + let txId = try await broadcastStep(serializedTx: signed.serializedTx) + return HwFundingBroadcastResult( + txId: txId, + miningFeeSats: signed.miningFeeSats, + feeRate: UInt64(signed.feeRate.rounded(.up)), + totalSpent: signed.totalSpent + ) + } + + /// Best-effort pre-connect of the device before signing (fire-and-forget). Delegates to the + /// device-session capability, which no-ops unless it's a known BLE device that isn't connected. + func warmUp(deviceId: String) { + connecting.warmUpConnection(deviceId: deviceId) + } + + private func ensureConnected(deviceId: String) async throws { + do { + try await withTimeout(timeouts.reconnect) { + try await connecting.ensureConnected(deviceId: deviceId) + } + } catch is CancellationError { + throw CancellationError() + } catch { + // A device-decline during reconnect must stay silent — propagate it raw so the caller's + // cancellation check catches it, rather than surfacing a reconnect error. + if error.isTrezorUserCancellation() { throw error } + throw HwTransferError.reconnect(isBluetooth: connecting.isKnownBluetoothDevice(deviceId: deviceId)) + } + } + + private func compose( + deviceId: String, + address: String, + sats: UInt64, + satsPerVByte: UInt64 + ) async throws -> HwFundingTransaction { + do { + return try await withTimeout(timeouts.compose) { + try await funding.composeFundingTransaction( + deviceId: deviceId, + address: address, + sats: sats, + satsPerVByte: satsPerVByte, + addressType: hwFundingDefaultAddressType + ) + } + } catch is CancellationError { + throw CancellationError() + } catch is Timeout { + await connecting.disconnectStaleSession(deviceId: deviceId) + throw HwTransferError.signingTimeout + } catch { + let message = (error as? AppError)?.debugMessage ?? (error as? AppError)?.message ?? error.localizedDescription + throw HwTransferError.funding(message) + } + } + + private func signStep(deviceId: String, funding tx: HwFundingTransaction) async throws -> HwFundingSignedTx { + do { + return try await withTimeout(timeouts.sign) { + try await funding.signFunding(deviceId: deviceId, funding: tx) + } + } catch is CancellationError { + throw CancellationError() + } catch is Timeout { + await connecting.disconnectStaleSession(deviceId: deviceId) + throw HwTransferError.signingTimeout + } + // 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 + /// already been handed to the network must never be reported as a signing timeout, so a timeout + /// here surfaces `.broadcastUncertain` (the funding tx may still confirm) without tearing down the + /// device session. + private func broadcastStep(serializedTx: String) async throws -> String { + do { + return try await withTimeout(timeouts.broadcast) { + try await funding.broadcastFunding(serializedTx: serializedTx) + } + } catch is CancellationError { + throw CancellationError() + } catch is Timeout { + throw HwTransferError.broadcastUncertain + } + // Any other (real broadcast) error propagates to the caller's generic handler. + } + + private func resolvedSatsPerVByte() async -> UInt64 { + await feeRateProvider() ?? fallbackSatsPerVByte + } + + /// Pure fee-reserve computation. With a known fee rate: `rate × vbytes`. Without one (estimates + /// unavailable): `max(minReserve, balance × fallbackPercent)`. + static func feeReserve( + balanceSats: UInt64, + satsPerVByte: UInt64?, + txVBytes: UInt64 = 1200, + fallbackSatsPerVByte: UInt64 = 1, + fallbackFeePercent: Double = 0.1 + ) -> UInt64 { + guard let satsPerVByte else { + let minReserve = fallbackSatsPerVByte * txVBytes + let fallback = UInt64(Double(balanceSats) * fallbackFeePercent) + return max(minReserve, fallback) + } + return satsPerVByte * txVBytes + } + + private struct Timeout: Error {} + + /// Race an async operation against a timeout. Cancellation (user dismiss) propagates as + /// `CancellationError`; the deadline throws `Timeout`. + 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 { + try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + throw Timeout() + } + defer { group.cancelAll() } + guard let result = try await group.next() else { throw Timeout() } + return result + } + } +} diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift index 9c5d300c7..374b4d817 100644 --- a/Bitkit/ViewModels/NavigationViewModel.swift +++ b/Bitkit/ViewModels/NavigationViewModel.swift @@ -28,7 +28,11 @@ enum Route: Hashable { case transferIntro case fundingOptions case spendingIntro + case spendingIntroHw(deviceId: String) case spendingAmount + case spendingAmountHw(deviceId: String) + case spendingHwSign(deviceId: String) + case spendingHwSigned case spendingConfirm(order: IBtOrder) case spendingAdvanced(order: IBtOrder) case transferLearnMore(order: IBtOrder) diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 5c8d66b39..aaf1aa18c 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -15,6 +15,63 @@ struct TransferValues { var maxClientBalance: UInt64 = 0 } +/// Limits/flags for the hardware-wallet transfer-to-spending flow, sourced from the device balance. +struct HwSpendingState: Equatable { + var isLoading = false + var isSigning = false + var maxAllowedToSend: UInt64 = 0 + var balanceAfterFee: UInt64 = 0 + var quarterAmount: UInt64 = 0 +} + +/// A recoverable failure surfaced by the hardware-wallet transfer flow. The Sign screen maps each +/// case to the matching localized toast. +enum HwTransferError: Error, Equatable { + /// Reconnect failed. `isBluetooth` selects the softer INFO toast for a known BLE device + /// (advertises intermittently — "check that it is unlocked and try again") vs the ERROR toast. + case reconnect(isBluetooth: Bool) + case signingTimeout + case broadcastUncertain + case funding(String?) + case generic(String?) +} + +/// The hardware-wallet funding capability the transfer flow needs. Implemented by `HwWalletManager`; +/// declared as a protocol so the flow stays testable. +@MainActor +protocol HwTransferFunding: Sendable { + func getFundingAccount(deviceId: String, addressType: AddressScriptType) throws -> HwFundingAccount + func maxSpendableFunding( + deviceId: String, + destinationAddress: String, + satsPerVByte: UInt64, + addressType: AddressScriptType + ) async throws -> UInt64 + func composeFundingTransaction( + deviceId: String, + address: String, + sats: UInt64, + satsPerVByte: UInt64, + addressType: AddressScriptType + ) async throws -> HwFundingTransaction + func signFunding(deviceId: String, funding: HwFundingTransaction) async throws -> HwFundingSignedTx + func broadcastFunding(serializedTx: String) async throws -> String +} + +/// The device-session capability the transfer flow needs for on-device signing. Implemented by +/// `TrezorManager`. +@MainActor +protocol HwTransferConnecting: Sendable { + func ensureConnected(deviceId: String) async throws + func disconnectStaleSession(deviceId: String) async + /// Whether the device is 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 reconnect error. + func isKnownBluetoothDevice(deviceId: String) -> Bool + /// Best-effort pre-connect when the sign screen appears, so tapping Open Trezor Connect is less + /// likely to hit a cold reconnect. Fire-and-forget. + func warmUpConnection(deviceId: String) +} + @MainActor class TransferViewModel: ObservableObject { @Published var uiState = TransferUiState() @@ -24,15 +81,26 @@ class TransferViewModel: ObservableObject { @Published var channelsToClose: [ChannelDetails] = [] @Published var transferUnavailable = false + /// Hardware-wallet transfer-to-spending state. + @Published var hwSpending = HwSpendingState() + /// Bumped when a hardware funding tx is signed + broadcast, so the Sign screen advances. + @Published var hwSignedEvent = 0 + /// A recoverable hardware transfer failure the Sign screen should toast, then clear. + @Published var hwTransferError: HwTransferError? + private let coreService: CoreService private let lightningService: LightningService private let currencyService: CurrencyService private let transferService: TransferService private let sheetViewModel: SheetViewModel private let onBalanceRefresh: (() async -> Void)? + /// The device-signing orchestration for a hardware-wallet transfer; nil when the HW capabilities + /// aren't injected (previews/plain init). + private let hwSigner: HwFundingSigner? private var refreshTimer: Timer? private var refreshTask: Task? + private var hwSignTask: Task? private let retryInterval: TimeInterval = 60 // 1 min private let giveUpInterval: TimeInterval = 30 * 60 // 30 min @@ -44,6 +112,11 @@ class TransferViewModel: ObservableObject { currencyService: CurrencyService = .shared, transferService: TransferService, sheetViewModel: SheetViewModel, + hwFunding: HwTransferFunding? = nil, + hwConnecting: HwTransferConnecting? = nil, + hwFeeRateProvider: (() async -> UInt64?)? = nil, + hwAddressProvider: (() async throws -> String)? = nil, + hwTimeouts: (reconnect: Double, compose: Double, sign: Double, broadcast: Double) = (reconnect: 30, compose: 45, sign: 120, broadcast: 120), onBalanceRefresh: (() async -> Void)? = nil ) { self.coreService = coreService @@ -52,6 +125,17 @@ class TransferViewModel: ObservableObject { self.transferService = transferService self.sheetViewModel = sheetViewModel self.onBalanceRefresh = onBalanceRefresh + if let hwFunding, let hwConnecting { + hwSigner = HwFundingSigner( + funding: hwFunding, + connecting: hwConnecting, + feeRateProvider: hwFeeRateProvider ?? { nil }, + addressProvider: hwAddressProvider ?? { throw AppError(message: "No address provider", debugMessage: "") }, + timeouts: hwTimeouts + ) + } else { + hwSigner = nil + } } /// Convenience initializer for testing and previews @@ -74,6 +158,35 @@ class TransferViewModel: ObservableObject { ) } + /// Convenience initializer for hardware-wallet transfer tests. Builds the `TransferService` + /// inside the app module so callers don't construct cross-module service types. + convenience init( + hwFunding: HwTransferFunding?, + hwConnecting: HwTransferConnecting?, + hwFeeRateProvider: (() async -> UInt64?)? = nil, + hwAddressProvider: (() async throws -> String)? = nil, + hwTimeouts: (reconnect: Double, compose: Double, sign: Double, broadcast: Double) = (reconnect: 30, compose: 45, sign: 120, broadcast: 120), + coreService: CoreService = .shared, + lightningService: LightningService = .shared, + sheetViewModel: SheetViewModel = SheetViewModel() + ) { + let transferService = TransferService( + lightningService: lightningService, + blocktankService: coreService.blocktank + ) + self.init( + coreService: coreService, + lightningService: lightningService, + transferService: transferService, + sheetViewModel: sheetViewModel, + hwFunding: hwFunding, + hwConnecting: hwConnecting, + hwFeeRateProvider: hwFeeRateProvider, + hwAddressProvider: hwAddressProvider, + hwTimeouts: hwTimeouts + ) + } + deinit { Task { @MainActor [weak self] in Logger.debug("Stopping poll for order") @@ -154,29 +267,48 @@ class TransferViewModel: ObservableObject { let txTotalSats = order.feeSat + txFee - // Create transfer tracking record for spending - do { - // Create pre-activity metadata for the transfer transaction - let currentTime = UInt64(Date().timeIntervalSince1970) - let preActivityMetadata = BitkitCore.PreActivityMetadata( - walletId: WalletScope.default, - paymentId: txid, - tags: [], - paymentHash: nil, - txId: txid, - address: address, - isReceive: false, - feeRate: UInt64(satsPerVbyte), - isTransfer: true, - channelId: nil, - createdAt: currentTime - ) - try? await coreService.activity.addPreActivityMetadata(preActivityMetadata) + // Pre-activity metadata lets the LDK activity sync recognize this send as a transfer. + let currentTime = UInt64(Date().timeIntervalSince1970) + let preActivityMetadata = BitkitCore.PreActivityMetadata( + walletId: WalletScope.default, + paymentId: txid, + tags: [], + paymentHash: nil, + txId: txid, + address: address, + isReceive: false, + feeRate: UInt64(satsPerVbyte), + isTransfer: true, + channelId: nil, + createdAt: currentTime + ) + try? await coreService.activity.addPreActivityMetadata(preActivityMetadata) + await fundPaidOrder( + order: order, + txId: txid, + txTotalSats: txTotalSats, + preTransferOnchainSats: preTransferOnchainSats + ) + } + + /// Records a paid order and starts watching it, after the funding tx was broadcast (local LDK + /// send or hardware-signed). For the hardware path, also creates the pending on-chain activity + /// (the tx is broadcast externally, so LDK's own activity sync won't surface it). + private func fundPaidOrder( + order: IBtOrder, + txId: String, + createTransferActivity: Bool = false, + fee: UInt64 = 0, + feeRate: UInt64 = 0, + txTotalSats: UInt64? = nil, + preTransferOnchainSats: UInt64? = nil + ) async { + do { let transferId = try await transferService.createTransfer( type: .toSpending, amountSats: order.clientBalanceSat, - fundingTxId: txid, + fundingTxId: txId, lspOrderId: order.id, txTotalSats: txTotalSats, preTransferOnchainSats: preTransferOnchainSats @@ -187,7 +319,12 @@ class TransferViewModel: ObservableObject { // Don't throw - we still want to continue with the order } + if createTransferActivity { + await transferService.createPendingToSpendingActivity(order: order, txId: txId, fee: fee, feeRate: feeRate) + } + lightningSetupStep = 0 + await onBalanceRefresh?() watchOrder(orderId: order.id) } @@ -315,10 +452,124 @@ class TransferViewModel: ObservableObject { uiState.isAdvanced = false } - func resetState() { - uiState = TransferUiState() - transferValues = TransferValues() - selectedChannelIds = [] + // MARK: - Hardware Wallet Transfer + + /// Compute the available/MAX/quarter limits for a hardware-wallet transfer: the signer resolves + /// the device's native-segwit balance minus an on-chain fee reserve, then the shared + /// spending-limit calculation clamps it to the LSP receiving cap. + func updateHwLimits( + deviceId: String, + blocktankInfo: IBtInfo?, + estimateOrderFee: @escaping (_ clientBalance: UInt64, _ lspBalance: UInt64) async throws + -> (networkFeeSat: UInt64, serviceFeeSat: UInt64) + ) async { + guard let hwSigner else { return } + hwSpending = HwSpendingState(isLoading: true) + + let availability: HwFundingSigner.Availability + do { + availability = try await hwSigner.availability(deviceId: deviceId) + } catch { + hwSpending = HwSpendingState(isLoading: false) + hwTransferError = .generic((error as? AppError)?.message ?? error.localizedDescription) + return + } + + do { + let (avail, maxAmount) = try await calculateSpendingLimits( + onchainAvailable: availability.available, + lspMaxClientBalance: blocktankInfo?.options.maxClientBalanceSat, + transferValues: { self.calculateTransferValues(clientBalanceSat: $0, blocktankInfo: blocktankInfo) }, + estimateOrderFee: estimateOrderFee + ) + hwSpending.balanceAfterFee = avail + hwSpending.maxAllowedToSend = maxAmount + hwSpending.quarterAmount = min(availability.balanceSats / 4, maxAmount) + } catch { + hwSpending.balanceAfterFee = 0 + hwSpending.maxAllowedToSend = 0 + hwSpending.quarterAmount = 0 + } + + hwSpending.isLoading = false + } + + /// Pay for the order by composing and signing the funding send on the Trezor (via the signer), + /// then record and watch it. Coordination only — the device orchestration lives in `HwFundingSigner`. + func onTransferToSpendingHwConfirm(order: IBtOrder, deviceId: String) { + guard !hwSpending.isSigning else { return } + guard let hwSigner else { + hwTransferError = .generic(t("common__error")) + return + } + guard let address = order.payment?.onchain?.address, !address.isEmpty else { + hwTransferError = .generic(t("common__error")) + return + } + + hwSpending.isSigning = true + hwTransferError = nil + + hwSignTask = Task { @MainActor [weak self] in + guard let self else { return } + defer { + self.hwSpending.isSigning = false + self.hwSignTask = nil + } + + do { + let result = try await hwSigner.sign(order: order, deviceId: deviceId, address: address) + await fundPaidOrder( + order: order, + txId: result.txId, + createTransferActivity: true, + fee: result.miningFeeSats, + feeRate: result.feeRate + ) + hwSignedEvent += 1 + } catch is CancellationError { + // User dismissed the flow — no toast. + } catch let error as HwTransferError { + self.handleHardwareTransferFailure(error, deviceId: deviceId) + } catch { + if error.isTrezorUserCancellation() { + Logger.info("Hardware transfer cancelled on device '\(deviceId)'", context: "TransferViewModel") + return + } + hwTransferError = .generic((error as? AppError)?.message ?? error.localizedDescription) + } + } + } + + /// Pre-connect the hardware device when the sign screen appears, mirroring Android's warm-up, so + /// tapping Open Trezor Connect is less likely to hit a cold reconnect. Best-effort no-op without + /// the HW capabilities. + func warmUpHardwareConnection(deviceId: String) { + hwSigner?.warmUp(deviceId: deviceId) + } + + /// Cancel an in-flight hardware signing task when the user abandons the sign flow, so a later + /// on-device approval can't still sign/broadcast/record. Idempotent. + func cancelHwSigning() { + hwSignTask?.cancel() + hwSignTask = nil + hwSpending.isSigning = false + } + + private func handleHardwareTransferFailure(_ error: HwTransferError, deviceId: String) { + switch error { + case .reconnect: + Logger.error("Failed to reconnect hardware device '\(deviceId)'", context: "TransferViewModel") + case .signingTimeout: + Logger.warn("Timed out hardware transfer signing for '\(deviceId)'", context: "TransferViewModel") + case .broadcastUncertain: + Logger.warn("Hardware funding broadcast timed out (uncertain) for '\(deviceId)'", context: "TransferViewModel") + case let .funding(message): + Logger.warn("Failed to compose hardware funding for '\(deviceId)': \(message ?? "")", context: "TransferViewModel") + case .generic: + break + } + hwTransferError = error } // MARK: - Balance Calculation @@ -779,3 +1030,8 @@ actor ChannelPendingCapture { return channelData } } + +// MARK: - Hardware transfer capability conformances + +extension HwWalletManager: HwTransferFunding {} +extension TrezorManager: HwTransferConnecting {} diff --git a/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift b/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift new file mode 100644 index 000000000..a9f18a6fa --- /dev/null +++ b/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift @@ -0,0 +1,197 @@ +import BitkitCore +import LDKNode +import SwiftUI + +/// Amount entry for a transfer to spending funded from a paired hardware wallet. Mirrors +/// `SpendingAmount`, but the available/MAX/quarter limits come from the device's native-segwit +/// balance via `TransferViewModel.updateHwLimits`, and Continue advances to the on-device Sign step. +struct SpendingAmountHw: View { + let deviceId: String + + @EnvironmentObject var app: AppViewModel + @EnvironmentObject var blocktank: BlocktankViewModel + @EnvironmentObject var currency: CurrencyViewModel + @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var transfer: TransferViewModel + @EnvironmentObject var wallet: WalletViewModel + + @State private var amountViewModel = AmountInputViewModel() + @State private var isLoading = false + + private var amountSats: UInt64 { + amountViewModel.amountSats + } + + private var maxAllowed: UInt64 { + transfer.hwSpending.maxAllowedToSend + } + + /// Inputs the limit calculation depends on. Keying the `.task` on this reruns `updateHwLimits` + /// when Blocktank info arrives after the screen opens (the LSP caps are otherwise nil → 0 limits). + private struct HwLimitInputs: Equatable { + let deviceId: String + let maxChannelSizeSat: UInt64? + let maxClientBalanceSat: UInt64? + } + + private var hwLimitInputs: HwLimitInputs { + HwLimitInputs( + deviceId: deviceId, + maxChannelSizeSat: blocktank.info?.options.maxChannelSizeSat, + maxClientBalanceSat: blocktank.info?.options.maxClientBalanceSat + ) + } + + private var isValidAmount: Bool { + !transfer.hwSpending.isLoading && maxAllowed > 0 && amountSats <= maxAllowed + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: t("lightning__transfer__nav_title")) + .padding(.bottom, 16) + + DisplayText(t("lightning__spending_amount__title"), accentColor: .purpleAccent) + .fixedSize(horizontal: false, vertical: true) + + NumberPadTextField(viewModel: amountViewModel, showConversion: false) + .onTapGesture { + amountViewModel.togglePrimaryDisplay(currency: currency) + } + .padding(.top, 32) + + Spacer() + + HStack(alignment: .bottom) { + if transfer.hwSpending.isLoading { + HStack(spacing: 4) { + CaptionMText(t("wallet__send_available")) + ProgressView() + .scaleEffect(0.7) + } + } else { + AvailableAmount( + label: t("wallet__send_available"), + amount: Int(transfer.hwSpending.balanceAfterFee), + testIdentifier: "HardwareTransferAmountAvailable" + ) + } + + Spacer() + + actionButtons + } + .padding(.bottom, 12) + + Divider() + + NumberPad( + type: amountViewModel.getNumberPadType(currency: currency), + errorKey: amountViewModel.errorKey, + isDisabled: transfer.hwSpending.isLoading + ) { key in + amountViewModel.handleNumberPadInput(key, currency: currency) + } + + CustomButton( + title: t("common__continue"), + isDisabled: !isValidAmount, + isLoading: isLoading + ) { + await onContinue() + } + .accessibilityIdentifier("HardwareTransferAmountContinue") + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + .offlineOverlay(title: t("lightning__transfer__nav_title")) + .task(id: hwLimitInputs) { + await transfer.updateHwLimits( + deviceId: deviceId, + blocktankInfo: blocktank.info, + estimateOrderFee: { clientBalance, lspBalance in + let estimate = try await blocktank.estimateOrderFee(clientBalance: clientBalance, lspBalance: lspBalance) + return (estimate.networkFeeSat, estimate.serviceFeeSat) + } + ) + } + .onChange(of: maxAllowed) { updateInputCap() } + .onChange(of: amountViewModel.maxExceededCount) { onMaxExceeded() } + .onChange(of: transfer.hwTransferError) { _, error in + guard let error else { return } + app.toast(error) + transfer.hwTransferError = nil + } + } + + private func updateInputCap() { + amountViewModel.maxAmountOverride = maxAllowed > 0 ? maxAllowed : nil + } + + private func onMaxExceeded() { + amountViewModel.updateFromSats(maxAllowed, currency: currency) + app.toast( + type: .warning, + title: t("lightning__spending_amount__error_max__title"), + description: t( + "lightning__spending_amount__error_max__description", + variables: ["amount": CurrencyFormatter.formatSats(maxAllowed)] + ), + visibilityTime: Toast.visibilityTimeShort + ) + } + + private var actionButtons: some View { + HStack(spacing: 8) { + NumberPadActionButton( + text: currency.primaryDisplay == .bitcoin ? "Bitcoin" : currency.selectedCurrency, + imageName: "arrow-up-down" + ) { + withAnimation { + amountViewModel.togglePrimaryDisplay(currency: currency) + } + } + + NumberPadActionButton(text: t("lightning__spending_amount__quarter")) { + amountViewModel.updateFromSats(transfer.hwSpending.quarterAmount, currency: currency) + } + .accessibilityIdentifier("HardwareTransferAmountQuarter") + + NumberPadActionButton(text: t("common__max")) { + amountViewModel.updateFromSats(maxAllowed, currency: currency) + } + .accessibilityIdentifier("HardwareTransferAmountMax") + } + } + + private func onContinue() async { + isLoading = true + defer { isLoading = false } + + // Wait for the node to be running if it's not already (needed to open the channel later). + if wallet.nodeLifecycleState != .running { + let isReady = await wallet.waitForNodeToRun(timeoutSeconds: 30.0) + guard isReady else { + app.toast( + type: .error, + title: "Lightning node not ready", + description: "Please wait for the Lightning node to start and try again." + ) + return + } + } + + do { + let values = transfer.calculateTransferValues(clientBalanceSat: amountSats, blocktankInfo: blocktank.info) + let lspBalance = max(values.defaultLspBalance, values.minLspBalance) + let order = try await blocktank.createOrder(clientBalance: amountSats, lspBalance: lspBalance) + + transfer.onOrderCreated(order: order) + navigation.navigate(.spendingHwSign(deviceId: deviceId)) + } catch { + let appError = AppError(error: error) + app.toast(type: .error, title: appError.message, description: appError.debugMessage) + } + } +} diff --git a/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift b/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift new file mode 100644 index 000000000..f16d60990 --- /dev/null +++ b/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift @@ -0,0 +1,171 @@ +import BitkitCore +import SwiftUI + +/// "Sign with your device" — shows the Blocktank order fees and asks the user to sign the funding +/// transaction on the Trezor. Reuses the existing Learn More / Advanced controls; on-device signing +/// replaces the local swipe-to-pay. Advances to the Signed screen on success. +struct SpendingHwSign: View { + let deviceId: String + + @EnvironmentObject var app: AppViewModel + @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var transfer: TransferViewModel + + var body: some View { + Group { + if let order = transfer.uiState.order { + content(order: transfer.displayOrder(for: order)) + } else { + // No active order (e.g. after process death) — bail back to the wallet. + Color.clear.onAppear { navigation.reset() } + } + } + } + + /// Figma "Visual" width as a fraction of the 375-wide frame (256/375). + private let illustrationWidthRatio = 256.0 / 375.0 + /// Figma top of the Trezor "Visual" within the content area below the nav bar: + /// (visualTop - navHeight) / (frameHeight - navHeight - homeIndicator). + private let illustrationTopRatio = (488.0 - 92.0) / (812.0 - 92.0 - 34.0) + + private func content(order: IBtOrder) -> some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: t("lightning__transfer__nav_title")) + .padding(.bottom, 16) + + // The Trezor is a background visual behind the content (including the bottom button), so + // it renders at its natural aspect and doesn't get squeezed by the vertical layout. + ZStack(alignment: .top) { + trezorIllustration + + belowNav(order: order) + } + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + .task { + // Warm up the BLE connection ahead of the user tapping Open Trezor Connect. + transfer.warmUpHardwareConnection(deviceId: deviceId) + } + .onChange(of: transfer.hwSignedEvent) { + navigation.navigate(.spendingHwSigned) + } + .onChange(of: transfer.hwTransferError) { _, error in + guard let error else { return } + app.toast(error) + transfer.hwTransferError = nil + } + .onDisappear { + // Cancel an in-flight sign only when the user truly leaves the flow (back/reset), not when + // pushing deeper (Learn More / Advanced / Signed) which keeps this route in the path. + let stillInFlow = navigation.path.contains { + if case .spendingHwSign = $0 { return true } else { return false } + } + if !stillInFlow { transfer.cancelHwSigning() } + } + } + + private func belowNav(order: IBtOrder) -> some View { + VStack(alignment: .leading, spacing: 0) { + DisplayText(t("lightning__transfer_hw__sign_title"), accentColor: .purpleAccent) + + SpendingHwFeeGrid(order: order) + .padding(.top, 16) + + HStack(spacing: 16) { + CustomButton(title: t("common__learn_more"), size: .small) { + navigation.navigate(.transferLearnMore(order: order)) + } + .accessibilityIdentifier("HardwareTransferSignLearnMore") + + if transfer.uiState.isAdvanced { + CustomButton( + title: t("lightning__spending_confirm__default"), + size: .small, + isDisabled: transfer.hwSpending.isSigning + ) { + transfer.onDefaultClick() + } + .accessibilityIdentifier("HardwareTransferSignDefault") + } else { + CustomButton( + title: t("common__advanced"), + size: .small, + isDisabled: transfer.hwSpending.isSigning + ) { + navigation.navigate(.spendingAdvanced(order: order)) + } + .accessibilityIdentifier("HardwareTransferSignAdvanced") + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 24) + + Spacer() + + CustomButton( + title: t("lightning__transfer_hw__open_connect"), + isDisabled: transfer.hwSpending.isSigning, + isLoading: transfer.hwSpending.isSigning + ) { + transfer.onTransferToSpendingHwConfirm(order: order, deviceId: deviceId) + } + .accessibilityIdentifier("HardwareTransferOpenTrezorConnect") + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private var trezorIllustration: some View { + GeometryReader { geo in + let side = geo.size.width * illustrationWidthRatio + Image("trezor-card") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: side, height: side) + .position(x: geo.size.width / 2, y: geo.size.height * illustrationTopRatio + side / 2) + } + // Span the full screen width (negate the screen's horizontal content padding) so the visual + // matches the Figma sizing, which is measured against the full frame. + .padding(.horizontal, -16) + .allowsHitTesting(false) + .accessibilityHidden(true) + } +} + +/// Blocktank order fee summary shared by the hardware Sign and Signed screens. +struct SpendingHwFeeGrid: View { + let order: IBtOrder + + var body: some View { + VStack(spacing: 16) { + HStack { + FeeDisplayRow( + label: t("lightning__spending_confirm__network_fee"), + amount: order.networkFeeSat + ) + .frame(maxWidth: .infinity) + + FeeDisplayRow( + label: t("lightning__spending_confirm__lsp_fee"), + amount: order.serviceFeeSat + ) + .frame(maxWidth: .infinity) + } + + HStack { + FeeDisplayRow( + label: t("lightning__spending_confirm__amount"), + amount: order.clientBalanceSat + ) + .frame(maxWidth: .infinity) + + FeeDisplayRow( + label: t("lightning__spending_confirm__total"), + amount: order.feeSat + ) + .frame(maxWidth: .infinity) + } + } + } +} diff --git a/Bitkit/Views/Transfer/Hardware/SpendingHwSigned.swift b/Bitkit/Views/Transfer/Hardware/SpendingHwSigned.swift new file mode 100644 index 000000000..57f46dda6 --- /dev/null +++ b/Bitkit/Views/Transfer/Hardware/SpendingHwSigned.swift @@ -0,0 +1,52 @@ +import BitkitCore +import SwiftUI + +/// "Transaction signed" — a brief confirmation of the signed funding tx before auto-forwarding to +/// the shared "Funds in Transfer" progress screen. +struct SpendingHwSigned: View { + @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var transfer: TransferViewModel + + /// Figma handoff delay before forwarding from the signed confirmation. + private let autoForwardDelay: UInt64 = 1_000_000_000 + + var body: some View { + Group { + if let order = transfer.uiState.order { + content(order: transfer.displayOrder(for: order)) + } else { + Color.clear.onAppear { navigation.reset() } + } + } + } + + private func content(order: IBtOrder) -> some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: t("lightning__transfer__nav_title")) + .padding(.bottom, 16) + + DisplayText(t("lightning__transfer_hw__signed_title"), accentColor: .purpleAccent) + + SpendingHwFeeGrid(order: order) + .padding(.top, 16) + + Image("check") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 256, height: 256) + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityIdentifier("HardwareTransferSigned") + + Spacer() + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + .task { + try? await Task.sleep(nanoseconds: autoForwardDelay) + guard !Task.isCancelled else { return } + navigation.navigate(.settingUp) + } + } +} diff --git a/Bitkit/Views/Transfer/SpendingIntroView.swift b/Bitkit/Views/Transfer/SpendingIntroView.swift index 3af6436a9..3b34ed8d2 100644 --- a/Bitkit/Views/Transfer/SpendingIntroView.swift +++ b/Bitkit/Views/Transfer/SpendingIntroView.swift @@ -1,6 +1,9 @@ import SwiftUI struct SpendingIntroView: View { + /// When set, this intro is for a hardware-wallet transfer; Continue routes to the HW amount flow. + var deviceId: String? + @EnvironmentObject var app: AppViewModel @EnvironmentObject var navigation: NavigationViewModel @@ -13,7 +16,11 @@ struct SpendingIntroView: View { buttonText: t("lightning__spending_intro__button"), onButtonPress: { app.hasSeenTransferToSpendingIntro = true - navigation.navigate(.spendingAmount) + if let deviceId { + navigation.navigate(.spendingAmountHw(deviceId: deviceId)) + } else { + navigation.navigate(.spendingAmount) + } }, accentColor: .purpleAccent, imagePosition: .center, diff --git a/Bitkit/Views/Trezor/TrezorRootView.swift b/Bitkit/Views/Trezor/TrezorRootView.swift index 844157edf..c97c73d92 100644 --- a/Bitkit/Views/Trezor/TrezorRootView.swift +++ b/Bitkit/Views/Trezor/TrezorRootView.swift @@ -110,24 +110,12 @@ private struct TrezorLifecycleModifier: ViewModifier { } } -// MARK: - Debug Log Wrapper - -/// Isolates the debug log panel's ViewModel binding. -private struct TrezorDebugLogWrapper: View { - @Environment(TrezorViewModel.self) private var trezor - - var body: some View { - @Bindable var trezor = trezor - TrezorDebugLogPanel( - isExpanded: $trezor.showDebugLog - ) - } -} - // MARK: - Dialogs Modifier -/// Groups all sheet and overlay presentations that depend on ViewModel state, -/// keeping TrezorRootView's body free of @Environment access. +/// Groups all sheet and overlay presentations that depend on ViewModel state, keeping +/// TrezorRootView's body free of @Environment access. Dev-only: this is the independent PIN/passphrase +/// path used by the Trezor dashboard / emulator testing. The user-facing hardware transfer flow relies +/// on the on-connect flow (`HardwareConnectSheet`) and on-device PIN entry instead. private struct TrezorDialogsModifier: ViewModifier { @Environment(TrezorManager.self) private var trezorManager @@ -176,6 +164,20 @@ private struct TrezorDialogsModifier: ViewModifier { } } +// MARK: - Debug Log Wrapper + +/// Isolates the debug log panel's ViewModel binding. +private struct TrezorDebugLogWrapper: View { + @Environment(TrezorViewModel.self) private var trezor + + var body: some View { + @Bindable var trezor = trezor + TrezorDebugLogPanel( + isExpanded: $trezor.showDebugLog + ) + } +} + // MARK: - Network Selector private struct NetworkSelectorRow: View { diff --git a/Bitkit/Views/Wallets/HardwareWalletScreen.swift b/Bitkit/Views/Wallets/HardwareWalletScreen.swift index 283ca6931..343f965c0 100644 --- a/Bitkit/Views/Wallets/HardwareWalletScreen.swift +++ b/Bitkit/Views/Wallets/HardwareWalletScreen.swift @@ -38,6 +38,9 @@ struct HardwareWalletScreen: View { .task(id: wallet?.walletId) { await loadActivities() } + .task { + await app.checkGeoStatus() + } .onReceive(activity.activitiesChangedPublisher) { _ in Task { await loadActivities() } } @@ -61,8 +64,9 @@ struct HardwareWalletScreen: View { } private func content(for wallet: HwWallet) -> some View { - let hasFunds = wallet.balanceSats > 0 let hasActivity = !activities.isEmpty + // Only the native-segwit account can currently fund a transfer to spending. + let canTransferToSpending = wallet.fundingBalanceSats > 0 return VStack(spacing: 0) { ScrollView(showsIndicators: false) { @@ -74,7 +78,7 @@ struct HardwareWalletScreen: View { testIdPrefix: "TotalBalance" ) - if hasFunds { + if canTransferToSpending { transferButton .padding(.top, 28) } @@ -123,11 +127,16 @@ struct HardwareWalletScreen: View { .resizable() .scaledToFit() .frame(width: 16, height: 16) - .foregroundColor(.white80) + .foregroundColor(.white80), + isDisabled: GeoService.shared.isGeoBlocked ) { - app.toast(type: .warning, title: t("hardware__transfer_not_implemented")) + if app.hasSeenTransferToSpendingIntro { + navigation.navigate(.spendingAmountHw(deviceId: deviceId)) + } else { + navigation.navigate(.spendingIntroHw(deviceId: deviceId)) + } } - .accessibilityIdentifier("HwTransferToSpending") + .accessibilityIdentifier("HardwareTransferToSpending") } private func removeButton(for wallet: HwWallet) -> some View { diff --git a/BitkitTests/ActivityHardwareTests.swift b/BitkitTests/ActivityHardwareTests.swift index b4a41ba46..10ad50195 100644 --- a/BitkitTests/ActivityHardwareTests.swift +++ b/BitkitTests/ActivityHardwareTests.swift @@ -5,12 +5,12 @@ import XCTest /// Covers the `Activity` walletId helpers that drive the merged activity list + the blue /// hardware-wallet icon variant. final class ActivityHardwareTests: XCTestCase { - private func onchain(walletId: String) -> Activity { + private func onchain(walletId: String, txId: String = "tx1") -> Activity { .onchain(OnchainActivity( walletId: walletId, - id: "tx1", + id: txId, txType: .received, - txId: "tx1", + txId: txId, value: 1000, fee: 0, feeRate: 1, @@ -60,4 +60,25 @@ final class ActivityHardwareTests: XCTestCase { XCTAssertFalse(onchain(walletId: WalletScope.default).isHardwareWallet) XCTAssertFalse(lightning(walletId: WalletScope.default).isHardwareWallet) } + + func testWithoutHardwareDuplicatesCollapsesSharedTx() { + let mainTransfer = onchain(walletId: WalletScope.default, txId: "tx1") + let hwDuplicate = onchain(walletId: "trezor:abc", txId: "tx1") + let hwOnly = onchain(walletId: "trezor:abc", txId: "tx2") + let ln = lightning(walletId: WalletScope.default) + + let result = [mainTransfer, hwDuplicate, hwOnly, ln].withoutHardwareDuplicates() + + func txIds(hardware: Bool) -> [String] { + result.compactMap { activity in + guard activity.isHardwareWallet == hardware, case let .onchain(onchain) = activity else { return nil } + return onchain.txId + } + } + + XCTAssertEqual(result.count, 3, "the hardware duplicate of tx1 is dropped") + XCTAssertEqual(txIds(hardware: false), ["tx1"], "the main-wallet transfer row is kept") + XCTAssertEqual(txIds(hardware: true), ["tx2"], "hardware-only tx2 is kept, tx1 duplicate removed") + XCTAssertTrue(result.contains { if case .lightning = $0 { return true }; return false }, "lightning rows are untouched") + } } diff --git a/BitkitTests/HwFundingSignerTests.swift b/BitkitTests/HwFundingSignerTests.swift new file mode 100644 index 000000000..96112ba54 --- /dev/null +++ b/BitkitTests/HwFundingSignerTests.swift @@ -0,0 +1,227 @@ +@testable import Bitkit +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 +/// `TransferViewModel` via the `HwTransferFunding` / `HwTransferConnecting` mocks. +@MainActor +final class HwFundingSignerTests: XCTestCase { + private func makeSigner( + funding: MockHwFunding, + connecting: MockHwConnecting, + feeRate: UInt64? = 2, + address: String? = "bc1qtest", + timeouts: (reconnect: Double, compose: Double, sign: Double, broadcast: Double) = (reconnect: 5, compose: 5, sign: 5, broadcast: 5) + ) -> HwFundingSigner { + HwFundingSigner( + funding: funding, + connecting: connecting, + feeRateProvider: { feeRate }, + addressProvider: { if let address { return address } else { throw MockHwFunding.TestError() } }, + timeouts: timeouts + ) + } + + // MARK: - Fee reserve (fallback math) + + func testFeeReserveUsesRateWhenAvailable() { + XCTAssertEqual(HwFundingSigner.feeReserve(balanceSats: 1_000_000, satsPerVByte: 5), 5 * 1200) + } + + func testFeeReserveFallbackUsesPercentWhenLarger() { + // 10% of 1,000,000 = 100,000 dominates the 1,200 sat floor. + XCTAssertEqual(HwFundingSigner.feeReserve(balanceSats: 1_000_000, satsPerVByte: nil), 100_000) + } + + func testFeeReserveFallbackUsesFloorWhenPercentSmaller() { + // 10% of 5,000 = 500, below the 1 * 1200 floor. + XCTAssertEqual(HwFundingSigner.feeReserve(balanceSats: 5000, satsPerVByte: nil), 1200) + } + + // MARK: - Availability + + func testAvailabilityUsesRealMaxSpendable() async throws { + let funding = MockHwFunding() + funding.account = HwFundingAccount(xpub: "zpubNS", addressType: .nativeSegwit, balanceSats: 1_000_000) + funding.maxSpendable = 990_000 + let signer = makeSigner(funding: funding, connecting: MockHwConnecting(), feeRate: 2) + + let availability = try await signer.availability(deviceId: "dev1") + + XCTAssertEqual(availability.balanceSats, 1_000_000) + XCTAssertEqual(availability.available, 990_000, "available comes from the real sendMax estimate") + XCTAssertEqual(funding.maxSpendableCalls.first?.satsPerVByte, 2) + XCTAssertEqual(funding.maxSpendableCalls.first?.address, "bc1qtest") + } + + func testAvailabilityClampsSpendableToBalance() async throws { + let funding = MockHwFunding() + funding.account = HwFundingAccount(xpub: "zpubNS", addressType: .nativeSegwit, balanceSats: 800_000) + funding.maxSpendable = 990_000 + let signer = makeSigner(funding: funding, connecting: MockHwConnecting(), feeRate: 2) + + let availability = try await signer.availability(deviceId: "dev1") + + XCTAssertEqual(availability.available, 800_000, "available is clamped to the device balance") + } + + func testAvailabilityFallsBackToReserveWhenEstimateFails() async throws { + let funding = MockHwFunding() + funding.account = HwFundingAccount(xpub: "zpubNS", addressType: .nativeSegwit, balanceSats: 1_000_000) + funding.maxSpendableError = MockHwFunding.TestError() + let signer = makeSigner(funding: funding, connecting: MockHwConnecting(), feeRate: 2) + + let availability = try await signer.availability(deviceId: "dev1") + + XCTAssertEqual(availability.available, 1_000_000 - 2 * 1200, "falls back to the reserve estimate") + } + + func testAvailabilityFallsBackToReserveWhenAddressUnavailable() async throws { + let funding = MockHwFunding() + funding.account = HwFundingAccount(xpub: "zpubNS", addressType: .nativeSegwit, balanceSats: 1_000_000) + let signer = makeSigner(funding: funding, connecting: MockHwConnecting(), feeRate: 2, address: nil) + + let availability = try await signer.availability(deviceId: "dev1") + + XCTAssertTrue(funding.maxSpendableCalls.isEmpty, "no estimate without a destination address") + XCTAssertEqual(availability.available, 1_000_000 - 2 * 1200) + } + + // MARK: - Sign orchestration + + func testHappyPathComposesFinalOrderFeeAndReturnsBroadcast() async throws { + let funding = MockHwFunding() + let signer = makeSigner(funding: funding, connecting: MockHwConnecting()) + let order = IBtOrder.mock() // feeSat = 1000, address = "bc1q..." + + let result = try await signer.sign(order: order, deviceId: "dev1", address: XCTUnwrap(order.payment?.onchain?.address)) + + XCTAssertEqual(result.txId, "txid") + XCTAssertEqual(funding.composeCalls.count, 1) + XCTAssertEqual(funding.composeCalls.first?.sats, order.feeSat) + XCTAssertEqual(funding.composeCalls.first?.address, order.payment?.onchain?.address) + XCTAssertEqual(funding.composeCalls.first?.satsPerVByte, 2) + } + + func testReconnectFailureThrowsReconnectAndSkipsCompose() async { + let funding = MockHwFunding() + let connecting = MockHwConnecting() + connecting.connectError = MockHwFunding.TestError() + let signer = makeSigner(funding: funding, connecting: connecting) + + await assertThrowsAsync { + _ = try await signer.sign(order: .mock(), deviceId: "dev1", address: "bc1q...") + } _: { error in + XCTAssertEqual(error as? HwTransferError, .reconnect(isBluetooth: false)) + } + XCTAssertTrue(funding.composeCalls.isEmpty) + XCTAssertEqual(funding.signCalls, 0) + } + + func testComposeFailureThrowsFundingError() async { + let funding = MockHwFunding() + funding.composeError = MockHwFunding.TestError() + let signer = makeSigner(funding: funding, connecting: MockHwConnecting()) + + await assertThrowsAsync { + _ = try await signer.sign(order: .mock(), deviceId: "dev1", address: "bc1q...") + } _: { error in + if case .funding = error as? HwTransferError {} else { XCTFail("expected .funding, got \(error)") } + } + XCTAssertEqual(funding.signCalls, 0) + } + + func testSigningTimeoutThrowsTimeoutAndClearsStaleSession() async { + let funding = MockHwFunding() + funding.signDelay = 0.4 + let connecting = MockHwConnecting() + let signer = makeSigner(funding: funding, connecting: connecting, timeouts: (reconnect: 5, compose: 5, sign: 0.05, broadcast: 5)) + + await assertThrowsAsync { + _ = try await signer.sign(order: .mock(), deviceId: "dev1", address: "bc1q...") + } _: { error in + XCTAssertEqual(error as? HwTransferError, .signingTimeout) + } + XCTAssertEqual(connecting.staleDisconnects, ["dev1"]) + XCTAssertEqual(funding.signCalls, 1) + } + + func testBroadcastTimeoutThrowsBroadcastUncertainWithoutClearingSession() async { + let funding = MockHwFunding() + funding.broadcastDelay = 0.4 + let connecting = MockHwConnecting() + let signer = makeSigner(funding: funding, connecting: connecting, timeouts: (reconnect: 5, compose: 5, sign: 5, broadcast: 0.05)) + + await assertThrowsAsync { + _ = try await signer.sign(order: .mock(), deviceId: "dev1", address: "bc1q...") + } _: { error in + XCTAssertEqual(error as? HwTransferError, .broadcastUncertain) + } + XCTAssertEqual(funding.signCalls, 1, "signing completes before broadcast") + XCTAssertEqual(funding.broadcastCalls, 1) + XCTAssertTrue(connecting.staleDisconnects.isEmpty, "a broadcast timeout must not tear down the device session") + } + + func testRawBroadcastErrorPropagatesUnwrapped() async { + let funding = MockHwFunding() + funding.broadcastError = MockHwFunding.TestError() + let connecting = MockHwConnecting() + let signer = makeSigner(funding: funding, connecting: connecting) + + await assertThrowsAsync { + _ = try await signer.sign(order: .mock(), deviceId: "dev1", address: "bc1q...") + } _: { error in + XCTAssertTrue(error is MockHwFunding.TestError, "a real broadcast error must propagate unwrapped") + XCTAssertNil(error as? HwTransferError) + } + XCTAssertTrue(connecting.staleDisconnects.isEmpty) + } + + func testComposeTimeoutClearsStaleSessionAndThrowsTimeout() async { + let funding = MockHwFunding() + funding.composeDelay = 0.4 + let connecting = MockHwConnecting() + let signer = makeSigner(funding: funding, connecting: connecting, timeouts: (reconnect: 5, compose: 0.05, sign: 5, broadcast: 5)) + + await assertThrowsAsync { + _ = try await signer.sign(order: .mock(), deviceId: "dev1", address: "bc1q...") + } _: { error in + XCTAssertEqual(error as? HwTransferError, .signingTimeout) + } + XCTAssertEqual(connecting.staleDisconnects, ["dev1"], "a compose timeout must tear down the stale session") + XCTAssertEqual(funding.signCalls, 0, "signing must not run after a compose timeout") + } + + func testRawSignErrorPropagatesUnwrapped() async { + let funding = MockHwFunding() + funding.signError = MockHwFunding.TestError() + let connecting = MockHwConnecting() + let signer = makeSigner(funding: funding, connecting: connecting) + + await assertThrowsAsync { + _ = try await signer.sign(order: .mock(), deviceId: "dev1", address: "bc1q...") + } _: { error in + XCTAssertTrue(error is MockHwFunding.TestError, "a real sign/broadcast error must propagate unwrapped") + XCTAssertNil(error as? HwTransferError) + } + XCTAssertTrue(connecting.staleDisconnects.isEmpty, "a non-timeout error must not clear the session") + } +} + +/// Async variant of `XCTAssertThrowsError` using a plain (non-autoclosure) operation closure, so the +/// call site reads `await assertThrowsAsync { try await … }` without effect-hoisting ambiguity. +func assertThrowsAsync( + _ message: String = "", + file: StaticString = #filePath, + line: UInt = #line, + _ operation: () async throws -> Void, + _ errorHandler: (Error) -> Void = { _ in } +) async { + do { + try await operation() + XCTFail(message.isEmpty ? "Expected error but none thrown" : message, file: file, line: line) + } catch { + errorHandler(error) + } +} diff --git a/BitkitTests/HwTransferMocks.swift b/BitkitTests/HwTransferMocks.swift new file mode 100644 index 000000000..5a9558065 --- /dev/null +++ b/BitkitTests/HwTransferMocks.swift @@ -0,0 +1,97 @@ +@testable import Bitkit +import BitkitCore + +/// Shared mocks for the hardware-wallet transfer tests (`HwFundingSignerTests`, +/// `TransferViewModelHwTests`). +@MainActor +final class MockHwFunding: HwTransferFunding { + struct TestError: Error {} + + var account = HwFundingAccount(xpub: "zpubNS", addressType: .nativeSegwit, balanceSats: 1_000_000) + var accountError: Error? + var maxSpendable: UInt64 = 990_000 + var maxSpendableError: Error? + var composeError: Error? + var composeDelay: Double = 0 + var signError: Error? + var signDelay: Double = 0 + var broadcastError: Error? + var broadcastDelay: Double = 0 + var funding = HwFundingTransaction(psbt: "psbt", miningFeeSats: 141, feeRate: 1, totalSpent: 43186, satsPerVByte: 1) + var signedTx = HwFundingSignedTx(serializedTx: "rawtx", miningFeeSats: 141, feeRate: 1, totalSpent: 43186) + var broadcastTxId = "txid" + + private(set) var composeCalls: [(address: String, sats: UInt64, satsPerVByte: UInt64)] = [] + private(set) var maxSpendableCalls: [(address: String, satsPerVByte: UInt64)] = [] + private(set) var signCalls = 0 + private(set) var broadcastCalls = 0 + + func getFundingAccount(deviceId _: String, addressType _: AddressScriptType) throws -> HwFundingAccount { + if let accountError { throw accountError } + return account + } + + func maxSpendableFunding( + deviceId _: String, + destinationAddress: String, + satsPerVByte: UInt64, + addressType _: AddressScriptType + ) async throws -> UInt64 { + maxSpendableCalls.append((destinationAddress, satsPerVByte)) + if let maxSpendableError { throw maxSpendableError } + return maxSpendable + } + + func composeFundingTransaction( + deviceId _: String, + address: String, + sats: UInt64, + satsPerVByte: UInt64, + addressType _: AddressScriptType + ) async throws -> HwFundingTransaction { + composeCalls.append((address, sats, satsPerVByte)) + if composeDelay > 0 { try await Task.sleep(nanoseconds: UInt64(composeDelay * 1_000_000_000)) } + if let composeError { throw composeError } + return funding + } + + func signFunding(deviceId _: String, funding _: HwFundingTransaction) async throws -> HwFundingSignedTx { + signCalls += 1 + if signDelay > 0 { try await Task.sleep(nanoseconds: UInt64(signDelay * 1_000_000_000)) } + if let signError { throw signError } + return signedTx + } + + func broadcastFunding(serializedTx _: String) async throws -> String { + broadcastCalls += 1 + if broadcastDelay > 0 { try await Task.sleep(nanoseconds: UInt64(broadcastDelay * 1_000_000_000)) } + if let broadcastError { throw broadcastError } + return broadcastTxId + } +} + +@MainActor +final class MockHwConnecting: HwTransferConnecting { + var connectError: Error? + var isBluetooth = false + private(set) var ensureCalls = 0 + private(set) var staleDisconnects: [String] = [] + private(set) var warmUpCalls: [String] = [] + + func ensureConnected(deviceId _: String) async throws { + ensureCalls += 1 + if let connectError { throw connectError } + } + + func isKnownBluetoothDevice(deviceId _: String) -> Bool { + isBluetooth + } + + func warmUpConnection(deviceId: String) { + warmUpCalls.append(deviceId) + } + + func disconnectStaleSession(deviceId: String) async { + staleDisconnects.append(deviceId) + } +} diff --git a/BitkitTests/HwWalletManagerFundingTests.swift b/BitkitTests/HwWalletManagerFundingTests.swift new file mode 100644 index 000000000..57ae08195 --- /dev/null +++ b/BitkitTests/HwWalletManagerFundingTests.swift @@ -0,0 +1,102 @@ +@testable import Bitkit +import BitkitCore +import XCTest + +/// Funding-account coverage for `HwWalletManager`, adapting the funding cases in bitkit-android's +/// `HwWalletRepoTest`: the native-segwit funding balance is tracked separately from the aggregate +/// balance, and the funding account resolves the stored native-segwit xpub. The engine is driven +/// directly via `updateDevices` + `handleWatcherEvent` (no live device), as in `HwWalletManagerTests`. +@MainActor +final class HwWalletManagerFundingTests: XCTestCase { + private final class NoopWatcher: OnChainWatcherServicing, @unchecked Sendable { + func startWatcher(params _: WatcherParams, listener _: EventListener) async throws {} + func stopWatcher(watcherId _: String) throws {} + func stopAllWatchers() {} + } + + private func makeManager( + monitored: Set = ["legacy", "nestedSegwit", "nativeSegwit", "taproot"] + ) -> HwWalletManager { + HwWalletManager( + watcherService: NoopWatcher(), + monitoredTypes: { monitored }, + electrumUrl: { "ssl://test:1" }, + network: { .regtest }, + persistActivities: { _ in }, + deleteActivities: { _ in }, + syncHardwareActivity: { _ in } + ) + } + + private func makeDevice(id: String, xpubs: [String: String]) -> TrezorKnownDevice { + TrezorKnownDevice( + id: id, + name: id, + path: "ble:\(id)", + transportType: "bluetooth", + label: nil, + model: "Safe 5", + lastConnectedAt: Date(timeIntervalSince1970: 1000), + xpubs: xpubs + ) + } + + private func makeEvent(total: UInt64) -> WatcherEvent { + let balance = WalletBalance( + confirmed: total, immature: 0, trustedPending: 0, untrustedPending: 0, spendable: total, total: total + ) + return .transactionsChanged( + activities: [], + transactionDetails: [], + balance: balance, + txCount: 0, + blockHeight: 100, + accountType: .nativeSegwit + ) + } + + private func watcherId(_ deviceId: String, _ addressType: String) -> String { + "\(deviceId)|\(addressType)" + } + + func testFundingBalanceIsNativeSegwitOnly() throws { + let device = makeDevice(id: "dev1", xpubs: ["nativeSegwit": "zpubNS", "taproot": "zpubTR"]) + let manager = makeManager() + manager.updateDevices(knownDevices: [device], connectedDeviceId: "dev1") + + manager.handleWatcherEvent(watcherId: watcherId("dev1", "nativeSegwit"), event: makeEvent(total: 50000)) + manager.handleWatcherEvent(watcherId: watcherId("dev1", "taproot"), event: makeEvent(total: 30000)) + + let wallet = try XCTUnwrap(manager.wallets.first) + XCTAssertEqual(wallet.balanceSats, 80000, "aggregate balance spans all address types") + XCTAssertEqual(wallet.fundingBalanceSats, 50000, "funding balance is native-segwit only") + XCTAssertEqual(manager.fundingBalance(deviceId: "dev1"), 50000) + XCTAssertEqual(manager.fundingBalance(deviceId: "dev1", addressType: .taproot), 30000) + } + + func testGetFundingAccountReturnsNativeSegwitXpubAndBalance() throws { + let device = makeDevice(id: "dev1", xpubs: ["nativeSegwit": "zpubNS", "taproot": "zpubTR"]) + let manager = makeManager() + manager.updateDevices(knownDevices: [device], connectedDeviceId: "dev1") + manager.handleWatcherEvent(watcherId: watcherId("dev1", "nativeSegwit"), event: makeEvent(total: 42000)) + + let account = try manager.getFundingAccount(deviceId: "dev1") + XCTAssertEqual(account.xpub, "zpubNS") + XCTAssertEqual(account.addressType, .nativeSegwit) + XCTAssertEqual(account.accountType, .nativeSegwit) + XCTAssertEqual(account.balanceSats, 42000) + } + + func testGetFundingAccountThrowsForUnknownDevice() { + let manager = makeManager() + manager.updateDevices(knownDevices: [], connectedDeviceId: nil) + XCTAssertThrowsError(try manager.getFundingAccount(deviceId: "nope")) + } + + func testGetFundingAccountThrowsWhenNativeSegwitAccountMissing() { + let device = makeDevice(id: "dev1", xpubs: ["taproot": "zpubTR"]) + let manager = makeManager() + manager.updateDevices(knownDevices: [device], connectedDeviceId: "dev1") + XCTAssertThrowsError(try manager.getFundingAccount(deviceId: "dev1")) + } +} diff --git a/BitkitTests/TransferServiceActivityTests.swift b/BitkitTests/TransferServiceActivityTests.swift new file mode 100644 index 000000000..b7212a44b --- /dev/null +++ b/BitkitTests/TransferServiceActivityTests.swift @@ -0,0 +1,86 @@ +@testable import Bitkit +import BitkitCore +import XCTest + +/// Regression coverage for the hardware-wallet pending transfer activity. `OnchainActivity.channelId` +/// must hold the LDK `channelId.description` (BOLT id) — it is matched that way by +/// `ChannelDetailsViewModel.findChannel` — never the Blocktank order's short channel id. Storing the +/// SCID there breaks the Connection/channel lookup; the correct BOLT id is set later by +/// `markOnchainActivityAsTransfer` during `syncTransferStates`. +/// +/// App types are `Bitkit.`-qualified because some services are also compiled into the test target, +/// so unqualified names would resolve to the duplicate and mismatch `Bitkit.TransferService`. +final class TransferServiceActivityTests: XCTestCase { + private let testDbPath = NSTemporaryDirectory() + private let activity = Bitkit.CoreService.shared.activity + + override func setUp() async throws { + try await super.setUp() + _ = try initDb(basePath: testDbPath) + try await Task.sleep(nanoseconds: 1_000_000_000) + } + + override func tearDown() async throws { + try await super.tearDown() + let dbPath = (testDbPath as NSString).appendingPathComponent("activity.db") + if FileManager.default.fileExists(atPath: dbPath) { + try FileManager.default.removeItem(atPath: dbPath) + } + } + + private func makeService() -> Bitkit.TransferService { + Bitkit.TransferService(lightningService: .shared, blocktankService: Bitkit.CoreService.shared.blocktank) + } + + func testPendingToSpendingActivityDoesNotStoreShortChannelId() async throws { + var channel = IBtChannel.mock() + channel.shortChannelId = "820100x5x0" + let order = IBtOrder.mock(channel: channel) + + await makeService().createPendingToSpendingActivity(order: order, txId: "hwtx1", fee: 141, feeRate: 2) + + let stored = try await activity.getOnchainActivityByTxId(txid: "hwtx1") + let onchain = try XCTUnwrap(stored) + XCTAssertNil(onchain.channelId, "The order's short channel id must not be stored as the activity channelId") + XCTAssertTrue(onchain.isTransfer, "A hardware funding activity must be marked as a transfer") + XCTAssertEqual(onchain.value, order.feeSat) + } + + /// The atomic mark-as-transfer must only touch `isTransfer`/`channelId`, preserving confirmation + /// and fee that a concurrent watcher sync may have written. + func testMarkOnchainActivityAsTransferPreservesConfirmationAndFee() async throws { + let seeded = OnchainActivity( + walletId: WalletScope.default, + id: "hwtx2", + txType: .sent, + txId: "hwtx2", + value: 1000, + fee: 500, + feeRate: 2, + address: "bc1q...", + confirmed: true, + timestamp: 1, + isBoosted: false, + boostTxIds: [], + isTransfer: false, + doesExist: true, + confirmTimestamp: 123, + channelId: nil, + transferTxId: nil, + contact: nil, + createdAt: 1, + updatedAt: 1, + seenAt: 1 + ) + try await activity.insert(.onchain(seeded)) + + await activity.markOnchainActivityAsTransfer(txId: "hwtx2", channelId: "boltchan") + + let stored = try await activity.getOnchainActivityByTxId(txid: "hwtx2") + let onchain = try XCTUnwrap(stored) + XCTAssertTrue(onchain.isTransfer) + XCTAssertEqual(onchain.channelId, "boltchan") + XCTAssertTrue(onchain.confirmed, "confirmation must be preserved") + XCTAssertEqual(onchain.fee, 500, "fee must be preserved") + } +} diff --git a/BitkitTests/TransferViewModelHwTests.swift b/BitkitTests/TransferViewModelHwTests.swift new file mode 100644 index 000000000..54cdebef7 --- /dev/null +++ b/BitkitTests/TransferViewModelHwTests.swift @@ -0,0 +1,202 @@ +@testable import Bitkit +import BitkitCore +import XCTest + +/// Coordination coverage for the hardware-wallet transfer path in `TransferViewModel`: it drives the +/// `HwFundingSigner` (built from the injected capabilities), maps signer failures to published state, +/// and guards against re-entry. The device orchestration itself is covered by `HwFundingSignerTests`. +@MainActor +final class TransferViewModelHwTests: XCTestCase { + private func makeViewModel( + funding: MockHwFunding, + connecting: MockHwConnecting, + feeRate: UInt64? = 2, + timeouts: (reconnect: Double, compose: Double, sign: Double, broadcast: Double) = (reconnect: 5, compose: 5, sign: 5, broadcast: 5) + ) -> TransferViewModel { + TransferViewModel( + hwFunding: funding, + hwConnecting: connecting, + hwFeeRateProvider: { feeRate }, + hwTimeouts: timeouts + ) + } + + private func awaitSigningComplete(_ vm: TransferViewModel, timeout: Double = 3) async { + let deadline = Date().addingTimeInterval(timeout) + while vm.hwSpending.isSigning, Date() < deadline { + try? await Task.sleep(nanoseconds: 10_000_000) + } + } + + func testConfirmWithoutHwCapabilitiesSurfacesGenericError() { + let vm = TransferViewModel() // no signer injected + vm.onTransferToSpendingHwConfirm(order: .mock(), deviceId: "dev1") + if case .generic = vm.hwTransferError {} else { XCTFail("expected .generic error") } + XCTAssertFalse(vm.hwSpending.isSigning) + } + + func testUpdateHwLimitsSurfacesErrorWhenAvailabilityFails() async { + let funding = MockHwFunding() + funding.accountError = MockHwFunding.TestError() + let vm = makeViewModel(funding: funding, connecting: MockHwConnecting()) + + await vm.updateHwLimits(deviceId: "dev1", blocktankInfo: nil, estimateOrderFee: { _, _ in (0, 0) }) + + if case .generic = vm.hwTransferError {} else { XCTFail("expected .generic error") } + XCTAssertFalse(vm.hwSpending.isLoading) + } + + func testUpdateHwLimitsClearsStalePreviousDeviceCap() async { + let funding = MockHwFunding() + funding.accountError = MockHwFunding.TestError() + let vm = makeViewModel(funding: funding, connecting: MockHwConnecting()) + vm.hwSpending.maxAllowedToSend = 999_999 // stale cap from a previously-selected device + + await vm.updateHwLimits(deviceId: "dev1", blocktankInfo: nil, estimateOrderFee: { _, _ in (0, 0) }) + + XCTAssertEqual(vm.hwSpending.maxAllowedToSend, 0, "a reload must not keep the previous device's cap") + } + + func testReconnectFailureMapsToReconnectErrorAndResetsSigning() async { + let funding = MockHwFunding() + let connecting = MockHwConnecting() + connecting.connectError = MockHwFunding.TestError() + let vm = makeViewModel(funding: funding, connecting: connecting) + + vm.onTransferToSpendingHwConfirm(order: .mock(), deviceId: "dev1") + await awaitSigningComplete(vm) + + XCTAssertEqual(vm.hwTransferError, .reconnect(isBluetooth: false)) + XCTAssertFalse(vm.hwSpending.isSigning) + } + + func testReconnectFailureCarriesBluetoothFlagForKnownBleDevice() async { + let funding = MockHwFunding() + let connecting = MockHwConnecting() + connecting.connectError = MockHwFunding.TestError() + connecting.isBluetooth = true + let vm = makeViewModel(funding: funding, connecting: connecting) + + vm.onTransferToSpendingHwConfirm(order: .mock(), deviceId: "dev1") + await awaitSigningComplete(vm) + + XCTAssertEqual(vm.hwTransferError, .reconnect(isBluetooth: true), "a known BLE device gets the softer INFO reconnect toast") + } + + func testRawSignErrorMapsToGenericError() async { + let funding = MockHwFunding() + funding.signError = MockHwFunding.TestError() + let connecting = MockHwConnecting() + let vm = makeViewModel(funding: funding, connecting: connecting) + + vm.onTransferToSpendingHwConfirm(order: .mock(), deviceId: "dev1") + await awaitSigningComplete(vm) + + if case .generic = vm.hwTransferError {} else { XCTFail("expected .generic error, got \(String(describing: vm.hwTransferError))") } + XCTAssertTrue(connecting.staleDisconnects.isEmpty) + } + + func testDeviceCancelDuringSigningIsSilent() async { + let funding = MockHwFunding() + funding.signError = TrezorError.UserCancelled + let connecting = MockHwConnecting() + let vm = makeViewModel(funding: funding, connecting: connecting) + + vm.onTransferToSpendingHwConfirm(order: .mock(), deviceId: "dev1") + await awaitSigningComplete(vm) + + XCTAssertNil(vm.hwTransferError, "a device cancel must not surface a toast") + XCTAssertFalse(vm.hwSpending.isSigning) + XCTAssertEqual(vm.hwSignedEvent, 0, "a cancelled transfer must not advance the flow") + XCTAssertTrue(connecting.staleDisconnects.isEmpty, "a device cancel must not tear down the session") + } + + /// On device, `ServiceQueue` boxes the `TrezorError` into an `AppError` before it reaches the view + /// model, so the silent-cancel path must survive the wrapping (raw `TrezorError` is covered above). + /// `AppError` is qualified as `Bitkit.AppError` because `Errors.swift` is also compiled into this + /// test target — an unqualified `AppError` would be the test-target copy, not what production throws. + func testWrappedDeviceCancelDuringSigningIsSilent() async { + let funding = MockHwFunding() + funding.signError = Bitkit.AppError(error: TrezorError.UserCancelled) + let connecting = MockHwConnecting() + let vm = makeViewModel(funding: funding, connecting: connecting) + + vm.onTransferToSpendingHwConfirm(order: .mock(), deviceId: "dev1") + await awaitSigningComplete(vm) + + XCTAssertNil(vm.hwTransferError, "a wrapped device cancel must not surface a toast") + XCTAssertFalse(vm.hwSpending.isSigning) + XCTAssertEqual(vm.hwSignedEvent, 0, "a cancelled transfer must not advance the flow") + } + + func testDeviceCancelDuringReconnectIsSilent() async { + let funding = MockHwFunding() + let connecting = MockHwConnecting() + connecting.connectError = TrezorError.UserCancelled + let vm = makeViewModel(funding: funding, connecting: connecting) + + vm.onTransferToSpendingHwConfirm(order: .mock(), deviceId: "dev1") + await awaitSigningComplete(vm) + + XCTAssertNil(vm.hwTransferError, "a reconnect-step cancel must not surface a toast") + XCTAssertFalse(vm.hwSpending.isSigning) + XCTAssertTrue(funding.composeCalls.isEmpty, "compose must not run after a reconnect cancel") + } + + func testWarmUpHardwareConnectionDelegatesToConnecting() { + let funding = MockHwFunding() + let connecting = MockHwConnecting() + let vm = makeViewModel(funding: funding, connecting: connecting) + + vm.warmUpHardwareConnection(deviceId: "dev1") + + XCTAssertEqual(connecting.warmUpCalls, ["dev1"]) + } + + func testCancelHwSigningStopsInFlightSign() async { + let funding = MockHwFunding() + funding.signDelay = 1.0 // keep signing in-flight so cancel interrupts it + let connecting = MockHwConnecting() + let vm = makeViewModel(funding: funding, connecting: connecting) + + vm.onTransferToSpendingHwConfirm(order: .mock(), deviceId: "dev1") + while !vm.hwSpending.isSigning { + await Task.yield() + } + vm.cancelHwSigning() + await awaitSigningComplete(vm) + + XCTAssertFalse(vm.hwSpending.isSigning) + XCTAssertEqual(vm.hwSignedEvent, 0, "a cancelled sign must not advance the flow") + XCTAssertTrue(connecting.staleDisconnects.isEmpty, "cancelling must not tear down the session") + } + + func testReentrancyGuardIgnoresConcurrentConfirm() async { + let funding = MockHwFunding() + funding.composeError = MockHwFunding.TestError() // fail before the network-bound funding tail + let connecting = MockHwConnecting() + let vm = makeViewModel(funding: funding, connecting: connecting) + + vm.onTransferToSpendingHwConfirm(order: .mock(), deviceId: "dev1") + // Second call while the first is still signing must be ignored. + vm.onTransferToSpendingHwConfirm(order: .mock(), deviceId: "dev1") + await awaitSigningComplete(vm) + + XCTAssertEqual(connecting.ensureCalls, 1, "only the first confirm should run") + XCTAssertEqual(funding.composeCalls.count, 1) + } + + func testMissingOrderAddressSurfacesGenericErrorWithoutSigning() { + let funding = MockHwFunding() + let connecting = MockHwConnecting() + let vm = makeViewModel(funding: funding, connecting: connecting) + var order = IBtOrder.mock() + order.payment?.onchain?.address = "" + + vm.onTransferToSpendingHwConfirm(order: order, deviceId: "dev1") + + if case .generic = vm.hwTransferError {} else { XCTFail("expected .generic error") } + XCTAssertFalse(vm.hwSpending.isSigning) + XCTAssertEqual(connecting.ensureCalls, 0) + } +} diff --git a/BitkitTests/TrezorCancellationTests.swift b/BitkitTests/TrezorCancellationTests.swift new file mode 100644 index 000000000..2e938a35d --- /dev/null +++ b/BitkitTests/TrezorCancellationTests.swift @@ -0,0 +1,39 @@ +@testable import Bitkit +import BitkitCore +import XCTest + +/// Truth table for `Error.isTrezorUserCancellation()`, mirroring bitkit-android's +/// `TrezorExceptionExtTest`: the three on-device cancellation cases are treated as user cancellations, +/// everything else (including a Trezor timeout) is not. +final class TrezorCancellationTests: XCTestCase { + func testUserCancellationCasesReturnTrue() { + XCTAssertTrue(TrezorError.UserCancelled.isTrezorUserCancellation()) + XCTAssertTrue(TrezorError.PinCancelled.isTrezorUserCancellation()) + XCTAssertTrue(TrezorError.PassphraseCancelled.isTrezorUserCancellation()) + } + + func testNonCancellationErrorsReturnFalse() { + XCTAssertFalse(TrezorError.Timeout.isTrezorUserCancellation()) + XCTAssertFalse(TrezorError.DeviceDisconnected.isTrezorUserCancellation()) + XCTAssertFalse(AppError(message: "sign failed", debugMessage: nil).isTrezorUserCancellation()) + XCTAssertFalse(CancellationError().isTrezorUserCancellation()) + } + + /// On device, `ServiceQueue` boxes the raw `TrezorError` into an `AppError` before it reaches the + /// view model — the exact case that leaked a spurious error toast. The cancellation check must see + /// through the wrapper. + /// + /// `AppError` is qualified as `Bitkit.AppError`: `Errors.swift` is also compiled into this test + /// target, so an unqualified `AppError` would resolve to the test-target copy and fail the + /// `self as? Bitkit.AppError` cast inside `isTrezorUserCancellation()`. + func testCancellationWrappedInAppErrorReturnsTrue() { + XCTAssertTrue(Bitkit.AppError(error: TrezorError.UserCancelled).isTrezorUserCancellation()) + XCTAssertTrue(Bitkit.AppError(error: TrezorError.PinCancelled).isTrezorUserCancellation()) + XCTAssertTrue(Bitkit.AppError(error: TrezorError.PassphraseCancelled).isTrezorUserCancellation()) + } + + func testNonCancellationWrappedInAppErrorReturnsFalse() { + XCTAssertFalse(Bitkit.AppError(error: TrezorError.Timeout).isTrezorUserCancellation()) + XCTAssertFalse(Bitkit.AppError(error: TrezorError.DeviceDisconnected).isTrezorUserCancellation()) + } +} diff --git a/changelog.d/next/616.added.md b/changelog.d/next/616.added.md new file mode 100644 index 000000000..4c21f7c61 --- /dev/null +++ b/changelog.d/next/616.added.md @@ -0,0 +1 @@ +Transfer funds from a paired Trezor hardware wallet to your spending balance, signing the on-chain funding transaction on the device.