From 176ce3b9cabeb888bb9c2f974e529998d4dfde6f Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 3 Jul 2026 14:11:59 -0300 Subject: [PATCH 01/33] feat: Hw funding models --- Bitkit/Models/HwFunding.swift | 39 +++++++++++++++++++++++++++++++++++ Bitkit/Models/HwWallet.swift | 6 ++++++ 2 files changed, 45 insertions(+) create mode 100644 Bitkit/Models/HwFunding.swift diff --git a/Bitkit/Models/HwFunding.swift b/Bitkit/Models/HwFunding.swift new file mode 100644 index 000000000..752643138 --- /dev/null +++ b/Bitkit/Models/HwFunding.swift @@ -0,0 +1,39 @@ +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 +} + +/// 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] } } From f5a674e02564059c67e6efe5db60d8664f751417 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 3 Jul 2026 14:22:57 -0300 Subject: [PATCH 02/33] feat: funding methods --- Bitkit/Managers/HwWalletManager.swift | 111 ++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/Bitkit/Managers/HwWalletManager.swift b/Bitkit/Managers/HwWalletManager.swift index ee8e8c7b9..9b681d630 100644 --- a/Bitkit/Managers/HwWalletManager.swift +++ b/Bitkit/Managers/HwWalletManager.swift @@ -414,6 +414,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 +439,111 @@ 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) + ) + } + + /// 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 and broadcast it. Requires the device to be + /// connected. On signing failure the caller is responsible for clearing the stale session + /// (via `TrezorManager.disconnectStaleSession`). + func signAndBroadcastFunding( + deviceId _: String, + funding: HwFundingTransaction + ) async throws -> HwFundingBroadcastResult { + let network = networkProvider() + let signed = try await TrezorService.shared.signTxFromPsbt(psbtBase64: funding.psbt, network: network) + let txId = try await OnChainHwService.shared.broadcastRawTx( + serializedTx: signed.serializedTx, + electrumUrl: electrumUrlProvider() + ) + return HwFundingBroadcastResult( + txId: txId, + miningFeeSats: funding.miningFeeSats, + feeRate: UInt64(funding.feeRate.rounded(.up)), + totalSpent: funding.totalSpent + ) + } + // MARK: - Helpers private func deviceId(fromWatcherId watcherId: String) -> String { @@ -445,6 +551,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 { From 7ca7a39d98de3acfa1dc2d53dbb38d083a1e4b66 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 3 Jul 2026 14:27:25 -0300 Subject: [PATCH 03/33] feat: reconnect hardening --- Bitkit/Managers/TrezorManager.swift | 62 +++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/Bitkit/Managers/TrezorManager.swift b/Bitkit/Managers/TrezorManager.swift index 66ffb363e..5cc90ab88 100644 --- a/Bitkit/Managers/TrezorManager.swift +++ b/Bitkit/Managers/TrezorManager.swift @@ -639,6 +639,68 @@ 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)'") + } + } + + /// 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. From 6ff029965086b901f860e07926a1baa63e142c6c Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 3 Jul 2026 14:39:40 -0300 Subject: [PATCH 04/33] feat: transfer viewmodel methods --- Bitkit/AppScene.swift | 15 +- Bitkit/ViewModels/TransferViewModel.swift | 362 ++++++++++++++++++++-- 2 files changed, 356 insertions(+), 21 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 83f0d2947..709031408 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -74,9 +74,21 @@ 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)) + }, onBalanceRefresh: { await walletVm.updateBalanceState() } )) _widgets = StateObject(wrappedValue: WidgetsViewModel()) @@ -84,11 +96,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/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 5c8d66b39..33b473f09 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -15,6 +15,47 @@ 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 { + case reconnect + case signingTimeout + 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 composeFundingTransaction( + deviceId: String, + address: String, + sats: UInt64, + satsPerVByte: UInt64, + addressType: AddressScriptType + ) async throws -> HwFundingTransaction + func signAndBroadcastFunding(deviceId: String, funding: HwFundingTransaction) async throws -> HwFundingBroadcastResult +} + +/// 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 +} + @MainActor class TransferViewModel: ObservableObject { @Published var uiState = TransferUiState() @@ -24,26 +65,51 @@ 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)? + private let hwFunding: HwTransferFunding? + private let hwConnecting: HwTransferConnecting? + private let hwFeeRateProvider: (() async -> UInt64?)? 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 private var coopCloseRetryTask: Task? + /// Hardware funding fee tuning. + /// Conservative vbyte reserve for multi-input hardware funding before the exact compose runs. + private let hwFundingTxVBytes: UInt64 = 1200 + /// Minimum fallback fee rate when fee estimates are temporarily unavailable. + private let hwFundingFallbackSatsPerVByte: UInt64 = 1 + /// Fallback fee percentage used when fee estimates are temporarily unavailable. + private let hwFundingFallbackFeePercent = 0.1 + private let hwReconnectTimeout: Double = 30 + private let hwComposeTimeout: Double = 45 + private let hwSignTimeout: Double = 120 + init( coreService: CoreService = .shared, lightningService: LightningService = .shared, currencyService: CurrencyService = .shared, transferService: TransferService, sheetViewModel: SheetViewModel, + hwFunding: HwTransferFunding? = nil, + hwConnecting: HwTransferConnecting? = nil, + hwFeeRateProvider: (() async -> UInt64?)? = nil, onBalanceRefresh: (() async -> Void)? = nil ) { self.coreService = coreService @@ -51,6 +117,9 @@ class TransferViewModel: ObservableObject { self.currencyService = currencyService self.transferService = transferService self.sheetViewModel = sheetViewModel + self.hwFunding = hwFunding + self.hwConnecting = hwConnecting + self.hwFeeRateProvider = hwFeeRateProvider self.onBalanceRefresh = onBalanceRefresh } @@ -154,29 +223,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 +275,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) } @@ -316,11 +409,237 @@ class TransferViewModel: ObservableObject { } func resetState() { + hwSignTask?.cancel() + hwSignTask = nil + hwSpending = HwSpendingState() + hwTransferError = nil uiState = TransferUiState() transferValues = TransferValues() selectedChannelIds = [] } + // MARK: - Hardware Wallet Transfer + + /// Compute the available/MAX/quarter limits for a hardware-wallet transfer, sourcing the + /// spendable balance from the device's native-segwit account minus an on-chain fee reserve, then + /// clamping to the LSP receiving cap via the shared spending-limit calculation. + func updateHwLimits( + deviceId: String, + blocktankInfo: IBtInfo?, + estimateOrderFee: @escaping (_ clientBalance: UInt64, _ lspBalance: UInt64) async throws + -> (networkFeeSat: UInt64, serviceFeeSat: UInt64) + ) async { + guard let hwFunding else { return } + hwSpending.isLoading = true + + let account: HwFundingAccount + do { + account = try hwFunding.getFundingAccount(deviceId: deviceId, addressType: hwFundingDefaultAddressType) + } catch { + hwSpending = HwSpendingState(isLoading: false) + hwTransferError = .generic((error as? AppError)?.message ?? error.localizedDescription) + return + } + + let reserve = await hwFundingFeeReserve(balanceSats: account.balanceSats) + let available = account.balanceSats > reserve ? account.balanceSats - reserve : 0 + + do { + let (avail, maxAmount) = try await calculateSpendingLimits( + onchainAvailable: available, + lspMaxClientBalance: blocktankInfo?.options.maxClientBalanceSat, + transferValues: { self.calculateTransferValues(clientBalanceSat: $0, blocktankInfo: blocktankInfo) }, + estimateOrderFee: estimateOrderFee + ) + hwSpending.balanceAfterFee = avail + hwSpending.maxAllowedToSend = maxAmount + hwSpending.quarterAmount = min(account.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, then watch it. + func onTransferToSpendingHwConfirm(order: IBtOrder, deviceId: String) { + guard !hwSpending.isSigning else { return } + guard let hwFunding, let hwConnecting 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 + } + + guard let address = order.payment?.onchain?.address, !address.isEmpty else { + hwTransferError = .generic(t("common__error")) + return + } + + do { + let result = try await signTransferToSpendingWithHardware( + order: order, + deviceId: deviceId, + address: address, + hwFunding: hwFunding, + hwConnecting: hwConnecting + ) + 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 { + hwTransferError = .generic((error as? AppError)?.message ?? error.localizedDescription) + } + } + } + + private func signTransferToSpendingWithHardware( + order: IBtOrder, + deviceId: String, + address: String, + hwFunding: HwTransferFunding, + hwConnecting: HwTransferConnecting + ) async throws -> HwFundingBroadcastResult { + try await ensureHardwareConnected(deviceId: deviceId, hwConnecting: hwConnecting) + let satsPerVByte = await hwFundingSatsPerVByte() + let funding = try await composeHardwareFundingTransaction( + deviceId: deviceId, + address: address, + sats: order.feeSat, + satsPerVByte: satsPerVByte, + hwFunding: hwFunding + ) + return try await signAndBroadcastHardwareFunding( + deviceId: deviceId, + funding: funding, + hwFunding: hwFunding, + hwConnecting: hwConnecting + ) + } + + private func ensureHardwareConnected(deviceId: String, hwConnecting: HwTransferConnecting) async throws { + do { + try await withHwTimeout(hwReconnectTimeout) { + try await hwConnecting.ensureConnected(deviceId: deviceId) + } + } catch is CancellationError { + throw CancellationError() + } catch { + throw HwTransferError.reconnect + } + } + + private func composeHardwareFundingTransaction( + deviceId: String, + address: String, + sats: UInt64, + satsPerVByte: UInt64, + hwFunding: HwTransferFunding + ) async throws -> HwFundingTransaction { + do { + return try await withHwTimeout(hwComposeTimeout) { + try await hwFunding.composeFundingTransaction( + deviceId: deviceId, + address: address, + sats: sats, + satsPerVByte: satsPerVByte, + addressType: hwFundingDefaultAddressType + ) + } + } catch is CancellationError { + throw CancellationError() + } catch { + let message = (error as? AppError)?.debugMessage ?? (error as? AppError)?.message ?? error.localizedDescription + throw HwTransferError.funding(message) + } + } + + private func signAndBroadcastHardwareFunding( + deviceId: String, + funding: HwFundingTransaction, + hwFunding: HwTransferFunding, + hwConnecting: HwTransferConnecting + ) async throws -> HwFundingBroadcastResult { + do { + return try await withHwTimeout(hwSignTimeout) { + try await hwFunding.signAndBroadcastFunding(deviceId: deviceId, funding: funding) + } + } catch is CancellationError { + throw CancellationError() + } catch is HwTimeout { + await hwConnecting.disconnectStaleSession(deviceId: deviceId) + throw HwTransferError.signingTimeout + } + // Any other (real sign/broadcast) error propagates to the generic toast. + } + + 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 let .funding(message): + Logger.warn("Failed to compose hardware funding for '\(deviceId)': \(message ?? "")", context: "TransferViewModel") + case .generic: + break + } + hwTransferError = error + } + + /// On-chain fee reserve to hold back from the device balance before the exact compose runs. + private func hwFundingFeeReserve(balanceSats: UInt64) async -> UInt64 { + guard let satsPerVByte = await hwFeeRateProvider?() else { + let minReserve = hwFundingFallbackSatsPerVByte * hwFundingTxVBytes + let fallback = UInt64(Double(balanceSats) * hwFundingFallbackFeePercent) + return max(minReserve, fallback) + } + return satsPerVByte * hwFundingTxVBytes + } + + private func hwFundingSatsPerVByte() async -> UInt64 { + await hwFeeRateProvider?() ?? hwFundingFallbackSatsPerVByte + } + + private struct HwTimeout: Error {} + + /// Race an async operation against a timeout. Cancellation (user dismiss) propagates as + /// `CancellationError`; the deadline throws `HwTimeout`. + private func withHwTimeout( + _ 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 HwTimeout() + } + defer { group.cancelAll() } + guard let result = try await group.next() else { throw HwTimeout() } + return result + } + } + // MARK: - Balance Calculation /// Calculates channel liquidity options using bitkit-core @@ -779,3 +1098,8 @@ actor ChannelPendingCapture { return channelData } } + +// MARK: - Hardware transfer capability conformances + +extension HwWalletManager: HwTransferFunding {} +extension TrezorManager: HwTransferConnecting {} From af75113af42e345cd9f2a502611f50cb6145db98 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 3 Jul 2026 14:46:21 -0300 Subject: [PATCH 05/33] feat: transfer activity methods --- Bitkit/Managers/HwWalletManager.swift | 14 ++++++++- Bitkit/Services/CoreService.swift | 30 ++++++++++++++++++-- Bitkit/Services/TransferService.swift | 41 +++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 3 deletions(-) diff --git a/Bitkit/Managers/HwWalletManager.swift b/Bitkit/Managers/HwWalletManager.swift index 9b681d630..f36ae81ba 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) } diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index 6cb6e9994..92d700f3e 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -1102,6 +1102,8 @@ class ActivityService { amount: UInt64, fee: UInt64, feeRate: UInt32, + isTransfer: Bool = false, + channelId: String? = nil, contact: String? = nil ) async { do { @@ -1124,10 +1126,10 @@ class ActivityService { timestamp: now, isBoosted: false, boostTxIds: [], - isTransfer: false, + isTransfer: isTransfer, doesExist: true, confirmTimestamp: nil, - channelId: nil, + channelId: channelId, transferTxId: nil, contact: contact.map { PubkyPublicKeyFormat.normalized($0) ?? $0 }, createdAt: now, @@ -1144,6 +1146,30 @@ 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") + } + } + 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..1e89e3203 100644 --- a/Bitkit/Services/TransferService.swift +++ b/Bitkit/Services/TransferService.swift @@ -73,6 +73,25 @@ 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, + channelId: order.channel?.shortChannelId + ) + } + /// Mark a transfer as settled func markSettled(id: String) async throws { let settledAt = UInt64(Date().timeIntervalSince1970) @@ -125,6 +144,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 markOnchainActivityAsTransfer(txid: fundingTxId, channelId: channelId) + } + // Check if channel is ready (usable) if let channel = channels.first(where: { $0.channelId.description == channelId }), channel.isUsable @@ -225,6 +250,22 @@ class TransferService { } } + /// Mark the on-chain activity for `txid` as a transfer associated with `channelId`. Preserves + /// activities that are already correctly tagged. + private func markOnchainActivityAsTransfer(txid: String, channelId: String) async { + guard let activity = try? await coreService.activity.getOnchainActivityByTxId(txid: txid) else { return } + if activity.isTransfer, activity.channelId == channelId { return } + var updated = activity + updated.isTransfer = true + updated.channelId = channelId + do { + try await coreService.activity.update(id: activity.id, activity: .onchain(updated)) + Logger.debug("Marked activity \(activity.id) as transfer for channel \(channelId)", context: "TransferService") + } catch { + Logger.error("Failed to mark activity as transfer for \(txid): \(error)", context: "TransferService") + } + } + /// Resolve channel ID for a transfer /// For LSP orders: match via order->fundingTx, for manual: use directly func resolveChannelId(for transfer: Transfer, channels: [ChannelDetails]) async throws -> String? { From e48bad94791c96767401af2c8cb3acb28c6edead Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 3 Jul 2026 14:57:37 -0300 Subject: [PATCH 06/33] fix: balance double counting --- Bitkit/Managers/BalanceManager.swift | 55 +++++++++++++++++++--------- 1 file changed, 38 insertions(+), 17 deletions(-) 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 } From 2c4a1407989adfa72c87ba323866982f8c724a2f Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 3 Jul 2026 15:05:29 -0300 Subject: [PATCH 07/33] feat: UI logic --- .../Transfer/Hardware/SpendingAmountHw.swift | 176 ++++++++++++++++++ .../Transfer/Hardware/SpendingHwSign.swift | 149 +++++++++++++++ .../Transfer/Hardware/SpendingHwSigned.swift | 52 ++++++ 3 files changed, 377 insertions(+) create mode 100644 Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift create mode 100644 Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift create mode 100644 Bitkit/Views/Transfer/Hardware/SpendingHwSigned.swift diff --git a/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift b/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift new file mode 100644 index 000000000..9c8829211 --- /dev/null +++ b/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift @@ -0,0 +1,176 @@ +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 + } + + private var isValidAmount: Bool { + 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: deviceId) { + 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() } + } + + 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..11d4aae6a --- /dev/null +++ b/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift @@ -0,0 +1,149 @@ +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() } + } + } + } + + 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__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) { + transfer.onDefaultClick() + } + .accessibilityIdentifier("HardwareTransferSignDefault") + } else { + CustomButton(title: t("common__advanced"), size: .small) { + navigation.navigate(.spendingAdvanced(order: order)) + } + .accessibilityIdentifier("HardwareTransferSignAdvanced") + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 24) + + trezorIllustration + + 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") + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + .onChange(of: transfer.hwSignedEvent) { + navigation.navigate(.spendingHwSigned) + } + .onChange(of: transfer.hwTransferError) { _, error in + guard let error else { return } + showToast(for: error) + transfer.hwTransferError = nil + } + } + + private var trezorIllustration: some View { + Image("trezor-device") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: .infinity) + .frame(height: 220) + .padding(.top, 24) + .accessibilityHidden(true) + } + + private func showToast(for error: HwTransferError) { + switch error { + case .reconnect: + app.toast( + type: .error, + title: t("lightning__transfer_hw__reconnect_error_title"), + description: t("lightning__transfer_hw__reconnect_error_description") + ) + case .signingTimeout: + app.toast( + type: .error, + title: t("common__error"), + description: t("wallet__toast_payment_failed_timeout") + ) + case let .funding(message): + app.toast(type: .error, title: t("common__error"), description: message ?? t("common__error_body")) + case let .generic(message): + app.toast(type: .error, title: t("common__error"), description: message ?? t("common__error_body")) + } + } +} + +/// 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) + } + } +} From 93e760a62c38f7d0873ca148ca97d7b88f55d385 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 3 Jul 2026 15:13:07 -0300 Subject: [PATCH 08/33] feat: navigation --- Bitkit/MainNavView.swift | 4 ++++ Bitkit/ViewModels/NavigationViewModel.swift | 4 ++++ Bitkit/Views/Transfer/SpendingIntroView.swift | 9 ++++++++- Bitkit/Views/Wallets/HardwareWalletScreen.swift | 13 +++++++++---- 4 files changed, 25 insertions(+), 5 deletions(-) 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/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/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/Wallets/HardwareWalletScreen.swift b/Bitkit/Views/Wallets/HardwareWalletScreen.swift index 283ca6931..6f6b1b33a 100644 --- a/Bitkit/Views/Wallets/HardwareWalletScreen.swift +++ b/Bitkit/Views/Wallets/HardwareWalletScreen.swift @@ -61,8 +61,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 +75,7 @@ struct HardwareWalletScreen: View { testIdPrefix: "TotalBalance" ) - if hasFunds { + if canTransferToSpending { transferButton .padding(.top, 28) } @@ -125,9 +126,13 @@ struct HardwareWalletScreen: View { .frame(width: 16, height: 16) .foregroundColor(.white80) ) { - 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 { From 75ba1f0f5a899526eff21459609af28260da30d2 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 3 Jul 2026 15:21:27 -0300 Subject: [PATCH 09/33] feat: localization and asets --- .../Resources/Localization/en.lproj/Localizable.strings | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 172a89073..28b419332 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -64,7 +64,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"; @@ -88,6 +87,7 @@ "common__copy" = "Copy"; "common__edit" = "Edit"; "common__error" = "Error"; +"common__error_body" = "Unknown error"; "common__share" = "Share"; "common__search" = "Search"; "common__discard" = "Discard"; @@ -174,6 +174,11 @@ "lightning__transfer__confirm" = "Please\nconfirm"; "lightning__transfer__custom_fee" = "Custom fee"; "lightning__transfer__swipe" = "Swipe To Transfer"; +"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"; @@ -1260,6 +1265,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"; From b7b520b74ddce49ae4f87d4814f31ffddb32fd67 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 3 Jul 2026 15:36:01 -0300 Subject: [PATCH 10/33] test: transfer tests --- Bitkit/ViewModels/TransferViewModel.swift | 39 +++- BitkitTests/HwWalletManagerFundingTests.swift | 102 ++++++++++ BitkitTests/TransferViewModelHwTests.swift | 188 ++++++++++++++++++ 3 files changed, 319 insertions(+), 10 deletions(-) create mode 100644 BitkitTests/HwWalletManagerFundingTests.swift create mode 100644 BitkitTests/TransferViewModelHwTests.swift diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 33b473f09..5cadb5882 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -97,9 +97,8 @@ class TransferViewModel: ObservableObject { private let hwFundingFallbackSatsPerVByte: UInt64 = 1 /// Fallback fee percentage used when fee estimates are temporarily unavailable. private let hwFundingFallbackFeePercent = 0.1 - private let hwReconnectTimeout: Double = 30 - private let hwComposeTimeout: Double = 45 - private let hwSignTimeout: Double = 120 + /// Per-phase timeouts (seconds). Injectable so tests can drive the timeout paths. + private let hwTimeouts: (reconnect: Double, compose: Double, sign: Double) init( coreService: CoreService = .shared, @@ -110,6 +109,7 @@ class TransferViewModel: ObservableObject { hwFunding: HwTransferFunding? = nil, hwConnecting: HwTransferConnecting? = nil, hwFeeRateProvider: (() async -> UInt64?)? = nil, + hwTimeouts: (reconnect: Double, compose: Double, sign: Double) = (reconnect: 30, compose: 45, sign: 120), onBalanceRefresh: (() async -> Void)? = nil ) { self.coreService = coreService @@ -120,6 +120,7 @@ class TransferViewModel: ObservableObject { self.hwFunding = hwFunding self.hwConnecting = hwConnecting self.hwFeeRateProvider = hwFeeRateProvider + self.hwTimeouts = hwTimeouts self.onBalanceRefresh = onBalanceRefresh } @@ -538,7 +539,7 @@ class TransferViewModel: ObservableObject { private func ensureHardwareConnected(deviceId: String, hwConnecting: HwTransferConnecting) async throws { do { - try await withHwTimeout(hwReconnectTimeout) { + try await withHwTimeout(hwTimeouts.reconnect) { try await hwConnecting.ensureConnected(deviceId: deviceId) } } catch is CancellationError { @@ -556,7 +557,7 @@ class TransferViewModel: ObservableObject { hwFunding: HwTransferFunding ) async throws -> HwFundingTransaction { do { - return try await withHwTimeout(hwComposeTimeout) { + return try await withHwTimeout(hwTimeouts.compose) { try await hwFunding.composeFundingTransaction( deviceId: deviceId, address: address, @@ -580,7 +581,7 @@ class TransferViewModel: ObservableObject { hwConnecting: HwTransferConnecting ) async throws -> HwFundingBroadcastResult { do { - return try await withHwTimeout(hwSignTimeout) { + return try await withHwTimeout(hwTimeouts.sign) { try await hwFunding.signAndBroadcastFunding(deviceId: deviceId, funding: funding) } } catch is CancellationError { @@ -608,12 +609,30 @@ class TransferViewModel: ObservableObject { /// On-chain fee reserve to hold back from the device balance before the exact compose runs. private func hwFundingFeeReserve(balanceSats: UInt64) async -> UInt64 { - guard let satsPerVByte = await hwFeeRateProvider?() else { - let minReserve = hwFundingFallbackSatsPerVByte * hwFundingTxVBytes - let fallback = UInt64(Double(balanceSats) * hwFundingFallbackFeePercent) + await Self.hwFundingFeeReserve( + balanceSats: balanceSats, + satsPerVByte: hwFeeRateProvider?(), + txVBytes: hwFundingTxVBytes, + fallbackSatsPerVByte: hwFundingFallbackSatsPerVByte, + fallbackFeePercent: hwFundingFallbackFeePercent + ) + } + + /// Pure fee-reserve computation. With a known fee rate: `rate × vbytes`. Without one (estimates + /// unavailable): `max(minReserve, balance × fallbackPercent)`. + static func hwFundingFeeReserve( + 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 * hwFundingTxVBytes + return satsPerVByte * txVBytes } private func hwFundingSatsPerVByte() async -> UInt64 { 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/TransferViewModelHwTests.swift b/BitkitTests/TransferViewModelHwTests.swift new file mode 100644 index 000000000..162e4800e --- /dev/null +++ b/BitkitTests/TransferViewModelHwTests.swift @@ -0,0 +1,188 @@ +@testable import Bitkit +import BitkitCore +import XCTest + +/// Hardware-wallet transfer-to-spending coverage for `TransferViewModel`, adapting the HW cases in +/// bitkit-android's `TransferViewModelTest`: fee-reserve math, on-device sign orchestration, and the +/// reconnect / signing-timeout / compose-error state handling. The funding and device-session +/// capabilities are injected as mocks via the `HwTransferFunding` / `HwTransferConnecting` seams. +@MainActor +final class TransferViewModelHwTests: XCTestCase { + private struct TestError: Error {} + + // MARK: - Mocks + + private final class MockHwFunding: HwTransferFunding { + var account = HwFundingAccount(xpub: "zpubNS", addressType: .nativeSegwit, balanceSats: 1_000_000) + var accountError: Error? + var composeError: Error? + var signError: Error? + var signDelay: Double = 0 + var funding = HwFundingTransaction(psbt: "psbt", miningFeeSats: 141, feeRate: 1, totalSpent: 43186, satsPerVByte: 1) + var broadcast = HwFundingBroadcastResult(txId: "txid", miningFeeSats: 141, feeRate: 1, totalSpent: 43186) + + private(set) var composeCalls: [(address: String, sats: UInt64, satsPerVByte: UInt64)] = [] + private(set) var signCalls = 0 + + func getFundingAccount(deviceId _: String, addressType _: AddressScriptType) throws -> HwFundingAccount { + if let accountError { throw accountError } + return account + } + + func composeFundingTransaction( + deviceId _: String, + address: String, + sats: UInt64, + satsPerVByte: UInt64, + addressType _: AddressScriptType + ) async throws -> HwFundingTransaction { + composeCalls.append((address, sats, satsPerVByte)) + if let composeError { throw composeError } + return funding + } + + func signAndBroadcastFunding(deviceId _: String, funding _: HwFundingTransaction) async throws -> HwFundingBroadcastResult { + signCalls += 1 + if signDelay > 0 { try await Task.sleep(nanoseconds: UInt64(signDelay * 1_000_000_000)) } + if let signError { throw signError } + return broadcast + } + } + + private final class MockHwConnecting: HwTransferConnecting { + var connectError: Error? + private(set) var ensureCalls = 0 + private(set) var staleDisconnects: [String] = [] + + func ensureConnected(deviceId _: String) async throws { + ensureCalls += 1 + if let connectError { throw connectError } + } + + func disconnectStaleSession(deviceId: String) async { + staleDisconnects.append(deviceId) + } + } + + // MARK: - Helpers + + private func makeViewModel( + funding: MockHwFunding, + connecting: MockHwConnecting, + feeRate: UInt64? = 2, + timeouts: (reconnect: Double, compose: Double, sign: Double) = (reconnect: 5, compose: 5, sign: 5) + ) -> TransferViewModel { + TransferViewModel( + transferService: TransferService(lightningService: .shared, blocktankService: CoreService.shared.blocktank), + sheetViewModel: SheetViewModel(), + 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) + } + } + + // MARK: - Fee reserve + + func testFeeReserveUsesRateWhenAvailable() { + let reserve = TransferViewModel.hwFundingFeeReserve(balanceSats: 1_000_000, satsPerVByte: 5) + XCTAssertEqual(reserve, 5 * 1200) + } + + func testFeeReserveFallbackUsesPercentWhenLarger() { + // 10% of 1,000,000 = 100,000 dominates the 1,200 sat floor. + let reserve = TransferViewModel.hwFundingFeeReserve(balanceSats: 1_000_000, satsPerVByte: nil) + XCTAssertEqual(reserve, 100_000) + } + + func testFeeReserveFallbackUsesFloorWhenPercentSmaller() { + // 10% of 5,000 = 500, below the 1 * 1200 floor. + let reserve = TransferViewModel.hwFundingFeeReserve(balanceSats: 5000, satsPerVByte: nil) + XCTAssertEqual(reserve, 1200) + } + + // MARK: - Sign orchestration + + func testReconnectFailureSurfacesReconnectError() async { + let funding = MockHwFunding() + let connecting = MockHwConnecting() + connecting.connectError = TestError() + let vm = makeViewModel(funding: funding, connecting: connecting) + + vm.onTransferToSpendingHwConfirm(order: .mock(), deviceId: "dev1") + await awaitSigningComplete(vm) + + XCTAssertEqual(vm.hwTransferError, .reconnect) + XCTAssertFalse(vm.hwSpending.isSigning) + XCTAssertTrue(funding.composeCalls.isEmpty, "compose must not run when reconnect fails") + XCTAssertEqual(funding.signCalls, 0) + } + + func testComposeFundsFinalOrderFeeAndSurfacesFundingErrorOnFailure() async { + let funding = MockHwFunding() + funding.composeError = TestError() + let connecting = MockHwConnecting() + let vm = makeViewModel(funding: funding, connecting: connecting) + let order = IBtOrder.mock() // feeSat = 1000, onchain address = "bc1q..." + + vm.onTransferToSpendingHwConfirm(order: order, deviceId: "dev1") + await awaitSigningComplete(vm) + + // The signed funding output equals the final order.feeSat, sent to the order's address. + 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) + XCTAssertEqual(funding.signCalls, 0) + if case .funding = vm.hwTransferError {} else { XCTFail("expected .funding error, got \(String(describing: vm.hwTransferError))") } + } + + func testSigningTimeoutClearsStaleSession() async { + let funding = MockHwFunding() + funding.signDelay = 0.4 + let connecting = MockHwConnecting() + let vm = makeViewModel(funding: funding, connecting: connecting, timeouts: (reconnect: 5, compose: 5, sign: 0.05)) + + vm.onTransferToSpendingHwConfirm(order: .mock(), deviceId: "dev1") + await awaitSigningComplete(vm) + + XCTAssertEqual(vm.hwTransferError, .signingTimeout) + XCTAssertEqual(connecting.staleDisconnects, ["dev1"], "a signing timeout must clear the stale session") + XCTAssertEqual(funding.signCalls, 1) + } + + func testGenericSignErrorSurfacesGenericError() async { + let funding = MockHwFunding() + funding.signError = 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, "a non-timeout error must not clear the session") + } + + func testReentrancyGuardIgnoresConcurrentConfirm() async { + let funding = MockHwFunding() + funding.composeError = TestError() // fail before reaching 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) + } +} From f4725fa0ac9d257c283f550076a4293931f5ab15 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 3 Jul 2026 15:42:14 -0300 Subject: [PATCH 11/33] refactor: make code more testable --- Bitkit/ViewModels/TransferViewModel.swift | 27 ++++++++++++++++++++++ BitkitTests/TransferViewModelHwTests.swift | 2 -- changelog.d/next/615.added.md | 1 + 3 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 changelog.d/next/615.added.md diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 5cadb5882..e781af5e5 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -144,6 +144,33 @@ 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, + hwTimeouts: (reconnect: Double, compose: Double, sign: Double) = (reconnect: 30, compose: 45, sign: 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, + hwTimeouts: hwTimeouts + ) + } + deinit { Task { @MainActor [weak self] in Logger.debug("Stopping poll for order") diff --git a/BitkitTests/TransferViewModelHwTests.swift b/BitkitTests/TransferViewModelHwTests.swift index 162e4800e..cbc999879 100644 --- a/BitkitTests/TransferViewModelHwTests.swift +++ b/BitkitTests/TransferViewModelHwTests.swift @@ -73,8 +73,6 @@ final class TransferViewModelHwTests: XCTestCase { timeouts: (reconnect: Double, compose: Double, sign: Double) = (reconnect: 5, compose: 5, sign: 5) ) -> TransferViewModel { TransferViewModel( - transferService: TransferService(lightningService: .shared, blocktankService: CoreService.shared.blocktank), - sheetViewModel: SheetViewModel(), hwFunding: funding, hwConnecting: connecting, hwFeeRateProvider: { feeRate }, diff --git a/changelog.d/next/615.added.md b/changelog.d/next/615.added.md new file mode 100644 index 000000000..4c21f7c61 --- /dev/null +++ b/changelog.d/next/615.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. From a522880002589358de97043f3325707ff444a2dd Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Sun, 5 Jul 2026 14:43:18 -0300 Subject: [PATCH 12/33] refactor: extract HW methods from transfer viewmodel --- Bitkit/ViewModels/HwFundingSigner.swift | 155 ++++++++++++++++ Bitkit/ViewModels/TransferViewModel.swift | 203 +++------------------ BitkitTests/HwFundingSignerTests.swift | 138 ++++++++++++++ BitkitTests/HwTransferMocks.swift | 60 ++++++ BitkitTests/TransferViewModelHwTests.swift | 148 +++------------ 5 files changed, 409 insertions(+), 295 deletions(-) create mode 100644 Bitkit/ViewModels/HwFundingSigner.swift create mode 100644 BitkitTests/HwFundingSignerTests.swift create mode 100644 BitkitTests/HwTransferMocks.swift diff --git a/Bitkit/ViewModels/HwFundingSigner.swift b/Bitkit/ViewModels/HwFundingSigner.swift new file mode 100644 index 000000000..170d4ff6d --- /dev/null +++ b/Bitkit/ViewModels/HwFundingSigner.swift @@ -0,0 +1,155 @@ +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? + let timeouts: (reconnect: Double, compose: Double, sign: Double) + + /// Conservative vbyte reserve for multi-input hardware funding before the exact compose runs. + 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 after the fee reserve. + func availability( + deviceId: String, + addressType: AddressScriptType = hwFundingDefaultAddressType + ) async throws -> Availability { + let account = try funding.getFundingAccount(deviceId: deviceId, addressType: addressType) + let reserve = await feeReserve(balanceSats: account.balanceSats) + let available = account.balanceSats > reserve ? account.balanceSats - reserve : 0 + return Availability(balanceSats: account.balanceSats, available: available) + } + + /// 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) + return try await signAndBroadcast(deviceId: deviceId, funding: tx) + } + + 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 { + throw HwTransferError.reconnect + } + } + + 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 { + let message = (error as? AppError)?.debugMessage ?? (error as? AppError)?.message ?? error.localizedDescription + throw HwTransferError.funding(message) + } + } + + private func signAndBroadcast( + deviceId: String, + funding tx: HwFundingTransaction + ) async throws -> HwFundingBroadcastResult { + do { + return try await withTimeout(timeouts.sign) { + try await funding.signAndBroadcastFunding(deviceId: deviceId, funding: tx) + } + } catch is CancellationError { + throw CancellationError() + } catch is Timeout { + await connecting.disconnectStaleSession(deviceId: deviceId) + throw HwTransferError.signingTimeout + } + // Any other (real sign/broadcast) error propagates to the caller's generic handler. + } + + private func resolvedSatsPerVByte() async -> UInt64 { + await feeRateProvider() ?? fallbackSatsPerVByte + } + + private func feeReserve(balanceSats: UInt64) async -> UInt64 { + await Self.feeReserve( + balanceSats: balanceSats, + satsPerVByte: feeRateProvider(), + txVBytes: txVBytes, + fallbackSatsPerVByte: fallbackSatsPerVByte, + fallbackFeePercent: fallbackFeePercent + ) + } + + /// 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/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index e781af5e5..a70c091f4 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -78,9 +78,9 @@ class TransferViewModel: ObservableObject { private let transferService: TransferService private let sheetViewModel: SheetViewModel private let onBalanceRefresh: (() async -> Void)? - private let hwFunding: HwTransferFunding? - private let hwConnecting: HwTransferConnecting? - private let hwFeeRateProvider: (() async -> UInt64?)? + /// 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? @@ -90,16 +90,6 @@ class TransferViewModel: ObservableObject { private let giveUpInterval: TimeInterval = 30 * 60 // 30 min private var coopCloseRetryTask: Task? - /// Hardware funding fee tuning. - /// Conservative vbyte reserve for multi-input hardware funding before the exact compose runs. - private let hwFundingTxVBytes: UInt64 = 1200 - /// Minimum fallback fee rate when fee estimates are temporarily unavailable. - private let hwFundingFallbackSatsPerVByte: UInt64 = 1 - /// Fallback fee percentage used when fee estimates are temporarily unavailable. - private let hwFundingFallbackFeePercent = 0.1 - /// Per-phase timeouts (seconds). Injectable so tests can drive the timeout paths. - private let hwTimeouts: (reconnect: Double, compose: Double, sign: Double) - init( coreService: CoreService = .shared, lightningService: LightningService = .shared, @@ -117,11 +107,17 @@ class TransferViewModel: ObservableObject { self.currencyService = currencyService self.transferService = transferService self.sheetViewModel = sheetViewModel - self.hwFunding = hwFunding - self.hwConnecting = hwConnecting - self.hwFeeRateProvider = hwFeeRateProvider - self.hwTimeouts = hwTimeouts self.onBalanceRefresh = onBalanceRefresh + if let hwFunding, let hwConnecting { + hwSigner = HwFundingSigner( + funding: hwFunding, + connecting: hwConnecting, + feeRateProvider: hwFeeRateProvider ?? { nil }, + timeouts: hwTimeouts + ) + } else { + hwSigner = nil + } } /// Convenience initializer for testing and previews @@ -448,40 +444,37 @@ class TransferViewModel: ObservableObject { // MARK: - Hardware Wallet Transfer - /// Compute the available/MAX/quarter limits for a hardware-wallet transfer, sourcing the - /// spendable balance from the device's native-segwit account minus an on-chain fee reserve, then - /// clamping to the LSP receiving cap via the shared spending-limit calculation. + /// 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 hwFunding else { return } + guard let hwSigner else { return } hwSpending.isLoading = true - let account: HwFundingAccount + let availability: HwFundingSigner.Availability do { - account = try hwFunding.getFundingAccount(deviceId: deviceId, addressType: hwFundingDefaultAddressType) + availability = try await hwSigner.availability(deviceId: deviceId) } catch { hwSpending = HwSpendingState(isLoading: false) hwTransferError = .generic((error as? AppError)?.message ?? error.localizedDescription) return } - let reserve = await hwFundingFeeReserve(balanceSats: account.balanceSats) - let available = account.balanceSats > reserve ? account.balanceSats - reserve : 0 - do { let (avail, maxAmount) = try await calculateSpendingLimits( - onchainAvailable: available, + 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(account.balanceSats / 4, maxAmount) + hwSpending.quarterAmount = min(availability.balanceSats / 4, maxAmount) } catch { hwSpending.balanceAfterFee = 0 hwSpending.maxAllowedToSend = 0 @@ -491,10 +484,15 @@ class TransferViewModel: ObservableObject { hwSpending.isLoading = false } - /// Pay for the order by composing and signing the funding send on the Trezor, then watch it. + /// 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 hwFunding, let hwConnecting else { + 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 } @@ -509,19 +507,8 @@ class TransferViewModel: ObservableObject { self.hwSignTask = nil } - guard let address = order.payment?.onchain?.address, !address.isEmpty else { - hwTransferError = .generic(t("common__error")) - return - } - do { - let result = try await signTransferToSpendingWithHardware( - order: order, - deviceId: deviceId, - address: address, - hwFunding: hwFunding, - hwConnecting: hwConnecting - ) + let result = try await hwSigner.sign(order: order, deviceId: deviceId, address: address) await fundPaidOrder( order: order, txId: result.txId, @@ -540,86 +527,6 @@ class TransferViewModel: ObservableObject { } } - private func signTransferToSpendingWithHardware( - order: IBtOrder, - deviceId: String, - address: String, - hwFunding: HwTransferFunding, - hwConnecting: HwTransferConnecting - ) async throws -> HwFundingBroadcastResult { - try await ensureHardwareConnected(deviceId: deviceId, hwConnecting: hwConnecting) - let satsPerVByte = await hwFundingSatsPerVByte() - let funding = try await composeHardwareFundingTransaction( - deviceId: deviceId, - address: address, - sats: order.feeSat, - satsPerVByte: satsPerVByte, - hwFunding: hwFunding - ) - return try await signAndBroadcastHardwareFunding( - deviceId: deviceId, - funding: funding, - hwFunding: hwFunding, - hwConnecting: hwConnecting - ) - } - - private func ensureHardwareConnected(deviceId: String, hwConnecting: HwTransferConnecting) async throws { - do { - try await withHwTimeout(hwTimeouts.reconnect) { - try await hwConnecting.ensureConnected(deviceId: deviceId) - } - } catch is CancellationError { - throw CancellationError() - } catch { - throw HwTransferError.reconnect - } - } - - private func composeHardwareFundingTransaction( - deviceId: String, - address: String, - sats: UInt64, - satsPerVByte: UInt64, - hwFunding: HwTransferFunding - ) async throws -> HwFundingTransaction { - do { - return try await withHwTimeout(hwTimeouts.compose) { - try await hwFunding.composeFundingTransaction( - deviceId: deviceId, - address: address, - sats: sats, - satsPerVByte: satsPerVByte, - addressType: hwFundingDefaultAddressType - ) - } - } catch is CancellationError { - throw CancellationError() - } catch { - let message = (error as? AppError)?.debugMessage ?? (error as? AppError)?.message ?? error.localizedDescription - throw HwTransferError.funding(message) - } - } - - private func signAndBroadcastHardwareFunding( - deviceId: String, - funding: HwFundingTransaction, - hwFunding: HwTransferFunding, - hwConnecting: HwTransferConnecting - ) async throws -> HwFundingBroadcastResult { - do { - return try await withHwTimeout(hwTimeouts.sign) { - try await hwFunding.signAndBroadcastFunding(deviceId: deviceId, funding: funding) - } - } catch is CancellationError { - throw CancellationError() - } catch is HwTimeout { - await hwConnecting.disconnectStaleSession(deviceId: deviceId) - throw HwTransferError.signingTimeout - } - // Any other (real sign/broadcast) error propagates to the generic toast. - } - private func handleHardwareTransferFailure(_ error: HwTransferError, deviceId: String) { switch error { case .reconnect: @@ -634,58 +541,6 @@ class TransferViewModel: ObservableObject { hwTransferError = error } - /// On-chain fee reserve to hold back from the device balance before the exact compose runs. - private func hwFundingFeeReserve(balanceSats: UInt64) async -> UInt64 { - await Self.hwFundingFeeReserve( - balanceSats: balanceSats, - satsPerVByte: hwFeeRateProvider?(), - txVBytes: hwFundingTxVBytes, - fallbackSatsPerVByte: hwFundingFallbackSatsPerVByte, - fallbackFeePercent: hwFundingFallbackFeePercent - ) - } - - /// Pure fee-reserve computation. With a known fee rate: `rate × vbytes`. Without one (estimates - /// unavailable): `max(minReserve, balance × fallbackPercent)`. - static func hwFundingFeeReserve( - 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 func hwFundingSatsPerVByte() async -> UInt64 { - await hwFeeRateProvider?() ?? hwFundingFallbackSatsPerVByte - } - - private struct HwTimeout: Error {} - - /// Race an async operation against a timeout. Cancellation (user dismiss) propagates as - /// `CancellationError`; the deadline throws `HwTimeout`. - private func withHwTimeout( - _ 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 HwTimeout() - } - defer { group.cancelAll() } - guard let result = try await group.next() else { throw HwTimeout() } - return result - } - } - // MARK: - Balance Calculation /// Calculates channel liquidity options using bitkit-core diff --git a/BitkitTests/HwFundingSignerTests.swift b/BitkitTests/HwFundingSignerTests.swift new file mode 100644 index 000000000..6cae39667 --- /dev/null +++ b/BitkitTests/HwFundingSignerTests.swift @@ -0,0 +1,138 @@ +@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, + timeouts: (reconnect: Double, compose: Double, sign: Double) = (reconnect: 5, compose: 5, sign: 5) + ) -> HwFundingSigner { + HwFundingSigner(funding: funding, connecting: connecting, feeRateProvider: { feeRate }, timeouts: timeouts) + } + + // MARK: - Fee reserve + + 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 testAvailabilityReturnsBalanceMinusReserve() 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) + + let availability = try await signer.availability(deviceId: "dev1") + + XCTAssertEqual(availability.balanceSats, 1_000_000) + 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) + } + 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)) + + 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 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..296865d63 --- /dev/null +++ b/BitkitTests/HwTransferMocks.swift @@ -0,0 +1,60 @@ +@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 composeError: Error? + var signError: Error? + var signDelay: Double = 0 + var funding = HwFundingTransaction(psbt: "psbt", miningFeeSats: 141, feeRate: 1, totalSpent: 43186, satsPerVByte: 1) + var broadcast = HwFundingBroadcastResult(txId: "txid", miningFeeSats: 141, feeRate: 1, totalSpent: 43186) + + private(set) var composeCalls: [(address: String, sats: UInt64, satsPerVByte: UInt64)] = [] + private(set) var signCalls = 0 + + func getFundingAccount(deviceId _: String, addressType _: AddressScriptType) throws -> HwFundingAccount { + if let accountError { throw accountError } + return account + } + + func composeFundingTransaction( + deviceId _: String, + address: String, + sats: UInt64, + satsPerVByte: UInt64, + addressType _: AddressScriptType + ) async throws -> HwFundingTransaction { + composeCalls.append((address, sats, satsPerVByte)) + if let composeError { throw composeError } + return funding + } + + func signAndBroadcastFunding(deviceId _: String, funding _: HwFundingTransaction) async throws -> HwFundingBroadcastResult { + signCalls += 1 + if signDelay > 0 { try await Task.sleep(nanoseconds: UInt64(signDelay * 1_000_000_000)) } + if let signError { throw signError } + return broadcast + } +} + +@MainActor +final class MockHwConnecting: HwTransferConnecting { + var connectError: Error? + private(set) var ensureCalls = 0 + private(set) var staleDisconnects: [String] = [] + + func ensureConnected(deviceId _: String) async throws { + ensureCalls += 1 + if let connectError { throw connectError } + } + + func disconnectStaleSession(deviceId: String) async { + staleDisconnects.append(deviceId) + } +} diff --git a/BitkitTests/TransferViewModelHwTests.swift b/BitkitTests/TransferViewModelHwTests.swift index cbc999879..6157bc134 100644 --- a/BitkitTests/TransferViewModelHwTests.swift +++ b/BitkitTests/TransferViewModelHwTests.swift @@ -2,70 +2,11 @@ import BitkitCore import XCTest -/// Hardware-wallet transfer-to-spending coverage for `TransferViewModel`, adapting the HW cases in -/// bitkit-android's `TransferViewModelTest`: fee-reserve math, on-device sign orchestration, and the -/// reconnect / signing-timeout / compose-error state handling. The funding and device-session -/// capabilities are injected as mocks via the `HwTransferFunding` / `HwTransferConnecting` seams. +/// 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 struct TestError: Error {} - - // MARK: - Mocks - - private final class MockHwFunding: HwTransferFunding { - var account = HwFundingAccount(xpub: "zpubNS", addressType: .nativeSegwit, balanceSats: 1_000_000) - var accountError: Error? - var composeError: Error? - var signError: Error? - var signDelay: Double = 0 - var funding = HwFundingTransaction(psbt: "psbt", miningFeeSats: 141, feeRate: 1, totalSpent: 43186, satsPerVByte: 1) - var broadcast = HwFundingBroadcastResult(txId: "txid", miningFeeSats: 141, feeRate: 1, totalSpent: 43186) - - private(set) var composeCalls: [(address: String, sats: UInt64, satsPerVByte: UInt64)] = [] - private(set) var signCalls = 0 - - func getFundingAccount(deviceId _: String, addressType _: AddressScriptType) throws -> HwFundingAccount { - if let accountError { throw accountError } - return account - } - - func composeFundingTransaction( - deviceId _: String, - address: String, - sats: UInt64, - satsPerVByte: UInt64, - addressType _: AddressScriptType - ) async throws -> HwFundingTransaction { - composeCalls.append((address, sats, satsPerVByte)) - if let composeError { throw composeError } - return funding - } - - func signAndBroadcastFunding(deviceId _: String, funding _: HwFundingTransaction) async throws -> HwFundingBroadcastResult { - signCalls += 1 - if signDelay > 0 { try await Task.sleep(nanoseconds: UInt64(signDelay * 1_000_000_000)) } - if let signError { throw signError } - return broadcast - } - } - - private final class MockHwConnecting: HwTransferConnecting { - var connectError: Error? - private(set) var ensureCalls = 0 - private(set) var staleDisconnects: [String] = [] - - func ensureConnected(deviceId _: String) async throws { - ensureCalls += 1 - if let connectError { throw connectError } - } - - func disconnectStaleSession(deviceId: String) async { - staleDisconnects.append(deviceId) - } - } - - // MARK: - Helpers - private func makeViewModel( funding: MockHwFunding, connecting: MockHwConnecting, @@ -87,31 +28,17 @@ final class TransferViewModelHwTests: XCTestCase { } } - // MARK: - Fee reserve - - func testFeeReserveUsesRateWhenAvailable() { - let reserve = TransferViewModel.hwFundingFeeReserve(balanceSats: 1_000_000, satsPerVByte: 5) - XCTAssertEqual(reserve, 5 * 1200) - } - - func testFeeReserveFallbackUsesPercentWhenLarger() { - // 10% of 1,000,000 = 100,000 dominates the 1,200 sat floor. - let reserve = TransferViewModel.hwFundingFeeReserve(balanceSats: 1_000_000, satsPerVByte: nil) - XCTAssertEqual(reserve, 100_000) - } - - func testFeeReserveFallbackUsesFloorWhenPercentSmaller() { - // 10% of 5,000 = 500, below the 1 * 1200 floor. - let reserve = TransferViewModel.hwFundingFeeReserve(balanceSats: 5000, satsPerVByte: nil) - XCTAssertEqual(reserve, 1200) + 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) } - // MARK: - Sign orchestration - - func testReconnectFailureSurfacesReconnectError() async { + func testReconnectFailureMapsToReconnectErrorAndResetsSigning() async { let funding = MockHwFunding() let connecting = MockHwConnecting() - connecting.connectError = TestError() + connecting.connectError = MockHwFunding.TestError() let vm = makeViewModel(funding: funding, connecting: connecting) vm.onTransferToSpendingHwConfirm(order: .mock(), deviceId: "dev1") @@ -119,68 +46,47 @@ final class TransferViewModelHwTests: XCTestCase { XCTAssertEqual(vm.hwTransferError, .reconnect) XCTAssertFalse(vm.hwSpending.isSigning) - XCTAssertTrue(funding.composeCalls.isEmpty, "compose must not run when reconnect fails") - XCTAssertEqual(funding.signCalls, 0) } - func testComposeFundsFinalOrderFeeAndSurfacesFundingErrorOnFailure() async { + func testRawSignErrorMapsToGenericError() async { let funding = MockHwFunding() - funding.composeError = TestError() + funding.signError = MockHwFunding.TestError() let connecting = MockHwConnecting() let vm = makeViewModel(funding: funding, connecting: connecting) - let order = IBtOrder.mock() // feeSat = 1000, onchain address = "bc1q..." - - vm.onTransferToSpendingHwConfirm(order: order, deviceId: "dev1") - await awaitSigningComplete(vm) - - // The signed funding output equals the final order.feeSat, sent to the order's address. - 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) - XCTAssertEqual(funding.signCalls, 0) - if case .funding = vm.hwTransferError {} else { XCTFail("expected .funding error, got \(String(describing: vm.hwTransferError))") } - } - - func testSigningTimeoutClearsStaleSession() async { - let funding = MockHwFunding() - funding.signDelay = 0.4 - let connecting = MockHwConnecting() - let vm = makeViewModel(funding: funding, connecting: connecting, timeouts: (reconnect: 5, compose: 5, sign: 0.05)) vm.onTransferToSpendingHwConfirm(order: .mock(), deviceId: "dev1") await awaitSigningComplete(vm) - XCTAssertEqual(vm.hwTransferError, .signingTimeout) - XCTAssertEqual(connecting.staleDisconnects, ["dev1"], "a signing timeout must clear the stale session") - XCTAssertEqual(funding.signCalls, 1) + if case .generic = vm.hwTransferError {} else { XCTFail("expected .generic error, got \(String(describing: vm.hwTransferError))") } + XCTAssertTrue(connecting.staleDisconnects.isEmpty) } - func testGenericSignErrorSurfacesGenericError() async { + func testReentrancyGuardIgnoresConcurrentConfirm() async { let funding = MockHwFunding() - funding.signError = TestError() + 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) - if case .generic = vm.hwTransferError {} else { XCTFail("expected .generic error, got \(String(describing: vm.hwTransferError))") } - XCTAssertTrue(connecting.staleDisconnects.isEmpty, "a non-timeout error must not clear the session") + XCTAssertEqual(connecting.ensureCalls, 1, "only the first confirm should run") + XCTAssertEqual(funding.composeCalls.count, 1) } - func testReentrancyGuardIgnoresConcurrentConfirm() async { + func testMissingOrderAddressSurfacesGenericErrorWithoutSigning() { let funding = MockHwFunding() - funding.composeError = TestError() // fail before reaching the network-bound funding tail let connecting = MockHwConnecting() let vm = makeViewModel(funding: funding, connecting: connecting) + var order = IBtOrder.mock() + order.payment?.onchain?.address = "" - 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) + vm.onTransferToSpendingHwConfirm(order: order, deviceId: "dev1") - XCTAssertEqual(connecting.ensureCalls, 1, "only the first confirm should run") - XCTAssertEqual(funding.composeCalls.count, 1) + if case .generic = vm.hwTransferError {} else { XCTFail("expected .generic error") } + XCTAssertFalse(vm.hwSpending.isSigning) + XCTAssertEqual(connecting.ensureCalls, 0) } } From 63be3e70b20fcc7377a4df0aa36ef6ef1c850353 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Sun, 5 Jul 2026 15:38:41 -0300 Subject: [PATCH 13/33] fix: add longer read time-out --- .../Trezor/TrezorBridgeTransport.swift | 14 ++++- .../Transfer/Hardware/SpendingHwSign.swift | 63 +++++++++++++------ 2 files changed, 54 insertions(+), 23 deletions(-) diff --git a/Bitkit/Services/Trezor/TrezorBridgeTransport.swift b/Bitkit/Services/Trezor/TrezorBridgeTransport.swift index 90138e9c8..a63a430a4 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 + /// Longer read timeout for the on-device signing call, which blocks while the user confirms on + /// the Trezor. Without it the standard 30s timeout fires before confirmation completes. + private static let signTxReadTimeout: TimeInterval = 120 + /// Trezor protobuf `MessageType_SignTx`. The only call that waits for on-device signing. + private static let signTxMessageType: UInt16 = 15 private let decoder = JSONDecoder() private let sessionLock = NSLock() @@ -133,7 +138,10 @@ final class TrezorBridgeTransport { do { let request = Self.encodeFrame(messageType: messageType, data: data) - let response = try post(path: "/call/\(Self.encode(session))", body: request) + // Signing blocks on device confirmation, so it needs a longer read timeout than the + // standard management calls. + let readTimeout = messageType == Self.signTxMessageType ? Self.signTxReadTimeout : Self.readTimeout + let response = try post(path: "/call/\(Self.encode(session))", body: request, readTimeout: readTimeout) return try Self.decodeFrame(response) } catch { debugLog("callMessage FAILED: \(error.localizedDescription)") @@ -141,14 +149,14 @@ final class TrezorBridgeTransport { } } - 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/Views/Transfer/Hardware/SpendingHwSign.swift b/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift index 11d4aae6a..18adba897 100644 --- a/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift +++ b/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift @@ -22,11 +22,40 @@ struct SpendingHwSign: View { } } + /// 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() + .onChange(of: transfer.hwSignedEvent) { + navigation.navigate(.spendingHwSigned) + } + .onChange(of: transfer.hwTransferError) { _, error in + guard let error else { return } + showToast(for: error) + transfer.hwTransferError = nil + } + } + + private func belowNav(order: IBtOrder) -> some View { + VStack(alignment: .leading, spacing: 0) { DisplayText(t("lightning__transfer_hw__sign_title"), accentColor: .purpleAccent) SpendingHwFeeGrid(order: order) @@ -53,8 +82,6 @@ struct SpendingHwSign: View { .frame(maxWidth: .infinity, alignment: .leading) .padding(.top, 24) - trezorIllustration - Spacer() CustomButton( @@ -66,27 +93,23 @@ struct SpendingHwSign: View { } .accessibilityIdentifier("HardwareTransferOpenTrezorConnect") } - .navigationBarHidden(true) - .padding(.horizontal, 16) - .bottomSafeAreaPadding() - .onChange(of: transfer.hwSignedEvent) { - navigation.navigate(.spendingHwSigned) - } - .onChange(of: transfer.hwTransferError) { _, error in - guard let error else { return } - showToast(for: error) - transfer.hwTransferError = nil - } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } private var trezorIllustration: some View { - Image("trezor-device") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(maxWidth: .infinity) - .frame(height: 220) - .padding(.top, 24) - .accessibilityHidden(true) + 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) } private func showToast(for error: HwTransferError) { From 2a63fa9d9ccbd8c96ba6e7540a7c85ae0a5c13c5 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 6 Jul 2026 07:23:19 -0300 Subject: [PATCH 14/33] doc: update changelog entry --- changelog.d/next/{615.added.md => 616.added.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{615.added.md => 616.added.md} (100%) diff --git a/changelog.d/next/615.added.md b/changelog.d/next/616.added.md similarity index 100% rename from changelog.d/next/615.added.md rename to changelog.d/next/616.added.md From d9feafa851b5a29f886e1bcc1955cf3e24db506a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 6 Jul 2026 09:20:43 -0300 Subject: [PATCH 15/33] fix: increase timout for /call messages --- .../Trezor/TrezorBridgeTransport.swift | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/Bitkit/Services/Trezor/TrezorBridgeTransport.swift b/Bitkit/Services/Trezor/TrezorBridgeTransport.swift index a63a430a4..93b1c3223 100644 --- a/Bitkit/Services/Trezor/TrezorBridgeTransport.swift +++ b/Bitkit/Services/Trezor/TrezorBridgeTransport.swift @@ -12,11 +12,11 @@ final class TrezorBridgeTransport { private static let headerSize = 6 private static let connectTimeout: TimeInterval = 5 private static let readTimeout: TimeInterval = 30 - /// Longer read timeout for the on-device signing call, which blocks while the user confirms on - /// the Trezor. Without it the standard 30s timeout fires before confirmation completes. - private static let signTxReadTimeout: TimeInterval = 120 - /// Trezor protobuf `MessageType_SignTx`. The only call that waits for on-device signing. - private static let signTxMessageType: UInt16 = 15 + /// 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() @@ -138,13 +138,12 @@ final class TrezorBridgeTransport { do { let request = Self.encodeFrame(messageType: messageType, data: data) - // Signing blocks on device confirmation, so it needs a longer read timeout than the - // standard management calls. - let readTimeout = messageType == Self.signTxMessageType ? Self.signTxReadTimeout : Self.readTimeout - let response = try post(path: "/call/\(Self.encode(session))", body: request, readTimeout: readTimeout) + // 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) } } From 3b9d13186a0c06816e8943904d8e7dabc3e494a1 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 6 Jul 2026 17:34:47 -0300 Subject: [PATCH 16/33] fix: display availability error on amount screen --- Bitkit/ViewModels/AppViewModel.swift | 17 ++++++++++++++ .../Transfer/Hardware/SpendingAmountHw.swift | 5 ++++ .../Transfer/Hardware/SpendingHwSign.swift | 23 +------------------ 3 files changed, 23 insertions(+), 22 deletions(-) diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 8c8f1bb42..30c10fa47 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -283,6 +283,23 @@ extension AppViewModel { toast(type: .error, title: "Error", description: error.localizedDescription) } + func toast(_ error: HwTransferError) { + switch error { + case .reconnect: + 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 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/Views/Transfer/Hardware/SpendingAmountHw.swift b/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift index 9c8829211..93508c079 100644 --- a/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift +++ b/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift @@ -102,6 +102,11 @@ struct SpendingAmountHw: View { } .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() { diff --git a/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift b/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift index 18adba897..898e62b6b 100644 --- a/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift +++ b/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift @@ -49,7 +49,7 @@ struct SpendingHwSign: View { } .onChange(of: transfer.hwTransferError) { _, error in guard let error else { return } - showToast(for: error) + app.toast(error) transfer.hwTransferError = nil } } @@ -111,27 +111,6 @@ struct SpendingHwSign: View { .allowsHitTesting(false) .accessibilityHidden(true) } - - private func showToast(for error: HwTransferError) { - switch error { - case .reconnect: - app.toast( - type: .error, - title: t("lightning__transfer_hw__reconnect_error_title"), - description: t("lightning__transfer_hw__reconnect_error_description") - ) - case .signingTimeout: - app.toast( - type: .error, - title: t("common__error"), - description: t("wallet__toast_payment_failed_timeout") - ) - case let .funding(message): - app.toast(type: .error, title: t("common__error"), description: message ?? t("common__error_body")) - case let .generic(message): - app.toast(type: .error, title: t("common__error"), description: message ?? t("common__error_body")) - } - } } /// Blocktank order fee summary shared by the hardware Sign and Signed screens. From 1a682b75ea3e5a3184c27c4f1358baf6b1b360f2 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 6 Jul 2026 17:49:03 -0300 Subject: [PATCH 17/33] fix: stop storing the SCID in the activity's channelId; pass nil (it's correctly set later by markOnchainActivityAsTransfer) --- Bitkit/Services/CoreService.swift | 3 +- Bitkit/Services/TransferService.swift | 3 +- .../TransferServiceActivityTests.swift | 48 +++++++++++++++++++ 3 files changed, 50 insertions(+), 4 deletions(-) create mode 100644 BitkitTests/TransferServiceActivityTests.swift diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index 92d700f3e..535d7cb47 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -1103,7 +1103,6 @@ class ActivityService { fee: UInt64, feeRate: UInt32, isTransfer: Bool = false, - channelId: String? = nil, contact: String? = nil ) async { do { @@ -1129,7 +1128,7 @@ class ActivityService { isTransfer: isTransfer, doesExist: true, confirmTimestamp: nil, - channelId: channelId, + channelId: nil, transferTxId: nil, contact: contact.map { PubkyPublicKeyFormat.normalized($0) ?? $0 }, createdAt: now, diff --git a/Bitkit/Services/TransferService.swift b/Bitkit/Services/TransferService.swift index 1e89e3203..9b8b8973c 100644 --- a/Bitkit/Services/TransferService.swift +++ b/Bitkit/Services/TransferService.swift @@ -87,8 +87,7 @@ class TransferService { amount: order.feeSat, fee: fee, feeRate: UInt32(feeRate), - isTransfer: true, - channelId: order.channel?.shortChannelId + isTransfer: true ) } diff --git a/BitkitTests/TransferServiceActivityTests.swift b/BitkitTests/TransferServiceActivityTests.swift new file mode 100644 index 000000000..e303ab7ba --- /dev/null +++ b/BitkitTests/TransferServiceActivityTests.swift @@ -0,0 +1,48 @@ +@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) + } +} From f553e8fa90b24303aa62d37e5cb108719245d0ba Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 6 Jul 2026 17:52:47 -0300 Subject: [PATCH 18/33] fix: handle compose error --- Bitkit/ViewModels/HwFundingSigner.swift | 3 +++ BitkitTests/HwFundingSignerTests.swift | 15 +++++++++++++++ BitkitTests/HwTransferMocks.swift | 2 ++ 3 files changed, 20 insertions(+) diff --git a/Bitkit/ViewModels/HwFundingSigner.swift b/Bitkit/ViewModels/HwFundingSigner.swift index 170d4ff6d..cc172122b 100644 --- a/Bitkit/ViewModels/HwFundingSigner.swift +++ b/Bitkit/ViewModels/HwFundingSigner.swift @@ -79,6 +79,9 @@ struct HwFundingSigner { } } 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) diff --git a/BitkitTests/HwFundingSignerTests.swift b/BitkitTests/HwFundingSignerTests.swift index 6cae39667..ba0112e5d 100644 --- a/BitkitTests/HwFundingSignerTests.swift +++ b/BitkitTests/HwFundingSignerTests.swift @@ -104,6 +104,21 @@ final class HwFundingSignerTests: XCTestCase { XCTAssertEqual(funding.signCalls, 1) } + 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)) + + 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() diff --git a/BitkitTests/HwTransferMocks.swift b/BitkitTests/HwTransferMocks.swift index 296865d63..5d8bc7171 100644 --- a/BitkitTests/HwTransferMocks.swift +++ b/BitkitTests/HwTransferMocks.swift @@ -10,6 +10,7 @@ final class MockHwFunding: HwTransferFunding { var account = HwFundingAccount(xpub: "zpubNS", addressType: .nativeSegwit, balanceSats: 1_000_000) var accountError: Error? var composeError: Error? + var composeDelay: Double = 0 var signError: Error? var signDelay: Double = 0 var funding = HwFundingTransaction(psbt: "psbt", miningFeeSats: 141, feeRate: 1, totalSpent: 43186, satsPerVByte: 1) @@ -31,6 +32,7 @@ final class MockHwFunding: HwTransferFunding { 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 } From cdd13d70fae424d98199e90a8eec2538d3f35ec8 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 6 Jul 2026 17:59:28 -0300 Subject: [PATCH 19/33] fix: mark activity as transfer atomically --- Bitkit/Services/CoreService.swift | 20 ++++++++++ Bitkit/Services/TransferService.swift | 18 +-------- .../TransferServiceActivityTests.swift | 38 +++++++++++++++++++ 3 files changed, 59 insertions(+), 17 deletions(-) diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index 535d7cb47..e8471815e 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -1169,6 +1169,26 @@ class 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 9b8b8973c..f4d51543b 100644 --- a/Bitkit/Services/TransferService.swift +++ b/Bitkit/Services/TransferService.swift @@ -146,7 +146,7 @@ class 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 markOnchainActivityAsTransfer(txid: fundingTxId, channelId: channelId) + await coreService.activity.markOnchainActivityAsTransfer(txId: fundingTxId, channelId: channelId) } // Check if channel is ready (usable) @@ -249,22 +249,6 @@ class TransferService { } } - /// Mark the on-chain activity for `txid` as a transfer associated with `channelId`. Preserves - /// activities that are already correctly tagged. - private func markOnchainActivityAsTransfer(txid: String, channelId: String) async { - guard let activity = try? await coreService.activity.getOnchainActivityByTxId(txid: txid) else { return } - if activity.isTransfer, activity.channelId == channelId { return } - var updated = activity - updated.isTransfer = true - updated.channelId = channelId - do { - try await coreService.activity.update(id: activity.id, activity: .onchain(updated)) - Logger.debug("Marked activity \(activity.id) as transfer for channel \(channelId)", context: "TransferService") - } catch { - Logger.error("Failed to mark activity as transfer for \(txid): \(error)", context: "TransferService") - } - } - /// Resolve channel ID for a transfer /// For LSP orders: match via order->fundingTx, for manual: use directly func resolveChannelId(for transfer: Transfer, channels: [ChannelDetails]) async throws -> String? { diff --git a/BitkitTests/TransferServiceActivityTests.swift b/BitkitTests/TransferServiceActivityTests.swift index e303ab7ba..b7212a44b 100644 --- a/BitkitTests/TransferServiceActivityTests.swift +++ b/BitkitTests/TransferServiceActivityTests.swift @@ -45,4 +45,42 @@ final class TransferServiceActivityTests: XCTestCase { 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") + } } From 102f8586611f936b8e0657017d7f013edee68316 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 6 Jul 2026 18:06:48 -0300 Subject: [PATCH 20/33] fix: improve send marx calc for hardware wallets --- Bitkit/AppScene.swift | 4 ++ Bitkit/Managers/HwWalletManager.swift | 38 +++++++++++++++++ Bitkit/ViewModels/HwFundingSigner.swift | 46 +++++++++++++------- Bitkit/ViewModels/TransferViewModel.swift | 20 ++++----- BitkitTests/HwFundingSignerTests.swift | 49 ++++++++++++++++++++-- BitkitTests/HwTransferMocks.swift | 14 +++++++ BitkitTests/TransferViewModelHwTests.swift | 11 +++++ 7 files changed, 155 insertions(+), 27 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 709031408..dc8068439 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -89,6 +89,10 @@ struct AppScene: View { 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()) diff --git a/Bitkit/Managers/HwWalletManager.swift b/Bitkit/Managers/HwWalletManager.swift index f36ae81ba..055e5c6a9 100644 --- a/Bitkit/Managers/HwWalletManager.swift +++ b/Bitkit/Managers/HwWalletManager.swift @@ -489,6 +489,44 @@ final class HwWalletManager { ) } + /// 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`). diff --git a/Bitkit/ViewModels/HwFundingSigner.swift b/Bitkit/ViewModels/HwFundingSigner.swift index cc172122b..de674188b 100644 --- a/Bitkit/ViewModels/HwFundingSigner.swift +++ b/Bitkit/ViewModels/HwFundingSigner.swift @@ -19,26 +19,54 @@ struct HwFundingSigner { 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) - /// Conservative vbyte reserve for multi-input hardware funding before the exact compose runs. + /// 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 after the fee reserve. + /// 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 reserve = await feeReserve(balanceSats: account.balanceSats) - let available = account.balanceSats > reserve ? account.balanceSats - reserve : 0 + 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. @@ -109,16 +137,6 @@ struct HwFundingSigner { await feeRateProvider() ?? fallbackSatsPerVByte } - private func feeReserve(balanceSats: UInt64) async -> UInt64 { - await Self.feeReserve( - balanceSats: balanceSats, - satsPerVByte: feeRateProvider(), - txVBytes: txVBytes, - fallbackSatsPerVByte: fallbackSatsPerVByte, - fallbackFeePercent: fallbackFeePercent - ) - } - /// Pure fee-reserve computation. With a known fee rate: `rate × vbytes`. Without one (estimates /// unavailable): `max(minReserve, balance × fallbackPercent)`. static func feeReserve( diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index a70c091f4..85b44077b 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -38,6 +38,12 @@ enum HwTransferError: Error, Equatable { @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, @@ -99,6 +105,7 @@ class TransferViewModel: ObservableObject { hwFunding: HwTransferFunding? = nil, hwConnecting: HwTransferConnecting? = nil, hwFeeRateProvider: (() async -> UInt64?)? = nil, + hwAddressProvider: (() async throws -> String)? = nil, hwTimeouts: (reconnect: Double, compose: Double, sign: Double) = (reconnect: 30, compose: 45, sign: 120), onBalanceRefresh: (() async -> Void)? = nil ) { @@ -113,6 +120,7 @@ class TransferViewModel: ObservableObject { funding: hwFunding, connecting: hwConnecting, feeRateProvider: hwFeeRateProvider ?? { nil }, + addressProvider: hwAddressProvider ?? { throw AppError(message: "No address provider", debugMessage: "") }, timeouts: hwTimeouts ) } else { @@ -146,6 +154,7 @@ class TransferViewModel: ObservableObject { hwFunding: HwTransferFunding?, hwConnecting: HwTransferConnecting?, hwFeeRateProvider: (() async -> UInt64?)? = nil, + hwAddressProvider: (() async throws -> String)? = nil, hwTimeouts: (reconnect: Double, compose: Double, sign: Double) = (reconnect: 30, compose: 45, sign: 120), coreService: CoreService = .shared, lightningService: LightningService = .shared, @@ -163,6 +172,7 @@ class TransferViewModel: ObservableObject { hwFunding: hwFunding, hwConnecting: hwConnecting, hwFeeRateProvider: hwFeeRateProvider, + hwAddressProvider: hwAddressProvider, hwTimeouts: hwTimeouts ) } @@ -432,16 +442,6 @@ class TransferViewModel: ObservableObject { uiState.isAdvanced = false } - func resetState() { - hwSignTask?.cancel() - hwSignTask = nil - hwSpending = HwSpendingState() - hwTransferError = nil - uiState = TransferUiState() - transferValues = TransferValues() - selectedChannelIds = [] - } - // MARK: - Hardware Wallet Transfer /// Compute the available/MAX/quarter limits for a hardware-wallet transfer: the signer resolves diff --git a/BitkitTests/HwFundingSignerTests.swift b/BitkitTests/HwFundingSignerTests.swift index ba0112e5d..145495e35 100644 --- a/BitkitTests/HwFundingSignerTests.swift +++ b/BitkitTests/HwFundingSignerTests.swift @@ -11,12 +11,19 @@ final class HwFundingSignerTests: XCTestCase { funding: MockHwFunding, connecting: MockHwConnecting, feeRate: UInt64? = 2, + address: String? = "bc1qtest", timeouts: (reconnect: Double, compose: Double, sign: Double) = (reconnect: 5, compose: 5, sign: 5) ) -> HwFundingSigner { - HwFundingSigner(funding: funding, connecting: connecting, feeRateProvider: { feeRate }, timeouts: timeouts) + HwFundingSigner( + funding: funding, + connecting: connecting, + feeRateProvider: { feeRate }, + addressProvider: { if let address { return address } else { throw MockHwFunding.TestError() } }, + timeouts: timeouts + ) } - // MARK: - Fee reserve + // MARK: - Fee reserve (fallback math) func testFeeReserveUsesRateWhenAvailable() { XCTAssertEqual(HwFundingSigner.feeReserve(balanceSats: 1_000_000, satsPerVByte: 5), 5 * 1200) @@ -34,14 +41,50 @@ final class HwFundingSignerTests: XCTestCase { // MARK: - Availability - func testAvailabilityReturnsBalanceMinusReserve() async throws { + 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) } diff --git a/BitkitTests/HwTransferMocks.swift b/BitkitTests/HwTransferMocks.swift index 5d8bc7171..af8cfcfd4 100644 --- a/BitkitTests/HwTransferMocks.swift +++ b/BitkitTests/HwTransferMocks.swift @@ -9,6 +9,8 @@ final class MockHwFunding: HwTransferFunding { 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? @@ -17,6 +19,7 @@ final class MockHwFunding: HwTransferFunding { var broadcast = HwFundingBroadcastResult(txId: "txid", miningFeeSats: 141, feeRate: 1, totalSpent: 43186) private(set) var composeCalls: [(address: String, sats: UInt64, satsPerVByte: UInt64)] = [] + private(set) var maxSpendableCalls: [(address: String, satsPerVByte: UInt64)] = [] private(set) var signCalls = 0 func getFundingAccount(deviceId _: String, addressType _: AddressScriptType) throws -> HwFundingAccount { @@ -24,6 +27,17 @@ final class MockHwFunding: HwTransferFunding { 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, diff --git a/BitkitTests/TransferViewModelHwTests.swift b/BitkitTests/TransferViewModelHwTests.swift index 6157bc134..e88448344 100644 --- a/BitkitTests/TransferViewModelHwTests.swift +++ b/BitkitTests/TransferViewModelHwTests.swift @@ -35,6 +35,17 @@ final class TransferViewModelHwTests: XCTestCase { 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 testReconnectFailureMapsToReconnectErrorAndResetsSigning() async { let funding = MockHwFunding() let connecting = MockHwConnecting() From 11e1690a2395f7998c570477b2c9c0054cb1a3f6 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 6 Jul 2026 18:57:36 -0300 Subject: [PATCH 21/33] fix: split sign timeout from broadcast --- Bitkit/Managers/HwWalletManager.swift | 27 ++++++++------ Bitkit/Models/HwFunding.swift | 10 +++++ .../Localization/en.lproj/Localizable.strings | 2 + Bitkit/ViewModels/AppViewModel.swift | 6 +++ Bitkit/ViewModels/HwFundingSigner.swift | 37 +++++++++++++++---- Bitkit/ViewModels/TransferViewModel.swift | 10 +++-- BitkitTests/HwFundingSignerTests.swift | 37 +++++++++++++++++-- BitkitTests/HwTransferMocks.swift | 17 +++++++-- BitkitTests/TransferViewModelHwTests.swift | 2 +- 9 files changed, 119 insertions(+), 29 deletions(-) diff --git a/Bitkit/Managers/HwWalletManager.swift b/Bitkit/Managers/HwWalletManager.swift index 055e5c6a9..9cea54543 100644 --- a/Bitkit/Managers/HwWalletManager.swift +++ b/Bitkit/Managers/HwWalletManager.swift @@ -573,27 +573,32 @@ final class HwWalletManager { ) } - /// Sign a composed funding payment on the device and broadcast it. Requires the device to be - /// connected. On signing failure the caller is responsible for clearing the stale session - /// (via `TrezorManager.disconnectStaleSession`). - func signAndBroadcastFunding( + /// 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 -> HwFundingBroadcastResult { + ) async throws -> HwFundingSignedTx { let network = networkProvider() let signed = try await TrezorService.shared.signTxFromPsbt(psbtBase64: funding.psbt, network: network) - let txId = try await OnChainHwService.shared.broadcastRawTx( + return HwFundingSignedTx( serializedTx: signed.serializedTx, - electrumUrl: electrumUrlProvider() - ) - return HwFundingBroadcastResult( - txId: txId, miningFeeSats: funding.miningFeeSats, - feeRate: UInt64(funding.feeRate.rounded(.up)), + 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 { diff --git a/Bitkit/Models/HwFunding.swift b/Bitkit/Models/HwFunding.swift index 752643138..c63f06c39 100644 --- a/Bitkit/Models/HwFunding.swift +++ b/Bitkit/Models/HwFunding.swift @@ -30,6 +30,16 @@ struct HwFundingTransaction: Equatable { 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 diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 8ed4ef965..cf01ee0b3 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -176,6 +176,8 @@ "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"; diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 30c10fa47..879af1a75 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -293,6 +293,12 @@ extension AppViewModel { ) 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): diff --git a/Bitkit/ViewModels/HwFundingSigner.swift b/Bitkit/ViewModels/HwFundingSigner.swift index de674188b..a9e33800d 100644 --- a/Bitkit/ViewModels/HwFundingSigner.swift +++ b/Bitkit/ViewModels/HwFundingSigner.swift @@ -21,7 +21,7 @@ struct HwFundingSigner { let feeRateProvider: () async -> UInt64? /// Provides a fee-estimation destination address (an app receive address); never broadcast to. let addressProvider: () async throws -> String - let timeouts: (reconnect: Double, compose: Double, sign: Double) + 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. @@ -74,7 +74,14 @@ struct HwFundingSigner { try await ensureConnected(deviceId: deviceId) let satsPerVByte = await resolvedSatsPerVByte() let tx = try await compose(deviceId: deviceId, address: address, sats: order.feeSat, satsPerVByte: satsPerVByte) - return try await signAndBroadcast(deviceId: deviceId, funding: tx) + 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 + ) } private func ensureConnected(deviceId: String) async throws { @@ -116,13 +123,10 @@ struct HwFundingSigner { } } - private func signAndBroadcast( - deviceId: String, - funding tx: HwFundingTransaction - ) async throws -> HwFundingBroadcastResult { + private func signStep(deviceId: String, funding tx: HwFundingTransaction) async throws -> HwFundingSignedTx { do { return try await withTimeout(timeouts.sign) { - try await funding.signAndBroadcastFunding(deviceId: deviceId, funding: tx) + try await funding.signFunding(deviceId: deviceId, funding: tx) } } catch is CancellationError { throw CancellationError() @@ -130,7 +134,24 @@ struct HwFundingSigner { await connecting.disconnectStaleSession(deviceId: deviceId) throw HwTransferError.signingTimeout } - // Any other (real sign/broadcast) error propagates to the caller's generic handler. + // 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 { diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 85b44077b..925a1a450 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -29,6 +29,7 @@ struct HwSpendingState: Equatable { enum HwTransferError: Error, Equatable { case reconnect case signingTimeout + case broadcastUncertain case funding(String?) case generic(String?) } @@ -51,7 +52,8 @@ protocol HwTransferFunding: Sendable { satsPerVByte: UInt64, addressType: AddressScriptType ) async throws -> HwFundingTransaction - func signAndBroadcastFunding(deviceId: String, funding: HwFundingTransaction) async throws -> HwFundingBroadcastResult + 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 @@ -106,7 +108,7 @@ class TransferViewModel: ObservableObject { hwConnecting: HwTransferConnecting? = nil, hwFeeRateProvider: (() async -> UInt64?)? = nil, hwAddressProvider: (() async throws -> String)? = nil, - hwTimeouts: (reconnect: Double, compose: Double, sign: Double) = (reconnect: 30, compose: 45, sign: 120), + hwTimeouts: (reconnect: Double, compose: Double, sign: Double, broadcast: Double) = (reconnect: 30, compose: 45, sign: 120, broadcast: 60), onBalanceRefresh: (() async -> Void)? = nil ) { self.coreService = coreService @@ -155,7 +157,7 @@ class TransferViewModel: ObservableObject { hwConnecting: HwTransferConnecting?, hwFeeRateProvider: (() async -> UInt64?)? = nil, hwAddressProvider: (() async throws -> String)? = nil, - hwTimeouts: (reconnect: Double, compose: Double, sign: Double) = (reconnect: 30, compose: 45, sign: 120), + hwTimeouts: (reconnect: Double, compose: Double, sign: Double, broadcast: Double) = (reconnect: 30, compose: 45, sign: 120, broadcast: 60), coreService: CoreService = .shared, lightningService: LightningService = .shared, sheetViewModel: SheetViewModel = SheetViewModel() @@ -533,6 +535,8 @@ class TransferViewModel: ObservableObject { 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: diff --git a/BitkitTests/HwFundingSignerTests.swift b/BitkitTests/HwFundingSignerTests.swift index 145495e35..471449b30 100644 --- a/BitkitTests/HwFundingSignerTests.swift +++ b/BitkitTests/HwFundingSignerTests.swift @@ -12,7 +12,7 @@ final class HwFundingSignerTests: XCTestCase { connecting: MockHwConnecting, feeRate: UInt64? = 2, address: String? = "bc1qtest", - timeouts: (reconnect: Double, compose: Double, sign: Double) = (reconnect: 5, compose: 5, sign: 5) + timeouts: (reconnect: Double, compose: Double, sign: Double, broadcast: Double) = (reconnect: 5, compose: 5, sign: 5, broadcast: 5) ) -> HwFundingSigner { HwFundingSigner( funding: funding, @@ -136,7 +136,7 @@ final class HwFundingSignerTests: XCTestCase { let funding = MockHwFunding() funding.signDelay = 0.4 let connecting = MockHwConnecting() - let signer = makeSigner(funding: funding, connecting: connecting, timeouts: (reconnect: 5, compose: 5, sign: 0.05)) + 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...") @@ -147,11 +147,42 @@ final class HwFundingSignerTests: XCTestCase { 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)) + 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...") diff --git a/BitkitTests/HwTransferMocks.swift b/BitkitTests/HwTransferMocks.swift index af8cfcfd4..e19c4063a 100644 --- a/BitkitTests/HwTransferMocks.swift +++ b/BitkitTests/HwTransferMocks.swift @@ -15,12 +15,16 @@ final class MockHwFunding: HwTransferFunding { 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 broadcast = HwFundingBroadcastResult(txId: "txid", miningFeeSats: 141, feeRate: 1, totalSpent: 43186) + 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 } @@ -51,11 +55,18 @@ final class MockHwFunding: HwTransferFunding { return funding } - func signAndBroadcastFunding(deviceId _: String, funding _: HwFundingTransaction) async throws -> HwFundingBroadcastResult { + 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 broadcast + 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 } } diff --git a/BitkitTests/TransferViewModelHwTests.swift b/BitkitTests/TransferViewModelHwTests.swift index e88448344..957766161 100644 --- a/BitkitTests/TransferViewModelHwTests.swift +++ b/BitkitTests/TransferViewModelHwTests.swift @@ -11,7 +11,7 @@ final class TransferViewModelHwTests: XCTestCase { funding: MockHwFunding, connecting: MockHwConnecting, feeRate: UInt64? = 2, - timeouts: (reconnect: Double, compose: Double, sign: Double) = (reconnect: 5, compose: 5, sign: 5) + timeouts: (reconnect: Double, compose: Double, sign: Double, broadcast: Double) = (reconnect: 5, compose: 5, sign: 5, broadcast: 5) ) -> TransferViewModel { TransferViewModel( hwFunding: funding, From 5016c33e078aee0c72f846bdd5f61e5526de2b96 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 6 Jul 2026 19:01:05 -0300 Subject: [PATCH 22/33] fix: guard geoblocked HW orders --- Bitkit/Views/Wallets/HardwareWalletScreen.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Bitkit/Views/Wallets/HardwareWalletScreen.swift b/Bitkit/Views/Wallets/HardwareWalletScreen.swift index 6f6b1b33a..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() } } @@ -124,7 +127,8 @@ struct HardwareWalletScreen: View { .resizable() .scaledToFit() .frame(width: 16, height: 16) - .foregroundColor(.white80) + .foregroundColor(.white80), + isDisabled: GeoService.shared.isGeoBlocked ) { if app.hasSeenTransferToSpendingIntro { navigation.navigate(.spendingAmountHw(deviceId: deviceId)) From 0de59ff7a5eb734704abfec3a1e79cd93d9cf760 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 6 Jul 2026 19:05:59 -0300 Subject: [PATCH 23/33] fix: Disable Continue on stale HW limits --- Bitkit/ViewModels/TransferViewModel.swift | 2 +- Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift | 2 +- BitkitTests/TransferViewModelHwTests.swift | 11 +++++++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 925a1a450..74cbdd9bb 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -456,7 +456,7 @@ class TransferViewModel: ObservableObject { -> (networkFeeSat: UInt64, serviceFeeSat: UInt64) ) async { guard let hwSigner else { return } - hwSpending.isLoading = true + hwSpending = HwSpendingState(isLoading: true) let availability: HwFundingSigner.Availability do { diff --git a/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift b/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift index 93508c079..9c9c30894 100644 --- a/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift +++ b/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift @@ -27,7 +27,7 @@ struct SpendingAmountHw: View { } private var isValidAmount: Bool { - maxAllowed > 0 && amountSats <= maxAllowed + !transfer.hwSpending.isLoading && maxAllowed > 0 && amountSats <= maxAllowed } var body: some View { diff --git a/BitkitTests/TransferViewModelHwTests.swift b/BitkitTests/TransferViewModelHwTests.swift index 957766161..d2bdd69f3 100644 --- a/BitkitTests/TransferViewModelHwTests.swift +++ b/BitkitTests/TransferViewModelHwTests.swift @@ -46,6 +46,17 @@ final class TransferViewModelHwTests: XCTestCase { 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() From 6a5a6674e93e0584253e28a81cee9cf031e4d47f Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 7 Jul 2026 08:05:44 -0300 Subject: [PATCH 24/33] fix: handle user cancelation error --- Bitkit/Extensions/TrezorError+Cancellation.swift | 16 ++++++++++++++++ Bitkit/ViewModels/AppViewModel.swift | 2 +- Bitkit/ViewModels/HwFundingSigner.swift | 3 +++ Bitkit/ViewModels/TransferViewModel.swift | 4 ++++ 4 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 Bitkit/Extensions/TrezorError+Cancellation.swift diff --git a/Bitkit/Extensions/TrezorError+Cancellation.swift b/Bitkit/Extensions/TrezorError+Cancellation.swift new file mode 100644 index 000000000..518a19813 --- /dev/null +++ b/Bitkit/Extensions/TrezorError+Cancellation.swift @@ -0,0 +1,16 @@ +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 { + guard let trezorError = self as? TrezorError else { return false } + switch trezorError { + case .UserCancelled, .PinCancelled, .PassphraseCancelled: + return true + default: + return false + } + } +} diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 879af1a75..d2051b4a8 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -277,7 +277,7 @@ 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) diff --git a/Bitkit/ViewModels/HwFundingSigner.swift b/Bitkit/ViewModels/HwFundingSigner.swift index a9e33800d..ad8857fcc 100644 --- a/Bitkit/ViewModels/HwFundingSigner.swift +++ b/Bitkit/ViewModels/HwFundingSigner.swift @@ -92,6 +92,9 @@ struct HwFundingSigner { } 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 } } diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 74cbdd9bb..e30329c61 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -524,6 +524,10 @@ class TransferViewModel: ObservableObject { } 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) } } From 99f05b77647309d3a93911647e752bc0ad861d49 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 7 Jul 2026 08:08:20 -0300 Subject: [PATCH 25/33] test: device cancelation tests --- BitkitTests/TransferViewModelHwTests.swift | 29 ++++++++++++++++++++++ BitkitTests/TrezorCancellationTests.swift | 21 ++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 BitkitTests/TrezorCancellationTests.swift diff --git a/BitkitTests/TransferViewModelHwTests.swift b/BitkitTests/TransferViewModelHwTests.swift index d2bdd69f3..accdb0d7f 100644 --- a/BitkitTests/TransferViewModelHwTests.swift +++ b/BitkitTests/TransferViewModelHwTests.swift @@ -83,6 +83,35 @@ final class TransferViewModelHwTests: XCTestCase { 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") + } + + 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 testReentrancyGuardIgnoresConcurrentConfirm() async { let funding = MockHwFunding() funding.composeError = MockHwFunding.TestError() // fail before the network-bound funding tail diff --git a/BitkitTests/TrezorCancellationTests.swift b/BitkitTests/TrezorCancellationTests.swift new file mode 100644 index 000000000..2e2e885bc --- /dev/null +++ b/BitkitTests/TrezorCancellationTests.swift @@ -0,0 +1,21 @@ +@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()) + } +} From e293b4920e0f26f0a557fdcff2034382d2949e3f Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 7 Jul 2026 08:15:41 -0300 Subject: [PATCH 26/33] fix: collapse HW activities duplicates --- Bitkit/Extensions/Activity+Contact.swift | 16 +++++++++++ Bitkit/ViewModels/ActivityListViewModel.swift | 7 ++--- BitkitTests/ActivityHardwareTests.swift | 27 ++++++++++++++++--- 3 files changed, 44 insertions(+), 6 deletions(-) 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/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/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") + } } From eff5dcfc1cec8d059d499e62d48a534319801def Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 7 Jul 2026 08:52:12 -0300 Subject: [PATCH 27/33] fix: fallback to cached channelId on order fetch error --- Bitkit/Services/TransferService.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Bitkit/Services/TransferService.swift b/Bitkit/Services/TransferService.swift index f4d51543b..831f4ff54 100644 --- a/Bitkit/Services/TransferService.swift +++ b/Bitkit/Services/TransferService.swift @@ -261,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 { From f215db514378543a91ecae632c581fadbeb677b0 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 7 Jul 2026 08:53:22 -0300 Subject: [PATCH 28/33] fix: rerun HW limits on Blocktank info --- .../Transfer/Hardware/SpendingAmountHw.swift | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift b/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift index 9c9c30894..a9f18a6fa 100644 --- a/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift +++ b/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift @@ -26,6 +26,22 @@ struct SpendingAmountHw: View { 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 } @@ -90,7 +106,7 @@ struct SpendingAmountHw: View { .padding(.horizontal, 16) .bottomSafeAreaPadding() .offlineOverlay(title: t("lightning__transfer__nav_title")) - .task(id: deviceId) { + .task(id: hwLimitInputs) { await transfer.updateHwLimits( deviceId: deviceId, blocktankInfo: blocktank.info, From cdf4112b74e72888bccd9abdcaa50b2d8cce0304 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 7 Jul 2026 08:58:43 -0300 Subject: [PATCH 29/33] fix: cancel hwSignTask on abandonment --- Bitkit/ViewModels/TransferViewModel.swift | 8 ++++++++ .../Transfer/Hardware/SpendingHwSign.swift | 8 ++++++++ BitkitTests/TransferViewModelHwTests.swift | 18 ++++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index e30329c61..6eded871a 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -533,6 +533,14 @@ class TransferViewModel: ObservableObject { } } + /// 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: diff --git a/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift b/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift index 898e62b6b..ef954716a 100644 --- a/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift +++ b/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift @@ -52,6 +52,14 @@ struct SpendingHwSign: View { 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 { diff --git a/BitkitTests/TransferViewModelHwTests.swift b/BitkitTests/TransferViewModelHwTests.swift index accdb0d7f..f663742ce 100644 --- a/BitkitTests/TransferViewModelHwTests.swift +++ b/BitkitTests/TransferViewModelHwTests.swift @@ -112,6 +112,24 @@ final class TransferViewModelHwTests: XCTestCase { XCTAssertTrue(funding.composeCalls.isEmpty, "compose must not run after a reconnect cancel") } + 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 From 1d13e31089b02a5e117a221a41d63d78b5d113d7 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 7 Jul 2026 10:57:38 -0300 Subject: [PATCH 30/33] fix: port android warmup logic and reconnect toast --- Bitkit/Managers/TrezorManager.swift | 22 ++++++++++++ Bitkit/ViewModels/AppViewModel.swift | 18 ++++++---- Bitkit/ViewModels/HwFundingSigner.swift | 8 ++++- Bitkit/ViewModels/TransferViewModel.swift | 17 +++++++++- .../Transfer/Hardware/SpendingHwSign.swift | 4 +++ Bitkit/Views/Trezor/TrezorRootView.swift | 34 ++++++++++--------- BitkitTests/HwFundingSignerTests.swift | 2 +- BitkitTests/HwTransferMocks.swift | 10 ++++++ BitkitTests/TransferViewModelHwTests.swift | 25 +++++++++++++- 9 files changed, 114 insertions(+), 26 deletions(-) diff --git a/Bitkit/Managers/TrezorManager.swift b/Bitkit/Managers/TrezorManager.swift index d5db53ae5..71eadebc2 100644 --- a/Bitkit/Managers/TrezorManager.swift +++ b/Bitkit/Managers/TrezorManager.swift @@ -681,6 +681,28 @@ final class TrezorManager { } } + /// 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 { diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index d2051b4a8..473bb95c0 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -285,12 +285,18 @@ extension AppViewModel { func toast(_ error: HwTransferError) { switch error { - case .reconnect: - toast( - type: .error, - title: t("lightning__transfer_hw__reconnect_error_title"), - description: t("lightning__transfer_hw__reconnect_error_description") - ) + 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: diff --git a/Bitkit/ViewModels/HwFundingSigner.swift b/Bitkit/ViewModels/HwFundingSigner.swift index ad8857fcc..8463ba8d4 100644 --- a/Bitkit/ViewModels/HwFundingSigner.swift +++ b/Bitkit/ViewModels/HwFundingSigner.swift @@ -84,6 +84,12 @@ struct HwFundingSigner { ) } + /// 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) { @@ -95,7 +101,7 @@ struct HwFundingSigner { // 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 + throw HwTransferError.reconnect(isBluetooth: connecting.isKnownBluetoothDevice(deviceId: deviceId)) } } diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 6eded871a..6db1e95ad 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -27,7 +27,9 @@ struct HwSpendingState: Equatable { /// 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 { - case reconnect + /// 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?) @@ -62,6 +64,12 @@ protocol HwTransferFunding: Sendable { 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 @@ -533,6 +541,13 @@ class TransferViewModel: ObservableObject { } } + /// 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() { diff --git a/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift b/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift index ef954716a..12c3a13df 100644 --- a/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift +++ b/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift @@ -44,6 +44,10 @@ struct SpendingHwSign: View { .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) } 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/BitkitTests/HwFundingSignerTests.swift b/BitkitTests/HwFundingSignerTests.swift index 471449b30..96112ba54 100644 --- a/BitkitTests/HwFundingSignerTests.swift +++ b/BitkitTests/HwFundingSignerTests.swift @@ -113,7 +113,7 @@ final class HwFundingSignerTests: XCTestCase { await assertThrowsAsync { _ = try await signer.sign(order: .mock(), deviceId: "dev1", address: "bc1q...") } _: { error in - XCTAssertEqual(error as? HwTransferError, .reconnect) + XCTAssertEqual(error as? HwTransferError, .reconnect(isBluetooth: false)) } XCTAssertTrue(funding.composeCalls.isEmpty) XCTAssertEqual(funding.signCalls, 0) diff --git a/BitkitTests/HwTransferMocks.swift b/BitkitTests/HwTransferMocks.swift index e19c4063a..5a9558065 100644 --- a/BitkitTests/HwTransferMocks.swift +++ b/BitkitTests/HwTransferMocks.swift @@ -73,14 +73,24 @@ final class MockHwFunding: HwTransferFunding { @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/TransferViewModelHwTests.swift b/BitkitTests/TransferViewModelHwTests.swift index f663742ce..156a53330 100644 --- a/BitkitTests/TransferViewModelHwTests.swift +++ b/BitkitTests/TransferViewModelHwTests.swift @@ -66,10 +66,23 @@ final class TransferViewModelHwTests: XCTestCase { vm.onTransferToSpendingHwConfirm(order: .mock(), deviceId: "dev1") await awaitSigningComplete(vm) - XCTAssertEqual(vm.hwTransferError, .reconnect) + 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() @@ -112,6 +125,16 @@ final class TransferViewModelHwTests: XCTestCase { 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 From ee780fa078deb5ded5baf1baba7ec01b3b6d7399 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 7 Jul 2026 13:22:05 -0300 Subject: [PATCH 31/33] fix: bump the iOS broadcast timeout to 120s to match Android's budget --- Bitkit/ViewModels/TransferViewModel.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 6db1e95ad..aaf1aa18c 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -116,7 +116,7 @@ class TransferViewModel: ObservableObject { hwConnecting: HwTransferConnecting? = nil, hwFeeRateProvider: (() async -> UInt64?)? = nil, hwAddressProvider: (() async throws -> String)? = nil, - hwTimeouts: (reconnect: Double, compose: Double, sign: Double, broadcast: Double) = (reconnect: 30, compose: 45, sign: 120, broadcast: 60), + hwTimeouts: (reconnect: Double, compose: Double, sign: Double, broadcast: Double) = (reconnect: 30, compose: 45, sign: 120, broadcast: 120), onBalanceRefresh: (() async -> Void)? = nil ) { self.coreService = coreService @@ -165,7 +165,7 @@ class TransferViewModel: ObservableObject { hwConnecting: HwTransferConnecting?, hwFeeRateProvider: (() async -> UInt64?)? = nil, hwAddressProvider: (() async throws -> String)? = nil, - hwTimeouts: (reconnect: Double, compose: Double, sign: Double, broadcast: Double) = (reconnect: 30, compose: 45, sign: 120, broadcast: 60), + 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() From 2484d8d9e150c50c8dcb5cd83d3c08551ee3faba Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 7 Jul 2026 13:38:22 -0300 Subject: [PATCH 32/33] fix: disable order mutating controls while signing to prevent change amont mid sign --- Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift b/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift index 12c3a13df..f16d60990 100644 --- a/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift +++ b/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift @@ -80,12 +80,20 @@ struct SpendingHwSign: View { .accessibilityIdentifier("HardwareTransferSignLearnMore") if transfer.uiState.isAdvanced { - CustomButton(title: t("lightning__spending_confirm__default"), size: .small) { + 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) { + CustomButton( + title: t("common__advanced"), + size: .small, + isDisabled: transfer.hwSpending.isSigning + ) { navigation.navigate(.spendingAdvanced(order: order)) } .accessibilityIdentifier("HardwareTransferSignAdvanced") From 3f799c203ab0668f62b0b053a7385f03f580cc1e Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 8 Jul 2026 07:15:55 -0300 Subject: [PATCH 33/33] fix: validate rezer cancelaition error from inderlying error --- .../Extensions/TrezorError+Cancellation.swift | 21 +++++++++++++------ Bitkit/Utilities/Errors.swift | 12 +++++++++-- BitkitTests/TransferViewModelHwTests.swift | 18 ++++++++++++++++ BitkitTests/TrezorCancellationTests.swift | 18 ++++++++++++++++ 4 files changed, 61 insertions(+), 8 deletions(-) diff --git a/Bitkit/Extensions/TrezorError+Cancellation.swift b/Bitkit/Extensions/TrezorError+Cancellation.swift index 518a19813..328150ff2 100644 --- a/Bitkit/Extensions/TrezorError+Cancellation.swift +++ b/Bitkit/Extensions/TrezorError+Cancellation.swift @@ -5,12 +5,21 @@ extension Error { /// / 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 { - guard let trezorError = self as? TrezorError else { return false } - switch trezorError { - case .UserCancelled, .PinCancelled, .PassphraseCancelled: - return true - default: - return false + 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/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/BitkitTests/TransferViewModelHwTests.swift b/BitkitTests/TransferViewModelHwTests.swift index 156a53330..54cdebef7 100644 --- a/BitkitTests/TransferViewModelHwTests.swift +++ b/BitkitTests/TransferViewModelHwTests.swift @@ -111,6 +111,24 @@ final class TransferViewModelHwTests: XCTestCase { 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() diff --git a/BitkitTests/TrezorCancellationTests.swift b/BitkitTests/TrezorCancellationTests.swift index 2e2e885bc..2e938a35d 100644 --- a/BitkitTests/TrezorCancellationTests.swift +++ b/BitkitTests/TrezorCancellationTests.swift @@ -18,4 +18,22 @@ final class TrezorCancellationTests: XCTestCase { 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()) + } }