From c24a0d394320b085bbdd6b019175b26894f9967c Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 27 Aug 2026 10:23:02 -0500 Subject: [PATCH 1/2] feat: add trezor receive --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- Bitkit/Components/CopyAddressCard.swift | 26 +- Bitkit/Components/TabBar/TabBar.swift | 11 + Bitkit/MainNavView.swift | 4 +- Bitkit/Managers/HwWalletManager.swift | 109 ++++++- Bitkit/Models/HwWallet.swift | 7 + .../Localization/en.lproj/Localizable.strings | 3 + Bitkit/ViewModels/SheetViewModel.swift | 2 +- .../ViewModels/Trezor/TrezorViewModel.swift | 2 +- Bitkit/Views/Wallets/Receive/QrArea.swift | 38 ++- .../Views/Wallets/Receive/ReceiveEdit.swift | 16 +- Bitkit/Views/Wallets/Receive/ReceiveQr.swift | 266 +++++++++++++++++- .../Views/Wallets/Receive/ReceiveSheet.swift | 32 ++- BitkitTests/HwWalletManagerFundingTests.swift | 7 +- BitkitTests/HwWalletManagerTests.swift | 37 ++- BitkitTests/TrezorViewModelWatcherTests.swift | 7 +- changelog.d/next/693.added.md | 1 + 18 files changed, 519 insertions(+), 55 deletions(-) create mode 100644 changelog.d/next/693.added.md 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..3014c86b1 100644 --- a/Bitkit/Managers/HwWalletManager.swift +++ b/Bitkit/Managers/HwWalletManager.swift @@ -626,17 +626,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 +916,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 OnChainHwService.shared.getAccountInfo( + extendedKey: account.xpub, + electrumUrl: electrumUrlProvider(), + network: networkProvider(), + gapLimit: Constants.defaultGapLimit, + scriptType: 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 +1185,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..b50984e3e 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 { @@ -119,7 +154,11 @@ struct ReceiveQr: View { } .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 +166,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 +192,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 +224,12 @@ struct ReceiveQr: View { } } } + .onDisappear { + verifyTask?.cancel() + verifyTask = nil + passphraseTask?.cancel() + passphraseTask = nil + } } func tabContent(for tab: ReceiveTab) -> some View { @@ -184,12 +250,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 +310,8 @@ struct ReceiveQr: View { imageAsset: "ln", accentColor: .purpleAccent ) + case .trezor: + return (uri: "", imageAsset: "btc-circle-blue", accentColor: .blueAccent) } } @@ -309,13 +408,42 @@ 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 + ) + } + + if tab == .trezor { + CustomButton( + title: t("hardware__verify_address"), + isDisabled: displayedHardwareAddress == nil, + isLoading: isVerifyingHardwareAddress, + shouldExpand: true, + background: Color.blueAccent + ) { + startHardwareAddressVerification() + } + .padding(.top, 16) + .accessibilityIdentifier("HardwareVerifyAddress") } Spacer() @@ -336,6 +464,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..c417d7290 100644 --- a/BitkitTests/HwWalletManagerTests.swift +++ b/BitkitTests/HwWalletManagerTests.swift @@ -226,7 +226,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 +242,8 @@ final class HwWalletManagerTests: XCTestCase { balance: balance, txCount: UInt32(activities.count), blockHeight: 100, - accountType: .nativeSegwit + accountType: .nativeSegwit, + nextUnusedExternalAddress: receiveAddress ) } @@ -280,6 +286,33 @@ final class HwWalletManagerTests: XCTestCase { XCTAssertEqual(vm.hwWalletIds, [wallet.walletId]) } + func testWatcherEventProvidesReceiveAddress() throws { + let device = makeDevice(id: "dev1", xpubs: ["nativeSegwit": "zpubNS"]) + let vm = makeViewModel(monitored: ["nativeSegwit"]) + 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) + ) + } + 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. From 3231855fcd6a80656a64fe90c7ab83af4149ae77 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 27 Aug 2026 11:20:38 -0500 Subject: [PATCH 2/2] fix: polish trezor receive actions --- Bitkit/Views/Wallets/Receive/ReceiveQr.swift | 47 ++++++++++---------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/Bitkit/Views/Wallets/Receive/ReceiveQr.swift b/Bitkit/Views/Wallets/Receive/ReceiveQr.swift index b50984e3e..531f1f3ef 100644 --- a/Bitkit/Views/Wallets/Receive/ReceiveQr.swift +++ b/Bitkit/Views/Wallets/Receive/ReceiveQr.swift @@ -143,16 +143,31 @@ 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"), @@ -432,20 +447,6 @@ struct ReceiveQr: View { ) } - if tab == .trezor { - CustomButton( - title: t("hardware__verify_address"), - isDisabled: displayedHardwareAddress == nil, - isLoading: isVerifyingHardwareAddress, - shouldExpand: true, - background: Color.blueAccent - ) { - startHardwareAddressVerification() - } - .padding(.top, 16) - .accessibilityIdentifier("HardwareVerifyAddress") - } - Spacer() } }