diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 1d3e942b7..94fb429ab 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1214,7 +1214,7 @@ repositoryURL = "https://github.com/synonymdev/bitkit-core"; requirement = { kind = exactVersion; - version = 0.5.3; + version = 0.5.10; }; }; 96E20CD22CB6D91A00C24149 /* XCRemoteSwiftPackageReference "CodeScanner" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index cf2e05754..e0f0dc4ef 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/bitkit-core", "state" : { - "revision" : "4c859d3f2c0022fda25baa62a029e2c03d1892a7", - "version" : "0.5.3" + "revision" : "4078267213b5a0b1241e69e0b821d1243417ce2b", + "version" : "0.5.10" } }, { diff --git a/Bitkit/Components/CopyAddressCard.swift b/Bitkit/Components/CopyAddressCard.swift index 659bbcc25..1d16dca68 100644 --- a/Bitkit/Components/CopyAddressCard.swift +++ b/Bitkit/Components/CopyAddressCard.swift @@ -14,6 +14,8 @@ struct CopyAddressPair { struct CopyAddressCard: View { let addresses: [CopyAddressPair] @Binding var navigationPath: [ReceiveRoute] + var editRoute: ReceiveRoute? = .edit(onchainOnly: false) + var accentColor: Color? @State private var showTooltipForIndex: Int? = nil var body: some View { @@ -32,19 +34,21 @@ struct CopyAddressCard: View { .padding(.bottom, 12) HStack(spacing: 8) { - CustomButton( - title: t("common__edit"), - size: .small, - icon: Image("pencil").foregroundColor(pair.type == .lightning ? .purpleAccent : .brandAccent), - shouldExpand: true - ) { - navigationPath.append(.edit) + if let editRoute { + CustomButton( + title: t("common__edit"), + size: .small, + icon: Image("pencil").foregroundColor(buttonAccentColor(for: pair)), + shouldExpand: true + ) { + navigationPath.append(editRoute) + } } CustomButton( title: t("common__copy"), size: .small, - icon: Image("copy").foregroundColor(pair.type == .lightning ? .purpleAccent : .brandAccent), + icon: Image("copy").foregroundColor(buttonAccentColor(for: pair)), shouldExpand: true ) { onCopy(address: pair.address, index: index) @@ -57,7 +61,7 @@ struct CopyAddressCard: View { CustomButton( title: t("common__share"), size: .small, - icon: Image("share").foregroundColor(pair.type == .lightning ? .purpleAccent : .brandAccent), + icon: Image("share").foregroundColor(buttonAccentColor(for: pair)), shouldExpand: true ) } @@ -88,6 +92,10 @@ struct CopyAddressCard: View { .aspectRatio(1, contentMode: .fit) } + private func buttonAccentColor(for pair: CopyAddressPair) -> Color { + accentColor ?? (pair.type == .lightning ? .purpleAccent : .brandAccent) + } + private func onCopy(address: String, index: Int) { UIPasteboard.general.string = address Haptics.play(.copiedToClipboard) diff --git a/Bitkit/Components/TabBar/TabBar.swift b/Bitkit/Components/TabBar/TabBar.swift index f1774a987..e0535fcd9 100644 --- a/Bitkit/Components/TabBar/TabBar.swift +++ b/Bitkit/Components/TabBar/TabBar.swift @@ -55,6 +55,17 @@ struct TabBar: View { } private func onReceivePress() { + if case let .hardwareWallet(walletId) = navigation.currentRoute { + sheets.showSheet( + .receive, + data: ReceiveConfig( + view: .qr(cjitInvoice: nil, tab: .trezor), + hardwareWalletId: walletId + ) + ) + return + } + let hasInboundCapacity = (wallet.totalInboundLightningSats ?? 0) > 0 let hasPendingTransfersToSpending = wallet.balanceInTransferToSpending > 0 diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index f5d4f4e89..a1b201357 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -234,7 +234,9 @@ struct MainNavView: View { // surface the app-wide Pair Device sheet. Hidden again once submitted/cancelled. if needsCode { guard !sheets.hardwareConnectHandlesPairing else { return } - if sheets.activeSheetConfiguration?.id == .send { + if let activeId = sheets.activeSheetConfiguration?.id, + activeId == .send || activeId == .receive + { return } sheets.showSheet(.hardwarePairing) diff --git a/Bitkit/Managers/HwWalletManager.swift b/Bitkit/Managers/HwWalletManager.swift index af90ed6db..dd20afe63 100644 --- a/Bitkit/Managers/HwWalletManager.swift +++ b/Bitkit/Managers/HwWalletManager.swift @@ -19,6 +19,14 @@ import Foundation @Observable @MainActor final class HwWalletManager { + typealias AccountInfoProvider = @MainActor ( + String, + String, + TrezorCoinType, + UInt32, + AccountType + ) async throws -> AccountInfoResult + private enum Constants { static let watcherIdSeparator = "|" static let watcherStartRetryDelay: Duration = .seconds(30) @@ -54,6 +62,7 @@ final class HwWalletManager { private let monitoredTypesProvider: () -> Set private let electrumUrlProvider: () -> String private let networkProvider: () -> TrezorCoinType + private let accountInfoProvider: AccountInfoProvider private let persistSnapshot: @MainActor (HwWalletSnapshot) async throws -> Void private let deleteActivities: @MainActor (String) async throws -> Void private let readTagMetadata: @MainActor (String) async throws -> [PreActivityMetadata] @@ -117,6 +126,15 @@ final class HwWalletManager { monitoredTypes: (() -> Set)? = nil, electrumUrl: (() -> String)? = nil, network: (() -> TrezorCoinType)? = nil, + accountInfoProvider: @escaping AccountInfoProvider = { extendedKey, electrumUrl, network, gapLimit, scriptType in + try await OnChainHwService.shared.getAccountInfo( + extendedKey: extendedKey, + electrumUrl: electrumUrl, + network: network, + gapLimit: gapLimit, + scriptType: scriptType + ) + }, persistSnapshot: (@MainActor (HwWalletSnapshot) async throws -> Void)? = nil, deleteActivities: (@MainActor (String) async throws -> Void)? = nil, readTagMetadata: (@MainActor (String) async throws -> [PreActivityMetadata])? = nil, @@ -129,6 +147,7 @@ final class HwWalletManager { Set(SettingsViewModel.shared.addressTypesToMonitor.map(\.stringValue)) } electrumUrlProvider = electrumUrl ?? { OnChainHwService.getElectrumUrl() } + self.accountInfoProvider = accountInfoProvider // Both seams are plain writes: queueing, failure handling and cache repair live in // `persist(_:)` / `delete(walletId:)`, so an injected seam exercises them too. self.persistSnapshot = persistSnapshot ?? { snapshot in @@ -626,17 +645,32 @@ final class HwWalletManager { /// Update aggregated state from a watcher event. The first event after a watcher starts /// delivers the full history (baseline); only later inbound txs are surfaced as received. - /// Core builds the persistence-ready activities (core 0.3.4 watch-only watcher); the manager - /// stores, aggregates, and scopes them to the wallet. + /// Core builds the persistence-ready activities; the manager stores, aggregates, and scopes + /// them to the wallet. func handleWatcherEvent(watcherId: String, event: WatcherEvent) { - guard case let .transactionsChanged(activities, transactionDetails, balance, _, _, _) = event else { return } + guard case let .transactionsChanged( + activities, + transactionDetails, + balance, + _, + _, + _, + nextUnusedExternalAddress + ) = event, + let addressType = AddressScriptType.from(string: addressType(fromWatcherId: watcherId)) + else { return } let walletId = walletId(fromWatcherId: watcherId) let previous = watcherData[watcherId] watcherData[watcherId] = HwWatcherData( walletId: walletId, balanceSats: balance.total, activities: activities, - transactionDetails: transactionDetails + transactionDetails: transactionDetails, + receiveAddress: HwReceiveAddress( + address: nextUnusedExternalAddress.address, + path: nextUnusedExternalAddress.path, + addressType: addressType + ) ) let groups = deviceGroups() recomputeDerivedState(groups: groups) @@ -901,6 +935,80 @@ final class HwWalletManager { ) } + func watcherReceiveAddress( + walletId: String, + addressType: AddressScriptType = hwFundingDefaultAddressType + ) -> HwReceiveAddress? { + let watcherId = "\(walletId)\(Constants.watcherIdSeparator)\(addressType.stringValue)" + return watcherData[watcherId]?.receiveAddress + } + + /// Resolves the next unused external address from watcher state, falling back to an account scan. + func getReceiveAddress( + walletId: String, + addressType: AddressScriptType = hwFundingDefaultAddressType + ) async throws -> HwReceiveAddress { + if let address = watcherReceiveAddress(walletId: walletId, addressType: addressType) { + return address + } + let account = try getFundingAccount(walletId: walletId, addressType: addressType) + let info = try await accountInfoProvider( + account.xpub, + electrumUrlProvider(), + networkProvider(), + Constants.defaultGapLimit, + account.accountType + ) + guard let unused = info.account.addresses.unused.first else { + throw AppError( + message: t("hardware__receive_address_error"), + debugMessage: "No unused external address returned for wallet '\(walletId)'" + ) + } + let scannedAddress = HwReceiveAddress(address: unused.address, path: unused.path, addressType: addressType) + return watcherReceiveAddress(walletId: walletId, addressType: addressType) ?? scannedAddress + } + + /// Displays the exact address currently shown by Bitkit on the device and rejects a mismatch. + func verifyReceiveAddress(walletId: String, receiveAddress: HwReceiveAddress) async throws { + try await ensureConnected(walletId: walletId) + + let response: TrezorAddressResponse + do { + response = try await readAddressOnDevice(receiveAddress) + } catch { + guard error.isTrezorSessionFailure() else { throw error } + await disconnectStaleSession(walletId: walletId) + try await ensureConnected(walletId: walletId) + do { + response = try await readAddressOnDevice(receiveAddress) + } catch { + if error.isTrezorSessionFailure() { + await disconnectStaleSession(walletId: walletId) + } + throw error + } + } + + guard response.address == receiveAddress.address else { + throw AppError( + message: t("hardware__verify_address_error"), + debugMessage: "Trezor returned '\(response.address)' for '\(receiveAddress.path)', expected '\(receiveAddress.address)'" + ) + } + } + + private func readAddressOnDevice(_ receiveAddress: HwReceiveAddress) async throws -> TrezorAddressResponse { + try await TrezorService.shared.getAddress( + params: TrezorGetAddressParams( + path: receiveAddress.path, + coin: networkProvider(), + showOnTrezor: true, + scriptType: receiveAddress.addressType.trezorScriptType + ) + ) + } + /// 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. @@ -1096,6 +1204,18 @@ final class HwWalletManager { let balanceSats: UInt64 let activities: [Activity] let transactionDetails: [TransactionDetails] + let receiveAddress: HwReceiveAddress + } +} + +private extension AddressScriptType { + var trezorScriptType: TrezorScriptType { + switch self { + case .legacy: .spendAddress + case .nestedSegwit: .spendP2shWitness + case .nativeSegwit: .spendWitness + case .taproot: .spendTaproot + } } } diff --git a/Bitkit/Models/HwWallet.swift b/Bitkit/Models/HwWallet.swift index 4915ed5d5..0a7f81a28 100644 --- a/Bitkit/Models/HwWallet.swift +++ b/Bitkit/Models/HwWallet.swift @@ -71,6 +71,13 @@ struct HwWalletReceivedTx: Equatable { let sats: UInt64 } +/// The next unused external address for a paired hardware-wallet account. +struct HwReceiveAddress: Equatable { + let address: String + let path: String + let addressType: AddressScriptType +} + extension HwWallet { var toBalance: HwWalletBalance { HwWalletBalance(id: id, sats: balanceSats) diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 8444bbee5..634feaa4b 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -74,6 +74,7 @@ "hardware__passphrase_mismatch" = "That passphrase opens a different wallet. Enter the one you paired this wallet with."; "hardware__pairing_title" = "Pair Device"; "hardware__pairing_text" = "Enter the 6-digit code shown on your hardware device."; +"hardware__receive_address_error" = "Could not load the hardware wallet address."; "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."; @@ -83,6 +84,8 @@ "hardware__send_confirm_address" = "To address (confirm on device)"; "hardware__send_open_connect" = "Open Trezor Connect"; "hardware__send_sign_title" = "Sign With Device"; +"hardware__verify_address" = "Verify on Device"; +"hardware__verify_address_error" = "Address verification failed. Check the address on your device and try again."; "cards__buyBitcoin__title" = "Buy"; "cards__buyBitcoin__description" = "Buy some bitcoin"; "cards__btFailed__title" = "Failed"; diff --git a/Bitkit/ViewModels/SheetViewModel.swift b/Bitkit/ViewModels/SheetViewModel.swift index 613498bfa..c226c657e 100644 --- a/Bitkit/ViewModels/SheetViewModel.swift +++ b/Bitkit/ViewModels/SheetViewModel.swift @@ -318,7 +318,7 @@ class SheetViewModel: ObservableObject { guard let config = activeSheetConfiguration, config.id == .receive else { return nil } let receiveConfig = config.data as? ReceiveConfig let initialRoute = receiveConfig?.initialRoute ?? .qr(cjitInvoice: nil, tab: nil) - return ReceiveSheetItem(initialRoute: initialRoute) + return ReceiveSheetItem(initialRoute: initialRoute, hardwareWalletId: receiveConfig?.hardwareWalletId) } set { if newValue == nil { diff --git a/Bitkit/ViewModels/Trezor/TrezorViewModel.swift b/Bitkit/ViewModels/Trezor/TrezorViewModel.swift index 24b44886a..3c141df3f 100644 --- a/Bitkit/ViewModels/Trezor/TrezorViewModel.swift +++ b/Bitkit/ViewModels/Trezor/TrezorViewModel.swift @@ -1139,7 +1139,7 @@ class TrezorViewModel { guard watcherId == activeWatcherId || watcherId == startingWatcherId else { return } switch event { - case let .transactionsChanged(activities, _, balance, txCount, blockHeight, accountType): + case let .transactionsChanged(activities, _, balance, txCount, blockHeight, accountType, _): watcherConnectionStatus = .connected watcherError = nil watcherActivities = activities diff --git a/Bitkit/Views/Wallets/Receive/QrArea.swift b/Bitkit/Views/Wallets/Receive/QrArea.swift index 6d6c4e980..b518fd387 100644 --- a/Bitkit/Views/Wallets/Receive/QrArea.swift +++ b/Bitkit/Views/Wallets/Receive/QrArea.swift @@ -6,6 +6,24 @@ struct QrArea: View { let imageAsset: String? let accentColor: Color @Binding var navigationPath: [ReceiveRoute] + let copyValue: String? + let editRoute: ReceiveRoute? + + init( + uri: String, + imageAsset: String?, + accentColor: Color, + navigationPath: Binding<[ReceiveRoute]>, + copyValue: String? = nil, + editRoute: ReceiveRoute? = .edit(onchainOnly: false) + ) { + self.uri = uri + self.imageAsset = imageAsset + self.accentColor = accentColor + _navigationPath = navigationPath + self.copyValue = copyValue + self.editRoute = editRoute + } @State private var showCopyTooltip = false @State private var showShareSheet = false @@ -33,15 +51,17 @@ struct QrArea: View { } HStack { - CustomButton( - title: t("common__edit"), - size: .small, - icon: Image("pencil").foregroundColor(accentColor), - shouldExpand: true - ) { - navigationPath.append(.edit) + if let editRoute { + CustomButton( + title: t("common__edit"), + size: .small, + icon: Image("pencil").foregroundColor(accentColor), + shouldExpand: true + ) { + navigationPath.append(editRoute) + } + .accessibilityIdentifier("SpecifyInvoiceButton") } - .accessibilityIdentifier("SpecifyInvoiceButton") CustomButton( title: t("common__copy"), @@ -115,7 +135,7 @@ struct QrArea: View { } private func onCopy() { - UIPasteboard.general.string = uri + UIPasteboard.general.string = copyValue ?? uri Haptics.play(.copiedToClipboard) // Show tooltip diff --git a/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift b/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift index f8bf147b4..4e83726f2 100644 --- a/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift +++ b/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift @@ -14,6 +14,7 @@ struct ReceiveEdit: View { @AppStorage(PaykitFeatureFlags.uiEnabledKey) private var isPaykitUIEnabled = false @Binding var navigationPath: [ReceiveRoute] + let onchainOnly: Bool let onSendPaymentRequest: (PaykitPaymentRequestDraft) -> Void @State private var amountViewModel = AmountInputViewModel() @@ -56,7 +57,7 @@ struct ReceiveEdit: View { isFocused: $isNoteEditorFocused ) - if !isNoteEditorFocused { + if !isNoteEditorFocused, !onchainOnly { VStack(alignment: .leading, spacing: 0) { CaptionMText(t("wallet__tags")) .padding(.top, 16) @@ -80,7 +81,8 @@ struct ReceiveEdit: View { Spacer() - if PaykitFeatureFlags.isUIAvailable, + if !onchainOnly, + PaykitFeatureFlags.isUIAvailable, isPaykitUIEnabled, !paymentRequests.eligibleTargets.isEmpty { @@ -150,11 +152,17 @@ struct ReceiveEdit: View { } private func onShowQR() async { + wallet.invoiceAmountSats = amountSats + wallet.invoiceNote = note + + if onchainOnly { + dismiss() + return + } + // Wait until node is running if it's in starting state if await wallet.waitForNodeToRun() { do { - wallet.invoiceAmountSats = amountSats - wallet.invoiceNote = note try await wallet.refreshBip21(forceRefreshBolt11: true) // Check if CJIT flow should be shown diff --git a/Bitkit/Views/Wallets/Receive/ReceiveQr.swift b/Bitkit/Views/Wallets/Receive/ReceiveQr.swift index 236b1f712..531f1f3ef 100644 --- a/Bitkit/Views/Wallets/Receive/ReceiveQr.swift +++ b/Bitkit/Views/Wallets/Receive/ReceiveQr.swift @@ -4,22 +4,34 @@ struct ReceiveQr: View { @EnvironmentObject private var app: AppViewModel @EnvironmentObject private var blocktank: BlocktankViewModel @EnvironmentObject private var wallet: WalletViewModel + @Environment(HwWalletManager.self) private var hwWalletManager @Binding var navigationPath: [ReceiveRoute] let cjitInvoice: String? let tab: ReceiveTab? + let hardwareWalletId: String? @State private var selectedTab: ReceiveTab @State private var showDetails = false @State private var hasAppliedDefaultTab = false + @State private var hardwareAddress: HwReceiveAddress? + @State private var hardwareAddressLoadFailed = false + @State private var isLoadingHardwareAddress = false + @State private var isVerifyingHardwareAddress = false + @State private var isPassphraseRequired = false + @State private var isVerifyingPassphrase = false + @State private var verifyTask: Task? + @State private var passphraseTask: Task? init( navigationPath: Binding<[ReceiveRoute]>, cjitInvoice: String? = nil, - tab: ReceiveTab? = nil + tab: ReceiveTab? = nil, + hardwareWalletId: String? = nil ) { _navigationPath = navigationPath self.cjitInvoice = cjitInvoice self.tab = tab + self.hardwareWalletId = hardwareWalletId // Default to unified tab if available, otherwise use provided tab or savings let defaultTab: ReceiveTab = if tab != nil { @@ -32,7 +44,7 @@ struct ReceiveQr: View { } enum ReceiveTab: CaseIterable, CustomStringConvertible { - case savings, unified, spending + case savings, unified, spending, trezor var description: String { switch self { @@ -42,24 +54,43 @@ struct ReceiveQr: View { return "Auto" case .spending: return t("lightning__spending") + case .trezor: + return t("hardware__device_model_trezor") } } } private var availableTabItems: [TabItem] { - // Show unified tab when we have a Lightning invoice (even if channels not yet usable) - if !wallet.bolt11.isEmpty { - return [ + var items: [TabItem] + // Show unified tab when we have a Lightning invoice (even if channels not yet usable) + = if !wallet.bolt11.isEmpty + { + [ TabItem(.savings), TabItem(.unified), TabItem(.spending), ] } else { - return [ + [ TabItem(.savings), TabItem(.spending), ] } + if selectedHardwareWalletId != nil { + items.insert(TabItem(.trezor), at: 0) + } + return items + } + + private var selectedHardwareWalletId: String? { + if let hardwareWalletId { return hardwareWalletId } + guard hwWalletManager.wallets.count == 1 else { return nil } + return hwWalletManager.wallets.first?.id + } + + private var displayedHardwareAddress: HwReceiveAddress? { + guard let walletId = selectedHardwareWalletId else { return nil } + return hwWalletManager.watcherReceiveAddress(walletId: walletId) ?? hardwareAddress } var showingCjitOnboarding: Bool { @@ -78,6 +109,10 @@ struct ReceiveQr: View { VStack(spacing: 0) { TabView(selection: $selectedTab) { + if selectedHardwareWalletId != nil { + tabContent(for: .trezor) + } + tabContent(for: .savings) if !wallet.bolt11.isEmpty { @@ -108,18 +143,37 @@ struct ReceiveQr: View { } } } else if showDetails { - CustomButton( - title: t("wallet__receive_show_qr"), - icon: Image("qr") - .resizable() - .frame(width: 16, height: 16) - .foregroundColor(.textPrimary) - ) { - showDetails.toggle() + VStack(spacing: 16) { + if selectedTab == .trezor { + CustomButton( + title: t("hardware__verify_address"), + variant: .secondary, + isDisabled: displayedHardwareAddress == nil, + isLoading: isVerifyingHardwareAddress, + shouldExpand: true + ) { + startHardwareAddressVerification() + } + .accessibilityIdentifier("HardwareVerifyAddress") + } + + CustomButton( + title: t("wallet__receive_show_qr"), + icon: Image("qr") + .resizable() + .frame(width: 16, height: 16) + .foregroundColor(.textPrimary) + ) { + showDetails.toggle() + } + .accessibilityIdentifier("QRCode") } - .accessibilityIdentifier("QRCode") } else { - CustomButton(title: t("common__show_details"), variant: .tertiary) { + CustomButton( + title: t("common__show_details"), + variant: .tertiary, + isDisabled: selectedTab == .trezor && displayedHardwareAddress == nil + ) { showDetails.toggle() } .accessibilityIdentifier("ShowDetails") @@ -127,6 +181,15 @@ struct ReceiveQr: View { } .padding(.horizontal, 16) } + .onChange(of: selectedTab) { _, newTab in + showDetails = false + if newTab == .trezor { + Task { await loadHardwareAddress() } + } else { + verifyTask?.cancel() + verifyTask = nil + } + } .onAppear { // Apply the default-tab choice at most once, on the first appearance. The flag is set // unconditionally here (even before bolt11 is ready) so a later reappearance — e.g. @@ -144,6 +207,18 @@ struct ReceiveQr: View { .sheetBackground() .accessibilityElement(children: .contain) .accessibilityIdentifier("ReceiveScreen") + .sheet(isPresented: passphrasePromptBinding) { + HwPassphrasePromptSheet( + isVerifying: isVerifyingPassphrase, + onSubmit: reconnectWithPassphrase, + onCancel: dismissPassphrase + ) + } + .task(id: selectedHardwareWalletId) { + if selectedTab == .trezor { + await loadHardwareAddress() + } + } .task { do { try await withThrowingTaskGroup(of: Void.self) { group in @@ -164,6 +239,12 @@ struct ReceiveQr: View { } } } + .onDisappear { + verifyTask?.cancel() + verifyTask = nil + passphraseTask?.cancel() + passphraseTask = nil + } } func tabContent(for tab: ReceiveTab) -> some View { @@ -184,12 +265,43 @@ struct ReceiveQr: View { @ViewBuilder func qrContent(for tab: ReceiveTab) -> some View { - let config = qrConfig(for: tab) - - if !config.uri.isEmpty { - QrArea(uri: config.uri, imageAsset: config.imageAsset, accentColor: config.accentColor, navigationPath: $navigationPath) + if tab == .trezor { + if let hardwareAddress = displayedHardwareAddress { + let uri = hardwareBip21(address: hardwareAddress.address) + QrArea( + uri: uri, + imageAsset: "btc-circle-blue", + accentColor: .blueAccent, + navigationPath: $navigationPath, + copyValue: uri.contains("?") ? uri : hardwareAddress.address, + editRoute: .edit(onchainOnly: true) + ) + } else if hardwareAddressLoadFailed { + VStack(spacing: 16) { + BodyMText(t("hardware__receive_address_error"), textColor: .textSecondary) + .multilineTextAlignment(.center) + CustomButton(title: t("common__try_again"), variant: .tertiary) { + await loadHardwareAddress() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if isLoadingHardwareAddress { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } } else { - ProgressView() + let config = qrConfig(for: tab) + + if !config.uri.isEmpty { + QrArea( + uri: config.uri, + imageAsset: config.imageAsset, + accentColor: config.accentColor, + navigationPath: $navigationPath + ) + } else { + ProgressView() + } } } @@ -213,6 +325,8 @@ struct ReceiveQr: View { imageAsset: "ln", accentColor: .purpleAccent ) + case .trezor: + return (uri: "", imageAsset: "btc-circle-blue", accentColor: .blueAccent) } } @@ -309,13 +423,28 @@ struct ReceiveQr: View { ) ) } + case .trezor: + if let hardwareAddress = displayedHardwareAddress { + pairs.append( + CopyAddressPair( + title: t("wallet__receive_bitcoin_invoice"), + address: hardwareAddress.address, + type: .onchain + ) + ) + } } return pairs }() if !addressPairs.isEmpty { - CopyAddressCard(addresses: addressPairs, navigationPath: $navigationPath) + CopyAddressCard( + addresses: addressPairs, + navigationPath: $navigationPath, + editRoute: .edit(onchainOnly: tab == .trezor), + accentColor: tab == .trezor ? .blueAccent : nil + ) } Spacer() @@ -336,6 +465,118 @@ struct ReceiveQr: View { } } + private var passphrasePromptBinding: Binding { + Binding( + get: { isPassphraseRequired }, + set: { if !$0 { dismissPassphrase() } } + ) + } + + @MainActor + private func loadHardwareAddress() async { + guard let walletId = selectedHardwareWalletId else { + hardwareAddress = nil + hardwareAddressLoadFailed = false + return + } + isLoadingHardwareAddress = true + hardwareAddressLoadFailed = false + defer { isLoadingHardwareAddress = false } + do { + let address = try await hwWalletManager.getReceiveAddress(walletId: walletId) + guard selectedHardwareWalletId == walletId else { return } + hardwareAddress = address + } catch is CancellationError { + return + } catch { + hardwareAddress = nil + hardwareAddressLoadFailed = true + Logger.error(error, context: "ReceiveQr failed to load hardware address") + } + } + + @MainActor + private func verifyHardwareAddress() async { + guard !isVerifyingHardwareAddress, + let walletId = selectedHardwareWalletId, + let hardwareAddress = displayedHardwareAddress + else { return } + + isVerifyingHardwareAddress = true + defer { isVerifyingHardwareAddress = false } + do { + try await hwWalletManager.verifyReceiveAddress(walletId: walletId, receiveAddress: hardwareAddress) + } catch is CancellationError { + return + } catch HwPassphraseError.required { + isPassphraseRequired = true + } catch { + if !error.isTrezorUserCancellation() { + app.toast(error) + } + } + } + + private func startHardwareAddressVerification() { + guard verifyTask == nil else { return } + verifyTask = Task { @MainActor in + defer { verifyTask = nil } + await verifyHardwareAddress() + } + } + + private func reconnectWithPassphrase(_ passphrase: String) { + guard passphraseTask == nil, let walletId = selectedHardwareWalletId else { return } + isVerifyingPassphrase = true + passphraseTask = Task { @MainActor in + defer { + isVerifyingPassphrase = false + passphraseTask = nil + } + do { + try await hwWalletManager.reconnectWithPassphrase(walletId: walletId, passphrase: passphrase) + guard isPassphraseRequired else { throw CancellationError() } + isPassphraseRequired = false + await verifyHardwareAddress() + } catch is CancellationError { + return + } catch HwPassphraseError.mismatch { + app.toast(HwTransferError.passphraseMismatch) + } catch { + if !error.isTrezorUserCancellation() { + app.toast(error) + } + } + } + } + + private func dismissPassphrase() { + passphraseTask?.cancel() + isPassphraseRequired = false + isVerifyingPassphrase = false + } + + private func hardwareBip21(address: String) -> String { + var components = URLComponents() + components.scheme = "bitcoin" + components.path = address + + var queryItems: [URLQueryItem] = [] + if wallet.invoiceAmountSats > 0 { + queryItems.append( + URLQueryItem( + name: "amount", + value: WalletViewModel.formatBitcoinAmount(sats: wallet.invoiceAmountSats) + ) + ) + } + if !wallet.invoiceNote.isEmpty { + queryItems.append(URLQueryItem(name: "message", value: wallet.invoiceNote)) + } + components.queryItems = queryItems.isEmpty ? nil : queryItems + return components.string ?? "bitcoin:\(address)" + } + /// Strips the lightning parameter from a BIP21 URI while keeping other parameters /// - Parameter bip21: The original BIP21 URI string /// - Returns: BIP21 URI with lightning parameter removed diff --git a/Bitkit/Views/Wallets/Receive/ReceiveSheet.swift b/Bitkit/Views/Wallets/Receive/ReceiveSheet.swift index c917fb3e7..4ac768526 100644 --- a/Bitkit/Views/Wallets/Receive/ReceiveSheet.swift +++ b/Bitkit/Views/Wallets/Receive/ReceiveSheet.swift @@ -3,7 +3,7 @@ import SwiftUI enum ReceiveRoute: Hashable { case qr(cjitInvoice: String?, tab: ReceiveQr.ReceiveTab?) - case edit + case edit(onchainOnly: Bool) case tag case cjitAmount case cjitConfirm(entry: IcJitEntry, receiveAmountSats: UInt64, isAdditional: Bool) @@ -16,9 +16,11 @@ enum ReceiveRoute: Hashable { struct ReceiveConfig { let initialRoute: ReceiveRoute + let hardwareWalletId: String? - init(view: ReceiveRoute = .qr(cjitInvoice: nil, tab: nil)) { + init(view: ReceiveRoute = .qr(cjitInvoice: nil, tab: nil), hardwareWalletId: String? = nil) { initialRoute = view + self.hardwareWalletId = hardwareWalletId } } @@ -26,15 +28,18 @@ struct ReceiveSheetItem: SheetItem { let id: SheetID = .receive let size: SheetSize = .large let initialRoute: ReceiveRoute + let hardwareWalletId: String? - init(initialRoute: ReceiveRoute = .qr(cjitInvoice: nil, tab: nil)) { + init(initialRoute: ReceiveRoute = .qr(cjitInvoice: nil, tab: nil), hardwareWalletId: String? = nil) { self.initialRoute = initialRoute + self.hardwareWalletId = hardwareWalletId } } struct ReceiveSheet: View { @EnvironmentObject private var tagManager: TagManager @EnvironmentObject private var wallet: WalletViewModel + @Environment(TrezorManager.self) private var trezorManager let config: ReceiveSheetItem @@ -50,6 +55,9 @@ struct ReceiveSheet: View { } } .offlineSheetOverlay(title: t("wallet__receive_bitcoin")) + .sheet(isPresented: reconnectPairingBinding) { + HardwarePairingSheet(config: HardwarePairingSheetItem()) + } .onAppear { wallet.invoiceAmountSats = 0 wallet.invoiceNote = "" @@ -64,6 +72,17 @@ struct ReceiveSheet: View { } } + private var reconnectPairingBinding: Binding { + Binding( + get: { trezorManager.showPairingCode }, + set: { isPresented in + if !isPresented, trezorManager.showPairingCode { + trezorManager.cancelPairingCode() + } + } + ) + } + @ViewBuilder private func viewForRoute(_ route: ReceiveRoute) -> some View { switch route { @@ -71,10 +90,11 @@ struct ReceiveSheet: View { ReceiveQr( navigationPath: $navigationPath, cjitInvoice: cjitInvoice, - tab: tab + tab: tab, + hardwareWalletId: config.hardwareWalletId ) - case .edit: - ReceiveEdit(navigationPath: $navigationPath) { draft in + case let .edit(onchainOnly): + ReceiveEdit(navigationPath: $navigationPath, onchainOnly: onchainOnly) { draft in navigationPath.append(.paymentRequestRecipient(draft)) } case .tag: diff --git a/BitkitTests/HwWalletManagerFundingTests.swift b/BitkitTests/HwWalletManagerFundingTests.swift index 275645ff8..047261942 100644 --- a/BitkitTests/HwWalletManagerFundingTests.swift +++ b/BitkitTests/HwWalletManagerFundingTests.swift @@ -51,7 +51,12 @@ final class HwWalletManagerFundingTests: XCTestCase { balance: balance, txCount: 0, blockHeight: 100, - accountType: .nativeSegwit + accountType: .nativeSegwit, + nextUnusedExternalAddress: BitkitCore.AddressInfo( + address: "bcrt1qwatcher", + path: "m/84'/1'/0'/0/0", + transfers: 0 + ) ) } diff --git a/BitkitTests/HwWalletManagerTests.swift b/BitkitTests/HwWalletManagerTests.swift index 51aa5fb19..8106d8fd3 100644 --- a/BitkitTests/HwWalletManagerTests.swift +++ b/BitkitTests/HwWalletManagerTests.swift @@ -113,13 +113,17 @@ final class HwWalletManagerTests: XCTestCase { private func makeViewModel( watcherService: OnChainWatcherServicing = MockWatcherService(), - monitored: Set = ["legacy", "nestedSegwit", "nativeSegwit", "taproot"] + monitored: Set = ["legacy", "nestedSegwit", "nativeSegwit", "taproot"], + accountInfoProvider: @escaping HwWalletManager.AccountInfoProvider = { _, _, _, _, _ in + fatalError("Unexpected account info request") + } ) -> HwWalletManager { let vm = HwWalletManager( watcherService: watcherService, monitoredTypes: { monitored }, electrumUrl: { "ssl://test:1" }, network: { .regtest }, + accountInfoProvider: accountInfoProvider, persistSnapshot: { [weak self] in self?.persistedSnapshots.append($0) }, deleteActivities: { [weak self] walletId in self?.deleted.append(walletId) @@ -226,7 +230,12 @@ final class HwWalletManagerTests: XCTestCase { private func makeEvent( _ activities: [Activity], total: UInt64, - transactionDetails: [TransactionDetails] = [] + transactionDetails: [TransactionDetails] = [], + receiveAddress: BitkitCore.AddressInfo = BitkitCore.AddressInfo( + address: "bcrt1qwatcher", + path: "m/84'/1'/0'/0/0", + transfers: 0 + ) ) -> WatcherEvent { let balance = WalletBalance( confirmed: total, immature: 0, trustedPending: 0, untrustedPending: 0, spendable: total, total: total @@ -237,7 +246,22 @@ final class HwWalletManagerTests: XCTestCase { balance: balance, txCount: UInt32(activities.count), blockHeight: 100, - accountType: .nativeSegwit + accountType: .nativeSegwit, + nextUnusedExternalAddress: receiveAddress + ) + } + + private func makeAccountInfo(unused: [BitkitCore.AddressInfo]) -> AccountInfoResult { + AccountInfoResult( + account: ComposeAccount( + path: "m/84'/1'/0'", + addresses: BitkitCore.AccountAddresses(used: [], unused: unused, change: []), + utxo: [] + ), + balance: 0, + utxoCount: 0, + accountType: .nativeSegwit, + blockHeight: 100 ) } @@ -280,6 +304,98 @@ final class HwWalletManagerTests: XCTestCase { XCTAssertEqual(vm.hwWalletIds, [wallet.walletId]) } + func testGetReceiveAddressUsesWatcherWithoutScanning() async throws { + var scanCount = 0 + let device = makeDevice(id: "dev1", xpubs: ["nativeSegwit": "zpubNS"]) + let vm = makeViewModel(monitored: ["nativeSegwit"]) { _, _, _, _, _ in + scanCount += 1 + return self.makeAccountInfo(unused: []) + } + vm.updateDevices(knownDevices: [device], connectedDeviceId: nil) + let walletId = try HwWalletId.derive(xpubs: device.xpubs) + + XCTAssertNil(vm.watcherReceiveAddress(walletId: walletId)) + + vm.handleWatcherEvent( + watcherId: watcherId(device, "nativeSegwit"), + event: makeEvent( + [], + total: 0, + receiveAddress: BitkitCore.AddressInfo( + address: "bcrt1qnext", + path: "m/84'/1'/0'/0/3", + transfers: 0 + ) + ) + ) + + XCTAssertEqual( + vm.watcherReceiveAddress(walletId: walletId), + HwReceiveAddress(address: "bcrt1qnext", path: "m/84'/1'/0'/0/3", addressType: .nativeSegwit) + ) + + let address = try await vm.getReceiveAddress(walletId: walletId) + + XCTAssertEqual(address, HwReceiveAddress(address: "bcrt1qnext", path: "m/84'/1'/0'/0/3", addressType: .nativeSegwit)) + XCTAssertEqual(scanCount, 0) + } + + func testGetReceiveAddressFallsBackToFirstUnusedScannedAddress() async throws { + var request: (String, String, TrezorCoinType, UInt32, AccountType)? + let device = makeDevice(id: "dev1", xpubs: ["nativeSegwit": "zpubNS"]) + let scanned = BitkitCore.AddressInfo(address: "bcrt1qscanned", path: "m/84'/1'/0'/0/4", transfers: 0) + let later = BitkitCore.AddressInfo(address: "bcrt1qlater", path: "m/84'/1'/0'/0/5", transfers: 0) + let vm = makeViewModel(monitored: ["nativeSegwit"]) { extendedKey, electrumUrl, network, gapLimit, accountType in + request = (extendedKey, electrumUrl, network, gapLimit, accountType) + return self.makeAccountInfo(unused: [scanned, later]) + } + vm.updateDevices(knownDevices: [device], connectedDeviceId: nil) + + let address = try await vm.getReceiveAddress(walletId: HwWalletId.derive(xpubs: device.xpubs)) + + XCTAssertEqual(address, HwReceiveAddress(address: scanned.address, path: scanned.path, addressType: .nativeSegwit)) + XCTAssertEqual(request?.0, "zpubNS") + XCTAssertEqual(request?.1, "ssl://test:1") + XCTAssertEqual(request?.2, .regtest) + XCTAssertEqual(request?.3, 20) + XCTAssertEqual(request?.4, .nativeSegwit) + } + + func testGetReceiveAddressThrowsWhenScanHasNoUnusedAddress() async throws { + let device = makeDevice(id: "dev1", xpubs: ["nativeSegwit": "zpubNS"]) + let vm = makeViewModel(monitored: ["nativeSegwit"]) { _, _, _, _, _ in + self.makeAccountInfo(unused: []) + } + vm.updateDevices(knownDevices: [device], connectedDeviceId: nil) + let walletId = try HwWalletId.derive(xpubs: device.xpubs) + + do { + _ = try await vm.getReceiveAddress(walletId: walletId) + XCTFail("Expected an empty account scan to fail") + } catch { + XCTAssertEqual(error.localizedDescription, t("hardware__receive_address_error")) + } + } + + func testGetReceiveAddressPrefersWatcherUpdateReceivedDuringScan() async throws { + let device = makeDevice(id: "dev1", xpubs: ["nativeSegwit": "zpubNS"]) + let scanned = BitkitCore.AddressInfo(address: "bcrt1qscanned", path: "m/84'/1'/0'/0/4", transfers: 0) + let watcher = BitkitCore.AddressInfo(address: "bcrt1qwatcher", path: "m/84'/1'/0'/0/5", transfers: 0) + var vm: HwWalletManager! + vm = makeViewModel(monitored: ["nativeSegwit"]) { _, _, _, _, _ in + vm.handleWatcherEvent( + watcherId: self.watcherId(device, "nativeSegwit"), + event: self.makeEvent([], total: 0, receiveAddress: watcher) + ) + return self.makeAccountInfo(unused: [scanned]) + } + vm.updateDevices(knownDevices: [device], connectedDeviceId: nil) + + let address = try await vm.getReceiveAddress(walletId: HwWalletId.derive(xpubs: device.xpubs)) + + XCTAssertEqual(address, HwReceiveAddress(address: watcher.address, path: watcher.path, addressType: .nativeSegwit)) + } + func testBalanceAggregatesAcrossAddressTypes() { let device = makeDevice(id: "dev1", xpubs: ["nativeSegwit": "zpubNS", "taproot": "zpubTR"]) let vm = makeViewModel() diff --git a/BitkitTests/TrezorViewModelWatcherTests.swift b/BitkitTests/TrezorViewModelWatcherTests.swift index fdb22dcf2..a6039f485 100644 --- a/BitkitTests/TrezorViewModelWatcherTests.swift +++ b/BitkitTests/TrezorViewModelWatcherTests.swift @@ -140,7 +140,12 @@ final class TrezorViewModelWatcherTests: XCTestCase { balance: sampleBalance, txCount: 3, blockHeight: 850_000, - accountType: .nativeSegwit + accountType: .nativeSegwit, + nextUnusedExternalAddress: BitkitCore.AddressInfo( + address: "bcrt1qwatcher", + path: "m/84'/1'/0'/0/0", + transfers: 0 + ) ) } diff --git a/changelog.d/next/693.added.md b/changelog.d/next/693.added.md new file mode 100644 index 000000000..f242f0c0c --- /dev/null +++ b/changelog.d/next/693.added.md @@ -0,0 +1 @@ +Added a Trezor Receive tab with fast watch-only addresses and on-device verification.