diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index fff6b6c4a..9d20fc9c4 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1201,7 +1201,7 @@ repositoryURL = "https://github.com/synonymdev/bitkit-core"; requirement = { kind = exactVersion; - version = 0.1.66; + version = 0.3.4; }; }; 96E20CD22CB6D91A00C24149 /* XCRemoteSwiftPackageReference "CodeScanner" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 6a8a87a87..f21c062d4 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" : "99ffc3b610bdb199cbe3a35d9c9dc9435f769b85", - "version" : "0.1.66" + "revision" : "c098b41d961594ead9dfd474b8508e289f6be2de", + "version" : "0.3.4" } }, { diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 1507d7d06..dd9bd46b0 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -31,7 +31,9 @@ struct AppScene: View { @StateObject private var pubkyProfile = PubkyProfileManager() @StateObject private var contactsManager = ContactsManager() @State private var keyboardManager = KeyboardManager() - @State private var trezorViewModel = TrezorViewModel() + @State private var trezorManager: TrezorManager + @State private var trezorViewModel: TrezorViewModel + @State private var hwWalletManager: HwWalletManager @State private var calculatorInputManager = CalculatorInputManager() @State private var hideSplash = false @@ -82,6 +84,12 @@ 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()) + CoreService.shared.activity.setPrivatePaykitContactResolvers( invoice: { paymentHash in await PrivatePaykitService.shared.contactPublicKey(forPrivateInvoicePaymentHash: paymentHash) @@ -106,6 +114,13 @@ struct AppScene: View { .onChange(of: wallet.nodeLifecycleState) { _, newValue in handleNodeLifecycleChange(newValue) } .onChange(of: scenePhase, initial: true) { _, newValue in handleScenePhaseChange(newValue) } .onChange(of: network.isConnected) { _, isConnected in handleNetworkChange(isConnected) } + // Bridge Trezor device state into the watch-only manager without coupling the two: + // TrezorManager bumps devicesRevision on any device/connection change. + .onChange(of: trezorManager.devicesRevision) { _, _ in pushHardwareDevices() } + .onChange(of: isPinVerified) { _, verified in + if verified { Task { await trezorManager.autoReconnect() } } + } + .onReceive(settings.settingsPublisher) { _ in hwWalletManager.reconcileForSettingsChange() } .onChange(of: migrations.isShowingMigrationLoading) { _, isLoading in if !isLoading { SettingsViewModel.shared.updatePinEnabledState() @@ -148,7 +163,9 @@ struct AppScene: View { .environmentObject(pubkyProfile) .environmentObject(contactsManager) .environment(keyboardManager) + .environment(trezorManager) .environment(trezorViewModel) + .environment(hwWalletManager) .environment(calculatorInputManager) .onChange(of: pubkyProfile.authState, initial: true) { _, authState in if authState == .authenticated, let pk = pubkyProfile.publicKey { @@ -470,6 +487,12 @@ struct AppScene: View { await checkAndPerformRNMigration() try wallet.setWalletExistsState() + // Load any paired hardware devices from storage and feed the watch-only manager so its + // watchers start at launch (no-op until a device is paired). loadKnownDevices() also + // bumps devicesRevision, but push explicitly so the initial state is delivered. + trezorManager.loadKnownDevices() + pushHardwareDevices() + // Setup TimedSheetManager with all timed sheets TimedSheetManager.shared.setup( sheetViewModel: sheets, @@ -601,6 +624,10 @@ struct AppScene: View { } if newPhase == .active { + // Reconnect a known hardware device so its connection indicator turns green again; + if isPinVerified || !settings.pinEnabled { + Task { await trezorManager.autoReconnect() } + } if wallet.walletExists == true { Task { await clearDeliveredNotifications() @@ -634,6 +661,15 @@ struct AppScene: View { center.removeDeliveredNotifications(withIdentifiers: deliveredNotifications.map(\.request.identifier)) } + /// Feed the current Trezor device snapshot into the watch-only manager. This is the only link + /// between the two managers, kept in the composition root so neither type references the other. + private func pushHardwareDevices() { + hwWalletManager.updateDevices( + knownDevices: trezorManager.knownDevices, + connectedDeviceId: trezorManager.connectedDevice?.id + ) + } + private func handleNetworkChange(_ isConnected: Bool) { Logger.info("Network changed: \(isConnected ? "connected" : "disconnected")", context: "AppScene") diff --git a/Bitkit/Assets.xcassets/Illustrations/ledger.imageset/Contents.json b/Bitkit/Assets.xcassets/Illustrations/ledger.imageset/Contents.json new file mode 100644 index 000000000..e1bd58d29 --- /dev/null +++ b/Bitkit/Assets.xcassets/Illustrations/ledger.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "ledger.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "ledger@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Bitkit/Assets.xcassets/Illustrations/ledger.imageset/ledger.png b/Bitkit/Assets.xcassets/Illustrations/ledger.imageset/ledger.png new file mode 100644 index 000000000..ea544f7b4 Binary files /dev/null and b/Bitkit/Assets.xcassets/Illustrations/ledger.imageset/ledger.png differ diff --git a/Bitkit/Assets.xcassets/Illustrations/ledger.imageset/ledger@2x.png b/Bitkit/Assets.xcassets/Illustrations/ledger.imageset/ledger@2x.png new file mode 100644 index 000000000..b1cb6b579 Binary files /dev/null and b/Bitkit/Assets.xcassets/Illustrations/ledger.imageset/ledger@2x.png differ diff --git a/Bitkit/Assets.xcassets/Illustrations/trezor.imageset/Contents.json b/Bitkit/Assets.xcassets/Illustrations/trezor.imageset/Contents.json new file mode 100644 index 000000000..012c311e7 --- /dev/null +++ b/Bitkit/Assets.xcassets/Illustrations/trezor.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "trezor.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "trezor@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Bitkit/Assets.xcassets/Illustrations/trezor.imageset/trezor.png b/Bitkit/Assets.xcassets/Illustrations/trezor.imageset/trezor.png new file mode 100644 index 000000000..4c48b6f21 Binary files /dev/null and b/Bitkit/Assets.xcassets/Illustrations/trezor.imageset/trezor.png differ diff --git a/Bitkit/Assets.xcassets/Illustrations/trezor.imageset/trezor@2x.png b/Bitkit/Assets.xcassets/Illustrations/trezor.imageset/trezor@2x.png new file mode 100644 index 000000000..71f7e6293 Binary files /dev/null and b/Bitkit/Assets.xcassets/Illustrations/trezor.imageset/trezor@2x.png differ diff --git a/Bitkit/Assets.xcassets/icons/bluetooth-connected.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/bluetooth-connected.imageset/Contents.json new file mode 100644 index 000000000..3625a5352 --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/bluetooth-connected.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "bluetooth-connected.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Bitkit/Assets.xcassets/icons/bluetooth-connected.imageset/bluetooth-connected.svg b/Bitkit/Assets.xcassets/icons/bluetooth-connected.imageset/bluetooth-connected.svg new file mode 100644 index 000000000..9acbaa7dc --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/bluetooth-connected.imageset/bluetooth-connected.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Bitkit/Assets.xcassets/icons/btc-circle-blue.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/btc-circle-blue.imageset/Contents.json new file mode 100644 index 000000000..ee51d1f73 --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/btc-circle-blue.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "btc-circle-blue.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Bitkit/Assets.xcassets/icons/btc-circle-blue.imageset/btc-circle-blue.svg b/Bitkit/Assets.xcassets/icons/btc-circle-blue.imageset/btc-circle-blue.svg new file mode 100644 index 000000000..5fa214834 --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/btc-circle-blue.imageset/btc-circle-blue.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Bitkit/Components/HardwareWalletsGrid.swift b/Bitkit/Components/HardwareWalletsGrid.swift new file mode 100644 index 000000000..04b2eab52 --- /dev/null +++ b/Bitkit/Components/HardwareWalletsGrid.swift @@ -0,0 +1,105 @@ +import SwiftUI + +/// Two-column grid of paired hardware wallets shown on Home, under the Savings/Spending tiles. +/// Mirrors bitkit-android's `HwDevices` (rows chunked in pairs, divided like the on-chain tiles). +struct HardwareWalletsGrid: View { + let wallets: [HwWallet] + let onTap: (HwWallet) -> Void + + private var rows: [[HwWallet]] { + chunked(wallets, into: 2) + } + + private func chunked(_ wallets: [HwWallet], into size: Int) -> [[HwWallet]] { + stride(from: 0, to: wallets.count, by: size).map { start in + let end = min(start + size, wallets.count) + return Array(wallets[start ..< end]) + } + } + + var body: some View { + VStack(spacing: 16) { + ForEach(Array(rows.enumerated()), id: \.offset) { _, row in + HStack(spacing: 16) { + HardwareWalletCell(wallet: row[0], onTap: onTap) + + CustomDivider(color: .gray4, type: .vertical) + + if row.count > 1 { + HardwareWalletCell(wallet: row[1], onTap: onTap) + } else { + Color.clear.frame(maxWidth: .infinity) + } + } + .frame(height: 50) + } + } + } +} + +private struct HardwareWalletCell: View { + let wallet: HwWallet + let onTap: (HwWallet) -> Void + + var body: some View { + Button { + onTap(wallet) + } label: { + VStack(alignment: .leading) { + CaptionMText(wallet.name) + .lineLimit(1) + .padding(.bottom, 4) + + HStack(spacing: 4) { + Image("btc-circle-blue") + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + .padding(.trailing, 4) + + MoneyText( + sats: Int(clamping: wallet.balanceSats), + size: .subtitle, + enableHide: true, + symbolColor: .textPrimary + ) + + HwWalletConnectionIcon(isConnected: wallet.isConnected) + .frame(width: 16, height: 16) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + .accessibilityIdentifier("ActivityHardware") + } +} + +#Preview { + func wallet(_ id: String, _ name: String, connected: Bool, sats: UInt64) -> HwWallet { + HwWallet(id: id, walletId: "trezor:\(id)", name: name, model: name, isConnected: connected, balanceSats: sats) + } + + return VStack(spacing: 32) { + // Single device + HardwareWalletsGrid( + wallets: [wallet("1", "Trezor Safe 5", connected: true, sats: 1_234_567)], + onTap: { _ in } + ) + + CustomDivider() + + // Two devices (full 2-column row), one connected, one not + HardwareWalletsGrid( + wallets: [ + wallet("1", "Trezor Safe 5", connected: true, sats: 1_234_567), + wallet("2", "Trezor Model T", connected: false, sats: 89000), + ], + onTap: { _ in } + ) + } + .padding() + .environmentObject(CurrencyViewModel()) + .environmentObject(SettingsViewModel.shared) + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Components/Trezor/HwDeviceIllustrations.swift b/Bitkit/Components/Trezor/HwDeviceIllustrations.swift new file mode 100644 index 000000000..513fc381d --- /dev/null +++ b/Bitkit/Components/Trezor/HwDeviceIllustrations.swift @@ -0,0 +1,51 @@ +import SwiftUI + +/// Staggered hardware-device hero used by the hardware intro sheet: a Trezor on the left and a +/// blurred Ledger bleeding off the right. Ports bitkit-android's `HwDeviceIllustrations`. +struct HwDeviceIllustrations: View { + /// All measurements are expressed as fractions of the Figma design frame's width, so the hero + /// scales proportionally to whatever width it's given. Each device is rendered at its natural + /// (non-square) aspect ratio; the Trezor's left bleed is baked into the exported asset, while + /// the Ledger is offset to bleed off the right edge. + private enum Layout { + static let referenceWidth: CGFloat = 375 + + static let proportionalHeight: CGFloat = 256 / referenceWidth + static let trezorProportionalWidth: CGFloat = 172 / referenceWidth + static let ledgerProportionalWidth: CGFloat = 203 / referenceWidth + static let ledgerProportionalX: CGFloat = 172 / referenceWidth + static let proportionalStagger: CGFloat = 11.6 / referenceWidth + } + + var body: some View { + GeometryReader { geo in + let width = geo.size.width + let imageHeight = width * Layout.proportionalHeight + let staggerY = width * Layout.proportionalStagger + + ZStack { + Image("trezor") + .resizable() + .scaledToFit() + .frame(width: width * Layout.trezorProportionalWidth, height: imageHeight) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .offset(y: staggerY) + + Image("ledger") + .resizable() + .scaledToFit() + .frame(width: width * Layout.ledgerProportionalWidth, height: imageHeight) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .offset(x: width * Layout.ledgerProportionalX, y: -staggerY) + } + } + .accessibilityHidden(true) + } +} + +#Preview { + HwDeviceIllustrations() + .frame(height: 300) + .background(Color.black) + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Components/Trezor/HwWalletConnectionIcon.swift b/Bitkit/Components/Trezor/HwWalletConnectionIcon.swift new file mode 100644 index 000000000..91fb9a04a --- /dev/null +++ b/Bitkit/Components/Trezor/HwWalletConnectionIcon.swift @@ -0,0 +1,30 @@ +import SwiftUI + +/// Bluetooth connection indicator for a paired hardware wallet. iOS supports Bluetooth only, +/// so there is a single transport glyph — tinted green when connected, gray when disconnected. +/// Mirrors bitkit-android's `HwWalletConnectionIcon` (BLE branch). +struct HwWalletConnectionIcon: View { + let isConnected: Bool + + var body: some View { + Image("bluetooth-connected") + .renderingMode(.template) + .resizable() + .scaledToFit() + .foregroundColor(isConnected ? .greenAccent : .gray1) + .accessibilityLabel( + isConnected + ? t("hardware__connection_badge_connected_bluetooth") + : t("hardware__connection_badge_disconnected_bluetooth") + ) + } +} + +#Preview { + HStack(spacing: 24) { + HwWalletConnectionIcon(isConnected: true).frame(width: 16, height: 16) + HwWalletConnectionIcon(isConnected: false).frame(width: 16, height: 16) + } + .padding() + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Components/Trezor/TrezorDeviceRow.swift b/Bitkit/Components/Trezor/TrezorDeviceRow.swift index 7c8feacfc..04adeda98 100644 --- a/Bitkit/Components/Trezor/TrezorDeviceRow.swift +++ b/Bitkit/Components/Trezor/TrezorDeviceRow.swift @@ -24,7 +24,7 @@ struct TrezorDeviceRow: View { // Device info VStack(alignment: .leading, spacing: 4) { - Text(displayName) + Text(device.displayName) .font(.system(size: 16, weight: .semibold)) .foregroundColor(.white) @@ -58,20 +58,6 @@ struct TrezorDeviceRow: View { .accessibilityIdentifier(device.path.hasPrefix("bridge:") ? "TrezorDevice-bridge" : "TrezorDevice-\(device.path)") } - private var displayName: String { - if let label = device.label, !label.isEmpty { - return label - } - return modelName - } - - private var modelName: String { - if let model = device.model { - return "Trezor \(model)" - } - return "Trezor" - } - private var transportIcon: String { switch device.transportType { case .bluetooth: @@ -119,7 +105,7 @@ struct KnownDeviceRow: View { // Device info VStack(alignment: .leading, spacing: 4) { - Text(device.label ?? device.name) + Text(device.displayName) .font(.system(size: 16, weight: .semibold)) .foregroundColor(.white) diff --git a/Bitkit/Components/Widgets/Suggestions.swift b/Bitkit/Components/Widgets/Suggestions.swift index 81b48c2e7..779f0bb13 100644 --- a/Bitkit/Components/Widgets/Suggestions.swift +++ b/Bitkit/Components/Widgets/Suggestions.swift @@ -12,7 +12,7 @@ struct SuggestionCardData: Identifiable, Hashable { enum SuggestionAction: Hashable { case backup case buyBitcoin - // case hardware + case hardware case invite case notifications case profile @@ -33,9 +33,9 @@ enum WalletSuggestionState { /// Ordered suggestion card IDs per wallet state (priority: first = highest). /// Max 4 cards are shown; when one is dismissed or completed, the next in this list is shown. private let suggestionOrderByState: [WalletSuggestionState: [String]] = [ - .empty: ["buyBitcoin", "transferToSpending", "support", "backupSeedPhrase", "pin", "profile", "invite"], - .onchain: ["backupSeedPhrase", "pin", "transferToSpending", "support", "profile", "invite", "buyBitcoin"], - .spending: ["quickpay", "notifications", "shop", "profile", "support", "invite", "buyBitcoin"], + .empty: ["buyBitcoin", "transferToSpending", "hardware", "support", "backupSeedPhrase", "pin", "profile", "invite"], + .onchain: ["backupSeedPhrase", "pin", "transferToSpending", "hardware", "support", "profile", "invite", "buyBitcoin"], + .spending: ["quickpay", "notifications", "shop", "hardware", "profile", "support", "invite", "buyBitcoin"], ] let cards: [SuggestionCardData] = [ @@ -119,14 +119,14 @@ let cards: [SuggestionCardData] = [ color: .brand24, action: .profile ), - // SuggestionCardData( - // id: "hardware", - // title: t("cards__hardware__title"), - // description: t("cards__hardware__description"), - // imageName: "trezor-card", - // color: .blue24, - // action: .hardware - // ), + SuggestionCardData( + id: "hardware", + title: t("cards__hardware__title"), + description: t("cards__hardware__description"), + imageName: "trezor-card", + color: .blue24, + action: .hardware + ), ] private let cardsById: [String: SuggestionCardData] = Dictionary(uniqueKeysWithValues: cards.map { ($0.id, $0) }) @@ -138,8 +138,8 @@ extension SuggestionCardData { return "back_up" case .buyBitcoin: return "buy" - // case .hardware: - // return "hardware" + case .hardware: + return "hardware" case .invite: return "invite" case .notifications: @@ -179,6 +179,7 @@ struct Suggestions: View { @EnvironmentObject var suggestionsManager: SuggestionsManager @EnvironmentObject var wallet: WalletViewModel @EnvironmentObject var pubkyProfile: PubkyProfileManager + @Environment(HwWalletManager.self) private var hwWalletManager @AppStorage(PaykitFeatureFlags.uiEnabledKey) private var isPaykitUIEnabled = false @State private var showShareSheet = false @@ -196,6 +197,7 @@ struct Suggestions: View { settings: SettingsViewModel, suggestionsManager: SuggestionsManager, pubkyProfile: PubkyProfileManager? = nil, + hasHardwareWallet: Bool = false, isPaykitUIEnabled: Bool = PaykitFeatureFlags.isUIEnabled, isPreview: Bool = false, previewCardIds: [String]? = nil @@ -218,7 +220,7 @@ struct Suggestions: View { for id in orderedIds { guard let card = cardsById[id] else { continue } if !isPaykitUIEnabled, card.isPaykitCard { continue } - if isCardCompleted(card, app: app, settings: settings, pubkyProfile: pubkyProfile) { continue } + if isCardCompleted(card, app: app, settings: settings, pubkyProfile: pubkyProfile, hasHardwareWallet: hasHardwareWallet) { continue } if suggestionsManager.isDismissed(card.id) { continue } result.append(card) if result.count >= 4 { break } @@ -228,10 +230,11 @@ struct Suggestions: View { /// Whether the user has completed this suggestion (e.g. backup verified, pin enabled, notifications on). private static func isCardCompleted(_ card: SuggestionCardData, app: AppViewModel, settings: SettingsViewModel, - pubkyProfile: PubkyProfileManager? = nil) -> Bool + pubkyProfile: PubkyProfileManager? = nil, hasHardwareWallet: Bool = false) -> Bool { switch card.action { case .backup: return app.backupVerified + case .hardware: return hasHardwareWallet case .notifications: return settings.enableNotifications case .profile: return pubkyProfile?.isAuthenticated ?? false case .quickpay: return settings.enableQuickpay @@ -248,6 +251,7 @@ struct Suggestions: View { settings: settings, suggestionsManager: suggestionsManager, pubkyProfile: pubkyProfile, + hasHardwareWallet: isPreview ? false : !hwWalletManager.wallets.isEmpty, isPaykitUIEnabled: isPaykitUIActive, isPreview: isPreview, previewCardIds: previewCardIds @@ -353,8 +357,8 @@ struct Suggestions: View { route = app.hasSeenShopIntro ? .shopDiscover : .shopIntro case .support: route = .support - // case .hardware: - // route = .support + case .hardware: + sheets.showSheet(.hardwareIntro) case .transferToSpending: route = app.hasSeenTransferIntro ? .fundingOptions : .transferIntro } @@ -398,5 +402,6 @@ struct SuggestionsPreviewTile: View { .environmentObject(SuggestionsManager()) .environmentObject(WalletViewModel()) .environmentObject(PubkyProfileManager()) + .environment(HwWalletManager()) .preferredColorScheme(.dark) } diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift index ce029cfd6..fa4f346ea 100644 --- a/Bitkit/Constants/Env.swift +++ b/Bitkit/Constants/Env.swift @@ -164,11 +164,7 @@ enum Env { // MARK: Server URLs - static var electrumServerUrl: String { - if isE2E, e2eBackend == "local" { - return "tcp://127.0.0.1:60001" - } - + static func electrumServerUrl(for network: LDKNode.Network) -> String { switch network { case .bitcoin: return "ssl://bitkit.to:9999" case .signet: return "ssl://mempool.space:60602" @@ -177,6 +173,13 @@ enum Env { } } + static var electrumServerUrl: String { + if isE2E, e2eBackend == "local" { + return "tcp://127.0.0.1:60001" + } + return electrumServerUrl(for: network) + } + static var trezorBridgeEnabled: Bool { (isDebug || isE2E) && boolConfigValue("TREZOR_BRIDGE") } diff --git a/Bitkit/Extensions/Activity+Contact.swift b/Bitkit/Extensions/Activity+Contact.swift index da84be98a..ba94c2148 100644 --- a/Bitkit/Extensions/Activity+Contact.swift +++ b/Bitkit/Extensions/Activity+Contact.swift @@ -1,6 +1,25 @@ import BitkitCore extension Activity { + /// bitkit-core wallet id scoping this activity (`"bitkit"` for the normal wallet, a derived + /// id for watch-only hardware wallets — see `HwWalletId`). + var walletId: String { + switch self { + case let .lightning(lightning): + return lightning.walletId + case let .onchain(onchain): + return onchain.walletId + } + } + + /// Whether this activity belongs to a watch-only hardware wallet (not the normal Bitkit wallet). + // TODO: Used as an interim feature gate (see ActivityItemView.isHardwareActivity). The + // wallet-id shorthand holds only while CoreService activity mutations are default-scoped; + // replace with a real capability check when wallet_id mutation support lands. + var isHardwareWallet: Bool { + walletId != WalletScope.default + } + func contact(in contacts: [PubkyContact]) -> PubkyContact? { guard let contactPublicKey else { return nil } return contacts.first(where: { PubkyPublicKeyFormat.matches($0.publicKey, contactPublicKey) }) diff --git a/Bitkit/Extensions/FixedWidthInteger+Saturating.swift b/Bitkit/Extensions/FixedWidthInteger+Saturating.swift new file mode 100644 index 000000000..3a954b13a --- /dev/null +++ b/Bitkit/Extensions/FixedWidthInteger+Saturating.swift @@ -0,0 +1,8 @@ +import Foundation + +extension FixedWidthInteger { + func saturatingAdd(_ other: Self) -> Self { + let (sum, overflow) = addingReportingOverflow(other) + return overflow ? Self.max : sum + } +} diff --git a/Bitkit/Extensions/LDKNode+AddressType.swift b/Bitkit/Extensions/LDKNode+AddressType.swift index f42f862d3..c62fb5a4e 100644 --- a/Bitkit/Extensions/LDKNode+AddressType.swift +++ b/Bitkit/Extensions/LDKNode+AddressType.swift @@ -1,3 +1,4 @@ +import BitkitCore import LDKNode extension LDKNode.AddressType { @@ -68,6 +69,30 @@ extension LDKNode.AddressType { } } + /// Account-level BIP path (no chain/index suffix), e.g. `m/84'/0'/0'` — used to request a + /// device's account xpub. Distinct from `derivationPath`, which is the chain-level path. + func accountDerivationPath(coinType: String) -> String { + switch self { + case .legacy: return "m/44'/\(coinType)'/0'" // BIP 44 + case .nestedSegwit: return "m/49'/\(coinType)'/0'" // BIP 49 + case .nativeSegwit: return "m/84'/\(coinType)'/0'" // BIP 84 + case .taproot: return "m/86'/\(coinType)'/0'" // BIP 86 + } + } + + // MARK: - BitkitCore account type + + /// bitkit-core `AccountType` for this address type (used when deriving descriptors and + /// starting watch-only watchers). + var accountType: AccountType { + switch self { + case .legacy: return .legacy + case .nestedSegwit: return .wrappedSegwit + case .nativeSegwit: return .nativeSegwit + case .taproot: return .taproot + } + } + // MARK: - Localized display var localizedTitle: String { diff --git a/Bitkit/Extensions/TrezorDevice+DisplayName.swift b/Bitkit/Extensions/TrezorDevice+DisplayName.swift new file mode 100644 index 000000000..343816001 --- /dev/null +++ b/Bitkit/Extensions/TrezorDevice+DisplayName.swift @@ -0,0 +1,21 @@ +import BitkitCore + +/// Canonical Trezor display name: the user-set label when it differs from the factory model, +/// otherwise the vendor-prefixed model, falling back to "Trezor". +func resolveHwWalletName(label: String?, model: String?) -> String { + if let label, !label.isEmpty, label != model { return label } + guard let model else { return "Trezor" } + return model.hasPrefix("Trezor") ? model : "Trezor \(model)" +} + +extension TrezorKnownDevice { + var displayName: String { + resolveHwWalletName(label: label, model: model) + } +} + +extension TrezorDeviceInfo { + var displayName: String { + resolveHwWalletName(label: label, model: model) + } +} diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 90a46fa2b..311e8badd 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -13,6 +13,8 @@ struct MainNavView: View { @EnvironmentObject private var settings: SettingsViewModel @EnvironmentObject private var sheets: SheetViewModel @EnvironmentObject private var wallet: WalletViewModel + @Environment(TrezorManager.self) private var trezorManager + @Environment(HwWalletManager.self) private var hwWalletManager @Environment(\.scenePhase) var scenePhase @State private var showClipboardAlert = false @@ -195,6 +197,35 @@ struct MainNavView: View { ) { config in WidgetsSheet(config: config) } + .sheet( + item: $sheets.hardwareIntroSheetItem, + onDismiss: { + sheets.hideSheet() + } + ) { + config in HardwareIntroSheet(config: config) + } + .sheet( + item: $sheets.hardwarePairingSheetItem, + onDismiss: { + sheets.hideSheet() + } + ) { + config in HardwarePairingSheet(config: config) + } + .onChange(of: trezorManager.showPairingCode) { _, needsCode in + // A hardware device asked for its one-time pairing code (e.g. during reconnect); + // surface the app-wide Pair Device sheet. Hidden again once submitted/cancelled. + if needsCode { + sheets.showSheet(.hardwarePairing) + } else { + sheets.hideSheetIfActive(.hardwarePairing, reason: "Pairing code resolved") + } + } + .onReceive(hwWalletManager.receivedTxPublisher) { tx in + // New inbound transaction to a watched hardware wallet — show the received celebration. + sheets.showSheet(.receivedTx, data: ReceivedTxSheetDetails(type: .onchain, sats: tx.sats)) + } .accentColor(.white) .overlay { TabBar() diff --git a/Bitkit/Managers/HwWalletManager.swift b/Bitkit/Managers/HwWalletManager.swift new file mode 100644 index 000000000..ee8e8c7b9 --- /dev/null +++ b/Bitkit/Managers/HwWalletManager.swift @@ -0,0 +1,480 @@ +import BitkitCore +import Combine +import Foundation + +/// Production hardware-wallet business layer. Tracks paired Trezor devices as watch-only +/// balances by running one on-chain xpub watcher per (device, address type), aggregating the +/// per-device balance in memory, and persisting each device's on-chain activity into +/// bitkit-core scoped by a derived `walletId` (core 0.3.x wallet-scoped storage). +/// +/// Fully decoupled from `TrezorManager`: it receives the paired-device snapshot through +/// `updateDevices(...)`, fed by the composition root (`AppScene`). Adapts bitkit-android's +/// `HwWalletRepo`. iOS supports Bluetooth only, so the cross-transport (BLE+USB) dedup is reduced +/// to a plain xpub-based identity and USB-specific reconnect handling is omitted. +@Observable +@MainActor +final class HwWalletManager { + private enum Constants { + static let watcherIdSeparator = "|" + static let watcherStartRetryDelay: Duration = .seconds(30) + static let defaultGapLimit: UInt32 = 20 + } + + // MARK: - Published state + + /// Paired hardware wallets, one per physical device, with aggregated balance. + private(set) var wallets: [HwWallet] = [] + + /// Sum of every paired wallet's balance. + private(set) var totalSats: UInt64 = 0 + + /// bitkit-core wallet ids for the paired hardware wallets — the activity list queries these. + private(set) var hwWalletIds: Set = [] + + /// Whether the known-device store has been read at least once. + private(set) var walletsLoaded = false + + /// Inbound transactions detected by a running watcher after its initial history sync. + let receivedTxPublisher = PassthroughSubject() + + // MARK: - Dependencies + + private let watcherService: OnChainWatcherServicing + private let monitoredTypesProvider: () -> Set + private let electrumUrlProvider: () -> String + private let networkProvider: () -> TrezorCoinType + private let persistActivities: ([Activity]) -> Void + private let deleteActivities: (String) -> Void + + // MARK: - Internal state + + private var knownDevices: [TrezorKnownDevice] = [] + private var connectedDeviceId: String? + private var watcherData: [String: HwWatcherData] = [:] + private var activeWatchers: Set = [] + private var activeWatcherElectrumUrls: [String: String] = [:] + + /// Xpub each active watcher was started with. The watcher id is only `deviceId|addressType`, so + /// the same physical device re-saved with a different xpub for that type (e.g. a passphrase/ + /// hidden wallet, or re-fetched accounts) keeps the same watcher id and derives a new wallet id. + /// Tracked here so `syncWatchers()` restarts the watcher on the new xpub instead of leaving the + /// old one feeding the old wallet's balance/activity under the new wallet id. + private var activeWatcherXpubs: [String: String] = [:] + private var retryingWatcherStarts: Set = [] + + /// Watchers whose async start is dispatched but not yet confirmed in `activeWatchers`. + /// Guards against a second `syncWatchers()` double-starting the same watcher in that window. + private var pendingWatcherStarts: Set = [] + + /// Last watcher-relevant settings seen by `reconcileForSettingsChange()`, so an unrelated + /// settings change (theme, currency, …) doesn't trigger a needless watcher reconcile. + private var lastSyncedMonitored: Set? + private var lastSyncedElectrumUrl: String? + + /// Memoized `HwWalletId.derive` results keyed by an xpubs signature. The mapping is + /// deterministic and immutable, so caching avoids repeated FFI derivations on every watcher + /// event and sync. Pruned to the live device set on `updateDevices`/`removeDevice`. + private var walletIdCache: [String: String] = [:] + + /// Last activity set persisted per group wallet id, so an unchanged watcher event doesn't + /// re-upsert the whole history to core and fire a redundant activity-list reload. + private var lastPersisted: [String: [Activity]] = [:] + + private var emittedReceivedTxIds: Set = [] + private var listeners: [String: TrezorEventListener] = [:] + + init( + watcherService: OnChainWatcherServicing = OnChainHwService.shared, + monitoredTypes: (() -> Set)? = nil, + electrumUrl: (() -> String)? = nil, + network: (() -> TrezorCoinType)? = nil, + persistActivities: (([Activity]) -> Void)? = nil, + deleteActivities: ((String) -> Void)? = nil + ) { + self.watcherService = watcherService + networkProvider = network ?? { OnChainHwService.appDefaultCoinType } + monitoredTypesProvider = monitoredTypes ?? { + Set(SettingsViewModel.shared.addressTypesToMonitor.map(\.stringValue)) + } + electrumUrlProvider = electrumUrl ?? { OnChainHwService.getElectrumUrl() } + self.persistActivities = persistActivities ?? { activities in + guard !activities.isEmpty else { return } + Task { + try? await ServiceQueue.background(.core) { + try BitkitCore.upsertActivities(activities: activities) + CoreService.shared.activity.notifyActivitiesChanged() + } + } + } + self.deleteActivities = deleteActivities ?? { walletId in + Task { + try? await ServiceQueue.background(.core) { + _ = try BitkitCore.deleteActivitiesByWalletId(walletId: walletId) + CoreService.shared.activity.notifyActivitiesChanged() + } + } + } + } + + // MARK: - Device input + + /// Update the device snapshot and reconcile watchers. This is the manager's sole input: the + /// composition root (`AppScene`) feeds it the current Trezor device list, so this type stays + /// fully decoupled from `TrezorManager`. Also the test seam — tests drive it directly. + func updateDevices(knownDevices: [TrezorKnownDevice], connectedDeviceId: String?) { + let previousWalletIds = hwWalletIds + self.knownDevices = knownDevices + self.connectedDeviceId = connectedDeviceId + walletsLoaded = true + syncWatchers() + + // A device that dropped out of the snapshot (e.g. the user forgot it) would otherwise + // leave its watch-only activities orphaned in the merged activity list, which queries + // every wallet id. syncWatchers already stopped its watcher above; delete its persisted + // activities too. Cleans up on any removal path, keeping us decoupled from TrezorManager. + for walletId in previousWalletIds.subtracting(hwWalletIds) { + deleteActivities(walletId) + } + pruneCaches() + } + + // MARK: - Control + + /// Stop watching a paired hardware wallet and delete its stored activities. The caller is + /// responsible for forgetting the device entries (via `TrezorManager.forgetDevice`); the next + /// `updateDevices(...)` push then drops it from the tile list. + func removeDevice(id deviceId: String) { + let group = deviceGroups().first { $0.ids.contains(deviceId) } + let ids = group?.ids ?? [deviceId] + for watcherId in activeWatchers where ids.contains(self.deviceId(fromWatcherId: watcherId)) { + _ = stopActiveWatcher(watcherId) + } + if let group { + deleteActivities(group.walletId) + lastPersisted[group.walletId] = nil + } + if let device = knownDevices.first(where: { $0.id == deviceId }) { + walletIdCache[xpubsSignature(device.xpubs)] = nil + } + recomputeDerivedState() + } + + // MARK: - Watcher orchestration + + /// Reconcile watchers in response to a settings change, but only when the monitored address + /// types or the Electrum URL actually changed — `settingsPublisher` fires for every setting + /// (theme, currency, …), and a full `syncWatchers()` re-derives each device's wallet id over + /// the FFI, so we skip the work when nothing watcher-relevant moved. + func reconcileForSettingsChange() { + let monitored = monitoredTypesProvider() + let electrumUrl = electrumUrlProvider() + guard monitored != lastSyncedMonitored || electrumUrl != lastSyncedElectrumUrl else { return } + lastSyncedMonitored = monitored + lastSyncedElectrumUrl = electrumUrl + syncWatchers() + } + + func syncWatchers() { + let specs = desiredWatcherSpecs() + let desiredIds = Set(specs.map(\.watcherId)) + + for spec in specs { + // A start is already in flight for this watcher; skip so we don't launch a duplicate. + // The next sync after it completes reconciles any electrum-url change. + if pendingWatcherStarts.contains(spec.watcherId) { continue } + let isActive = activeWatchers.contains(spec.watcherId) + if isActive, + activeWatcherElectrumUrls[spec.watcherId] == spec.electrumUrl, + activeWatcherXpubs[spec.watcherId] == spec.xpub { continue } + if isActive, !stopActiveWatcher(spec.watcherId) { continue } + startWatcher(spec) + } + + // A failed stop stays active so the next sync retries it; dropping it here would leave the + // orphaned watcher feeding watcherData as a ghost balance. + for staleId in activeWatchers.subtracting(desiredIds) { + _ = stopActiveWatcher(staleId) + } + + // Stopping a stale watcher clears its cached balance/activities; recompute so the published + // totals reflect it immediately (a started watcher recomputes again on its first event). + recomputeDerivedState() + } + + /// Build the watcher specs the current device/settings snapshot wants running: one per + /// (device, monitored address type), deduped by (addressType, xpub) and scoped to the + /// device's derived wallet id (devices without xpubs are skipped). + private func desiredWatcherSpecs() -> [WatcherSpec] { + let monitored = monitoredTypesProvider() + let electrumUrl = electrumUrlProvider() + + var seen = Set() + var specs: [WatcherSpec] = [] + for device in knownDevices { + guard let walletId = walletId(for: device.xpubs) else { continue } + for (addressType, xpub) in device.xpubs where monitored.contains(addressType) { + guard seen.insert(dedupKey(addressType: addressType, xpub: xpub)).inserted else { continue } + specs.append(WatcherSpec(deviceId: device.id, walletId: walletId, addressType: addressType, xpub: xpub, electrumUrl: electrumUrl)) + } + } + return specs + } + + /// Identity for deduping watchers across devices that share an (addressType, xpub). Uses a + /// control-character separator that can't appear in either component. + private func dedupKey(addressType: String, xpub: String) -> String { + "\(addressType)\u{1}\(xpub)" + } + + /// Derive (and memoize) the wallet id for a device's xpubs. Returns nil when derivation fails + /// (e.g. no captured xpubs — `HwWalletId.derive` throws on empty), so callers skip the device. + private func walletId(for xpubs: [String: String]) -> String? { + let signature = xpubsSignature(xpubs) + if let cached = walletIdCache[signature] { return cached } + guard let derived = try? HwWalletId.derive(xpubs: xpubs) else { return nil } + walletIdCache[signature] = derived + return derived + } + + private func xpubsSignature(_ xpubs: [String: String]) -> String { + xpubs.sorted { $0.key < $1.key } + .map { dedupKey(addressType: $0.key, xpub: $0.value) } + .joined(separator: "\u{1f}") + } + + /// Drop cache entries for devices no longer in the snapshot, so the caches stay bounded to + /// live devices. + private func pruneCaches() { + let liveSignatures = Set(knownDevices.filter { !$0.xpubs.isEmpty }.map { xpubsSignature($0.xpubs) }) + walletIdCache = walletIdCache.filter { liveSignatures.contains($0.key) } + lastPersisted = lastPersisted.filter { hwWalletIds.contains($0.key) } + } + + private func startWatcher(_ spec: WatcherSpec) { + guard let addressType = AddressScriptType.from(string: spec.addressType) else { return } + let network = networkProvider() + let params = WatcherParams( + watcherId: spec.watcherId, + walletId: spec.walletId, + extendedKey: spec.xpub, + electrumUrl: spec.electrumUrl, + network: network.coreNetwork, + accountType: addressType.accountType, + gapLimit: Constants.defaultGapLimit + ) + let listener = TrezorEventListener { [weak self] id, event in + self?.handleWatcherEvent(watcherId: id, event: event) + } + listeners[spec.watcherId] = listener + pendingWatcherStarts.insert(spec.watcherId) + + Task { @MainActor in + do { + try await watcherService.startWatcher(params: params, listener: listener) + pendingWatcherStarts.remove(spec.watcherId) + activeWatchers.insert(spec.watcherId) + activeWatcherElectrumUrls[spec.watcherId] = spec.electrumUrl + activeWatcherXpubs[spec.watcherId] = spec.xpub + retryingWatcherStarts.remove(spec.watcherId) + syncWatchers() + } catch { + pendingWatcherStarts.remove(spec.watcherId) + listeners[spec.watcherId] = nil + Logger.warn("Retrying hardware watcher '\(spec.watcherId)' after start failure: \(error)") + scheduleWatcherStartRetry(spec.watcherId) + } + } + } + + @discardableResult + private func stopActiveWatcher(_ watcherId: String) -> Bool { + do { + try watcherService.stopWatcher(watcherId: watcherId) + activeWatchers.remove(watcherId) + activeWatcherElectrumUrls[watcherId] = nil + activeWatcherXpubs[watcherId] = nil + watcherData[watcherId] = nil + listeners[watcherId] = nil + return true + } catch { + return false + } + } + + private func scheduleWatcherStartRetry(_ watcherId: String) { + guard retryingWatcherStarts.insert(watcherId).inserted else { return } + Task { @MainActor in + try? await Task.sleep(for: Constants.watcherStartRetryDelay) + retryingWatcherStarts.remove(watcherId) + syncWatchers() + } + } + + // MARK: - Watcher events + + /// 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 device. + func handleWatcherEvent(watcherId: String, event: WatcherEvent) { + guard case let .transactionsChanged(activities, _, balance, _, _, _) = event else { return } + let deviceId = deviceId(fromWatcherId: watcherId) + let previous = watcherData[watcherId] + watcherData[watcherId] = HwWatcherData( + deviceId: deviceId, + balanceSats: balance.total, + activities: activities + ) + let groups = deviceGroups() + recomputeDerivedState(groups: groups) + persistGroupActivities(forDevice: deviceId, groups: groups) + emitReceivedTxs(previous: previous, activities: activities) + } + + private func emitReceivedTxs(previous: HwWatcherData?, activities: [Activity]) { + guard let previous else { return } + let knownTxIds = onchainTxIds(in: previous.activities) + for activity in activities { + guard case let .onchain(onchain) = activity, onchain.txType == .received else { continue } + guard !knownTxIds.contains(onchain.txId) else { continue } + guard emittedReceivedTxIds.insert(onchain.txId).inserted else { continue } + receivedTxPublisher.send(HwWalletReceivedTx(txid: onchain.txId, sats: onchain.value)) + } + } + + private func onchainTxIds(in activities: [Activity]) -> Set { + Set(activities.compactMap { activity in + guard case let .onchain(onchain) = activity else { return nil } + return onchain.txId + }) + } + + // MARK: - Persistence + + private func persistGroupActivities(forDevice deviceId: String, groups: [DeviceGroup]? = nil) { + let groups = groups ?? deviceGroups() + guard let group = groups.first(where: { $0.ids.contains(deviceId) }) else { return } + let merged = mergedActivities(for: group) + // Skip the core upsert + activity-list reload when nothing changed for this group. + guard lastPersisted[group.walletId] != merged else { return } + lastPersisted[group.walletId] = merged + persistActivities(merged) + } + + /// Aggregate the activities core emitted across a device-group's watchers, scoping each to the + /// group's wallet id and deduping by activity id (so the same tx seen by two address-type + /// watchers persists once). Watchers are walked in sorted `watcherId` order and the result is + /// sorted by activity id, so a tx observed by multiple address-type watchers (which can carry + /// different wallet-perspective directions) resolves to a deterministic winner — the + /// highest-ordered watcherId — rather than depending on dictionary iteration order. + private func mergedActivities(for group: DeviceGroup) -> [Activity] { + let watchers = watcherData + .filter { group.ids.contains($0.value.deviceId) } + .sorted { $0.key < $1.key } + .map(\.value) + var byId: [String: Activity] = [:] + for activity in watchers.flatMap(\.activities) { + let scoped = scopedToWallet(activity, walletId: group.walletId) + byId[activityId(of: scoped)] = scoped + } + return byId.values.sorted { activityId(of: $0) < activityId(of: $1) } + } + + private func scopedToWallet(_ activity: Activity, walletId: String) -> Activity { + switch activity { + case var .onchain(onchain): + onchain.walletId = walletId + return .onchain(onchain) + case var .lightning(lightning): + lightning.walletId = walletId + return .lightning(lightning) + } + } + + private func activityId(of activity: Activity) -> String { + switch activity { + case let .onchain(onchain): return onchain.id + case let .lightning(lightning): return lightning.id + } + } + + // MARK: - Aggregation + + private func recomputeDerivedState(groups: [DeviceGroup]? = nil) { + let groups = groups ?? deviceGroups() + + wallets = groups.map { group in + let connectedDevice = group.devices.first { $0.id == connectedDeviceId } + let device = connectedDevice ?? group.representative + let deviceWatchers = watcherData.values.filter { group.ids.contains($0.deviceId) } + return HwWallet( + id: device.id, + walletId: group.walletId, + name: device.displayName, + model: device.model, + isConnected: connectedDevice != nil, + balanceSats: deviceWatchers.reduce(UInt64(0)) { $0.saturatingAdd($1.balanceSats) }, + deviceIds: group.ids + ) + } + + totalSats = wallets.reduce(UInt64(0)) { $0.saturatingAdd($1.balanceSats) } + hwWalletIds = Set(groups.map(\.walletId)) + } + + /// Group device entries sharing an xpub identity (same physical device over different + /// transports), preserving first-seen order. Entries without captured xpubs are skipped. + private func deviceGroups() -> [DeviceGroup] { + var order: [String] = [] + var grouped: [String: [TrezorKnownDevice]] = [:] + for device in knownDevices where !device.xpubs.isEmpty { + guard let walletId = walletId(for: device.xpubs) else { continue } + if grouped[walletId] == nil { order.append(walletId) } + grouped[walletId, default: []].append(device) + } + return order.compactMap { walletId in + guard let devices = grouped[walletId] else { return nil } + return DeviceGroup(walletId: walletId, devices: devices) + } + } + + // MARK: - Helpers + + private func deviceId(fromWatcherId watcherId: String) -> String { + guard let range = watcherId.range(of: Constants.watcherIdSeparator) else { return watcherId } + return String(watcherId[.. { + Set(devices.map(\.id)) + } + + var representative: TrezorKnownDevice { + devices.max(by: { $0.lastConnectedAt < $1.lastConnectedAt }) ?? devices[0] + } + } + + private struct HwWatcherData { + let deviceId: String + let balanceSats: UInt64 + let activities: [Activity] + } +} diff --git a/Bitkit/Managers/TrezorManager.swift b/Bitkit/Managers/TrezorManager.swift new file mode 100644 index 000000000..50f9451a5 --- /dev/null +++ b/Bitkit/Managers/TrezorManager.swift @@ -0,0 +1,675 @@ +import BitkitCore +import Combine +import CoreBluetooth +import Foundation + +/// Device/connection orchestration and pairing/PIN/passphrase coordination for the Trezor +/// hardware wallet. Owns the device list, connect/disconnect lifecycle, known-device storage, +/// auto-reconnect, network selection, and the UI dialog state for PIN/passphrase/pairing flows. +/// +/// Split out of `TrezorViewModel` so production managers (e.g. `HwWalletManager`) depend on a +/// manager rather than a dev-screen ViewModel, keeping dependencies pointing Manager→Manager→Service. +@Observable +@MainActor +final class TrezorManager { + // MARK: - Network Configuration + + /// Independent of the app's global network — scoped to the Trezor dashboard. + var selectedNetwork: TrezorCoinType + + /// BIP44 coin type component based on the dashboard's selected network: "0'" for mainnet, "1'" for test networks + var coinTypeComponent: String { + selectedNetwork == .bitcoin ? "0'" : "1'" + } + + // MARK: - Connection State + + private var isInitialized: Bool = false + + var isScanning: Bool = false + + var devices: [TrezorDeviceInfo] = [] + + var connectedDevice: TrezorDeviceInfo? { + didSet { devicesRevision &+= 1 } + } + + /// Bumped whenever the device list or connection state changes, so observers (e.g. the + /// composition root that feeds `HwWalletManager`) can react without those types coupling. + private(set) var devicesRevision: Int = 0 + + var deviceFeatures: TrezorFeatures? + + var deviceFingerprint: String? + + var error: String? + + // MARK: - UI Dialog State + + var showPinEntry: Bool = false + + var showPassphraseEntry: Bool = false + + var showPairingCode: Bool = false + + var showConfirmOnDevice: Bool = false + + var confirmMessage: String = "" + + /// Only presented for devices that report on-device passphrase entry capability. + var showWalletModeChooser: Bool = false + + // MARK: - Wallet Mode State + + /// The binding to the device session is applied via setWalletMode (disconnect/reconnect), + /// not by mutating this property directly. + var walletMode: TrezorWalletMode = .standard + + var passphraseEntryCapable: Bool { + deviceFeatures?.passphraseEntryCapable == true + } + + // MARK: - Known Devices & Auto-Reconnect + + var knownDevices: [TrezorKnownDevice] = [] { + didSet { devicesRevision &+= 1 } + } + + var isAutoReconnecting: Bool = false + + var autoReconnectStatus: String? + + /// Prevents a user-initiated disconnect from immediately reconnecting + /// when the disconnected device list appears. + private var suppressNextAutoReconnect = false + + // MARK: - Bluetooth State + + /// Reads directly from BLEManager (@Observable chaining). + var bluetoothState: CBManagerState { + TrezorBLEManager.shared.bluetoothState + } + + var isBridgeModeEnabled: Bool { + transport.isBridgeEnabled + } + + // MARK: - Private Properties + + private let trezorService = TrezorService.shared + private let transport = TrezorTransport.shared + private let uiHandler = TrezorUiHandler.shared + private var cancellables = Set() + private var hasSetupSubscriptions = false + + // MARK: - Initialization + + init() { + selectedNetwork = OnChainHwService.appDefaultCoinType + // Callback subscriptions are deferred to setup() to avoid + // triggering BLE stack and Combine overhead at app launch. + } + + private func setupCallbackSubscriptions() { + transport.needsPairingCodePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] in + self?.showPairingCode = true + } + .store(in: &cancellables) + + uiHandler.needsPinPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] in + self?.showPinEntry = true + } + .store(in: &cancellables) + + // Passphrase entry is now driven proactively by the wallet-mode selector + // (see setWalletMode / requestPassphraseWallet). The device callback + // `onPassphraseRequest` is answered silently from the selected mode, so there + // is no reactive passphrase prompt to subscribe to here. + } + + // MARK: - Debug Log Helper + + private func trezorLog(_ message: String, level: String = "info") { + switch level { + case "error": + Logger.error(message, context: "TrezorManager") + case "warn": + Logger.warn(message, context: "TrezorManager") + default: + Logger.info(message, context: "TrezorManager") + } + TrezorDebugLog.shared.log(message) + } + + // MARK: - State Reset Helpers + + func clearWalletDerivedState() { + deviceFingerprint = nil + } + + func clearDisconnectedDeviceState(errorMessage: String? = nil) { + connectedDevice = nil + deviceFeatures = nil + clearWalletDerivedState() + error = errorMessage + showPinEntry = false + showPassphraseEntry = false + showConfirmOnDevice = false + showWalletModeChooser = false + uiHandler.setWalletMode(.standard) + walletMode = .standard + } + + // MARK: - Manager Setup + + /// Synchronous, non-blocking. Called from TrezorRootView's .task to prepare the UI layer. + func setup() { + guard !hasSetupSubscriptions else { return } + if !transport.isBridgeEnabled { + // Start BLE stack early so bluetoothState is updated by the time + // TrezorDeviceListView renders (the delegate callback fires async). + TrezorBLEManager.shared.ensureStarted() + } + setupCallbackSubscriptions() + hasSetupSubscriptions = true + } + + /// Async and potentially slow. Called lazily before first scan/connect. + func initialize() async { + setup() + + guard !isInitialized else { return } + + do { + try await trezorService.initialize() + isInitialized = true + error = nil + trezorLog("TrezorManager initialized") + } catch { + self.error = errorMessage(from: error) + trezorLog("Failed to initialize Trezor: \(error)", level: "error") + } + } + + // MARK: - Device Scanning + + func startScan(clearExisting: Bool = true) async { + if !isInitialized { + await initialize() + } + + isScanning = true + error = nil + + if clearExisting { + devices = [] + } + + if !transport.isBridgeEnabled { + transport.startBLEScanning() + + // Wait for BLE to discover devices (like Android's 3-second scan) before + // calling the FFI enumerate, then stop scanning to prevent race conditions. + try? await Task.sleep(nanoseconds: 3_000_000_000) + + transport.stopBLEScanning() + } + + do { + let foundDevices = try await trezorService.scan() + + // Deduplicate by path (in case of duplicate scan results) + var seenPaths = Set() + let uniqueDevices = foundDevices.filter { device in + if seenPaths.contains(device.path) { + return false + } + seenPaths.insert(device.path) + return true + } + + devices = uniqueDevices + trezorLog("Found \(uniqueDevices.count) Trezor devices (filtered from \(foundDevices.count))") + } catch { + self.error = errorMessage(from: error) + trezorLog("Scan failed: \(error)", level: "error") + } + + isScanning = false + } + + func stopScan() { + transport.stopBLEScanning() + isScanning = false + } + + // MARK: - Connection + + func connect(device: TrezorDeviceInfo) async { + error = nil + suppressNextAutoReconnect = false + + // Explicit user-initiated connect always opens the standard wallet — a + // passphrase/on-device selection left over from a previously connected device + // must not silently apply to a newly selected one. + uiHandler.setWalletMode(.standard) + walletMode = .standard + + trezorLog("=== Connecting to device: \(device.path) ===") + + do { + let features = try await trezorService.connect(deviceId: device.path, selection: uiHandler.currentSelection()) + connectedDevice = device + deviceFeatures = features + showConfirmOnDevice = false + + let savedComplete = await saveCurrentDeviceAsKnown() + if savedComplete { + trezorLog("Connected to Trezor: \(device.path)") + } else { + trezorLog("Connected to Trezor: \(device.path) with incomplete account-key capture", level: "warn") + } + } catch { + let errorMsg = errorMessage(from: error) + self.error = errorMsg + showConfirmOnDevice = false + trezorLog("Connection failed: \(error)", level: "error") + } + } + + func disconnect() async { + guard connectedDevice != nil else { return } + suppressNextAutoReconnect = true + + // NOTE: the event watcher is intentionally NOT stopped here. It subscribes to + // Electrum directly and does not require a connected device, so it survives a + // disconnect and remains controllable from the device-list screen. It is only + // torn down on a network switch (different Electrum server) or via stopWatcher(). + + do { + try await trezorService.disconnect() + // Clear connection state but preserve device list for quick reconnection + clearDisconnectedDeviceState() + + trezorLog("Disconnected from Trezor") + } catch { + // Even if disconnect fails, clear local state + clearDisconnectedDeviceState(errorMessage: errorMessage(from: error)) + trezorLog("Disconnect failed: \(error)", level: "error") + } + } + + var isConnected: Bool { + connectedDevice != nil + } + + // MARK: - UI Callbacks + + func submitPin(_ pin: String) { + showPinEntry = false + uiHandler.submitPin(pin) + } + + func cancelPin() { + showPinEntry = false + uiHandler.cancelPin() + } + + /// Opens the corresponding hidden wallet (or the standard wallet when empty) by resetting the session. + func submitPassphrase(_ passphrase: String) async { + showPassphraseEntry = false + showConfirmOnDevice = false + await setWalletMode(passphrase.isEmpty ? .standard : .passphraseHost, passphrase: passphrase) + } + + func cancelPassphrase() { + showPassphraseEntry = false + showConfirmOnDevice = false + showWalletModeChooser = false + } + + // MARK: - Wallet Mode Selection + + func selectStandardWallet() async { + guard walletMode != .standard else { return } + await setWalletMode(.standard) + } + + /// On a capable device this offers a choice of where to enter the passphrase; + /// otherwise it goes straight to host entry. + func requestPassphraseWallet() { + if passphraseEntryCapable { + showWalletModeChooser = true + } else { + showPassphraseEntry = true + } + } + + func choosePhonePassphraseEntry() { + showWalletModeChooser = false + showPassphraseEntry = true + } + + func chooseDevicePassphraseEntry() async { + showWalletModeChooser = false + await setWalletMode(.passphraseDevice) + } + + /// Switch between wallet modes. The Trezor caches the passphrase for the whole + /// session, so switching requires a fresh session: this records the desired mode, + /// then disconnects and reconnects by path. Mirrors bitkit-android's setWalletMode. + func setWalletMode(_ mode: TrezorWalletMode, passphrase: String = "") async { + guard let device = connectedDevice else { + error = "Not connected to a Trezor" + return + } + + error = nil + trezorLog("=== Switching wallet mode to \(mode); resetting session ===") + + // Reset the session. We call the service directly (not the manager's disconnect()) + // so connectedDevice/deviceFeatures stay populated for the reconnect. + do { + try await trezorService.disconnect() + } catch { + trezorLog("Disconnect before wallet-mode switch failed: \(error)", level: "warn") + } + + // Results derived from the previous wallet are no longer valid once the + // session has been reset for a different wallet mode. + clearWalletDerivedState() + + // Brief settle delay before reconnecting (matches Android's reconnect delay). + try? await Task.sleep(nanoseconds: 300_000_000) + + // Record the selection AFTER the disconnect so it survives into the new session. + // THP reads it via currentSelection() to bind the passphrase at session creation; + // non-THP devices re-request it mid-operation and are answered from the same value. + uiHandler.setWalletMode(mode, hostPassphrase: passphrase) + walletMode = mode + + do { + let features = try await trezorService.connect(deviceId: device.path, selection: uiHandler.currentSelection()) + connectedDevice = device + deviceFeatures = features + showConfirmOnDevice = false + trezorLog("Reconnected with wallet mode \(mode)") + + await saveCurrentDeviceAsKnown() + } catch { + clearDisconnectedDeviceState(errorMessage: errorMessage(from: error)) + trezorLog("Reconnect after wallet-mode switch failed: \(error)", level: "error") + } + } + + func submitPairingCode(_ code: String) { + showPairingCode = false + transport.submitPairingCode(code) + } + + func cancelPairingCode() { + showPairingCode = false + transport.cancelPairingCode() + } + + func dismissConfirmOnDevice() { + showConfirmOnDevice = false + confirmMessage = "" + } + + // MARK: - Known Devices + + func loadKnownDevices() { + knownDevices = TrezorKnownDeviceStorage.loadAll() + } + + /// Captures the connected device's account xpubs so watch-only balances/activity stay available + /// while disconnected. The watch-only wallet id is derived from the captured xpub set, so a save + /// is blocked only when an address type failed *transiently* (a retryable transport error) and + /// isn't already covered by a previous capture — saving then would start a watcher under an id + /// that changes once that type is read on a later connect. A type the device genuinely lacks + /// (e.g. unsupported taproot) is accepted: its absence is stable across reconnects, so it neither + /// blocks the device nor churns the id. Merging keeps previously-captured xpubs. + /// Returns whether the device was saved. + @discardableResult + func saveCurrentDeviceAsKnown() async -> Bool { + guard let device = connectedDevice else { return false } + let previous = TrezorKnownDeviceStorage.loadAll().first { $0.id == device.id } + let (fetched, transientFailures) = await fetchAccountXpubs() + let mergedXpubs = (previous?.xpubs ?? [:]).merging(fetched) { _, new in new } + + guard !mergedXpubs.isEmpty else { + trezorLog("No account xpubs could be read from device; not saving", level: "warn") + error = "Couldn't read any account keys from your Trezor. Please reconnect to try again." + return false + } + + let retryableGaps = transientFailures.filter { mergedXpubs[$0.stringValue] == nil } + guard retryableGaps.isEmpty else { + let names = retryableGaps.map(\.localizedTitle).sorted().joined(separator: ", ") + trezorLog("Incomplete xpub capture (transient failures: \(names)); not saving partial device", level: "warn") + error = "Couldn't read all account keys from your Trezor (\(names)). Please reconnect to try again." + return false + } + + let known = TrezorKnownDevice( + id: device.id, + name: device.name ?? "Trezor", + path: device.path, + transportType: device.transportType == .bluetooth ? "bluetooth" : "usb", + label: device.label ?? deviceFeatures?.label, + model: device.model ?? deviceFeatures?.model, + lastConnectedAt: Date(), + xpubs: mergedXpubs + ) + TrezorKnownDeviceStorage.save(known) + loadKnownDevices() + trezorLog("Saved known device: \(known.name) with \(mergedXpubs.count) xpubs") + return true + } + + private static let maxXpubFetchAttempts = 3 + private static let xpubFetchRetryDelayNanos: UInt64 = 300_000_000 + + /// Markers (matched against the underlying `TrezorError` carried in the wrapped error's text) + /// for transient transport problems worth retrying. Anything else is treated as the address + /// type being genuinely unavailable on this device — e.g. taproot on firmware without BIP86 — + /// which must not block the device, since that absence is stable across reconnects. + private static let transientFailureMarkers = [ + "TransportError", "ConnectionError", "DeviceDisconnected", "DeviceBusy", "Timeout", "IoError", "SessionError", + ] + + private static func isTransientTransportFailure(_ error: Error) -> Bool { + let text = (error as? AppError)?.debugMessage ?? "\(error)" + return transientFailureMarkers.contains { text.contains($0) } + } + + /// Reads one account xpub per address type. Transient transport failures (e.g. a BLE timeout + /// under load) are retried; a permanent rejection (an unsupported address type) is not. Returns + /// the captured xpubs and the address types that still failed *transiently* after all retries, + /// so the caller can block a save on those while accepting a device that merely lacks a type. + func fetchAccountXpubs() async -> (xpubs: [String: String], transientFailures: Set) { + let coinType = selectedNetwork == .bitcoin ? "0" : "1" + var result: [String: String] = [:] + var transientFailures: Set = [] + for addressType in AddressScriptType.allAddressTypes { + let params = TrezorGetPublicKeyParams( + path: addressType.accountDerivationPath(coinType: coinType), + coin: selectedNetwork, + showOnTrezor: false + ) + var lastError: Error? + for attempt in 1 ... Self.maxXpubFetchAttempts { + do { + result[addressType.stringValue] = try await trezorService.getPublicKey(params: params).xpub + lastError = nil + break + } catch { + lastError = error + trezorLog( + "Could not read xpub for '\(addressType.stringValue)' (attempt \(attempt)/\(Self.maxXpubFetchAttempts)): \(error)", + level: "warn" + ) + guard Self.isTransientTransportFailure(error) else { break } + if attempt < Self.maxXpubFetchAttempts { + try? await Task.sleep(nanoseconds: Self.xpubFetchRetryDelayNanos) + } + } + } + if let lastError, Self.isTransientTransportFailure(lastError) { + transientFailures.insert(addressType) + } + } + return (result, transientFailures) + } + + func forgetDevice(id: String) async { + if let device = knownDevices.first(where: { $0.id == id }) { + do { + try await trezorService.clearCredentials(deviceId: device.path) + } catch { + trezorLog("Failed to clear credentials for forgotten device: \(error)", level: "warn") + } + TrezorCredentialStorage.delete(deviceId: device.path) + } + TrezorKnownDeviceStorage.remove(id: id) + loadKnownDevices() + trezorLog("Forgot device: \(id)") + } + + // MARK: - Auto-Reconnect + + func autoReconnect() async { + guard !knownDevices.isEmpty else { return } + guard !isAutoReconnecting else { return } + guard connectedDevice == nil else { + trezorLog("Auto-reconnect: skipped, device already connected") + return + } + if suppressNextAutoReconnect { + suppressNextAutoReconnect = false + trezorLog("Auto-reconnect: skipped after manual disconnect") + return + } + + isAutoReconnecting = true + autoReconnectStatus = "Scanning for known devices..." + trezorLog("Auto-reconnect: starting scan") + + await startScan(clearExisting: true) + + let knownIds = Set(knownDevices.map(\.id)) + if let match = devices.first(where: { knownIds.contains($0.id) }) { + autoReconnectStatus = "Connecting to \(match.label ?? match.name ?? "Trezor")..." + trezorLog("Auto-reconnect: found known device \(match.path)") + await connect(device: match) + } else { + autoReconnectStatus = nil + trezorLog("Auto-reconnect: no known devices found nearby") + } + + isAutoReconnecting = false + autoReconnectStatus = nil + } + + // MARK: - Network Switching + + /// Switches the dashboard's network independently of the app's global network. + func setSelectedNetwork(_ network: TrezorCoinType) { + guard network != selectedNetwork else { return } + selectedNetwork = network + error = nil + trezorLog("Switched dashboard network to \(network)") + } + + // MARK: - Credential Management + + func clearCredentials() async { + guard let device = connectedDevice else { + error = "No device connected" + return + } + + do { + try await trezorService.clearCredentials(deviceId: device.path) + trezorLog("Cleared credentials for \(device.path)") + } catch { + self.error = errorMessage(from: error) + trezorLog("Failed to clear credentials: \(error)", level: "error") + } + } + + // MARK: - Error Handling + + private func errorMessage(from error: Error) -> String { + // ServiceQueue wraps all errors in AppError, so extract the original message + if let appError = error as? AppError { + // debugMessage contains the original error's localizedDescription + if let debugMessage = appError.debugMessage, !debugMessage.isEmpty { + return formatTrezorErrorMessage(debugMessage) + } + // Fall through to the app error message if no debug info + return appError.message + } + + if let trezorError = error as? TrezorError { + return trezorError.localizedDescription + } + + if let bleError = error as? TrezorBLEError { + return bleError.localizedDescription + } + + if let transportError = error as? TrezorTransportError { + return transportError.localizedDescription + } + + let description = error.localizedDescription + if description == "The operation couldn't be completed." || description.isEmpty { + return "Connection failed. Please ensure your Trezor is in pairing mode and try again." + } + return description + } + + private func formatTrezorErrorMessage(_ message: String) -> String { + let cleanedMessage = message + .replacingOccurrences(of: "Transport error: ", with: "") + .replacingOccurrences(of: "Connection error: ", with: "") + .replacingOccurrences(of: "Protocol error: ", with: "") + .replacingOccurrences(of: "Device error: ", with: "") + .replacingOccurrences(of: "Session error: ", with: "") + .replacingOccurrences(of: "IO error: ", with: "") + + if message.contains("Stale Bluetooth pairing") || message.contains("Peer removed pairing") { + return "Stale Bluetooth pairing detected. Go to iOS Settings → Bluetooth, forget your Trezor device, " + + "then put it back in pairing mode and try again." + } + if message.contains("Unable to open device") || message.contains("Failed to connect") { + return "Failed to connect to Trezor. Please ensure it's in pairing mode and try again." + } + if message.contains("Pairing required") { + return "Bluetooth pairing required. Please put your Trezor in pairing mode." + } + if message.contains("Pairing failed") || message.contains("Invalid credentials") { + return "Pairing failed. Please try putting your Trezor back in pairing mode." + } + if message.contains("THP handshake failed") { + return "Connection handshake failed. Please disconnect and try again." + } + if message.contains("timed out") || message.contains("Timeout") { + return "Connection timed out. Please try again." + } + if message.contains("Device disconnected") { + return "Trezor disconnected. Please reconnect and try again." + } + if message.contains("Action cancelled") { + return "Action was cancelled on the device." + } + + return cleanedMessage + } +} diff --git a/Bitkit/Models/HwWallet.swift b/Bitkit/Models/HwWallet.swift new file mode 100644 index 000000000..4fe3dba4d --- /dev/null +++ b/Bitkit/Models/HwWallet.swift @@ -0,0 +1,53 @@ +import BitkitCore +import Foundation + +/// A paired hardware wallet tracked as a watch-only balance. +/// +/// Activities are NOT held here — they are persisted in bitkit-core scoped by `walletId` +/// and read back through the normal activity pipeline (see `HwWalletManager`). +struct HwWallet: Identifiable { + let id: String + /// bitkit-core wallet id scoping this device's activities (see `HwWalletId`). + let walletId: String + let name: String + let model: String? + let isConnected: Bool + let balanceSats: UInt64 + let deviceIds: Set + + init( + id: String, + walletId: String, + name: String, + model: String?, + isConnected: Bool, + balanceSats: UInt64, + deviceIds: Set? = nil + ) { + self.id = id + self.walletId = walletId + self.name = name + self.model = model + self.isConnected = isConnected + self.balanceSats = balanceSats + self.deviceIds = deviceIds ?? [id] + } +} + +/// Per-device balance snapshot folded into the headline total via `BalanceState`. +struct HwWalletBalance: Codable, Equatable, Identifiable { + let id: String + let sats: UInt64 +} + +/// A newly detected inbound transaction to a watched hardware wallet. +struct HwWalletReceivedTx: Equatable { + let txid: String + let sats: UInt64 +} + +extension HwWallet { + var toBalance: HwWalletBalance { + HwWalletBalance(id: id, sats: balanceSats) + } +} diff --git a/Bitkit/Models/HwWalletId.swift b/Bitkit/Models/HwWalletId.swift new file mode 100644 index 000000000..0f1c45cf8 --- /dev/null +++ b/Bitkit/Models/HwWalletId.swift @@ -0,0 +1,14 @@ +import BitkitCore +import Foundation + +/// Derives a stable, cross-platform wallet id for a paired hardware wallet, used to scope +/// its activities in bitkit-core's wallet-scoped storage. Delegates to bitkit-core's +/// `deriveWalletId` (the canonical cross-platform derivation, finalized in core 0.3.4) so +/// iOS and Android produce identical ids for the same device. +enum HwWalletId { + /// Deterministic id derived from the device's account xpubs (transport-independent: the + /// same physical device shares its xpubs, hence its id). Throws if `xpubs` is empty. + static func derive(xpubs: [String: String], deviceType: String = "trezor") throws -> String { + try deriveWalletId(deviceType: deviceType, xpubs: Array(xpubs.values)) + } +} diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 9b7951617..234181839 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -31,6 +31,13 @@ "cards__support__description" = "Get assistance"; "cards__hardware__title" = "Hardware"; "cards__hardware__description" = "Connect device"; +"hardware__connection_badge_connected_bluetooth" = "Connected via Bluetooth"; +"hardware__connection_badge_disconnected_bluetooth" = "Disconnected via Bluetooth"; +"hardware__intro_title" = "Hardware Wallet"; +"hardware__intro_header" = "Add your hardware wallet"; +"hardware__intro_text" = "Connect your hardware device to watch or manage your long-term funds."; +"hardware__pairing_title" = "Pair Device"; +"hardware__pairing_text" = "Enter the 6-digit code shown on your hardware device."; "cards__buyBitcoin__title" = "Buy"; "cards__buyBitcoin__description" = "Buy some bitcoin"; "cards__btFailed__title" = "Failed"; diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index 50261c3d5..6cb6e9994 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -3,6 +3,18 @@ import Combine import Foundation import LDKNode +/// Wallet scoping for bitkit-core's wallet-scoped activity storage (added in core 0.3.x). +/// The app's normal on-chain/Lightning wallet uses the core default (`"bitkit"`); paired +/// hardware wallets use their own derived id (see `HwWalletId`). +/// +/// Defined here (rather than its own file) because `CoreService.swift` is shared with the +/// notification and widget extension targets, so the type must live in a file those targets +/// already compile. +enum WalletScope { + /// The default Bitkit wallet id (`DEFAULT_WALLET_ID` in bitkit-core). + static let `default`: String = getDefaultWalletId() +} + // MARK: - Local Types (removed from BitkitCore in Trezor module rewrite) /// Address info with usage data @@ -30,6 +42,12 @@ class ActivityService { activitiesChangedSubject.eraseToAnyPublisher() } + /// Notify observers that activities changed after a write made directly through BitkitCore + /// (bypassing this service), e.g. hardware-wallet watcher persistence. + func notifyActivitiesChanged() { + activitiesChangedSubject.send() + } + private let metadataChangedSubject = PassthroughSubject() var metadataChangedPublisher: AnyPublisher { @@ -110,6 +128,7 @@ class ActivityService { } return BitkitCore.TransactionDetails( + walletId: WalletScope.default, txId: txid, amountSats: details.amountSats, inputs: inputs, @@ -126,9 +145,9 @@ class ActivityService { } } - func getTransactionDetails(txid: String) async throws -> BitkitCore.TransactionDetails? { + func getTransactionDetails(txid: String, walletId: String = WalletScope.default) async throws -> BitkitCore.TransactionDetails? { try await ServiceQueue.background(.core) { - try BitkitCore.getTransactionDetails(txId: txid) + try BitkitCore.getTransactionDetails(walletId: walletId, txId: txid) } } @@ -136,7 +155,7 @@ class ActivityService { func isActivitySeen(id: String) async -> Bool { do { - if let activity = try getActivityById(activityId: id) { + if let activity = try getActivityById(walletId: WalletScope.default, activityId: id) { switch activity { case let .onchain(onchain): return onchain.seenAt != nil @@ -160,7 +179,7 @@ class ActivityService { do { try await ServiceQueue.background(.core) { - try BitkitCore.markActivityAsSeen(activityId: id, seenAt: timestamp) + try BitkitCore.markActivityAsSeen(walletId: WalletScope.default, activityId: id, seenAt: timestamp) self.activitiesChangedSubject.send() } } catch { @@ -201,7 +220,9 @@ class ActivityService { if !isSeen { try await ServiceQueue.background(.core) { - try BitkitCore.markActivityAsSeen(activityId: id, seenAt: timestamp) + try BitkitCore.markActivityAsSeen( + walletId: WalletScope.default, activityId: id, seenAt: timestamp + ) } didMarkAny = true } @@ -318,7 +339,15 @@ class ActivityService { try await ServiceQueue.background(.core) { // Get all activities and delete them one by one let activities = try getActivities( - filter: .all, txType: nil, tags: nil, search: nil, minDate: nil, maxDate: nil, limit: nil, sortDirection: nil + walletId: WalletScope.default, + filter: .all, + txType: nil, + tags: nil, + search: nil, + minDate: nil, + maxDate: nil, + limit: nil, + sortDirection: nil ) for activity in activities { let id: String = switch activity { @@ -326,7 +355,7 @@ class ActivityService { case let .onchain(on): on.id } - _ = try deleteActivityById(activityId: id) + _ = try deleteActivityById(walletId: WalletScope.default, activityId: id) } // Clear cache since all activities are deleted @@ -347,6 +376,7 @@ class ActivityService { try await ServiceQueue.background(.core) { try upsertActivities(activities: activities) await self.refreshBoostTxIdsCache() + self.activitiesChangedSubject.send() } } @@ -379,9 +409,9 @@ class ActivityService { let paymentTimestamp = payment.latestUpdateTimestamp // Look for existing activity by id first, then by txid (for migrated activities) - var existingActivity = try getActivityById(activityId: payment.id) + var existingActivity = try getActivityById(walletId: WalletScope.default, activityId: payment.id) if existingActivity == nil { - existingActivity = try BitkitCore.getActivityByTxId(txId: txid).map { .onchain($0) } + existingActivity = try BitkitCore.getActivityByTxId(walletId: WalletScope.default, txId: txid).map { .onchain($0) } } // Determine if confirmation status is changing @@ -483,6 +513,7 @@ class ActivityService { }() let onchain = OnchainActivity( + walletId: WalletScope.default, id: payment.id, txType: payment.direction == .outbound ? .sent : .received, txId: txid, @@ -699,7 +730,7 @@ class ActivityService { guard !(payment.status == .pending && payment.direction == .inbound) else { return } let paymentTimestamp = UInt64(payment.latestUpdateTimestamp) - let existingActivity = try getActivityById(activityId: payment.id) + let existingActivity = try getActivityById(walletId: WalletScope.default, activityId: payment.id) let existingLightning: LightningActivity? = if let existingActivity, case let .lightning(ln) = existingActivity { ln } else { nil } let state: BitkitCore.PaymentState = switch payment.status { @@ -727,6 +758,7 @@ class ActivityService { } let ln = LightningActivity( + walletId: WalletScope.default, id: payment.id, txType: payment.direction == .outbound ? .sent : .received, status: state, @@ -770,7 +802,7 @@ class ActivityService { for payment in payments { if case let .onchain(txid, _) = payment.kind { do { - let hadExistingActivity = try getActivityById(activityId: payment.id) != nil + let hadExistingActivity = try getActivityById(walletId: WalletScope.default, activityId: payment.id) != nil try await self.processOnchainPayment(payment, transactionDetails: nil) if hadExistingActivity { updatedCount += 1 @@ -783,7 +815,7 @@ class ActivityService { } } else if case .bolt11 = payment.kind { do { - let hadExistingActivity = try getActivityById(activityId: payment.id) != nil + let hadExistingActivity = try getActivityById(walletId: WalletScope.default, activityId: payment.id) != nil try await self.processLightningPayment(payment) if hadExistingActivity { updatedCount += 1 @@ -809,9 +841,11 @@ class ActivityService { /// Marks replacement transactions (with originalTxId in boostTxIds) as doesExist = false when original confirms /// Finds the channel ID associated with a transaction based on its direction - private func findChannelForTransaction(txid: String, direction: PaymentDirection, - transactionDetails: BitkitCore.TransactionDetails? = nil) async -> String? - { + private func findChannelForTransaction( + txid: String, + direction: PaymentDirection, + transactionDetails: BitkitCore.TransactionDetails? = nil + ) async -> String? { switch direction { case .inbound: // Check if this transaction is a channel close by checking if it spends a closed channel's funding UTXO @@ -933,9 +967,11 @@ class ActivityService { } /// Find the receiving address for an onchain transaction - private func findReceivingAddress(for txid: String, value: UInt64, - transactionDetails: BitkitCore.TransactionDetails? = nil) async throws -> String? - { + private func findReceivingAddress( + for txid: String, + value: UInt64, + transactionDetails: BitkitCore.TransactionDetails? = nil + ) async throws -> String? { let details = if let provided = transactionDetails { provided } else { await fetchTransactionDetails(txid: txid) } guard let details else { Logger.warn("Transaction details not available for \(txid)", context: "CoreService.findReceivingAddress") @@ -961,15 +997,15 @@ class ActivityService { return details.outputs.first?.scriptpubkeyAddress } - func getActivity(id: String) async throws -> Activity? { + func getActivity(id: String, walletId: String = WalletScope.default) async throws -> Activity? { try await ServiceQueue.background(.core) { - try getActivityById(activityId: id) + try getActivityById(walletId: walletId, activityId: id) } } func getOnchainActivityByTxId(txid: String) async throws -> OnchainActivity? { try await ServiceQueue.background(.core) { - try BitkitCore.getActivityByTxId(txId: txid) + try BitkitCore.getActivityByTxId(walletId: WalletScope.default, txId: txid) } } @@ -991,6 +1027,9 @@ class ActivityService { } } + /// Fetch activities. `walletId` defaults to the normal Bitkit wallet; pass `nil` to query + /// every wallet globally (Bitkit + watch-only hardware wallets) for the merged Home / All + /// Activity lists. func get( filter: ActivityFilter? = nil, txType: PaymentType? = nil, @@ -999,10 +1038,12 @@ class ActivityService { minDate: UInt64? = nil, maxDate: UInt64? = nil, limit: UInt32? = nil, - sortDirection: SortDirection? = nil + sortDirection: SortDirection? = nil, + walletId: String? = WalletScope.default ) async throws -> [Activity] { try await ServiceQueue.background(.core) { try getActivities( + walletId: walletId, filter: filter, txType: txType, tags: tags, @@ -1065,12 +1106,13 @@ class ActivityService { ) async { do { try await ServiceQueue.background(.core) { - if let _ = try? BitkitCore.getActivityByTxId(txId: txid) { + if let _ = try? BitkitCore.getActivityByTxId(walletId: WalletScope.default, txId: txid) { Logger.debug("Activity already exists for txid \(txid), skipping immediate creation", context: "ActivityService") return } let now = UInt64(Date().timeIntervalSince1970) let onchain = OnchainActivity( + walletId: WalletScope.default, id: txid, txType: .sent, txId: txid, @@ -1106,7 +1148,10 @@ class ActivityService { let normalizedContact = publicKey.map { PubkyPublicKeyFormat.normalized($0) ?? $0 } try await ServiceQueue.background(.core) { - guard let activity = try getActivityById(activityId: id) ?? (try? BitkitCore.getActivityByTxId(txId: id)).map(Activity.onchain) else { + guard let activity = try getActivityById(walletId: WalletScope.default, activityId: id) ?? (try? BitkitCore.getActivityByTxId( + walletId: WalletScope.default, + txId: id + )).map(Activity.onchain) else { throw AppError(message: "Activity not found", debugMessage: "Activity with ID \(id) not found") } @@ -1138,6 +1183,7 @@ class ActivityService { guard !activity.doesExist, activity.txType == .sent else { return false } let activities = try getActivities( + walletId: WalletScope.default, filter: .onchain, txType: nil, tags: nil, @@ -1161,12 +1207,12 @@ class ActivityService { func delete(id: String) async throws -> Bool { try await ServiceQueue.background(.core) { // Rebuild cache if deleting an onchain activity with boostTxIds - let activity = try? getActivityById(activityId: id) + let activity = try? getActivityById(walletId: WalletScope.default, activityId: id) if let activity, case let .onchain(onchain) = activity, !onchain.boostTxIds.isEmpty { await self.refreshBoostTxIdsCache() } - let result = try deleteActivityById(activityId: id) + let result = try deleteActivityById(walletId: WalletScope.default, activityId: id) self.activitiesChangedSubject.send() return result } @@ -1174,23 +1220,23 @@ class ActivityService { // MARK: - Tag Methods - func appendTags(toActivity id: String, _ tags: [String]) async throws { + func appendTags(toActivity id: String, _ tags: [String], walletId: String = WalletScope.default) async throws { try await ServiceQueue.background(.core) { - try addTags(activityId: id, tags: tags) + try addTags(walletId: walletId, activityId: id, tags: tags) self.activitiesChangedSubject.send() } } - func dropTags(fromActivity id: String, _ tags: [String]) async throws { + func dropTags(fromActivity id: String, _ tags: [String], walletId: String = WalletScope.default) async throws { try await ServiceQueue.background(.core) { - try removeTags(activityId: id, tags: tags) + try removeTags(walletId: walletId, activityId: id, tags: tags) self.activitiesChangedSubject.send() } } - func tags(forActivity id: String) async throws -> [String] { + func tags(forActivity id: String, walletId: String = WalletScope.default) async throws -> [String] { try await ServiceQueue.background(.core) { - try getTags(activityId: id) + try getTags(walletId: walletId, activityId: id) } } @@ -1223,34 +1269,34 @@ class ActivityService { func addPreActivityMetadataTags(paymentId: String, tags: [String]) async throws { try await ServiceQueue.background(.core) { - try BitkitCore.addPreActivityMetadataTags(paymentId: paymentId, tags: tags) + try BitkitCore.addPreActivityMetadataTags(walletId: WalletScope.default, paymentId: paymentId, tags: tags) self.metadataChangedSubject.send() } } func removePreActivityMetadataTags(paymentId: String, tags: [String]) async throws { try await ServiceQueue.background(.core) { - try BitkitCore.removePreActivityMetadataTags(paymentId: paymentId, tags: tags) + try BitkitCore.removePreActivityMetadataTags(walletId: WalletScope.default, paymentId: paymentId, tags: tags) self.metadataChangedSubject.send() } } func getPreActivityMetadata(searchKey: String, searchByAddress: Bool = false) async throws -> BitkitCore.PreActivityMetadata? { try await ServiceQueue.background(.core) { - try BitkitCore.getPreActivityMetadata(searchKey: searchKey, searchByAddress: searchByAddress) + try BitkitCore.getPreActivityMetadata(walletId: WalletScope.default, searchKey: searchKey, searchByAddress: searchByAddress) } } func deletePreActivityMetadata(paymentId: String) async throws { try await ServiceQueue.background(.core) { - try BitkitCore.deletePreActivityMetadata(paymentId: paymentId) + try BitkitCore.deletePreActivityMetadata(walletId: WalletScope.default, paymentId: paymentId) self.metadataChangedSubject.send() } } func resetPreActivityMetadataTags(paymentId: String) async throws { try await ServiceQueue.background(.core) { - try BitkitCore.resetPreActivityMetadataTags(paymentId: paymentId) + try BitkitCore.resetPreActivityMetadataTags(walletId: WalletScope.default, paymentId: paymentId) self.metadataChangedSubject.send() } } @@ -1272,7 +1318,7 @@ class ActivityService { func boostOnchainTransaction(activityId: String, feeRate: UInt32) async throws -> String { return try await ServiceQueue.background(.core) { // Get the existing activity - guard let existingActivity = try getActivityById(activityId: activityId) else { + guard let existingActivity = try getActivityById(walletId: WalletScope.default, activityId: activityId) else { throw AppError(message: "Activity not found", debugMessage: "Activity with ID \(activityId) not found") } @@ -1344,6 +1390,7 @@ class ActivityService { case .lightning: .lightning( LightningActivity( + walletId: WalletScope.default, id: id, txType: template.txType, status: template.status, @@ -1362,6 +1409,7 @@ class ActivityService { case .onchain: .onchain( OnchainActivity( + walletId: WalletScope.default, id: id, txType: template.txType, txId: String(repeating: "a", count: 64), diff --git a/Bitkit/Services/MigrationsService.swift b/Bitkit/Services/MigrationsService.swift index 5fce0e922..0a248d858 100644 --- a/Bitkit/Services/MigrationsService.swift +++ b/Bitkit/Services/MigrationsService.swift @@ -1400,6 +1400,7 @@ extension MigrationsService { let invoice = (item.address?.isEmpty == false) ? item.address! : "migrated:\(item.id)" let lightning = BitkitCore.LightningActivity( + walletId: WalletScope.default, id: item.id, txType: txType, status: status, @@ -1752,7 +1753,7 @@ extension MigrationsService { // Try to find on-chain activity by txId first if let onchain = try? await CoreService.shared.activity.getOnchainActivityByTxId(txid: activityId) { try await CoreService.shared.activity.upsertTags([ - ActivityTags(activityId: onchain.id, tags: tagList), + ActivityTags(walletId: WalletScope.default, activityId: onchain.id, tags: tagList), ]) applied += 1 } else if let activity = try? await CoreService.shared.activity.getActivity(id: activityId) { @@ -1760,12 +1761,12 @@ extension MigrationsService { switch activity { case .lightning: try await CoreService.shared.activity.upsertTags([ - ActivityTags(activityId: activityId, tags: tagList), + ActivityTags(walletId: WalletScope.default, activityId: activityId, tags: tagList), ]) applied += 1 case let .onchain(onchain): try await CoreService.shared.activity.upsertTags([ - ActivityTags(activityId: onchain.id, tags: tagList), + ActivityTags(walletId: WalletScope.default, activityId: onchain.id, tags: tagList), ]) applied += 1 } @@ -1843,6 +1844,7 @@ extension MigrationsService { let activityTimestamp = timestampSecs > 0 ? timestampSecs : now let onchain = BitkitCore.OnchainActivity( + walletId: WalletScope.default, id: item.id, txType: item.txType == "sent" ? .sent : .received, txId: txId, diff --git a/Bitkit/Services/OnChainHwService.swift b/Bitkit/Services/OnChainHwService.swift new file mode 100644 index 000000000..2cabcf081 --- /dev/null +++ b/Bitkit/Services/OnChainHwService.swift @@ -0,0 +1,204 @@ +import BitkitCore +import Foundation +import LDKNode + +/// Watcher-related service calls, extracted as a protocol so unit tests can +/// substitute a mock. +protocol OnChainWatcherServicing { + func startWatcher(params: WatcherParams, listener: EventListener) async throws + func stopWatcher(watcherId: String) throws + func stopAllWatchers() +} + +extension OnChainHwService: OnChainWatcherServicing {} + +/// Vendor-neutral on-chain service layer. Wraps the device-less `onchain*` BitkitCore FFI +/// functions — account/address info, transaction history/detail, transaction composition and +/// broadcasting, and the Electrum event watcher. None of these require a connected hardware +/// device; they query Electrum directly. Shared by all consumers (`HwWalletManager`, +/// `TrezorViewModel`, …) so the watch-only layer depends on this rather than `TrezorService`. +/// All operations run on ServiceQueue.background(.core) to ensure thread safety. +class OnChainHwService { + static let shared = OnChainHwService() + + private init() {} + + // MARK: - Account/Address Info (No Device Required) + + /// Get account info (balance, UTXOs) for an extended public key (xpub/ypub/zpub/tpub/upub/vpub). + /// This does NOT require a connected Trezor device — it queries the Electrum server directly. + func getAccountInfo( + extendedKey: String, + electrumUrl: String, + network: TrezorCoinType? = nil, + gapLimit: UInt32? = nil, + scriptType: AccountType? = nil + ) async throws -> AccountInfoResult { + let networkParam = network?.coreNetwork + return try await ServiceQueue.background(.core) { + try await onchainGetAccountInfo( + extendedKey: extendedKey, + electrumUrl: electrumUrl, + network: networkParam, + gapLimit: gapLimit, + scriptType: scriptType + ) + } + } + + /// Get address info (balance, UTXOs) for a single Bitcoin address. + /// This does NOT require a connected Trezor device — it queries the Electrum server directly. + func getAddressInfo( + address: String, + electrumUrl: String, + network: TrezorCoinType? = nil + ) async throws -> SingleAddressInfoResult { + let networkParam = network?.coreNetwork + return try await ServiceQueue.background(.core) { + try await onchainGetAddressInfo( + address: address, + electrumUrl: electrumUrl, + network: networkParam + ) + } + } + + // MARK: - Transaction History & Detail (No Device Required) + + /// Get transaction history for an extended public key (xpub/ypub/zpub/tpub/upub/vpub). + /// This does NOT require a connected Trezor device — it queries the Electrum server directly. + func getTransactionHistory( + extendedKey: String, + electrumUrl: String, + network: TrezorCoinType? = nil, + scriptType: AccountType? = nil + ) async throws -> TransactionHistoryResult { + let networkParam = network?.coreNetwork + return try await ServiceQueue.background(.core) { + try await onchainGetTransactionHistory( + extendedKey: extendedKey, + electrumUrl: electrumUrl, + network: networkParam, + scriptType: scriptType + ) + } + } + + /// Get detailed information for a specific transaction by its ID. + /// This does NOT require a connected Trezor device — it queries the Electrum server directly. + func getTransactionDetail( + extendedKey: String, + electrumUrl: String, + txid: String, + network: TrezorCoinType? = nil, + scriptType: AccountType? = nil + ) async throws -> TransactionDetail { + let networkParam = network?.coreNetwork + return try await ServiceQueue.background(.core) { + try await onchainGetTransactionDetail( + extendedKey: extendedKey, + electrumUrl: electrumUrl, + txid: txid, + network: networkParam, + scriptType: scriptType + ) + } + } + + // MARK: - Transaction Composition & Broadcasting + + /// Compose a transaction using BDK-based PSBT generation (signer-agnostic). + /// Does NOT require a connected Trezor device. + func composeTransaction(params: ComposeParams) async throws -> [ComposeResult] { + try await ServiceQueue.background(.core) { + await onchainComposeTransaction(params: params) + } + } + + /// Broadcast a signed raw transaction via Electrum. + /// - Returns: The transaction ID (txid) + func broadcastRawTx(serializedTx: String, electrumUrl: String) async throws -> String { + try await ServiceQueue.background(.core) { + try await onchainBroadcastRawTx(serializedTx: serializedTx, electrumUrl: electrumUrl) + } + } + + // MARK: - Event Watcher (No Device Required) + + /// Start watching an extended public key for on-chain transaction activity. + /// Events are delivered to `listener` until the watcher is stopped. + /// Does NOT require a connected Trezor device — it subscribes to Electrum directly. + func startWatcher(params: WatcherParams, listener: EventListener) async throws { + try await ServiceQueue.background(.core) { + try await onchainStartWatcher(params: params, listener: listener) + } + } + + /// Stop a specific watcher by its id. + func stopWatcher(watcherId: String) throws { + try onchainStopWatcher(watcherId: watcherId) + } + + /// Stop all active watchers. + func stopAllWatchers() { + onchainStopAllWatchers() + } +} + +// MARK: - Network / Electrum helpers + +/// Network- and Electrum-derivation helpers shared by all on-chain consumers (`TrezorManager`, +/// `TrezorViewModel`, `HwWalletManager`). They live on the service layer so feature managers +/// don't reference each other for plain network/electrum configuration. +extension OnChainHwService { + /// The app's global network mapped to a `TrezorCoinType`. + static var appDefaultCoinType: TrezorCoinType { + switch Env.network { + case .bitcoin: .bitcoin + case .testnet: .testnet + case .signet: .signet + case .regtest: .regtest + } + } + + /// BIP44 coin-type component for the app's global network: "0'" mainnet, "1'" test networks. + static var defaultCoinTypeComponent: String { + Env.network == .bitcoin ? "0'" : "1'" + } + + /// Electrum server URL per network (with the regtest dev override), delegating to `Env`. + static func electrumUrlForNetwork(_ network: TrezorCoinType) -> String { + if network == .regtest, let trezorElectrumUrl = Env.trezorElectrumUrl { + return trezorElectrumUrl + } + return Env.electrumServerUrl(for: network.ldkNetwork) + } + + /// The app's configured Electrum server (falls back to the default). + static func getElectrumUrl() -> String { + let server = ElectrumConfigService().getCurrentServer() + return server.fullUrl.isEmpty ? Env.electrumServerUrl : server.fullUrl + } +} + +extension TrezorCoinType { + /// The BitkitCore `Network` this coin type maps to, used by the onchain FFI functions. + var coreNetwork: BitkitCore.Network { + switch self { + case .bitcoin: .bitcoin + case .testnet: .testnet + case .signet: .signet + case .regtest: .regtest + } + } + + /// The `LDKNode.Network` this coin type maps to, used to resolve the Electrum URL from `Env`. + var ldkNetwork: LDKNode.Network { + switch self { + case .bitcoin: .bitcoin + case .testnet: .testnet + case .signet: .signet + case .regtest: .regtest + } + } +} diff --git a/Bitkit/Services/SamRockService.swift b/Bitkit/Services/SamRockService.swift index d0ddafbb6..c2327e340 100644 --- a/Bitkit/Services/SamRockService.swift +++ b/Bitkit/Services/SamRockService.swift @@ -341,18 +341,10 @@ final class SamRockService { } static func accountType(forSelectedAddressType selectedAddressType: String?) -> AccountType { - switch selectedAddressType { - case "legacy": - return .legacy - case "nestedSegwit": - return .wrappedSegwit - case "taproot": - return .taproot - case "nativeSegwit": - return .nativeSegwit - default: + guard let selectedAddressType, let addressType = AddressScriptType.from(string: selectedAddressType) else { return .nativeSegwit } + return addressType.accountType } private static func formEncode(_ value: String) -> String { diff --git a/Bitkit/Services/Trezor/TrezorBridgeTransport.swift b/Bitkit/Services/Trezor/TrezorBridgeTransport.swift index 402f4f9d9..90138e9c8 100644 --- a/Bitkit/Services/Trezor/TrezorBridgeTransport.swift +++ b/Bitkit/Services/Trezor/TrezorBridgeTransport.swift @@ -78,10 +78,10 @@ final class TrezorBridgeTransport { sessionLock.unlock() debugLog("openDevice: \(path)") - return TrezorTransportWriteResult(success: true, error: "") + return TrezorTransportWriteResult(success: true, error: "", errorCode: nil) } catch { debugLog("openDevice FAILED: \(error.localizedDescription)") - return TrezorTransportWriteResult(success: false, error: error.localizedDescription) + return TrezorTransportWriteResult(success: false, error: error.localizedDescription, errorCode: nil) } } @@ -91,25 +91,29 @@ final class TrezorBridgeTransport { sessionLock.unlock() guard let session else { - return TrezorTransportWriteResult(success: true, error: "") + return TrezorTransportWriteResult(success: true, error: "", errorCode: nil) } do { _ = try post(path: "/release/\(Self.encode(session))") debugLog("closeDevice: \(path)") - return TrezorTransportWriteResult(success: true, error: "") + return TrezorTransportWriteResult(success: true, error: "", errorCode: nil) } catch { debugLog("closeDevice FAILED: \(error.localizedDescription)") - return TrezorTransportWriteResult(success: false, error: error.localizedDescription) + return TrezorTransportWriteResult(success: false, error: error.localizedDescription, errorCode: nil) } } func readChunk(path: String) -> TrezorTransportReadResult { - TrezorTransportReadResult(success: false, data: Data(), error: "Trezor Bridge uses callMessage for \(path)") + TrezorTransportReadResult(success: false, data: Data(), error: "Trezor Bridge uses callMessage for \(path)", errorCode: nil) } func writeChunk(path: String, data: Data) -> TrezorTransportWriteResult { - TrezorTransportWriteResult(success: false, error: "Trezor Bridge uses callMessage for \(path) and ignored \(data.count) bytes") + TrezorTransportWriteResult( + success: false, + error: "Trezor Bridge uses callMessage for \(path) and ignored \(data.count) bytes", + errorCode: nil + ) } func callMessage(path: String, messageType: UInt16, data: Data) -> TrezorCallMessageResult { @@ -118,7 +122,13 @@ final class TrezorBridgeTransport { sessionLock.unlock() guard let session else { - return TrezorCallMessageResult(success: false, messageType: 0, data: Data(), error: "Trezor Bridge device not open: \(path)") + return TrezorCallMessageResult( + success: false, + messageType: 0, + data: Data(), + error: "Trezor Bridge device not open: \(path)", + errorCode: nil + ) } do { @@ -127,7 +137,7 @@ final class TrezorBridgeTransport { return try Self.decodeFrame(response) } catch { debugLog("callMessage FAILED: \(error.localizedDescription)") - return TrezorCallMessageResult(success: false, messageType: 0, data: Data(), error: error.localizedDescription) + return TrezorCallMessageResult(success: false, messageType: 0, data: Data(), error: error.localizedDescription, errorCode: nil) } } @@ -199,7 +209,7 @@ final class TrezorBridgeTransport { } let payload = bytes.subdata(in: headerSize ..< headerSize + Int(length)) - return TrezorCallMessageResult(success: true, messageType: messageType, data: payload, error: "") + return TrezorCallMessageResult(success: true, messageType: messageType, data: payload, error: "", errorCode: nil) } private static func toBridgePath(_ path: String) -> String { diff --git a/Bitkit/Services/Trezor/TrezorKnownDeviceStorage.swift b/Bitkit/Services/Trezor/TrezorKnownDeviceStorage.swift index 76c7894c1..63f22d649 100644 --- a/Bitkit/Services/Trezor/TrezorKnownDeviceStorage.swift +++ b/Bitkit/Services/Trezor/TrezorKnownDeviceStorage.swift @@ -9,6 +9,41 @@ struct TrezorKnownDevice: Codable, Identifiable { var label: String? var model: String? var lastConnectedAt: Date + /// Account-level extended public keys keyed by `AddressScriptType.stringValue`. + /// Persisted so watch-only balances/activity stay available while disconnected. + var xpubs: [String: String] + + init( + id: String, + name: String, + path: String, + transportType: String, + label: String? = nil, + model: String? = nil, + lastConnectedAt: Date, + xpubs: [String: String] = [:] + ) { + self.id = id + self.name = name + self.path = path + self.transportType = transportType + self.label = label + self.model = model + self.lastConnectedAt = lastConnectedAt + self.xpubs = xpubs + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + path = try container.decode(String.self, forKey: .path) + transportType = try container.decode(String.self, forKey: .transportType) + label = try container.decodeIfPresent(String.self, forKey: .label) + model = try container.decodeIfPresent(String.self, forKey: .model) + lastConnectedAt = try container.decode(Date.self, forKey: .lastConnectedAt) + xpubs = try container.decodeIfPresent([String: String].self, forKey: .xpubs) ?? [:] + } } /// Persists known Trezor device metadata in UserDefaults diff --git a/Bitkit/Services/Trezor/TrezorService.swift b/Bitkit/Services/Trezor/TrezorService.swift index a31d4052f..1736fb0be 100644 --- a/Bitkit/Services/Trezor/TrezorService.swift +++ b/Bitkit/Services/Trezor/TrezorService.swift @@ -1,16 +1,6 @@ import BitkitCore import Foundation -/// Watcher-related service calls, extracted as a protocol so unit tests can -/// substitute a mock (mirrors bitkit-android's mocked TrezorRepo in TrezorViewModelTest). -protocol TrezorWatcherServicing { - func startWatcher(params: WatcherParams, listener: EventListener) async throws - func stopWatcher(watcherId: String) throws - func stopAllWatchers() -} - -extension TrezorService: TrezorWatcherServicing {} - /// Service layer wrapper for Trezor FFI functions /// All operations run on ServiceQueue.background(.core) to ensure thread safety class TrezorService { @@ -182,140 +172,6 @@ class TrezorService { } } - // MARK: - Account/Address Info (No Device Required) - - /// Get account info (balance, UTXOs) for an extended public key (xpub/ypub/zpub/tpub/upub/vpub). - /// This does NOT require a connected Trezor device — it queries the Electrum server directly. - func getAccountInfo( - extendedKey: String, - electrumUrl: String, - network: TrezorCoinType? = nil, - gapLimit: UInt32? = nil, - scriptType: AccountType? = nil - ) async throws -> AccountInfoResult { - let networkParam = toNetwork(network) - return try await ServiceQueue.background(.core) { - try await onchainGetAccountInfo( - extendedKey: extendedKey, - electrumUrl: electrumUrl, - network: networkParam, - gapLimit: gapLimit, - scriptType: scriptType - ) - } - } - - /// Get address info (balance, UTXOs) for a single Bitcoin address. - /// This does NOT require a connected Trezor device — it queries the Electrum server directly. - func getAddressInfo( - address: String, - electrumUrl: String, - network: TrezorCoinType? = nil - ) async throws -> SingleAddressInfoResult { - let networkParam = toNetwork(network) - return try await ServiceQueue.background(.core) { - try await onchainGetAddressInfo( - address: address, - electrumUrl: electrumUrl, - network: networkParam - ) - } - } - - // MARK: - Transaction History & Detail (No Device Required) - - /// Get transaction history for an extended public key (xpub/ypub/zpub/tpub/upub/vpub). - /// This does NOT require a connected Trezor device — it queries the Electrum server directly. - func getTransactionHistory( - extendedKey: String, - electrumUrl: String, - network: TrezorCoinType? = nil, - scriptType: AccountType? = nil - ) async throws -> TransactionHistoryResult { - let networkParam = toNetwork(network) - return try await ServiceQueue.background(.core) { - try await onchainGetTransactionHistory( - extendedKey: extendedKey, - electrumUrl: electrumUrl, - network: networkParam, - scriptType: scriptType - ) - } - } - - /// Get detailed information for a specific transaction by its ID. - /// This does NOT require a connected Trezor device — it queries the Electrum server directly. - func getTransactionDetail( - extendedKey: String, - electrumUrl: String, - txid: String, - network: TrezorCoinType? = nil, - scriptType: AccountType? = nil - ) async throws -> TransactionDetail { - let networkParam = toNetwork(network) - return try await ServiceQueue.background(.core) { - try await onchainGetTransactionDetail( - extendedKey: extendedKey, - electrumUrl: electrumUrl, - txid: txid, - network: networkParam, - scriptType: scriptType - ) - } - } - - // MARK: - Transaction Composition & Broadcasting - - /// Compose a transaction using BDK-based PSBT generation (signer-agnostic). - /// Does NOT require a connected Trezor device. - func composeTransaction(params: ComposeParams) async throws -> [ComposeResult] { - try await ServiceQueue.background(.core) { - await onchainComposeTransaction(params: params) - } - } - - /// Broadcast a signed raw transaction via Electrum. - /// - Returns: The transaction ID (txid) - func broadcastRawTx(serializedTx: String, electrumUrl: String) async throws -> String { - try await ServiceQueue.background(.core) { - try await onchainBroadcastRawTx(serializedTx: serializedTx, electrumUrl: electrumUrl) - } - } - - // MARK: - Event Watcher (No Device Required) - - /// Start watching an extended public key for on-chain transaction activity. - /// Events are delivered to `listener` until the watcher is stopped. - /// Does NOT require a connected Trezor device — it subscribes to Electrum directly. - func startWatcher(params: WatcherParams, listener: EventListener) async throws { - try await ServiceQueue.background(.core) { - try await onchainStartWatcher(params: params, listener: listener) - } - } - - /// Stop a specific watcher by its id. - func stopWatcher(watcherId: String) throws { - try onchainStopWatcher(watcherId: watcherId) - } - - /// Stop all active watchers. - func stopAllWatchers() { - onchainStopAllWatchers() - } - - // MARK: - Helpers - - /// Convert TrezorCoinType to the Network enum used by onchain FFI functions - private func toNetwork(_ coin: TrezorCoinType?) -> Network? { - guard let coin else { return nil } - switch coin { - case .bitcoin: return .bitcoin - case .testnet: return .testnet - case .signet: return .signet - case .regtest: return .regtest - } - } - // MARK: - Credential Management /// Clear stored Bluetooth pairing credentials for a specific device diff --git a/Bitkit/Services/Trezor/TrezorTransport.swift b/Bitkit/Services/Trezor/TrezorTransport.swift index 35e35c4c0..4649c718f 100644 --- a/Bitkit/Services/Trezor/TrezorTransport.swift +++ b/Bitkit/Services/Trezor/TrezorTransport.swift @@ -86,10 +86,10 @@ final class TrezorTransport: TrezorTransportCallback { throw error } - return TrezorTransportWriteResult(success: true, error: "") + return TrezorTransportWriteResult(success: true, error: "", errorCode: nil) } catch { debugLog("openDevice FAILED: \(error.localizedDescription)") - return TrezorTransportWriteResult(success: false, error: error.localizedDescription) + return TrezorTransportWriteResult(success: false, error: error.localizedDescription, errorCode: nil) } } @@ -102,11 +102,11 @@ final class TrezorTransport: TrezorTransportCallback { } guard path.hasPrefix("ble:") else { - return TrezorTransportWriteResult(success: false, error: "Invalid device path: \(path)") + return TrezorTransportWriteResult(success: false, error: "Invalid device path: \(path)", errorCode: nil) } bleManager.disconnect(path: path) - return TrezorTransportWriteResult(success: true, error: "") + return TrezorTransportWriteResult(success: true, error: "", errorCode: nil) } /// Read a chunk of data from the device @@ -123,10 +123,10 @@ final class TrezorTransport: TrezorTransportCallback { let data = try bleManager.readChunk(path: path) debugLog("readChunk: \(data.count) bytes") - return TrezorTransportReadResult(success: true, data: data, error: "") + return TrezorTransportReadResult(success: true, data: data, error: "", errorCode: nil) } catch { debugLog("readChunk FAILED: \(error.localizedDescription)") - return TrezorTransportReadResult(success: false, data: Data(), error: error.localizedDescription) + return TrezorTransportReadResult(success: false, data: Data(), error: error.localizedDescription, errorCode: nil) } } @@ -162,10 +162,10 @@ final class TrezorTransport: TrezorTransportCallback { throw error } - return TrezorTransportWriteResult(success: true, error: "") + return TrezorTransportWriteResult(success: true, error: "", errorCode: nil) } catch { debugLog("writeChunk FAILED: \(error.localizedDescription)") - return TrezorTransportWriteResult(success: false, error: error.localizedDescription) + return TrezorTransportWriteResult(success: false, error: error.localizedDescription, errorCode: nil) } } diff --git a/Bitkit/Styles/Colors.swift b/Bitkit/Styles/Colors.swift index 389087b76..adb58e7d4 100644 --- a/Bitkit/Styles/Colors.swift +++ b/Bitkit/Styles/Colors.swift @@ -41,6 +41,7 @@ extension Color { static let white64 = Color.white.opacity(0.64) static let white80 = Color.white.opacity(0.80) + static let blue16 = Color.blueAccent.opacity(0.16) static let blue24 = Color.blueAccent.opacity(0.24) static let brand08 = Color.brandAccent.opacity(0.08) static let brand16 = Color.brandAccent.opacity(0.16) diff --git a/Bitkit/Utilities/AppReset.swift b/Bitkit/Utilities/AppReset.swift index 04d596a58..ddc12ddfe 100644 --- a/Bitkit/Utilities/AppReset.swift +++ b/Bitkit/Utilities/AppReset.swift @@ -23,6 +23,8 @@ enum AppReset { await VssBackupClient.shared.reset() VssStoreIdProvider.shared.clearCache() + OnChainHwService.shared.stopAllWatchers() + // Stop node and wipe LDK persistence via the wallet API. try await wallet.wipe() diff --git a/Bitkit/ViewModels/ActivityItemViewModel.swift b/Bitkit/ViewModels/ActivityItemViewModel.swift index a0e45515b..993ca68c4 100644 --- a/Bitkit/ViewModels/ActivityItemViewModel.swift +++ b/Bitkit/ViewModels/ActivityItemViewModel.swift @@ -37,7 +37,7 @@ class ActivityItemViewModel: ObservableObject { func loadTags() async { do { - tags = try await coreService.activity.tags(forActivity: activityId) + tags = try await coreService.activity.tags(forActivity: activityId, walletId: activity.walletId) } catch { Logger.error(error, context: "Failed to load tags for activity \(activityId)") tags = [] @@ -46,7 +46,7 @@ class ActivityItemViewModel: ObservableObject { func removeTag(_ tag: String) async { do { - try await coreService.activity.dropTags(fromActivity: activityId, [tag]) + try await coreService.activity.dropTags(fromActivity: activityId, [tag], walletId: activity.walletId) await loadTags() // Reload tags after removal } catch { Logger.error(error, context: "Failed to remove tag \(tag) from activity \(activityId)") @@ -55,7 +55,7 @@ class ActivityItemViewModel: ObservableObject { func refreshActivity() async { do { - if let updatedActivity = try await coreService.activity.getActivity(id: activityId) { + if let updatedActivity = try await coreService.activity.getActivity(id: activityId, walletId: activity.walletId) { activity = updatedActivity } else { // Activity not found by ID - it might have been replaced by RBF diff --git a/Bitkit/ViewModels/ActivityListViewModel.swift b/Bitkit/ViewModels/ActivityListViewModel.swift index 0d95b2273..f9adec550 100644 --- a/Bitkit/ViewModels/ActivityListViewModel.swift +++ b/Bitkit/ViewModels/ActivityListViewModel.swift @@ -138,8 +138,9 @@ class ActivityListViewModel: ObservableObject { do { // Get latest activities first as that's displayed on the home view let limitLatest = UInt32(ActivityDisplayConstants.maxHomeActivityItems) - // Fetch extra to account for potential filtering of replaced transactions - let latest = try await coreService.activity.get(filter: .all, limit: limitLatest * 3) + // 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) latestActivities = Array(filtered.prefix(Int(limitLatest))) @@ -188,13 +189,16 @@ class ActivityListViewModel: ObservableObject { return UInt64(nextDay.timeIntervalSince1970 - 1) } - // Apply base filtering + // Apply base filtering. walletId nil → global so the All Activity list merges the + // Bitkit wallet with watch-only hardware wallets (tag filters exclude hw items, which + // carry no tags, matching bitkit-android). let baseFilteredActivities = try await coreService.activity.get( filter: .all, tags: selectedTags.isEmpty ? nil : Array(selectedTags), search: searchText.isEmpty ? nil : searchText, minDate: minDate, - maxDate: maxDate + maxDate: maxDate, + walletId: nil ) // Filter out replaced sent transactions that appear in another transaction's boostTxIds diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 600e74916..8c8f1bb42 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -851,6 +851,7 @@ extension AppViewModel { let now = UInt64(Date().timeIntervalSince1970) let ln = LightningActivity( + walletId: WalletScope.default, id: channel.fundingTxo?.txid ?? "", txType: .received, status: .succeeded, diff --git a/Bitkit/ViewModels/SheetViewModel.swift b/Bitkit/ViewModels/SheetViewModel.swift index 838e7cd9b..f9e53c39c 100644 --- a/Bitkit/ViewModels/SheetViewModel.swift +++ b/Bitkit/ViewModels/SheetViewModel.swift @@ -24,6 +24,8 @@ enum SheetID: String, CaseIterable { case tagFilter case dateRangeSelector case widgets + case hardwareIntro + case hardwarePairing } struct SheetConfiguration { @@ -301,6 +303,30 @@ class SheetViewModel: ObservableObject { } } + var hardwareIntroSheetItem: HardwareIntroSheetItem? { + get { + guard let config = activeSheetConfiguration, config.id == .hardwareIntro else { return nil } + return HardwareIntroSheetItem() + } + set { + if newValue == nil { + activeSheetConfiguration = nil + } + } + } + + var hardwarePairingSheetItem: HardwarePairingSheetItem? { + get { + guard let config = activeSheetConfiguration, config.id == .hardwarePairing else { return nil } + return HardwarePairingSheetItem() + } + set { + if newValue == nil { + activeSheetConfiguration = nil + } + } + } + var scannerSheetItem: ScannerSheetItem? { get { guard let config = activeSheetConfiguration, config.id == .scanner else { return nil } diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 5a903bc26..5c8d66b39 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -159,6 +159,7 @@ class TransferViewModel: ObservableObject { // 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, diff --git a/Bitkit/ViewModels/Trezor/TrezorViewModel.swift b/Bitkit/ViewModels/Trezor/TrezorViewModel.swift index b077bf9d1..b8e4e580d 100644 --- a/Bitkit/ViewModels/Trezor/TrezorViewModel.swift +++ b/Bitkit/ViewModels/Trezor/TrezorViewModel.swift @@ -1,6 +1,5 @@ import BitkitCore import Combine -import CoreBluetooth import Foundation /// Represents the current step in the send transaction flow @@ -59,98 +58,30 @@ enum TrezorAccountTypeSelection: String, CaseIterable, Identifiable, CustomStrin } } -/// ViewModel for Trezor hardware wallet integration +/// ViewModel for the Trezor hardware-wallet dev/dashboard screens. Owns only the dev-screen +/// tools (address generation, signing, lookups, the single dev watcher, etc.); device/connection +/// orchestration and pairing/PIN/passphrase coordination live in `TrezorManager`, injected here +/// as `connection`. @Observable @MainActor class TrezorViewModel { - // MARK: - Network Configuration - - /// The network selected in the Trezor dashboard (independent of app's global network) - var selectedNetwork: TrezorCoinType - - /// Map the app's current network to the corresponding TrezorCoinType (used for default initialization) - static var appDefaultCoinType: TrezorCoinType { - switch Env.network { - case .bitcoin: .bitcoin - case .testnet: .testnet - case .signet: .signet - case .regtest: .regtest - } - } - - /// BIP44 coin type component based on the dashboard's selected network: "0'" for mainnet, "1'" for test networks - var coinTypeComponent: String { - selectedNetwork == .bitcoin ? "0'" : "1'" - } - - /// BIP44 coin type component based on the app's global network (used for initial default values) - private static var defaultCoinTypeComponent: String { - Env.network == .bitcoin ? "0'" : "1'" - } - - // MARK: - Connection State + // MARK: - Connection Manager - /// Whether the Trezor manager is initialized - private var isInitialized: Bool = false + /// Device/connection orchestration and pairing/PIN/passphrase coordination. + let connection: TrezorManager - /// Whether currently scanning for devices - var isScanning: Bool = false + // MARK: - Operation State /// Whether currently performing an operation (address, signing, etc.) var isOperating: Bool = false - /// List of discovered devices - var devices: [TrezorDeviceInfo] = [] - - /// Currently connected device - var connectedDevice: TrezorDeviceInfo? - - /// Features of the connected device - var deviceFeatures: TrezorFeatures? - - /// Device root fingerprint (hex string) - var deviceFingerprint: String? - - /// Last error message + /// Last error message from a dev operation var error: String? - // MARK: - UI Dialog State - - /// Show PIN entry dialog - var showPinEntry: Bool = false - - /// Show passphrase entry dialog - var showPassphraseEntry: Bool = false - - /// Show BLE pairing code dialog - var showPairingCode: Bool = false - - /// Show "Confirm on device" overlay - var showConfirmOnDevice: Bool = false - - /// Message for confirm on device overlay - var confirmMessage: String = "" - - /// Show the "where to enter the passphrase" chooser (phone vs Trezor). - /// Only presented for devices that report on-device passphrase entry capability. - var showWalletModeChooser: Bool = false - - // MARK: - Wallet Mode State - - /// The currently selected wallet mode (standard / hidden-on-phone / hidden-on-device). - /// Drives the wallet-mode selector UI; the binding to the device session is applied - /// via setWalletMode (disconnect/reconnect). - var walletMode: TrezorWalletMode = .standard - - /// Whether the connected device supports entering the passphrase on the Trezor itself. - var passphraseEntryCapable: Bool { - deviceFeatures?.passphraseEntryCapable == true - } - // MARK: - Address Generation State /// Current derivation path - var derivationPath: String = "m/84'/\(defaultCoinTypeComponent)/0'/0/0" + var derivationPath: String = "m/84'/\(OnChainHwService.defaultCoinTypeComponent)/0'/0/0" /// Current script type for address generation var selectedScriptType: TrezorScriptType = .spendWitness @@ -167,26 +98,11 @@ class TrezorViewModel { var messageToSign: String = "Hello, Trezor!" /// Path for message signing - var messageSigningPath: String = "m/84'/\(defaultCoinTypeComponent)/0'/0/0" + var messageSigningPath: String = "m/84'/\(OnChainHwService.defaultCoinTypeComponent)/0'/0/0" /// Signed message result var signedMessage: TrezorSignedMessageResponse? - // MARK: - Known Devices & Auto-Reconnect - - /// Previously connected devices loaded from storage - var knownDevices: [TrezorKnownDevice] = [] - - /// Whether auto-reconnect is in progress - var isAutoReconnecting: Bool = false - - /// Status text during auto-reconnect - var autoReconnectStatus: String? - - /// Prevents a user-initiated disconnect from immediately reconnecting - /// when the disconnected device list appears. - private var suppressNextAutoReconnect = false - // MARK: - Address Index /// Current address index (last path component) @@ -195,7 +111,7 @@ class TrezorViewModel { // MARK: - Public Key State /// Account-level derivation path for public key - var publicKeyPath: String = "m/84'/\(defaultCoinTypeComponent)/0'" + var publicKeyPath: String = "m/84'/\(OnChainHwService.defaultCoinTypeComponent)/0'" /// Retrieved xpub string var xpub: String? @@ -329,8 +245,8 @@ class TrezorViewModel { /// Transaction count reported by the watcher var watcherTransactionCount: UInt32 = 0 - /// Latest transactions reported by the watcher - var watcherTransactions: [HistoryTransaction] = [] + /// Latest activities reported by the watcher (core builds these in 0.3.4). + var watcherActivities: [Activity] = [] /// Rolling event log (most recent last, capped) var watcherEvents: [String] = [] @@ -356,57 +272,17 @@ class TrezorViewModel { !watcherEvents.isEmpty } - // MARK: - Bluetooth State - - /// Current Bluetooth state — reads directly from BLEManager (@Observable chaining) - var bluetoothState: CBManagerState { - TrezorBLEManager.shared.bluetoothState - } - - var isBridgeModeEnabled: Bool { - transport.isBridgeEnabled - } - // MARK: - Private Properties private let trezorService = TrezorService.shared - private let watcherService: TrezorWatcherServicing - private let transport = TrezorTransport.shared - private let uiHandler = TrezorUiHandler.shared - private var cancellables = Set() - private var hasSetupSubscriptions = false + private let onChainService = OnChainHwService.shared + private let watcherService: OnChainWatcherServicing // MARK: - Initialization - init(watcherService: TrezorWatcherServicing = TrezorService.shared) { + init(connection: TrezorManager, watcherService: OnChainWatcherServicing = OnChainHwService.shared) { + self.connection = connection self.watcherService = watcherService - selectedNetwork = Self.appDefaultCoinType - // Callback subscriptions are deferred to initialize() to avoid - // triggering BLE stack and Combine overhead at app launch. - } - - /// Subscribe to callback publishers for UI notifications - private func setupCallbackSubscriptions() { - // Pairing code request - transport.needsPairingCodePublisher - .receive(on: DispatchQueue.main) - .sink { [weak self] in - self?.showPairingCode = true - } - .store(in: &cancellables) - - // PIN request from device - uiHandler.needsPinPublisher - .receive(on: DispatchQueue.main) - .sink { [weak self] in - self?.showPinEntry = true - } - .store(in: &cancellables) - - // Passphrase entry is now driven proactively by the wallet-mode selector - // (see setWalletMode / requestPassphraseWallet). The device callback - // `onPassphraseRequest` is answered silently from the selected mode, so there - // is no reactive passphrase prompt to subscribe to here. } // MARK: - Debug Log Helper @@ -424,185 +300,12 @@ class TrezorViewModel { TrezorDebugLog.shared.log(message) } - // MARK: - State Reset Helpers - - func clearWalletDerivedState() { - deviceFingerprint = nil - generatedAddress = nil - signedMessage = nil - xpub = nil - publicKeyHex = nil - } - - func clearDisconnectedDeviceState(errorMessage: String? = nil) { - connectedDevice = nil - deviceFeatures = nil - clearWalletDerivedState() - error = errorMessage - showPinEntry = false - showPassphraseEntry = false - showConfirmOnDevice = false - showWalletModeChooser = false - uiHandler.setWalletMode(.standard) - walletMode = .standard - } - - // MARK: - Manager Setup - - /// Set up subscriptions and start BLE stack (synchronous, non-blocking). - /// Called from TrezorRootView's .task to prepare the UI layer. - func setup() { - guard !hasSetupSubscriptions else { return } - if !transport.isBridgeEnabled { - // Start BLE stack early so bluetoothState is updated by the time - // TrezorDeviceListView renders (the delegate callback fires async). - TrezorBLEManager.shared.ensureStarted() - } - setupCallbackSubscriptions() - hasSetupSubscriptions = true - } - - /// Initialize the Trezor FFI manager (async, may be slow). - /// Called lazily before first scan/connect. - func initialize() async { - setup() - - guard !isInitialized else { return } - - do { - try await trezorService.initialize() - isInitialized = true - error = nil - trezorLog("TrezorViewModel initialized") - } catch { - self.error = errorMessage(from: error) - trezorLog("Failed to initialize Trezor: \(error)", level: "error") - } - } - - // MARK: - Device Scanning - - /// Start scanning for Trezor devices - /// - Parameter clearExisting: Whether to clear existing device list before scanning - func startScan(clearExisting: Bool = true) async { - if !isInitialized { - await initialize() - } - - isScanning = true - error = nil - - if clearExisting { - devices = [] - } - - if !transport.isBridgeEnabled { - // Start BLE scanning - transport.startBLEScanning() - - // Wait for BLE to discover devices (like Android's 3-second scan) - // This ensures devices are found before we call the FFI enumerate - try? await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds - - // Stop BLE scanning before calling FFI to prevent race conditions - transport.stopBLEScanning() - } - - do { - // Trigger FFI scan which will use our transport callbacks - let foundDevices = try await trezorService.scan() - - // Deduplicate by path (in case of duplicate scan results) - var seenPaths = Set() - let uniqueDevices = foundDevices.filter { device in - if seenPaths.contains(device.path) { - return false - } - seenPaths.insert(device.path) - return true - } - - devices = uniqueDevices - trezorLog("Found \(uniqueDevices.count) Trezor devices (filtered from \(foundDevices.count))") - } catch { - self.error = errorMessage(from: error) - trezorLog("Scan failed: \(error)", level: "error") - } - - isScanning = false - } - - /// Stop scanning for devices - func stopScan() { - transport.stopBLEScanning() - isScanning = false - } - - // MARK: - Connection - - /// Connect to a device - func connect(device: TrezorDeviceInfo) async { - error = nil - suppressNextAutoReconnect = false - - // Explicit user-initiated connect always opens the standard wallet — a - // passphrase/on-device selection left over from a previously connected device - // must not silently apply to a newly selected one. - uiHandler.setWalletMode(.standard) - walletMode = .standard - - trezorLog("=== Connecting to device: \(device.path) ===") - - do { - let features = try await trezorService.connect(deviceId: device.path, selection: uiHandler.currentSelection()) - connectedDevice = device - deviceFeatures = features - showConfirmOnDevice = false - - saveCurrentDeviceAsKnown() - trezorLog("Connected to Trezor: \(device.path)") - } catch { - let errorMsg = errorMessage(from: error) - self.error = errorMsg - showConfirmOnDevice = false - trezorLog("Connection failed: \(error)", level: "error") - } - } - - /// Disconnect from current device - func disconnect() async { - guard connectedDevice != nil else { return } - suppressNextAutoReconnect = true - - // NOTE: the event watcher is intentionally NOT stopped here. It subscribes to - // Electrum directly and does not require a connected device, so it survives a - // disconnect and remains controllable from the device-list screen. It is only - // torn down on a network switch (different Electrum server) or via stopWatcher(). - - do { - try await trezorService.disconnect() - // Clear connection state but preserve device list for quick reconnection - clearDisconnectedDeviceState() - - trezorLog("Disconnected from Trezor") - } catch { - // Even if disconnect fails, clear local state - clearDisconnectedDeviceState(errorMessage: errorMessage(from: error)) - trezorLog("Disconnect failed: \(error)", level: "error") - } - } - - /// Check if currently connected - var isConnected: Bool { - connectedDevice != nil - } - // MARK: - Address Operations /// Get address from connected device /// - Parameter showOnDevice: Whether to display address on Trezor screen func getAddress(showOnDevice: Bool = true) async { - guard isConnected else { + guard connection.isConnected else { error = "Not connected to a Trezor" return } @@ -613,19 +316,19 @@ class TrezorViewModel { do { let params = TrezorGetAddressParams( path: derivationPath, - coin: selectedNetwork, + coin: connection.selectedNetwork, showOnTrezor: showOnDevice, scriptType: selectedScriptType ) let response = try await trezorService.getAddress(params: params) generatedAddress = response.address - showConfirmOnDevice = false + connection.showConfirmOnDevice = false trezorLog("Generated address: \(response.address)") } catch { self.error = errorMessage(from: error) - showConfirmOnDevice = false + connection.showConfirmOnDevice = false trezorLog("Get address failed: \(error)", level: "error") } @@ -636,7 +339,7 @@ class TrezorViewModel { /// Sign a message with the connected device func signMessage() async { - guard isConnected else { + guard connection.isConnected else { error = "Not connected to a Trezor" return } @@ -653,17 +356,17 @@ class TrezorViewModel { let params = TrezorSignMessageParams( path: messageSigningPath, message: messageToSign, - coin: selectedNetwork + coin: connection.selectedNetwork ) let response = try await trezorService.signMessage(params: params) signedMessage = response - showConfirmOnDevice = false + connection.showConfirmOnDevice = false trezorLog("Message signed successfully") } catch { self.error = errorMessage(from: error) - showConfirmOnDevice = false + connection.showConfirmOnDevice = false trezorLog("Sign message failed: \(error)", level: "error") } @@ -672,7 +375,7 @@ class TrezorViewModel { /// Verify a signed message func verifyMessage(address: String, signature: String, message: String) async -> Bool { - guard isConnected else { + guard connection.isConnected else { error = "Not connected to a Trezor" return false } @@ -685,11 +388,11 @@ class TrezorViewModel { address: address, signature: signature, message: message, - coin: selectedNetwork + coin: connection.selectedNetwork ) let isValid = try await trezorService.verifyMessage(params: params) - showConfirmOnDevice = false + connection.showConfirmOnDevice = false trezorLog("Message verification result: \(isValid)") @@ -697,7 +400,7 @@ class TrezorViewModel { return isValid } catch { self.error = errorMessage(from: error) - showConfirmOnDevice = false + connection.showConfirmOnDevice = false trezorLog("Verify message failed: \(error)", level: "error") isOperating = false @@ -705,204 +408,6 @@ class TrezorViewModel { } } - // MARK: - UI Callbacks - - /// Submit PIN from UI - func submitPin(_ pin: String) { - showPinEntry = false - uiHandler.submitPin(pin) - } - - /// Cancel PIN entry - func cancelPin() { - showPinEntry = false - uiHandler.cancelPin() - } - - /// Submit a host-entered passphrase from the UI — opens the corresponding hidden - /// wallet (or the standard wallet when empty) by resetting the session. - func submitPassphrase(_ passphrase: String) async { - showPassphraseEntry = false - showConfirmOnDevice = false - await setWalletMode(passphrase.isEmpty ? .standard : .passphraseHost, passphrase: passphrase) - } - - /// Cancel passphrase entry - func cancelPassphrase() { - showPassphraseEntry = false - showConfirmOnDevice = false - showWalletModeChooser = false - } - - // MARK: - Wallet Mode Selection - - /// User tapped the "Standard" wallet option in the selector. - func selectStandardWallet() async { - guard walletMode != .standard else { return } - await setWalletMode(.standard) - } - - /// User tapped the "Passphrase" wallet option. On a capable device this offers a - /// choice of where to enter the passphrase; otherwise it goes straight to host entry. - func requestPassphraseWallet() { - if passphraseEntryCapable { - showWalletModeChooser = true - } else { - showPassphraseEntry = true - } - } - - /// Wallet-mode chooser: user chose to enter the passphrase on this phone. - func choosePhonePassphraseEntry() { - showWalletModeChooser = false - showPassphraseEntry = true - } - - /// Wallet-mode chooser: user chose to enter the passphrase on the Trezor. - func chooseDevicePassphraseEntry() async { - showWalletModeChooser = false - await setWalletMode(.passphraseDevice) - } - - /// Switch between wallet modes. The Trezor caches the passphrase for the whole - /// session, so switching requires a fresh session: this records the desired mode, - /// then disconnects and reconnects by path. Mirrors bitkit-android's setWalletMode. - func setWalletMode(_ mode: TrezorWalletMode, passphrase: String = "") async { - guard let device = connectedDevice else { - error = "Not connected to a Trezor" - return - } - - isOperating = true - error = nil - trezorLog("=== Switching wallet mode to \(mode); resetting session ===") - - // Reset the session. We call the service directly (not the VM's disconnect()) - // so connectedDevice/deviceFeatures stay populated for the reconnect. - do { - try await trezorService.disconnect() - } catch { - trezorLog("Disconnect before wallet-mode switch failed: \(error)", level: "warn") - } - - // Results derived from the previous wallet are no longer valid once the - // session has been reset for a different wallet mode. - clearWalletDerivedState() - - // Brief settle delay before reconnecting (matches Android's reconnect delay). - try? await Task.sleep(nanoseconds: 300_000_000) - - // Record the selection AFTER the disconnect so it survives into the new session. - // THP reads it via currentSelection() to bind the passphrase at session creation; - // non-THP devices re-request it mid-operation and are answered from the same value. - uiHandler.setWalletMode(mode, hostPassphrase: passphrase) - walletMode = mode - - do { - let features = try await trezorService.connect(deviceId: device.path, selection: uiHandler.currentSelection()) - connectedDevice = device - deviceFeatures = features - showConfirmOnDevice = false - trezorLog("Reconnected with wallet mode \(mode)") - } catch { - clearDisconnectedDeviceState(errorMessage: errorMessage(from: error)) - trezorLog("Reconnect after wallet-mode switch failed: \(error)", level: "error") - } - - isOperating = false - } - - /// Submit pairing code from UI - func submitPairingCode(_ code: String) { - showPairingCode = false - transport.submitPairingCode(code) - } - - /// Cancel pairing code entry - func cancelPairingCode() { - showPairingCode = false - transport.cancelPairingCode() - } - - /// Dismiss confirm on device overlay - func dismissConfirmOnDevice() { - showConfirmOnDevice = false - confirmMessage = "" - } - - // MARK: - Known Devices - - /// Load known devices from storage - func loadKnownDevices() { - knownDevices = TrezorKnownDeviceStorage.loadAll() - } - - /// Save the currently connected device as a known device - func saveCurrentDeviceAsKnown() { - guard let device = connectedDevice else { return } - let known = TrezorKnownDevice( - id: device.id, - name: device.name ?? "Trezor", - path: device.path, - transportType: device.transportType == .bluetooth ? "bluetooth" : "usb", - label: device.label ?? deviceFeatures?.label, - model: device.model ?? deviceFeatures?.model, - lastConnectedAt: Date() - ) - TrezorKnownDeviceStorage.save(known) - loadKnownDevices() - trezorLog("Saved known device: \(known.name)") - } - - /// Forget a known device — removes from storage and clears credentials - func forgetDevice(id: String) async { - // Find the device to get its path for credential clearing - if let device = knownDevices.first(where: { $0.id == id }) { - do { - try await trezorService.clearCredentials(deviceId: device.path) - } catch { - trezorLog("Failed to clear credentials for forgotten device: \(error)", level: "warn") - } - TrezorCredentialStorage.delete(deviceId: device.path) - } - TrezorKnownDeviceStorage.remove(id: id) - loadKnownDevices() - trezorLog("Forgot device: \(id)") - } - - // MARK: - Auto-Reconnect - - /// Automatically scan and reconnect to the first matching known device - func autoReconnect() async { - guard !knownDevices.isEmpty else { return } - guard !isAutoReconnecting else { return } - if suppressNextAutoReconnect { - suppressNextAutoReconnect = false - trezorLog("Auto-reconnect: skipped after manual disconnect") - return - } - - isAutoReconnecting = true - autoReconnectStatus = "Scanning for known devices..." - trezorLog("Auto-reconnect: starting scan") - - await startScan(clearExisting: true) - - // Find the first scanned device that matches a known device - let knownIds = Set(knownDevices.map(\.id)) - if let match = devices.first(where: { knownIds.contains($0.id) }) { - autoReconnectStatus = "Connecting to \(match.label ?? match.name ?? "Trezor")..." - trezorLog("Auto-reconnect: found known device \(match.path)") - await connect(device: match) - } else { - autoReconnectStatus = nil - trezorLog("Auto-reconnect: no known devices found nearby") - } - - isAutoReconnecting = false - autoReconnectStatus = nil - } - // MARK: - Address Index /// Increment the address index and update derivation path @@ -930,7 +435,7 @@ class TrezorViewModel { /// Get public key (xpub) from connected device func getPublicKey(showOnDevice: Bool = false) async { - guard isConnected else { + guard connection.isConnected else { error = "Not connected to a Trezor" return } @@ -941,19 +446,19 @@ class TrezorViewModel { do { let params = TrezorGetPublicKeyParams( path: publicKeyPath, - coin: selectedNetwork, + coin: connection.selectedNetwork, showOnTrezor: showOnDevice ) let response = try await trezorService.getPublicKey(params: params) xpub = response.xpub publicKeyHex = response.publicKey - showConfirmOnDevice = false + connection.showConfirmOnDevice = false trezorLog("Got public key for path: \(publicKeyPath)") } catch { self.error = errorMessage(from: error) - showConfirmOnDevice = false + connection.showConfirmOnDevice = false trezorLog("Get public key failed: \(error)", level: "error") } @@ -964,7 +469,7 @@ class TrezorViewModel { /// Sign a Bitcoin transaction func signTx(params: TrezorSignTxParams) async -> TrezorSignedTx? { - guard isConnected else { + guard connection.isConnected else { error = "Not connected to a Trezor" return nil } @@ -974,13 +479,13 @@ class TrezorViewModel { do { let result = try await trezorService.signTx(params: params) - showConfirmOnDevice = false + connection.showConfirmOnDevice = false trezorLog("Transaction signed successfully") isOperating = false return result } catch { self.error = errorMessage(from: error) - showConfirmOnDevice = false + connection.showConfirmOnDevice = false trezorLog("Sign tx failed: \(error)", level: "error") isOperating = false return nil @@ -993,7 +498,7 @@ class TrezorViewModel { /// - Parameter psbtBase64: Base64-encoded PSBT data /// - Returns: The signed transaction, or nil on failure func signTxFromPsbt(psbtBase64: String) async -> TrezorSignedTx? { - guard isConnected else { + guard connection.isConnected else { error = "Not connected to a Trezor" return nil } @@ -1004,15 +509,15 @@ class TrezorViewModel { do { let result = try await trezorService.signTxFromPsbt( psbtBase64: psbtBase64, - network: selectedNetwork + network: connection.selectedNetwork ) - showConfirmOnDevice = false + connection.showConfirmOnDevice = false trezorLog("PSBT signed successfully") isOperating = false return result } catch { self.error = errorMessage(from: error) - showConfirmOnDevice = false + connection.showConfirmOnDevice = false trezorLog("Sign PSBT failed: \(error)", level: "error") isOperating = false return nil @@ -1023,7 +528,7 @@ class TrezorViewModel { /// Get the device's master root fingerprint func getDeviceFingerprint() async { - guard isConnected else { + guard connection.isConnected else { error = "Not connected to a Trezor" return } @@ -1033,7 +538,7 @@ class TrezorViewModel { do { let fingerprint = try await trezorService.getDeviceFingerprint() - deviceFingerprint = fingerprint + connection.deviceFingerprint = fingerprint trezorLog("Device fingerprint: \(fingerprint)") } catch { self.error = errorMessage(from: error) @@ -1043,48 +548,27 @@ class TrezorViewModel { isOperating = false } - // MARK: - Electrum URL Helpers - - /// Get the Electrum server URL for a specific network (hardcoded per-network URLs) - static func electrumUrlForNetwork(_ network: TrezorCoinType) -> String { - if network == .regtest, let trezorElectrumUrl = Env.trezorElectrumUrl { - return trezorElectrumUrl - } - - switch network { - case .bitcoin: - return "ssl://bitkit.to:9999" - case .testnet, .signet: - return "ssl://electrum.blockstream.info:60002" - case .regtest: - return "ssl://electrs.bitkit.stag0.blocktank.to:9999" - } - } - - /// Get the current Electrum server URL from configuration (uses app's configured server) - static func getElectrumUrl() -> String { - let configService = ElectrumConfigService() - let server = configService.getCurrentServer() - return server.fullUrl.isEmpty ? Env.electrumServerUrl : server.fullUrl - } - // MARK: - Network Switching - /// Switch the dashboard's network independently of the app's global network - func setSelectedNetwork(_ network: TrezorCoinType) { - guard network != selectedNetwork else { return } - selectedNetwork = network - + /// React to a dashboard network switch by resetting the dev-tool derivation paths, + /// results and the dev watcher. The network change itself is owned by `TrezorManager`. + func handleNetworkChange() { // A running watcher is bound to the previous network's Electrum server. stopWatcher() // Reset derivation paths with the new coin type - derivationPath = "m/84'/\(coinTypeComponent)/0'/0/0" - publicKeyPath = "m/84'/\(coinTypeComponent)/0'" - messageSigningPath = "m/84'/\(coinTypeComponent)/0'/0/0" + derivationPath = "m/84'/\(connection.coinTypeComponent)/0'/0/0" + publicKeyPath = "m/84'/\(connection.coinTypeComponent)/0'" + messageSigningPath = "m/84'/\(connection.coinTypeComponent)/0'/0/0" addressIndex = 0 - // Clear results from previous network + clearWalletResults() + } + + /// Clear dev-tool results derived from the connected wallet (generated address, xpub, public + /// key, signed message and lookup results). Called on network switch, disconnect and + /// wallet-mode change so a previous wallet's data is never shown for a different/absent wallet. + func clearWalletResults() { generatedAddress = nil xpub = nil publicKeyHex = nil @@ -1094,8 +578,6 @@ class TrezorViewModel { addressResult = nil lookupError = nil resetSendFlow() - - trezorLog("Switched dashboard network to \(network)") } // MARK: - Balance Lookup Operations @@ -1136,22 +618,22 @@ class TrezorViewModel { addressResult = nil resetSendFlow() - let electrumUrl = Self.electrumUrlForNetwork(selectedNetwork) + let electrumUrl = OnChainHwService.electrumUrlForNetwork(connection.selectedNetwork) do { switch Self.detectInputType(trimmedInput) { case .extendedKey: - accountResult = try await trezorService.getAccountInfo( + accountResult = try await onChainService.getAccountInfo( extendedKey: trimmedInput, electrumUrl: electrumUrl, - network: selectedNetwork, + network: connection.selectedNetwork, scriptType: onchainAccountTypeSelection.accountType ) case .address: - addressResult = try await trezorService.getAddressInfo( + addressResult = try await onChainService.getAddressInfo( address: trimmedInput, electrumUrl: electrumUrl, - network: selectedNetwork + network: connection.selectedNetwork ) case .unknown: lookupError = "Unrecognized input. Enter a Bitcoin address or extended public key (xpub/ypub/zpub/tpub/upub/vpub)." @@ -1208,13 +690,13 @@ class TrezorViewModel { txHistoryError = nil txHistoryResult = nil - let electrumUrl = Self.electrumUrlForNetwork(selectedNetwork) + let electrumUrl = OnChainHwService.electrumUrlForNetwork(connection.selectedNetwork) do { - txHistoryResult = try await trezorService.getTransactionHistory( + txHistoryResult = try await onChainService.getTransactionHistory( extendedKey: trimmedKey, electrumUrl: electrumUrl, - network: selectedNetwork, + network: connection.selectedNetwork, scriptType: onchainAccountTypeSelection.accountType ) } catch { @@ -1236,14 +718,14 @@ class TrezorViewModel { txDetailError = nil txDetailResult = nil - let electrumUrl = Self.electrumUrlForNetwork(selectedNetwork) + let electrumUrl = OnChainHwService.electrumUrlForNetwork(connection.selectedNetwork) do { - txDetailResult = try await trezorService.getTransactionDetail( + txDetailResult = try await onChainService.getTransactionDetail( extendedKey: trimmedKey, electrumUrl: electrumUrl, txid: trimmedTxid, - network: selectedNetwork, + network: connection.selectedNetwork, scriptType: onchainAccountTypeSelection.accountType ) } catch { @@ -1293,9 +775,9 @@ class TrezorViewModel { // Ensure we have the device fingerprint for proper PSBT derivation paths. // Without it, BDK produces relative paths (e.g. m/0/0) that the Trezor // rejects as "Forbidden key path". - if deviceFingerprint == nil { + if connection.deviceFingerprint == nil { do { - deviceFingerprint = try await trezorService.getDeviceFingerprint() + connection.deviceFingerprint = try await trezorService.getDeviceFingerprint() } catch { trezorLog("Failed to get device fingerprint: \(error)", level: "error") sendError = "Failed to get device fingerprint" @@ -1313,13 +795,13 @@ class TrezorViewModel { ? .sendMax(address: address) : .payment(address: address, amountSats: UInt64(sendAmountSats) ?? 0) - let electrumUrl = Self.electrumUrlForNetwork(selectedNetwork) - let network = toNetwork(selectedNetwork) + let electrumUrl = OnChainHwService.electrumUrlForNetwork(connection.selectedNetwork) + let network = connection.selectedNetwork.coreNetwork let wallet = WalletParams( extendedKey: extendedKey, electrumUrl: electrumUrl, - fingerprint: deviceFingerprint, + fingerprint: connection.deviceFingerprint, network: network, accountType: accountInfo.accountType ) @@ -1332,7 +814,7 @@ class TrezorViewModel { ) do { - let results = try await trezorService.composeTransaction(params: params) + let results = try await onChainService.composeTransaction(params: params) handleComposeResults(results) } catch { trezorLog("composeTx FAILED: \(error)", level: "error") @@ -1388,7 +870,7 @@ class TrezorViewModel { return } - guard isConnected else { + guard connection.isConnected else { sendError = "Not connected to a Trezor" return } @@ -1404,9 +886,9 @@ class TrezorViewModel { trezorLog("Calling trezor signTxFromPsbt...") let signedTx = try await trezorService.signTxFromPsbt( psbtBase64: psbt, - network: selectedNetwork + network: connection.selectedNetwork ) - showConfirmOnDevice = false + connection.showConfirmOnDevice = false trezorLog("=== signComposedTx SUCCESS ===") trezorLog("signatures=\(signedTx.signatures.count), txid=\(signedTx.txid ?? "nil"), rawTxLen=\(signedTx.serializedTx.count)") @@ -1414,7 +896,7 @@ class TrezorViewModel { signedTxResult = signedTx sendStep = .signed } catch { - showConfirmOnDevice = false + connection.showConfirmOnDevice = false trezorLog("signComposedTx FAILED: \(error)", level: "error") sendError = errorMessage(from: error) } @@ -1429,10 +911,10 @@ class TrezorViewModel { isBroadcasting = true sendError = nil - let electrumUrl = Self.electrumUrlForNetwork(selectedNetwork) + let electrumUrl = OnChainHwService.electrumUrlForNetwork(connection.selectedNetwork) do { - let txid = try await trezorService.broadcastRawTx(serializedTx: rawTx, electrumUrl: electrumUrl) + let txid = try await onChainService.broadcastRawTx(serializedTx: rawTx, electrumUrl: electrumUrl) trezorLog("BROADCAST SUCCESS txid=\(txid)") broadcastTxid = txid } catch { @@ -1469,15 +951,7 @@ class TrezorViewModel { // MARK: - Helpers - /// Convert TrezorCoinType to the Network enum used by onchain FFI functions - private func toNetwork(_ coin: TrezorCoinType) -> Network? { - switch coin { - case .bitcoin: return .bitcoin - case .testnet: return .testnet - case .signet: return .signet - case .regtest: return .regtest - } - } + // Convert TrezorCoinType to the Network enum used by onchain FFI functions // MARK: - Error Handling @@ -1558,24 +1032,6 @@ class TrezorViewModel { return cleanedMessage } - // MARK: - Credential Management - - /// Clear stored credentials for current device - func clearCredentials() async { - guard let device = connectedDevice else { - error = "No device connected" - return - } - - do { - try await trezorService.clearCredentials(deviceId: device.path) - trezorLog("Cleared credentials for \(device.path)") - } catch { - self.error = errorMessage(from: error) - trezorLog("Failed to clear credentials: \(error)", level: "error") - } - } - // MARK: - Event Watcher Operations /// Copy the most recently retrieved xpub into the watcher's extended-key field. @@ -1600,14 +1056,17 @@ class TrezorViewModel { } let watcherId = UUID().uuidString - let network = selectedNetwork + let network = connection.selectedNetwork let accountType = onchainAccountTypeSelection.accountType + // Dev watcher: scope its emitted activities under an id derived from the watched key. + let walletId = (try? HwWalletId.derive(xpubs: ["watcher": key])) ?? "trezor:watcher" let params = WatcherParams( watcherId: watcherId, + walletId: walletId, extendedKey: key, - electrumUrl: Self.electrumUrlForNetwork(network), - network: toNetwork(network), + electrumUrl: OnChainHwService.electrumUrlForNetwork(network), + network: network.coreNetwork, accountType: accountType, gapLimit: gapLimit ) @@ -1620,7 +1079,7 @@ class TrezorViewModel { isStartingWatcher = true startingWatcherId = watcherId watcherConnectionStatus = .starting - watcherTransactions = [] + watcherActivities = [] watcherEvents = ["starting: \(watcherId)"] watcherBalance = nil watcherTransactionCount = 0 @@ -1637,7 +1096,7 @@ class TrezorViewModel { return } - if selectedNetwork != network { + if connection.selectedNetwork != network { try? watcherService.stopWatcher(watcherId: watcherId) finishStoppedWatcherStartup(watcherId: watcherId) return @@ -1706,7 +1165,7 @@ class TrezorViewModel { watcherConnectionStatus = .idle watcherListener = nil watcherBalance = nil - watcherTransactions = [] + watcherActivities = [] watcherTransactionCount = 0 watcherBlockHeight = 0 watcherAccountType = nil @@ -1715,18 +1174,13 @@ class TrezorViewModel { trezorLog("Watcher stopped: \(watcherId)") } - /// Tear down all watchers when the Trezor dashboard is dismissed. On Android this - /// happens in the ViewModel's onCleared, but this ViewModel is app-lifetime, so the - /// root view calls it from onDisappear. - func stopAllWatchers() { - stopWatcher() - watcherService.stopAllWatchers() - } - - /// Full teardown when the Trezor dashboard is dismissed: stop all watchers and - /// reset the watcher input state so the next visit starts fresh. + /// Full teardown when the Trezor dashboard is dismissed: stop only this dashboard's dev + /// watcher and reset the watcher input state so the next visit starts fresh. The global + /// `watcherService.stopAllWatchers()` is intentionally NOT called here — production + /// hardware watchers owned by `HwWalletManager` share the same service and must stay live; + /// the global stop is reserved for app reset/wipe (`AppReset`). func handleDashboardDismiss() { - stopAllWatchers() + stopWatcher() watcherExtendedKey = "" watcherGapLimit = "20" onchainAccountTypeSelection = .automatic @@ -1757,10 +1211,10 @@ class TrezorViewModel { guard watcherId == activeWatcherId || watcherId == startingWatcherId else { return } switch event { - case let .transactionsChanged(transactions, balance, txCount, blockHeight, accountType): + case let .transactionsChanged(activities, _, balance, txCount, blockHeight, accountType): watcherConnectionStatus = .connected watcherError = nil - watcherTransactions = transactions + watcherActivities = activities watcherBalance = balance watcherTransactionCount = txCount watcherBlockHeight = blockHeight @@ -1814,7 +1268,7 @@ class TrezorViewModel { watcherConnectionStatus = .idle watcherListener = nil watcherBalance = nil - watcherTransactions = [] + watcherActivities = [] watcherTransactionCount = 0 watcherBlockHeight = 0 watcherAccountType = nil @@ -1827,22 +1281,22 @@ class TrezorViewModel { func testShowPinPrompt() { guard Env.isTrezorEmulatorTesting else { return } - showPinEntry = true + connection.showPinEntry = true } func testShowPassphrasePrompt() { guard Env.isTrezorEmulatorTesting else { return } - showPassphraseEntry = true + connection.showPassphraseEntry = true } func testShowPairingCodePrompt() { guard Env.isTrezorEmulatorTesting else { return } - showPairingCode = true + connection.showPairingCode = true } func testShowConfirmOnDevicePrompt() { guard Env.isTrezorEmulatorTesting else { return } - confirmMessage = "Confirm test action on your Trezor" - showConfirmOnDevice = true + connection.confirmMessage = "Confirm test action on your Trezor" + connection.showConfirmOnDevice = true } } diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 6646fc71a..beefc13de 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -385,8 +385,11 @@ class WalletViewModel: ObservableObject { case .unreachablePeers: Logger.warn("⚠️ [DEBUG] Simulating unreachable API peers") return [ - LnPeer(nodeId: "000000000000000000000000000000000000000000000000000000000000000001", - host: "192.0.2.1", port: 9735), + LnPeer( + nodeId: "000000000000000000000000000000000000000000000000000000000000000001", + host: "192.0.2.1", + port: 9735 + ), ] case .none: break @@ -1248,6 +1251,7 @@ class WalletViewModel: ObservableObject { let currentTime = UInt64(Date().timeIntervalSince1970) let preActivityMetadata = BitkitCore.PreActivityMetadata( + walletId: WalletScope.default, paymentId: paymentId, tags: tags, paymentHash: paymentHash, diff --git a/Bitkit/Views/Gift/GiftLoading.swift b/Bitkit/Views/Gift/GiftLoading.swift index b903db4c3..bc7c04a83 100644 --- a/Bitkit/Views/Gift/GiftLoading.swift +++ b/Bitkit/Views/Gift/GiftLoading.swift @@ -100,6 +100,7 @@ struct GiftLoading: View { // Create activity item for the received gift let lightningActivity = LightningActivity( + walletId: WalletScope.default, id: openedOrder.channel?.fundingTx.id ?? orderId, txType: .received, status: .succeeded, diff --git a/Bitkit/Views/Home/HomeWalletView.swift b/Bitkit/Views/Home/HomeWalletView.swift index 603ff6d29..0a033425e 100644 --- a/Bitkit/Views/Home/HomeWalletView.swift +++ b/Bitkit/Views/Home/HomeWalletView.swift @@ -5,15 +5,23 @@ struct HomeWalletView: View { @EnvironmentObject var app: AppViewModel @EnvironmentObject var settings: SettingsViewModel @EnvironmentObject var wallet: WalletViewModel + @Environment(HwWalletManager.self) private var hwWalletManager var hasActivity: Bool { return activity.latestActivities?.isEmpty == false } + /// Headline total including watch-only hardware-wallet balances (keeps `totalBalanceSats` + /// semantics unchanged for send/transfer logic; only the headline folds hardware in). + private var headlineSats: Int { + let hw = Int(clamping: hwWalletManager.totalSats) + return wallet.totalBalanceSats.saturatingAdd(hw) + } + var body: some View { VStack(spacing: 0) { MoneyStack( - sats: wallet.totalBalanceSats, + sats: headlineSats, showSymbol: true, showEyeIcon: true, enableSwipeGesture: settings.swipeBalanceToHide, @@ -43,6 +51,13 @@ struct HomeWalletView: View { .frame(height: 50) .padding(.bottom, 32) + if !hwWalletManager.wallets.isEmpty { + HardwareWalletsGrid(wallets: hwWalletManager.wallets) { _ in + app.toast(type: .info, title: t("coming_soon__nav_title")) + } + .padding(.bottom, 32) + } + if hasActivity { ActivityLatest() diff --git a/Bitkit/Views/Home/HomeWidgetsView.swift b/Bitkit/Views/Home/HomeWidgetsView.swift index 7ab55c9f0..97ea293a0 100644 --- a/Bitkit/Views/Home/HomeWidgetsView.swift +++ b/Bitkit/Views/Home/HomeWidgetsView.swift @@ -2,6 +2,7 @@ import SwiftUI struct HomeWidgetsView: View { @Environment(CalculatorInputManager.self) private var calculatorInput + @Environment(HwWalletManager.self) private var hwWalletManager @EnvironmentObject var app: AppViewModel @Environment(KeyboardManager.self) private var keyboard @EnvironmentObject var navigation: NavigationViewModel @@ -49,6 +50,7 @@ struct HomeWidgetsView: View { app: app, settings: settings, suggestionsManager: suggestionsManager, + hasHardwareWallet: !hwWalletManager.wallets.isEmpty, isPaykitUIEnabled: isPaykitUIActive ).isEmpty } diff --git a/Bitkit/Views/Sheets/HardwareIntroSheet.swift b/Bitkit/Views/Sheets/HardwareIntroSheet.swift new file mode 100644 index 000000000..12bf3efd3 --- /dev/null +++ b/Bitkit/Views/Sheets/HardwareIntroSheet.swift @@ -0,0 +1,47 @@ +import SwiftUI + +struct HardwareIntroSheetItem: SheetItem { + let id: SheetID = .hardwareIntro + let size: SheetSize = .large +} + +/// Intro sheet opened from the Hardware suggestion card. Mirrors bitkit-android's `HwIntroSheet`: +/// staggered device hero, accent title, copy, and Cancel + (disabled) Continue. Continue is +/// intentionally disabled — the connect flow ships in a later PR. +struct HardwareIntroSheet: View { + @EnvironmentObject private var sheets: SheetViewModel + let config: HardwareIntroSheetItem + + var body: some View { + Sheet(id: .hardwareIntro, data: config) { + VStack(spacing: 0) { + SheetHeader(title: t("hardware__intro_title")) + .padding(.horizontal, 16) + + HwDeviceIllustrations() + .frame(maxWidth: .infinity, maxHeight: .infinity) + + VStack(alignment: .leading, spacing: 0) { + DisplayText(t("hardware__intro_header"), accentColor: .blueAccent) + + BodyMText(t("hardware__intro_text")) + .padding(.top, 8) + + HStack(spacing: 16) { + CustomButton(title: t("common__cancel"), variant: .secondary, shouldExpand: true) { + sheets.hideSheet() + } + .accessibilityIdentifier("HwIntroCancel") + + CustomButton(title: t("common__continue"), isDisabled: true, shouldExpand: true) + .accessibilityIdentifier("HwIntroContinue") + } + .padding(.top, 32) + .padding(.bottom, 16) + } + .padding(.horizontal, 32) + } + .accessibilityIdentifier("HwIntroSheet") + } + } +} diff --git a/Bitkit/Views/Sheets/HardwarePairingSheet.swift b/Bitkit/Views/Sheets/HardwarePairingSheet.swift new file mode 100644 index 000000000..6efb4fbad --- /dev/null +++ b/Bitkit/Views/Sheets/HardwarePairingSheet.swift @@ -0,0 +1,83 @@ +import SwiftUI + +struct HardwarePairingSheetItem: SheetItem { + let id: SheetID = .hardwarePairing + let size: SheetSize = .large +} + +/// App-wide sheet for the one-time pairing code a hardware device shows during connect/reconnect. +/// Dismissing without entering the full code cancels the pending pairing request. +struct HardwarePairingSheet: View { + @Environment(TrezorManager.self) private var trezorManager + @Environment(\.scenePhase) private var scenePhase + let config: HardwarePairingSheetItem + + private let codeLength = 6 + private let cellWidth: CGFloat = 32 + + @State private var code = "" + @State private var submitted = false + + var body: some View { + Sheet(id: .hardwarePairing, data: config) { + VStack(spacing: 0) { + SheetHeader(title: t("hardware__pairing_title")) + .padding(.horizontal, 16) + + VStack(spacing: 0) { + BodyMText(t("hardware__pairing_text")) + .multilineTextAlignment(.center) + + Spacer() + + HStack(spacing: 8) { + ForEach(0 ..< codeLength, id: \.self) { index in + let digit = digit(at: index) + DisplayText(digit ?? "•", textColor: digit != nil ? .textPrimary : .white32) + .frame(width: cellWidth) + } + } + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.horizontal, 32) + + NumberPad(type: .simple, onPress: handleKey) + .frame(height: NumberPad.contentHeight) + } + .accessibilityIdentifier("HwPairSheet") + } + .onDisappear { + // Treat this as a user cancel only when it's a genuine foreground dismissal with the + // request still pending. Backgrounding (scenePhase != .active) or the request already + // being resolved/submitted (showPairingCode == false) must not abort the pairing. + guard !submitted, scenePhase == .active, trezorManager.showPairingCode else { return } + trezorManager.cancelPairingCode() + } + } + + private func digit(at index: Int) -> String? { + let characters = Array(code) + return index < characters.count ? String(characters[index]) : nil + } + + private func handleKey(_ key: String) { + if key == "delete" { + if !code.isEmpty { code.removeLast() } + } else if code.count < codeLength { + code += key + if code.count == codeLength { + submitted = true + trezorManager.submitPairingCode(code) + } + } + } +} + +#Preview { + HardwarePairingSheet(config: HardwarePairingSheetItem()) + .environmentObject(SheetViewModel()) + .environment(TrezorManager()) + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Trezor/TrezorAddressView.swift b/Bitkit/Views/Trezor/TrezorAddressView.swift index 95537e0e7..c53b254b5 100644 --- a/Bitkit/Views/Trezor/TrezorAddressView.swift +++ b/Bitkit/Views/Trezor/TrezorAddressView.swift @@ -39,6 +39,7 @@ enum TrezorAddressScriptType: String, CaseIterable { /// Inline content for address generation, used by expandable section. struct TrezorAddressContent: View { + @Environment(TrezorManager.self) private var trezorManager @Environment(TrezorViewModel.self) private var trezor @State private var selectedScriptType: TrezorAddressScriptType = .segwit @@ -51,7 +52,7 @@ struct TrezorAddressContent: View { AddressResultSection() } .onChange(of: selectedScriptType) { newValue in - trezor.derivationPath = newValue.defaultPath(coinType: trezor.coinTypeComponent) + trezor.derivationPath = newValue.defaultPath(coinType: trezorManager.coinTypeComponent) trezor.selectedScriptType = newValue.trezorScriptType trezor.addressIndex = 0 } @@ -104,6 +105,7 @@ private struct AddressTypeSection: View { // MARK: - Derivation Path Section private struct DerivationPathSection: View { + @Environment(TrezorManager.self) private var trezorManager @Environment(TrezorViewModel.self) private var trezor let selectedScriptType: TrezorAddressScriptType @FocusState private var isFieldFocused: Bool @@ -157,7 +159,7 @@ private struct DerivationPathSection: View { .trezorAccessibilityAnchor("TrezorAddressIndex") Button(action: { - trezor.derivationPath = selectedScriptType.defaultPath(coinType: trezor.coinTypeComponent) + trezor.derivationPath = selectedScriptType.defaultPath(coinType: trezorManager.coinTypeComponent) trezor.addressIndex = 0 }) { Text("Use default path") @@ -404,7 +406,8 @@ private struct CopyButton: View { NavigationStack { TrezorAddressView() } - .environment(TrezorViewModel()) + .environment(TrezorManager()) + .environment(TrezorViewModel(connection: TrezorManager())) } } #endif diff --git a/Bitkit/Views/Trezor/TrezorBalanceLookupView.swift b/Bitkit/Views/Trezor/TrezorBalanceLookupView.swift index af533557c..83d1e268f 100644 --- a/Bitkit/Views/Trezor/TrezorBalanceLookupView.swift +++ b/Bitkit/Views/Trezor/TrezorBalanceLookupView.swift @@ -56,6 +56,7 @@ private struct LookupButtonWrapper: View { /// keeping the parent body free of ViewModel property accesses. private struct BalanceLookupResultsSection: View { let input: String + @Environment(TrezorManager.self) private var trezorManager @Environment(TrezorViewModel.self) private var trezor private var hasResults: Bool { @@ -89,7 +90,7 @@ private struct BalanceLookupResultsSection: View { isComposing: trezor.isComposing, isOperating: trezor.isOperating, isBroadcasting: trezor.isBroadcasting, - isDeviceConnected: trezor.isConnected, + isDeviceConnected: trezorManager.isConnected, composeResult: trezor.composeResult, signedTxResult: trezor.signedTxResult, broadcastTxid: trezor.broadcastTxid, diff --git a/Bitkit/Views/Trezor/TrezorConnectedView.swift b/Bitkit/Views/Trezor/TrezorConnectedView.swift index ace5faab2..35426a3d4 100644 --- a/Bitkit/Views/Trezor/TrezorConnectedView.swift +++ b/Bitkit/Views/Trezor/TrezorConnectedView.swift @@ -4,7 +4,7 @@ import SwiftUI /// View displayed when connected to a Trezor device. /// Uses expandable sections instead of navigation to separate screens. struct TrezorConnectedView: View { - @Environment(TrezorViewModel.self) private var trezor + @Environment(TrezorManager.self) private var trezorManager @State private var isAddressExpanded = false @State private var isSignMessageExpanded = false @State private var isPublicKeyExpanded = false @@ -19,8 +19,8 @@ struct TrezorConnectedView: View { VStack(spacing: 24) { // Device card DeviceInfoCard( - device: trezor.connectedDevice, - features: trezor.deviceFeatures + device: trezorManager.connectedDevice, + features: trezorManager.deviceFeatures ) // Wallet mode selector (standard vs hidden/passphrase wallet) @@ -112,7 +112,7 @@ struct TrezorConnectedView: View { // Disconnect button Button(action: { Task { - await trezor.disconnect() + await trezorManager.disconnect() } }) { HStack(spacing: 8) { @@ -142,8 +142,8 @@ struct TrezorConnectedView: View { .toolbar { ToolbarItem(placement: .navigationBarTrailing) { TrezorStatusBadge( - isConnected: trezor.isConnected, - deviceName: trezor.deviceFeatures?.label + isConnected: trezorManager.isConnected, + deviceName: trezorManager.deviceFeatures?.label ) .allowsHitTesting(false) } @@ -156,6 +156,7 @@ struct TrezorConnectedView: View { /// Lets the user switch between the standard wallet and a hidden (passphrase) wallet. /// Switching resets the device session (handled by the ViewModel). private struct WalletModeSelectorRow: View { + @Environment(TrezorManager.self) private var trezorManager @Environment(TrezorViewModel.self) private var trezor private enum WalletModeTab: CaseIterable, CustomStringConvertible { @@ -175,13 +176,13 @@ private struct WalletModeSelectorRow: View { /// passphrase flow completes). private var selectedTab: Binding { Binding( - get: { trezor.walletMode == .standard ? .standard : .passphrase }, + get: { trezorManager.walletMode == .standard ? .standard : .passphrase }, set: { newValue in switch newValue { case .standard: - Task { await trezor.selectStandardWallet() } + Task { await trezorManager.selectStandardWallet() } case .passphrase: - trezor.requestPassphraseWallet() + trezorManager.requestPassphraseWallet() } } ) @@ -253,7 +254,7 @@ private struct DeviceInfoCard: View { NavigationStack { TrezorConnectedView() } - .environment(TrezorViewModel()) + .environment(TrezorManager()) } } #endif diff --git a/Bitkit/Views/Trezor/TrezorDeviceFeaturesView.swift b/Bitkit/Views/Trezor/TrezorDeviceFeaturesView.swift index 322f4ef93..9dc216e9b 100644 --- a/Bitkit/Views/Trezor/TrezorDeviceFeaturesView.swift +++ b/Bitkit/Views/Trezor/TrezorDeviceFeaturesView.swift @@ -3,14 +3,14 @@ import SwiftUI /// Inline content for device features, used by expandable section. struct TrezorDeviceFeaturesContent: View { - @Environment(TrezorViewModel.self) private var trezor + @Environment(TrezorManager.self) private var trezorManager var body: some View { VStack(spacing: 24) { - if let features = trezor.deviceFeatures { + if let features = trezorManager.deviceFeatures { FirmwareSection(features: features) SecuritySection(features: features) - IdentifiersSection(features: features, device: trezor.connectedDevice) + IdentifiersSection(features: features, device: trezorManager.connectedDevice) ActionsSection() } else { NoFeaturesView() @@ -140,13 +140,13 @@ private struct IdentifiersSection: View { // MARK: - Actions Section private struct ActionsSection: View { - @Environment(TrezorViewModel.self) private var trezor + @Environment(TrezorManager.self) private var trezorManager var body: some View { VStack(spacing: 12) { Button(action: { Task { - await trezor.clearCredentials() + await trezorManager.clearCredentials() } }) { HStack(spacing: 8) { @@ -272,7 +272,7 @@ private struct TrezorStatusRow: View { NavigationStack { TrezorDeviceFeaturesView() } - .environment(TrezorViewModel()) + .environment(TrezorManager()) } } #endif diff --git a/Bitkit/Views/Trezor/TrezorDeviceListView.swift b/Bitkit/Views/Trezor/TrezorDeviceListView.swift index 358560798..56eb2833b 100644 --- a/Bitkit/Views/Trezor/TrezorDeviceListView.swift +++ b/Bitkit/Views/Trezor/TrezorDeviceListView.swift @@ -4,14 +4,14 @@ import SwiftUI /// View displaying discovered Trezor devices struct TrezorDeviceListView: View { - @Environment(TrezorViewModel.self) private var trezor + @Environment(TrezorManager.self) private var trezorManager @State private var connectingDevicePath: String? @State private var isWatcherExpanded = false /// Scanned devices that are NOT already in the known devices list private var nearbyDevices: [TrezorDeviceInfo] { - let knownIds = Set(trezor.knownDevices.map(\.id)) - return trezor.devices.filter { !knownIds.contains($0.id) } + let knownIds = Set(trezorManager.knownDevices.map(\.id)) + return trezorManager.devices.filter { !knownIds.contains($0.id) } } var body: some View { @@ -20,28 +20,28 @@ struct TrezorDeviceListView: View { ScrollView { VStack(spacing: 24) { // Bluetooth status (don't show during initial .unknown state) - if !trezor.isBridgeModeEnabled, trezor.bluetoothState != .poweredOn, trezor.bluetoothState != .unknown { - BluetoothStatusCard(state: trezor.bluetoothState) + if !trezorManager.isBridgeModeEnabled, trezorManager.bluetoothState != .poweredOn, trezorManager.bluetoothState != .unknown { + BluetoothStatusCard(state: trezorManager.bluetoothState) } // Auto-reconnect indicator - if trezor.isAutoReconnecting, let status = trezor.autoReconnectStatus { + if trezorManager.isAutoReconnecting, let status = trezorManager.autoReconnectStatus { AutoReconnectIndicator(status: status) } // Scanning indicator - if trezor.isScanning, !trezor.isAutoReconnecting { + if trezorManager.isScanning, !trezorManager.isAutoReconnecting { ScanningIndicator() } // Known devices section - if !trezor.knownDevices.isEmpty { + if !trezorManager.knownDevices.isEmpty { VStack(alignment: .leading, spacing: 12) { Text("My Devices") .font(.system(size: 14, weight: .medium)) .foregroundColor(.white.opacity(0.6)) - ForEach(trezor.knownDevices) { device in + ForEach(trezorManager.knownDevices) { device in KnownDeviceRow( device: device, isConnecting: connectingDevicePath == device.path @@ -49,7 +49,7 @@ struct TrezorDeviceListView: View { connectToKnownDevice(device) } onForget: { Task { - await trezor.forgetDevice(id: device.id) + await trezorManager.forgetDevice(id: device.id) } } } @@ -75,14 +75,14 @@ struct TrezorDeviceListView: View { } // Empty state - if !trezor.isScanning, !trezor.isAutoReconnecting, - trezor.knownDevices.isEmpty, trezor.devices.isEmpty + if !trezorManager.isScanning, !trezorManager.isAutoReconnecting, + trezorManager.knownDevices.isEmpty, trezorManager.devices.isEmpty { TrezorEmptyStateView() } // Error display - if let error = trezor.error { + if let error = trezorManager.error { ErrorCard(message: error) } @@ -103,17 +103,17 @@ struct TrezorDeviceListView: View { } // Bottom action button - if !trezor.isScanning, !trezor.isAutoReconnecting, - trezor.isBridgeModeEnabled || trezor.bluetoothState == .poweredOn || trezor.bluetoothState == .unknown + if !trezorManager.isScanning, !trezorManager.isAutoReconnecting, + trezorManager.isBridgeModeEnabled || trezorManager.bluetoothState == .poweredOn || trezorManager.bluetoothState == .unknown { Button(action: { Task { - await trezor.startScan() + await trezorManager.startScan() } }) { HStack(spacing: 8) { Image(systemName: "antenna.radiowaves.left.and.right") - Text(trezor.devices.isEmpty ? "Scan for Devices" : "Scan Again") + Text(trezorManager.devices.isEmpty ? "Scan for Devices" : "Scan Again") } .font(.system(size: 16, weight: .semibold)) .foregroundColor(.black) @@ -131,27 +131,27 @@ struct TrezorDeviceListView: View { .navigationBarTitleDisplayMode(.inline) .trezorAccessibilityAnchor("TrezorDeviceList") .task { - trezor.loadKnownDevices() + trezorManager.loadKnownDevices() // Wait briefly for BLE state to settle if still unknown // (CBCentralManager fires centralManagerDidUpdateState async after creation) - if trezor.bluetoothState == .unknown { + if trezorManager.bluetoothState == .unknown { for _ in 0 ..< 10 { try? await Task.sleep(nanoseconds: 100_000_000) // 100ms - if trezor.bluetoothState != .unknown { break } + if trezorManager.bluetoothState != .unknown { break } } } - guard trezor.isBridgeModeEnabled || trezor.bluetoothState == .poweredOn else { return } + guard trezorManager.isBridgeModeEnabled || trezorManager.bluetoothState == .poweredOn else { return } - if !trezor.knownDevices.isEmpty { - await trezor.autoReconnect() - } else if trezor.devices.isEmpty { - await trezor.startScan() + if !trezorManager.knownDevices.isEmpty { + await trezorManager.autoReconnect() + } else if trezorManager.devices.isEmpty { + await trezorManager.startScan() } } .onDisappear { - trezor.stopScan() + trezorManager.stopScan() } } @@ -159,7 +159,7 @@ struct TrezorDeviceListView: View { connectingDevicePath = device.path Task { - await trezor.connect(device: device) + await trezorManager.connect(device: device) connectingDevicePath = nil } } @@ -169,15 +169,15 @@ struct TrezorDeviceListView: View { Task { // Check if this device was found in the last scan - if let scanned = trezor.devices.first(where: { $0.id == knownDevice.id }) { - await trezor.connect(device: scanned) + if let scanned = trezorManager.devices.first(where: { $0.id == knownDevice.id }) { + await trezorManager.connect(device: scanned) } else { // Need to scan first to find the device - await trezor.startScan(clearExisting: false) - if let scanned = trezor.devices.first(where: { $0.id == knownDevice.id }) { - await trezor.connect(device: scanned) + await trezorManager.startScan(clearExisting: false) + if let scanned = trezorManager.devices.first(where: { $0.id == knownDevice.id }) { + await trezorManager.connect(device: scanned) } else { - trezor.error = "Device not found nearby. Make sure your Trezor is turned on." + trezorManager.error = "Device not found nearby. Make sure your Trezor is turned on." } } connectingDevicePath = nil @@ -329,7 +329,7 @@ private struct ErrorCard: View { NavigationStack { TrezorDeviceListView() } - .environment(TrezorViewModel()) + .environment(TrezorManager()) } } #endif diff --git a/Bitkit/Views/Trezor/TrezorPublicKeyView.swift b/Bitkit/Views/Trezor/TrezorPublicKeyView.swift index cb51ae12e..ffa956681 100644 --- a/Bitkit/Views/Trezor/TrezorPublicKeyView.swift +++ b/Bitkit/Views/Trezor/TrezorPublicKeyView.swift @@ -155,7 +155,7 @@ private struct CopyableField: View { NavigationStack { TrezorPublicKeyView() } - .environment(TrezorViewModel()) + .environment(TrezorViewModel(connection: TrezorManager())) } } #endif diff --git a/Bitkit/Views/Trezor/TrezorRootView.swift b/Bitkit/Views/Trezor/TrezorRootView.swift index 1f3dc7971..844157edf 100644 --- a/Bitkit/Views/Trezor/TrezorRootView.swift +++ b/Bitkit/Views/Trezor/TrezorRootView.swift @@ -57,17 +57,30 @@ extension View { /// Isolates the connected/disconnected toggle so only this view /// re-renders when connection state changes. private struct TrezorContentSwitcher: View { + @Environment(TrezorManager.self) private var trezorManager @Environment(TrezorViewModel.self) private var trezor var body: some View { Group { - if trezor.isConnected { + if trezorManager.isConnected { TrezorConnectedView() } else { TrezorDeviceListView() } } - .animation(.easeInOut(duration: 0.25), value: trezor.isConnected) + .animation(.easeInOut(duration: 0.25), value: trezorManager.isConnected) + .onChange(of: trezorManager.isConnected) { _, isConnected in + // Drop the previous wallet's dev-tool results on disconnect so a different/absent + // wallet never shows stale xpub/address/public-key data. + if !isConnected { + trezor.clearWalletResults() + } + } + .onChange(of: trezorManager.walletMode) { _, _ in + // A wallet-mode switch rebinds the session to a different wallet but keeps the device + // connected, so the disconnect path above doesn't fire — clear results here too. + trezor.clearWalletResults() + } } } @@ -80,12 +93,13 @@ private struct TrezorContentSwitcher: View { /// The ViewModel is only accessed inside closures, so the root body still /// establishes no observation dependencies. private struct TrezorLifecycleModifier: ViewModifier { + @Environment(TrezorManager.self) private var trezorManager @Environment(TrezorViewModel.self) private var trezor func body(content: Content) -> some View { content .task { - trezor.setup() + trezorManager.setup() } .onDisappear { // The ViewModel outlives this screen (app-lifetime), so watchers and @@ -115,47 +129,46 @@ private struct TrezorDebugLogWrapper: View { /// Groups all sheet and overlay presentations that depend on ViewModel state, /// keeping TrezorRootView's body free of @Environment access. private struct TrezorDialogsModifier: ViewModifier { - @Environment(TrezorViewModel.self) private var trezor + @Environment(TrezorManager.self) private var trezorManager func body(content: Content) -> some View { - @Bindable var trezor = trezor + @Bindable var trezorManager = trezorManager content - .sheet(isPresented: $trezor.showPinEntry) { + .sheet(isPresented: $trezorManager.showPinEntry) { TrezorPinEntrySheet() } - .sheet(isPresented: $trezor.showPairingCode) { - TrezorPairingCodeSheet() - } - .sheet(isPresented: $trezor.showPassphraseEntry) { + // Pairing code is presented app-wide via the SheetViewModel system (see MainNavView), + // so the home hardware feature shows it regardless of being on this dev screen. + .sheet(isPresented: $trezorManager.showPassphraseEntry) { TrezorPassphraseSheet() } .confirmationDialog( "Passphrase Entry", - isPresented: $trezor.showWalletModeChooser, + isPresented: $trezorManager.showWalletModeChooser, titleVisibility: .visible ) { Button("On this phone") { - trezor.choosePhonePassphraseEntry() + trezorManager.choosePhonePassphraseEntry() } .accessibilityIdentifier("TrezorWalletModeOnPhone") Button("On the Trezor") { - Task { await trezor.chooseDevicePassphraseEntry() } + Task { await trezorManager.chooseDevicePassphraseEntry() } } .accessibilityIdentifier("TrezorWalletModeOnTrezor") Button("Cancel", role: .cancel) { - trezor.showWalletModeChooser = false + trezorManager.showWalletModeChooser = false } } message: { Text("Where do you want to enter the passphrase for your hidden wallet?") } .overlay { - if trezor.showConfirmOnDevice { + if trezorManager.showConfirmOnDevice { TrezorConfirmOnDeviceOverlay( - message: trezor.confirmMessage, + message: trezorManager.confirmMessage, onCancel: { - trezor.dismissConfirmOnDevice() + trezorManager.dismissConfirmOnDevice() } ) } @@ -166,6 +179,7 @@ private struct TrezorDialogsModifier: ViewModifier { // MARK: - Network Selector private struct NetworkSelectorRow: View { + @Environment(TrezorManager.self) private var trezorManager @Environment(TrezorViewModel.self) private var trezor private let networks: [(TrezorCoinType, String)] = [ @@ -183,13 +197,13 @@ private struct NetworkSelectorRow: View { HStack(spacing: 8) { ForEach(Array(networks.enumerated()), id: \.offset) { _, item in let (network, label) = item - Button(action: { trezor.setSelectedNetwork(network) }) { + Button(action: { selectNetwork(network) }) { Text(label) .font(.system(size: 12, weight: .semibold)) - .foregroundColor(trezor.selectedNetwork == network ? .white : .white.opacity(0.5)) + .foregroundColor(trezorManager.selectedNetwork == network ? .white : .white.opacity(0.5)) .padding(.horizontal, 14) .padding(.vertical, 6) - .background(trezor.selectedNetwork == network ? Color.white.opacity(0.2) : Color.white.opacity(0.05)) + .background(trezorManager.selectedNetwork == network ? Color.white.opacity(0.2) : Color.white.opacity(0.05)) .clipShape(Capsule()) } .accessibilityIdentifier("TrezorNetwork-\(label)") @@ -200,12 +214,18 @@ private struct NetworkSelectorRow: View { .frame(maxWidth: .infinity) .background(Color.white.opacity(0.02)) } + + private func selectNetwork(_ network: TrezorCoinType) { + guard network != trezorManager.selectedNetwork else { return } + trezorManager.setSelectedNetwork(network) + trezor.handleNetworkChange() + } } // MARK: - PIN Entry Sheet struct TrezorPinEntrySheet: View { - @Environment(TrezorViewModel.self) private var trezor + @Environment(TrezorManager.self) private var trezorManager @Environment(\.dismiss) private var dismiss @State private var pin: String = "" @@ -234,7 +254,7 @@ struct TrezorPinEntrySheet: View { // Buttons HStack(spacing: 16) { Button(action: { - trezor.cancelPin() + trezorManager.cancelPin() dismiss() }) { Text("Cancel") @@ -248,7 +268,7 @@ struct TrezorPinEntrySheet: View { .accessibilityIdentifier("TrezorPinCancel") Button(action: { - trezor.submitPin(pin) + trezorManager.submitPin(pin) dismiss() }) { Text("Confirm") @@ -276,7 +296,7 @@ struct TrezorPinEntrySheet: View { // MARK: - Pairing Code Sheet struct TrezorPairingCodeSheet: View { - @Environment(TrezorViewModel.self) private var trezor + @Environment(TrezorManager.self) private var trezorManager @Environment(\.dismiss) private var dismiss @State private var code: String = "" @State private var hasSubmitted = false @@ -308,7 +328,7 @@ struct TrezorPairingCodeSheet: View { // Buttons HStack(spacing: 16) { Button(action: { - trezor.cancelPairingCode() + trezorManager.cancelPairingCode() dismiss() }) { Text("Cancel") @@ -324,7 +344,7 @@ struct TrezorPairingCodeSheet: View { Button(action: { guard !hasSubmitted else { return } hasSubmitted = true - trezor.submitPairingCode(code) + trezorManager.submitPairingCode(code) dismiss() }) { Text("Confirm") @@ -350,7 +370,7 @@ struct TrezorPairingCodeSheet: View { if newValue.count == digitCount { guard !hasSubmitted else { return } hasSubmitted = true - trezor.submitPairingCode(newValue) + trezorManager.submitPairingCode(newValue) dismiss() } } @@ -360,7 +380,7 @@ struct TrezorPairingCodeSheet: View { // MARK: - Passphrase Sheet struct TrezorPassphraseSheet: View { - @Environment(TrezorViewModel.self) private var trezor + @Environment(TrezorManager.self) private var trezorManager @Environment(\.dismiss) private var dismiss @State private var passphrase: String = "" @State private var confirmPassphrase: String = "" @@ -427,10 +447,10 @@ struct TrezorPassphraseSheet: View { } // Offer on-device entry when the connected Trezor supports it - if trezor.passphraseEntryCapable { + if trezorManager.passphraseEntryCapable { CustomButton(title: "Enter on Trezor instead", variant: .tertiary) { dismiss() - await trezor.chooseDevicePassphraseEntry() + await trezorManager.chooseDevicePassphraseEntry() } .padding(.top, 4) .accessibilityIdentifier("TrezorPassphraseUseDevice") @@ -443,7 +463,7 @@ struct TrezorPassphraseSheet: View { // Buttons HStack(spacing: 16) { Button(action: { - trezor.cancelPassphrase() + trezorManager.cancelPassphrase() dismiss() }) { Text("Cancel") @@ -459,7 +479,7 @@ struct TrezorPassphraseSheet: View { Button(action: { let entered = passphrase dismiss() - Task { await trezor.submitPassphrase(entered) } + Task { await trezorManager.submitPassphrase(entered) } }) { Text("Confirm") .font(.system(size: 16, weight: .semibold)) @@ -653,7 +673,8 @@ struct TrezorDebugLogPanel: View { struct TrezorRootView_Previews: PreviewProvider { static var previews: some View { TrezorRootView() - .environment(TrezorViewModel()) + .environment(TrezorManager()) + .environment(TrezorViewModel(connection: TrezorManager())) } } #endif diff --git a/Bitkit/Views/Trezor/TrezorSignMessageView.swift b/Bitkit/Views/Trezor/TrezorSignMessageView.swift index 01b600383..b3e2e2b37 100644 --- a/Bitkit/Views/Trezor/TrezorSignMessageView.swift +++ b/Bitkit/Views/Trezor/TrezorSignMessageView.swift @@ -369,7 +369,7 @@ private struct VerificationResultBanner: View { NavigationStack { TrezorSignMessageView() } - .environment(TrezorViewModel()) + .environment(TrezorViewModel(connection: TrezorManager())) } } #endif diff --git a/Bitkit/Views/Trezor/TrezorTransactionDetailView.swift b/Bitkit/Views/Trezor/TrezorTransactionDetailView.swift index 937cb9be0..ffea197d5 100644 --- a/Bitkit/Views/Trezor/TrezorTransactionDetailView.swift +++ b/Bitkit/Views/Trezor/TrezorTransactionDetailView.swift @@ -528,7 +528,7 @@ private struct ResultRow: View { NavigationStack { TrezorTransactionDetailView() } - .environment(TrezorViewModel()) + .environment(TrezorViewModel(connection: TrezorManager())) } } #endif diff --git a/Bitkit/Views/Trezor/TrezorTransactionHistoryView.swift b/Bitkit/Views/Trezor/TrezorTransactionHistoryView.swift index 9be5933f7..970cc7f7f 100644 --- a/Bitkit/Views/Trezor/TrezorTransactionHistoryView.swift +++ b/Bitkit/Views/Trezor/TrezorTransactionHistoryView.swift @@ -411,7 +411,7 @@ private struct ResultRow: View { NavigationStack { TrezorTransactionHistoryView() } - .environment(TrezorViewModel()) + .environment(TrezorViewModel(connection: TrezorManager())) } } #endif diff --git a/Bitkit/Views/Trezor/TrezorWatcherView.swift b/Bitkit/Views/Trezor/TrezorWatcherView.swift index bac2a939c..b35a9ba13 100644 --- a/Bitkit/Views/Trezor/TrezorWatcherView.swift +++ b/Bitkit/Views/Trezor/TrezorWatcherView.swift @@ -139,12 +139,12 @@ private struct WatcherStatusView: View { } // Transactions - if !trezor.watcherTransactions.isEmpty { - CaptionMText("Transactions (\(trezor.watcherTransactions.count))") + if !trezor.watcherActivities.isEmpty { + CaptionMText("Transactions (\(trezor.watcherActivities.count))") VStack(spacing: 4) { - ForEach(trezor.watcherTransactions, id: \.txid) { tx in - WatcherTransactionRow(tx: tx) + ForEach(trezor.watcherActivities, id: \.watcherRowId) { activity in + WatcherActivityRow(activity: activity) } } } @@ -181,33 +181,39 @@ private struct WatcherStatusView: View { } } -private struct WatcherTransactionRow: View { - let tx: HistoryTransaction +private struct WatcherActivityRow: View { + let activity: Activity + + private var onchain: OnchainActivity? { + guard case let .onchain(onchain) = activity else { return nil } + return onchain + } private var directionLabel: String { - switch tx.direction { + switch onchain?.txType { case .sent: return "Sent" case .received: return "Recv" - case .selfTransfer: return "Self" + case .none: return "-" } } private var directionColor: Color { - switch tx.direction { + switch onchain?.txType { case .sent: return .redAccent case .received: return .greenAccent - case .selfTransfer: return .white64 + case .none: return .white64 } } private var shortTxid: String { - guard tx.txid.count > 16 else { return tx.txid } - return "\(tx.txid.prefix(8))...\(tx.txid.suffix(8))" + let txid = onchain?.txId ?? "" + guard txid.count > 16 else { return txid } + return "\(txid.prefix(8))...\(txid.suffix(8))" } var body: some View { HStack { - CaptionText("\(directionLabel) \(tx.amount) sats", textColor: directionColor) + CaptionText("\(directionLabel) \(onchain?.value ?? 0) sats", textColor: directionColor) Spacer() Text(shortTxid) .font(.system(size: 12, design: .monospaced)) @@ -217,6 +223,16 @@ private struct WatcherTransactionRow: View { } } +private extension Activity { + /// Stable identifier for list rendering. + var watcherRowId: String { + switch self { + case let .onchain(onchain): return onchain.id + case let .lightning(lightning): return lightning.id + } + } +} + private struct InfoRow: View { let label: String let value: String diff --git a/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift b/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift index b208e29b3..3a8f0bffb 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift @@ -48,7 +48,10 @@ struct ActivityExplorerView: View { guard let onchain else { return } do { - let details = try await CoreService.shared.activity.getTransactionDetails(txid: onchain.txId) + let details = try await CoreService.shared.activity.getTransactionDetails( + txid: onchain.txId, + walletId: item.walletId + ) await MainActor.run { txDetails = details } @@ -68,7 +71,10 @@ struct ActivityExplorerView: View { private func refreshActivity() async { do { - if let updatedActivity = try await CoreService.shared.activity.getActivity(id: activityId) { + if let updatedActivity = try await CoreService.shared.activity.getActivity( + id: activityId, + walletId: item.walletId + ) { await MainActor.run { item = updatedActivity } @@ -273,6 +279,7 @@ struct ActivityExplorer_Previews: PreviewProvider { ActivityExplorerView( item: .lightning( LightningActivity( + walletId: WalletScope.default, id: "test-lightning-1", txType: .received, status: .succeeded, @@ -295,6 +302,7 @@ struct ActivityExplorer_Previews: PreviewProvider { ActivityExplorerView( item: .onchain( OnchainActivity( + walletId: WalletScope.default, id: "test-onchain-1", txType: .received, txId: "9c60a69005cbdb7323f8f0551d5c6f79a8c9c27c32475e4a0ad4a47d305c629d", diff --git a/Bitkit/Views/Wallets/Activity/ActivityIcon.swift b/Bitkit/Views/Wallets/Activity/ActivityIcon.swift index 13810c493..dcd97388b 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityIcon.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityIcon.swift @@ -17,11 +17,13 @@ struct ActivityIcon: View { let doesExist: Bool let isCpfpChild: Bool let context: Context + let isHwWallet: Bool init(activity: Activity, size: CGFloat = 32, isCpfpChild: Bool = false, context: Context = .detail) { self.size = size self.isCpfpChild = isCpfpChild self.context = context + isHwWallet = activity.isHardwareWallet switch activity { case let .lightning(ln): @@ -70,7 +72,10 @@ struct ActivityIcon: View { ) } else { let paymentIcon = txType == .sent ? "arrow-up" : "arrow-down" - let (iconColor, backgroundColor): (Color, Color) = if isTransfer { + let (iconColor, backgroundColor): (Color, Color) = if isHwWallet { + // Watch-only hardware-wallet activity reads blue. + (.blueAccent, .blue16) + } else if isTransfer { // From savings (to spending) = sent = orange, From spending (to savings) = received = purple txType == .sent ? (.brandAccent, .brand16) : (.purpleAccent, .purple16) } else { diff --git a/Bitkit/Views/Wallets/Activity/ActivityItemView.swift b/Bitkit/Views/Wallets/Activity/ActivityItemView.swift index f9d7260d8..5de9a1084 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityItemView.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityItemView.swift @@ -135,7 +135,20 @@ struct ActivityItemView: View { return DateFormatterHelpers.formatActivityDetail(activity.timestamp) } + // TODO: Interim feature-gating. Tag/contact/boost are hidden for hardware activities + // because CoreService's activity-mutation methods (markActivityAsSeen, setContact, delete, + // tag, boost) still hardcode WalletScope.default and can't address a non-default wallet id. + // Revisit once those methods are wallet_id-scoped (and wallet_id / hardware activities ship + // to production) so this becomes a real capability check rather than a wallet-id shorthand. + private var isHardwareActivity: Bool { + viewModel.activity.isHardwareWallet + } + private var shouldDisableBoostButton: Bool { + // Watch-only hardware wallets have no signing keys, so RBF is impossible. + if isHardwareActivity { + return true + } switch viewModel.activity { case .lightning: return true @@ -514,42 +527,45 @@ struct ActivityItemView: View { private var buttons: some View { VStack(spacing: 16) { - HStack(spacing: 16) { - if isPaykitUIActive { + // Contact and tag actions are hidden for watch-only hardware wallets. + if !isHardwareActivity { + HStack(spacing: 16) { + if isPaykitUIActive { + CustomButton( + title: assignedContact == nil ? t("wallet__activity_assign") : t("wallet__activity_detach"), size: .small, + icon: Image(assignedContact == nil ? "user-plus" : "user-minus") + .foregroundColor(accentColor), + shouldExpand: true + ) { + if assignedContact == nil { + navigation.navigate(.assignActivityContact(activityId: viewModel.activityId)) + } else { + Task { + await detachContact() + } + } + } + .accessibilityIdentifier(assignedContact == nil ? "ActivityAssignContact" : "ActivityDetachContact") + } + CustomButton( - title: assignedContact == nil ? t("wallet__activity_assign") : t("wallet__activity_detach"), size: .small, - icon: Image(assignedContact == nil ? "user-plus" : "user-minus") + title: t("wallet__activity_tag"), size: .small, + icon: Image("tag") .foregroundColor(accentColor), shouldExpand: true ) { - if assignedContact == nil { - navigation.navigate(.assignActivityContact(activityId: viewModel.activityId)) - } else { - Task { - await detachContact() - } + let activityId: String = switch viewModel.activity { + case let .lightning(activity): + activity.id + case let .onchain(activity): + activity.id } + sheets.showSheet(.addTag, data: AddTagConfig(activityId: activityId)) } - .accessibilityIdentifier(assignedContact == nil ? "ActivityAssignContact" : "ActivityDetachContact") + .accessibilityIdentifier("ActivityTag") } - - CustomButton( - title: t("wallet__activity_tag"), size: .small, - icon: Image("tag") - .foregroundColor(accentColor), - shouldExpand: true - ) { - let activityId: String = switch viewModel.activity { - case let .lightning(activity): - activity.id - case let .onchain(activity): - activity.id - } - sheets.showSheet(.addTag, data: AddTagConfig(activityId: activityId)) - } - .accessibilityIdentifier("ActivityTag") + .frame(maxWidth: .infinity) } - .frame(maxWidth: .infinity) HStack(spacing: 16) { CustomButton( @@ -644,6 +660,7 @@ struct ActivityItemView_Previews: PreviewProvider { ActivityItemView( item: .lightning( LightningActivity( + walletId: WalletScope.default, id: "test-lightning-1", txType: .sent, status: .succeeded, @@ -668,6 +685,7 @@ struct ActivityItemView_Previews: PreviewProvider { ActivityItemView( item: .onchain( OnchainActivity( + walletId: WalletScope.default, id: "test-onchain-1", txType: .received, txId: "abc123", diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 55547fc7b..9d1aae658 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -712,6 +712,7 @@ struct SendConfirmationView: View { ) async { let currentTime = UInt64(Date().timeIntervalSince1970) let preActivityMetadata = BitkitCore.PreActivityMetadata( + walletId: WalletScope.default, paymentId: paymentId, tags: tagManager.selectedTagsArray, paymentHash: paymentHash, diff --git a/Bitkit/Views/Wallets/Sheets/BoostSheet.swift b/Bitkit/Views/Wallets/Sheets/BoostSheet.swift index f514d47e2..107896edf 100644 --- a/Bitkit/Views/Wallets/Sheets/BoostSheet.swift +++ b/Bitkit/Views/Wallets/Sheets/BoostSheet.swift @@ -427,6 +427,7 @@ struct BoostSheet: View { BoostSheet( config: BoostSheetItem( onchainActivity: OnchainActivity( + walletId: WalletScope.default, id: "test-onchain-1", txType: .sent, txId: "abc123", diff --git a/Bitkit/Views/Widgets/WidgetPreviewSheetView.swift b/Bitkit/Views/Widgets/WidgetPreviewSheetView.swift index 9ef333299..6b3da1812 100644 --- a/Bitkit/Views/Widgets/WidgetPreviewSheetView.swift +++ b/Bitkit/Views/Widgets/WidgetPreviewSheetView.swift @@ -6,6 +6,7 @@ struct WidgetPreviewSheetView: View { let type: WidgetType @Binding var navigationPath: [WidgetsRoute] + @Environment(HwWalletManager.self) private var hwWalletManager @EnvironmentObject private var app: AppViewModel @EnvironmentObject private var currency: CurrencyViewModel @EnvironmentObject private var navigation: NavigationViewModel @@ -74,6 +75,7 @@ struct WidgetPreviewSheetView: View { settings: settings, suggestionsManager: suggestionsManager, pubkyProfile: pubkyProfile, + hasHardwareWallet: !hwWalletManager.wallets.isEmpty, isPaykitUIEnabled: isPaykitUIActive ).isEmpty } diff --git a/BitkitTests/ActivityHardwareTests.swift b/BitkitTests/ActivityHardwareTests.swift new file mode 100644 index 000000000..b4a41ba46 --- /dev/null +++ b/BitkitTests/ActivityHardwareTests.swift @@ -0,0 +1,63 @@ +@testable import Bitkit +import BitkitCore +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 { + .onchain(OnchainActivity( + walletId: walletId, + id: "tx1", + txType: .received, + txId: "tx1", + value: 1000, + fee: 0, + feeRate: 1, + address: "", + confirmed: true, + timestamp: 1, + isBoosted: false, + boostTxIds: [], + isTransfer: false, + doesExist: true, + confirmTimestamp: nil, + channelId: nil, + transferTxId: nil, + contact: nil, + createdAt: nil, + updatedAt: nil, + seenAt: nil + )) + } + + private func lightning(walletId: String) -> Activity { + .lightning(LightningActivity( + walletId: walletId, + id: "ln1", + txType: .received, + status: .succeeded, + value: 1000, + fee: nil, + invoice: "lnbc1", + message: "", + timestamp: 1, + preimage: nil, + contact: nil, + createdAt: nil, + updatedAt: nil, + seenAt: nil + )) + } + + func testWalletIdExtractedFromBothCases() { + XCTAssertEqual(onchain(walletId: "trezor:abc").walletId, "trezor:abc") + XCTAssertEqual(lightning(walletId: WalletScope.default).walletId, WalletScope.default) + } + + func testIsHardwareWallet() { + XCTAssertTrue(onchain(walletId: "trezor:abc").isHardwareWallet) + XCTAssertFalse(onchain(walletId: WalletScope.default).isHardwareWallet) + XCTAssertFalse(lightning(walletId: WalletScope.default).isHardwareWallet) + } +} diff --git a/BitkitTests/ActivityListTest.swift b/BitkitTests/ActivityListTest.swift index 3f065ddbc..3bdcb9d6e 100644 --- a/BitkitTests/ActivityListTest.swift +++ b/BitkitTests/ActivityListTest.swift @@ -33,6 +33,7 @@ final class ActivityTests: XCTestCase { // Create a lightning activity let lightningActivity = Activity.lightning( LightningActivity( + walletId: WalletScope.default, id: "test-lightning-1", txType: .sent, status: .succeeded, @@ -75,6 +76,7 @@ final class ActivityTests: XCTestCase { // Create an onchain activity let onchainActivity = Activity.onchain( OnchainActivity( + walletId: WalletScope.default, id: "test-onchain-1", txType: .received, txId: "abc123", @@ -121,6 +123,7 @@ final class ActivityTests: XCTestCase { // Create and insert an activity let activity = Activity.lightning( LightningActivity( + walletId: WalletScope.default, id: "test-tags-1", txType: .sent, status: .succeeded, @@ -160,6 +163,7 @@ final class ActivityTests: XCTestCase { let activities = [ Activity.lightning( LightningActivity( + walletId: WalletScope.default, id: "test-tag-filter-1", txType: .sent, status: .succeeded, @@ -177,6 +181,7 @@ final class ActivityTests: XCTestCase { ), Activity.lightning( LightningActivity( + walletId: WalletScope.default, id: "test-tag-filter-2", txType: .sent, status: .succeeded, @@ -220,6 +225,7 @@ final class ActivityTests: XCTestCase { let activities = [ Activity.lightning( LightningActivity( + walletId: WalletScope.default, id: "test-unique-tags-1", txType: .sent, status: .succeeded, @@ -237,6 +243,7 @@ final class ActivityTests: XCTestCase { ), Activity.onchain( OnchainActivity( + walletId: WalletScope.default, id: "test-unique-tags-2", txType: .received, txId: "abc123", @@ -300,6 +307,7 @@ final class ActivityTests: XCTestCase { // Create and insert an activity let initialActivity = Activity.lightning( LightningActivity( + walletId: WalletScope.default, id: "test-update-1", txType: .sent, status: .pending, @@ -321,6 +329,7 @@ final class ActivityTests: XCTestCase { // Create updated version let updatedActivity = Activity.lightning( LightningActivity( + walletId: WalletScope.default, id: "test-update-1", txType: .sent, status: .succeeded, @@ -446,6 +455,7 @@ final class ActivityTests: XCTestCase { // Create and insert an activity let activity = Activity.lightning( LightningActivity( + walletId: WalletScope.default, id: "test-delete-1", txType: .sent, status: .succeeded, @@ -484,6 +494,7 @@ final class ActivityTests: XCTestCase { let activities = [ Activity.lightning( LightningActivity( + walletId: WalletScope.default, id: "test-limit-1", txType: .sent, status: .succeeded, @@ -501,6 +512,7 @@ final class ActivityTests: XCTestCase { ), Activity.onchain( OnchainActivity( + walletId: WalletScope.default, id: "test-limit-2", txType: .received, txId: "abc123", @@ -525,6 +537,7 @@ final class ActivityTests: XCTestCase { ), Activity.lightning( LightningActivity( + walletId: WalletScope.default, id: "test-limit-3", txType: .received, status: .succeeded, @@ -566,6 +579,7 @@ final class ActivityTests: XCTestCase { let timestamp = UInt64(Date().timeIntervalSince1970) return Activity.onchain( OnchainActivity( + walletId: WalletScope.default, id: id, txType: .sent, txId: txId, diff --git a/BitkitTests/AddressTypeAccountTests.swift b/BitkitTests/AddressTypeAccountTests.swift new file mode 100644 index 000000000..4f7c1d1d0 --- /dev/null +++ b/BitkitTests/AddressTypeAccountTests.swift @@ -0,0 +1,34 @@ +@testable import Bitkit +import BitkitCore +import LDKNode +import XCTest + +/// Covers the hardware-wallet additions to `LDKNode.AddressType` (`AddressScriptType`): +/// the bitkit-core `accountType` mapping and the account-level derivation path. +final class AddressTypeAccountTests: XCTestCase { + func testAccountTypeMapping() { + XCTAssertEqual(AddressScriptType.legacy.accountType, .legacy) + XCTAssertEqual(AddressScriptType.nestedSegwit.accountType, .wrappedSegwit) + XCTAssertEqual(AddressScriptType.nativeSegwit.accountType, .nativeSegwit) + XCTAssertEqual(AddressScriptType.taproot.accountType, .taproot) + } + + func testAccountDerivationPathMainnet() { + XCTAssertEqual(AddressScriptType.legacy.accountDerivationPath(coinType: "0"), "m/44'/0'/0'") + XCTAssertEqual(AddressScriptType.nestedSegwit.accountDerivationPath(coinType: "0"), "m/49'/0'/0'") + XCTAssertEqual(AddressScriptType.nativeSegwit.accountDerivationPath(coinType: "0"), "m/84'/0'/0'") + XCTAssertEqual(AddressScriptType.taproot.accountDerivationPath(coinType: "0"), "m/86'/0'/0'") + } + + func testAccountDerivationPathTestNetworks() { + XCTAssertEqual(AddressScriptType.nativeSegwit.accountDerivationPath(coinType: "1"), "m/84'/1'/0'") + XCTAssertEqual(AddressScriptType.taproot.accountDerivationPath(coinType: "1"), "m/86'/1'/0'") + } + + /// The account path is the chain-level `derivationPath` without the trailing chain/index. + func testAccountPathIsChainPathWithoutSuffix() { + for type in AddressScriptType.allAddressTypes { + XCTAssertEqual(type.derivationPath(coinType: "0"), type.accountDerivationPath(coinType: "0") + "/0") + } + } +} diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index 758e16501..badac61a3 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -40,6 +40,7 @@ final class ContactsManagerTests: XCTestCase { let contact = makeContact(publicKey: "pubky\(rawKey)") let activity = Activity.lightning( LightningActivity( + walletId: WalletScope.default, id: "test-lightning-contact", txType: .sent, status: .succeeded, @@ -64,6 +65,7 @@ final class ContactsManagerTests: XCTestCase { let contact = makeContact(publicKey: "pubky\(rawKey)") let activity = Activity.onchain( OnchainActivity( + walletId: WalletScope.default, id: "test-onchain-boosting-contact", txType: .sent, txId: "txid", @@ -94,6 +96,7 @@ final class ContactsManagerTests: XCTestCase { let replacedTxId = "replaced_tx_id" let activity = Activity.onchain( OnchainActivity( + walletId: WalletScope.default, id: replacedTxId, txType: .sent, txId: replacedTxId, diff --git a/BitkitTests/HwWalletIdTests.swift b/BitkitTests/HwWalletIdTests.swift new file mode 100644 index 000000000..46bce75ed --- /dev/null +++ b/BitkitTests/HwWalletIdTests.swift @@ -0,0 +1,52 @@ +@testable import Bitkit +import CryptoKit +import XCTest + +final class HwWalletIdTests: XCTestCase { + func testDeterministicForSameXpubs() throws { + let a = try HwWalletId.derive(xpubs: ["nativeSegwit": "zNS", "taproot": "zTR"]) + let b = try HwWalletId.derive(xpubs: ["nativeSegwit": "zNS", "taproot": "zTR"]) + XCTAssertEqual(a, b, "id derives deterministically from xpubs") + } + + /// Pins the exact derivation against an independent reproduction of core's + /// `derive_wallet_id` (sort values, join with "\n", SHA256, lowercase hex, + /// prefix "{deviceType}:"). Fails if either core's contract or the Swift call + /// site (e.g. accidentally folding in the dictionary keys) drifts. + func testMatchesCanonicalDerivation() throws { + let xpubs = ["taproot": "zTR", "nativeSegwit": "zNS"] + let expected = "trezor:" + expectedHash(ofSortedValues: xpubs) + XCTAssertEqual(try HwWalletId.derive(xpubs: xpubs), expected) + } + + // The id must depend only on the set of xpub values, never on the address-type + // keys: the same values mapped to swapped keys yield two genuinely different + // dictionaries, yet must derive the same id (unlike two equal literals, this + // can fail if derivation ever starts depending on keys or insertion order). + func testIndependentOfAddressTypeKeys() throws { + let a = try HwWalletId.derive(xpubs: ["nativeSegwit": "xpubA", "taproot": "xpubB"]) + let b = try HwWalletId.derive(xpubs: ["taproot": "xpubA", "nativeSegwit": "xpubB"]) + XCTAssertEqual(a, b, "id depends on the value set, not the keys or their order") + } + + func testDifferentXpubsProduceDifferentIds() throws { + let a = try HwWalletId.derive(xpubs: ["nativeSegwit": "zNS"]) + let b = try HwWalletId.derive(xpubs: ["nativeSegwit": "DIFFERENT"]) + XCTAssertNotEqual(a, b) + } + + func testPrefix() throws { + XCTAssertTrue(try HwWalletId.derive(xpubs: ["nativeSegwit": "z"]).hasPrefix("trezor:")) + } + + func testThrowsWhenNoXpubs() { + XCTAssertThrowsError(try HwWalletId.derive(xpubs: [:])) + } + + private func expectedHash(ofSortedValues xpubs: [String: String]) -> String { + let joined = xpubs.values.sorted().joined(separator: "\n") + return SHA256.hash(data: Data(joined.utf8)) + .compactMap { String(format: "%02x", $0) } + .joined() + } +} diff --git a/BitkitTests/HwWalletManagerTests.swift b/BitkitTests/HwWalletManagerTests.swift new file mode 100644 index 000000000..6a07849fc --- /dev/null +++ b/BitkitTests/HwWalletManagerTests.swift @@ -0,0 +1,704 @@ +@testable import Bitkit +import BitkitCore +import Combine +import XCTest + +/// Engine tests for `HwWalletManager`, adapting bitkit-android's `HwWalletRepoTest`. +/// The engine is driven directly (no live `TrezorViewModel`) via `updateDevices` and +/// `handleWatcherEvent`, with spies for the bitkit-core persistence side. +@MainActor +final class HwWalletManagerTests: XCTestCase { + // MARK: - Mocks & spies + + private final class MockWatcherService: OnChainWatcherServicing, @unchecked Sendable { + private let lock = NSLock() + + private(set) var startedParams: [WatcherParams] = [] + private(set) var stoppedWatcherIds: [String] = [] + var stopShouldFail = false + + /// When set, keeps the native start call in flight until `completeStart()` resolves it, + /// mirroring the gate used in `TrezorViewModelWatcherTests`. + var holdStart = false + private var startContinuation: CheckedContinuation? + private var pendingStartResult: Result? + + struct StopError: Error {} + + func startWatcher(params: WatcherParams, listener _: EventListener) async throws { + lock.lock() + startedParams.append(params) + let shouldHold = holdStart + lock.unlock() + + guard shouldHold else { return } + try await withCheckedThrowingContinuation { continuation in + lock.lock() + defer { lock.unlock() } + if let result = pendingStartResult { + pendingStartResult = nil + continuation.resume(with: result) + } else { + startContinuation = continuation + } + } + } + + func completeStart(with result: Result = .success(())) { + lock.lock() + defer { lock.unlock() } + if let continuation = startContinuation { + startContinuation = nil + continuation.resume(with: result) + } else { + pendingStartResult = result + } + } + + func stopWatcher(watcherId: String) throws { + lock.lock() + defer { lock.unlock() } + stoppedWatcherIds.append(watcherId) + if stopShouldFail { throw StopError() } + } + + func stopAllWatchers() {} + } + + private var persisted: [[Activity]] = [] + private var deleted: [String] = [] + private var receivedTxs: [HwWalletReceivedTx] = [] + private var cancellables: Set = [] + + override func setUp() { + super.setUp() + persisted = [] + deleted = [] + receivedTxs = [] + cancellables = [] + } + + // MARK: - Factories + + private func makeViewModel( + watcherService: OnChainWatcherServicing = MockWatcherService(), + monitored: Set = ["legacy", "nestedSegwit", "nativeSegwit", "taproot"] + ) -> HwWalletManager { + let vm = HwWalletManager( + watcherService: watcherService, + monitoredTypes: { monitored }, + electrumUrl: { "ssl://test:1" }, + network: { .regtest }, + persistActivities: { [weak self] in self?.persisted.append($0) }, + deleteActivities: { [weak self] in self?.deleted.append($0) } + ) + vm.receivedTxPublisher + .sink { [weak self] in self?.receivedTxs.append($0) } + .store(in: &cancellables) + return vm + } + + private func makeDevice( + id: String, + xpubs: [String: String], + label: String? = nil, + model: String? = "Safe 5", + lastConnectedAt: Date = Date(timeIntervalSince1970: 1000) + ) -> TrezorKnownDevice { + TrezorKnownDevice( + id: id, + name: id, + path: "ble:\(id)", + transportType: "bluetooth", + label: label, + model: model, + lastConnectedAt: lastConnectedAt, + xpubs: xpubs + ) + } + + /// Build a persistence-ready onchain activity, mirroring what core's watch-only watcher + /// emits in 0.3.4. `walletId` defaults to empty because the manager re-scopes activities to + /// the device's derived wallet id before persisting. + private func makeActivity( + txId: String, + value: UInt64, + txType: PaymentType, + walletId: String = "" + ) -> Activity { + .onchain(OnchainActivity( + walletId: walletId, + id: txId, + txType: txType, + txId: txId, + value: value, + fee: 0, + feeRate: 1, + address: "", + confirmed: true, + timestamp: 1_700_000_000, + isBoosted: false, + boostTxIds: [], + isTransfer: false, + doesExist: true, + confirmTimestamp: 1_700_000_000, + channelId: nil, + transferTxId: nil, + contact: nil, + createdAt: 1_700_000_000, + updatedAt: 1_700_000_000, + seenAt: nil + )) + } + + private func makeEvent(_ activities: [Activity], total: UInt64) -> WatcherEvent { + let balance = WalletBalance( + confirmed: total, immature: 0, trustedPending: 0, untrustedPending: 0, spendable: total, total: total + ) + return .transactionsChanged( + activities: activities, + transactionDetails: [], + balance: balance, + txCount: UInt32(activities.count), + blockHeight: 100, + accountType: .nativeSegwit + ) + } + + private func watcherId(_ deviceId: String, _ addressType: String) -> String { + "\(deviceId)|\(addressType)" + } + + // MARK: - Tests + + func testPairedDeviceProducesWalletWithBalanceAndWalletId() throws { + let xpubs = ["nativeSegwit": "zpubNS"] + let device = makeDevice(id: "dev1", xpubs: xpubs, model: "Safe 5") + let vm = makeViewModel() + vm.updateDevices(knownDevices: [device], connectedDeviceId: "dev1") + + vm.handleWatcherEvent(watcherId: watcherId("dev1", "nativeSegwit"), event: makeEvent( + [makeActivity(txId: "tx1", value: 50000, txType: .received)], total: 50000 + )) + + XCTAssertEqual(vm.wallets.count, 1) + let wallet = vm.wallets[0] + XCTAssertEqual(wallet.id, "dev1") + XCTAssertEqual(wallet.balanceSats, 50000) + XCTAssertEqual(wallet.name, "Trezor Safe 5") + XCTAssertTrue(wallet.isConnected) + XCTAssertEqual(vm.totalSats, 50000) + XCTAssertEqual(wallet.walletId, try HwWalletId.derive(xpubs: xpubs)) + XCTAssertEqual(vm.hwWalletIds, [wallet.walletId]) + } + + func testBalanceAggregatesAcrossAddressTypes() { + let device = makeDevice(id: "dev1", xpubs: ["nativeSegwit": "zpubNS", "taproot": "zpubTR"]) + let vm = makeViewModel() + vm.updateDevices(knownDevices: [device], connectedDeviceId: nil) + + vm.handleWatcherEvent(watcherId: watcherId("dev1", "nativeSegwit"), event: makeEvent( + [makeActivity(txId: "txNS", value: 30000, txType: .received)], total: 30000 + )) + vm.handleWatcherEvent(watcherId: watcherId("dev1", "taproot"), event: makeEvent( + [makeActivity(txId: "txTR", value: 20000, txType: .received)], total: 20000 + )) + + XCTAssertEqual(vm.wallets.count, 1) + XCTAssertEqual(vm.wallets[0].balanceSats, 50000) + XCTAssertFalse(vm.wallets[0].isConnected) + } + + func testSamePhysicalDeviceDedupedByXpub() { + // Same xpubs, two device entries (e.g. re-paired) → one wallet, one walletId. + let xpubs = ["nativeSegwit": "zpubShared"] + let ble = makeDevice(id: "ble1", xpubs: xpubs, lastConnectedAt: Date(timeIntervalSince1970: 1000)) + let usb = makeDevice(id: "usb1", xpubs: xpubs, lastConnectedAt: Date(timeIntervalSince1970: 2000)) + let vm = makeViewModel() + vm.updateDevices(knownDevices: [ble, usb], connectedDeviceId: nil) + + vm.handleWatcherEvent(watcherId: watcherId("ble1", "nativeSegwit"), event: makeEvent( + [makeActivity(txId: "tx1", value: 70000, txType: .received)], total: 70000 + )) + + XCTAssertEqual(vm.wallets.count, 1) + XCTAssertEqual(vm.wallets[0].deviceIds, ["ble1", "usb1"]) + // Representative is the most recently connected entry. + XCTAssertEqual(vm.wallets[0].id, "usb1") + XCTAssertEqual(vm.hwWalletIds.count, 1) + } + + func testActivityPersistedWithDeviceWalletId() throws { + let xpubs = ["nativeSegwit": "zpubNS"] + let device = makeDevice(id: "dev1", xpubs: xpubs) + let vm = makeViewModel() + vm.updateDevices(knownDevices: [device], connectedDeviceId: "dev1") + + vm.handleWatcherEvent(watcherId: watcherId("dev1", "nativeSegwit"), event: makeEvent( + [makeActivity(txId: "txABC", value: 40000, txType: .received)], total: 40000 + )) + + // The manager re-scopes core's emitted activity to the device's derived wallet id. + let expectedWalletId = try HwWalletId.derive(xpubs: xpubs) + XCTAssertEqual(persisted.count, 1) + let activities = persisted[0] + XCTAssertEqual(activities.count, 1) + guard case let .onchain(onchain) = activities[0] else { return XCTFail("expected onchain activity") } + XCTAssertEqual(onchain.walletId, expectedWalletId) + XCTAssertEqual(onchain.txId, "txABC") + XCTAssertEqual(onchain.txType, .received) + XCTAssertEqual(onchain.value, 40000) + } + + func testUnchangedWatcherEventDoesNotRepersist() { + let device = makeDevice(id: "dev1", xpubs: ["nativeSegwit": "zpubNS"]) + let vm = makeViewModel() + vm.updateDevices(knownDevices: [device], connectedDeviceId: nil) + let wid = watcherId("dev1", "nativeSegwit") + let event = makeEvent([makeActivity(txId: "tx1", value: 40000, txType: .received)], total: 40000) + + vm.handleWatcherEvent(watcherId: wid, event: event) + XCTAssertEqual(persisted.count, 1) + + // Identical event again → no re-upsert / no redundant activity-list reload. + vm.handleWatcherEvent(watcherId: wid, event: event) + XCTAssertEqual(persisted.count, 1) + + // A changed event (new tx) → persists again. + let changed = makeEvent([ + makeActivity(txId: "tx1", value: 40000, txType: .received), + makeActivity(txId: "tx2", value: 10000, txType: .received), + ], total: 50000) + vm.handleWatcherEvent(watcherId: wid, event: changed) + XCTAssertEqual(persisted.count, 2) + } + + func testReceivedTxDetection() { + let device = makeDevice(id: "dev1", xpubs: ["nativeSegwit": "zpubNS"]) + let vm = makeViewModel() + vm.updateDevices(knownDevices: [device], connectedDeviceId: "dev1") + let wid = watcherId("dev1", "nativeSegwit") + + // Baseline (first event) — must NOT emit, even for received txs. + vm.handleWatcherEvent(watcherId: wid, event: makeEvent( + [makeActivity(txId: "old", value: 10000, txType: .received)], total: 10000 + )) + XCTAssertTrue(receivedTxs.isEmpty) + + // New inbound tx after baseline — emits once. + vm.handleWatcherEvent(watcherId: wid, event: makeEvent( + [ + makeActivity(txId: "old", value: 10000, txType: .received), + makeActivity(txId: "new", value: 25000, txType: .received), + ], total: 35000 + )) + XCTAssertEqual(receivedTxs.map(\.txid), ["new"]) + XCTAssertEqual(receivedTxs.first?.sats, 25000) + + // Outbound tx is ignored, and the same inbound is not re-emitted. + vm.handleWatcherEvent(watcherId: wid, event: makeEvent( + [ + makeActivity(txId: "old", value: 10000, txType: .received), + makeActivity(txId: "new", value: 25000, txType: .received), + makeActivity(txId: "spend", value: 5000, txType: .sent), + ], total: 30000 + )) + XCTAssertEqual(receivedTxs.map(\.txid), ["new"]) + } + + func testMonitoredAddressTypeFiltering() async { + let mock = MockWatcherService() + let device = makeDevice(id: "dev1", xpubs: ["nativeSegwit": "zpubNS", "taproot": "zpubTR"]) + let vm = makeViewModel(watcherService: mock, monitored: ["nativeSegwit"]) + vm.updateDevices(knownDevices: [device], connectedDeviceId: nil) + + await waitUntil { mock.startedParams.count == 1 } + XCTAssertEqual(mock.startedParams.count, 1) + XCTAssertEqual(mock.startedParams.first?.watcherId, watcherId("dev1", "nativeSegwit")) + } + + func testForgottenDeviceDuringInFlightStartIsTornDownNotActivated() async { + let mock = MockWatcherService() + mock.holdStart = true + let device = makeDevice(id: "dev1", xpubs: ["nativeSegwit": "zpubNS"]) + let vm = makeViewModel(watcherService: mock, monitored: ["nativeSegwit"]) + let wid = watcherId("dev1", "nativeSegwit") + + vm.updateDevices(knownDevices: [device], connectedDeviceId: "dev1") + await waitUntil { mock.startedParams.count == 1 } + + // Forget the device while its watcher start is still in flight. + vm.updateDevices(knownDevices: [], connectedDeviceId: nil) + + // Resolving the now-undesired start must tear the watcher down, not activate it. + mock.completeStart() + await waitUntil { mock.stoppedWatcherIds.contains(wid) } + + XCTAssertTrue(mock.stoppedWatcherIds.contains(wid)) + XCTAssertTrue(vm.wallets.isEmpty) + XCTAssertEqual(vm.totalSats, 0) + } + + func testZeroBalanceBeforeAnyWatcherEvent() { + let device = makeDevice(id: "dev1", xpubs: ["nativeSegwit": "zpubNS"]) + let vm = makeViewModel() + vm.updateDevices(knownDevices: [device], connectedDeviceId: nil) + + XCTAssertEqual(vm.wallets.count, 1) + XCTAssertEqual(vm.wallets[0].balanceSats, 0) + XCTAssertEqual(vm.totalSats, 0) + XCTAssertTrue(vm.walletsLoaded) + } + + func testNoWalletWithoutCapturedXpubs() { + let device = makeDevice(id: "dev1", xpubs: [:]) + let vm = makeViewModel() + vm.updateDevices(knownDevices: [device], connectedDeviceId: nil) + + XCTAssertTrue(vm.wallets.isEmpty) + XCTAssertTrue(vm.hwWalletIds.isEmpty) + } + + func testDisplayNameUsesDeviceLabelWhenSet() { + let device = makeDevice(id: "dev1", xpubs: ["nativeSegwit": "x"], label: "My Trezor", model: "Safe 5") + let vm = makeViewModel() + vm.updateDevices(knownDevices: [device], connectedDeviceId: nil) + XCTAssertEqual(vm.wallets.first?.name, "My Trezor") + } + + func testDisplayNameUsesVendorPrefixedModelWhenLabelMissing() { + let device = makeDevice(id: "dev1", xpubs: ["nativeSegwit": "x"], label: nil, model: "Safe 7") + let vm = makeViewModel() + vm.updateDevices(knownDevices: [device], connectedDeviceId: nil) + XCTAssertEqual(vm.wallets.first?.name, "Trezor Safe 7") + } + + func testDisplayNameUsesVendorPrefixWhenLabelIsFactoryDefault() { + // Factory label mirrors the model — fall back to the vendor-prefixed model. + let device = makeDevice(id: "dev1", xpubs: ["nativeSegwit": "x"], label: "Safe 7", model: "Safe 7") + let vm = makeViewModel() + vm.updateDevices(knownDevices: [device], connectedDeviceId: nil) + XCTAssertEqual(vm.wallets.first?.name, "Trezor Safe 7") + } + + func testDisplayNameKeepsModelAlreadyPrefixed() { + let device = makeDevice(id: "dev1", xpubs: ["nativeSegwit": "x"], label: nil, model: "Trezor Model T") + let vm = makeViewModel() + vm.updateDevices(knownDevices: [device], connectedDeviceId: nil) + XCTAssertEqual(vm.wallets.first?.name, "Trezor Model T") + } + + func testDisplayNameDefaultsToTrezorWhenNoModel() { + let device = makeDevice(id: "dev1", xpubs: ["nativeSegwit": "x"], label: nil, model: nil) + let vm = makeViewModel() + vm.updateDevices(knownDevices: [device], connectedDeviceId: nil) + XCTAssertEqual(vm.wallets.first?.name, "Trezor") + } + + /// The same tx seen by two address-type watchers persists once (deduped by activity id). + /// Value composition is core's job now (core 0.3.4 watch-only watcher), so this only checks + /// dedup, not summing. + func testDuplicateTxAcrossAddressTypesPersistsOnce() { + let device = makeDevice(id: "dev1", xpubs: ["nativeSegwit": "zNS", "taproot": "zTR"]) + let vm = makeViewModel() + vm.updateDevices(knownDevices: [device], connectedDeviceId: nil) + + vm.handleWatcherEvent(watcherId: watcherId("dev1", "nativeSegwit"), event: makeEvent( + [makeActivity(txId: "shared", value: 30000, txType: .received)], total: 30000 + )) + vm.handleWatcherEvent(watcherId: watcherId("dev1", "taproot"), event: makeEvent( + [makeActivity(txId: "shared", value: 30000, txType: .received)], total: 30000 + )) + + let lastPersisted = persisted.last ?? [] + XCTAssertEqual(lastPersisted.count, 1) + guard case let .onchain(onchain) = lastPersisted[0] else { return XCTFail("expected onchain") } + XCTAssertEqual(onchain.txId, "shared") + } + + func testMixedDirectionDuplicateResolvesDeterministically() { + /// The same txid seen by two address-type watchers can carry different wallet-perspective + /// directions; the merge must resolve to the same winner regardless of arrival order. + func mergedTxType(nativeSegwitFirst: Bool) -> PaymentType? { + persisted = [] + let device = makeDevice(id: "dev1", xpubs: ["nativeSegwit": "zNS", "taproot": "zTR"]) + let vm = makeViewModel() + vm.updateDevices(knownDevices: [device], connectedDeviceId: nil) + let ns = watcherId("dev1", "nativeSegwit") + let tr = watcherId("dev1", "taproot") + let nsEvent = makeEvent([makeActivity(txId: "shared", value: 5000, txType: .sent)], total: 5000) + let trEvent = makeEvent([makeActivity(txId: "shared", value: 30000, txType: .received)], total: 30000) + if nativeSegwitFirst { + vm.handleWatcherEvent(watcherId: ns, event: nsEvent) + vm.handleWatcherEvent(watcherId: tr, event: trEvent) + } else { + vm.handleWatcherEvent(watcherId: tr, event: trEvent) + vm.handleWatcherEvent(watcherId: ns, event: nsEvent) + } + let shared = (persisted.last ?? []).first { + if case let .onchain(onchain) = $0 { return onchain.txId == "shared" } + return false + } + guard case let .onchain(onchain) = shared else { return nil } + return onchain.txType + } + + let nsFirst = mergedTxType(nativeSegwitFirst: true) + let trFirst = mergedTxType(nativeSegwitFirst: false) + + XCTAssertEqual(nsFirst, trFirst) + // 'dev1|taproot' sorts after 'dev1|nativeSegwit', so the taproot perspective wins. + XCTAssertEqual(nsFirst, .received) + } + + func testWatcherStartedOnConfiguredElectrumAndNetwork() async { + let mock = MockWatcherService() + let device = makeDevice(id: "dev1", xpubs: ["nativeSegwit": "z"]) + let vm = makeViewModel(watcherService: mock, monitored: ["nativeSegwit"]) + vm.updateDevices(knownDevices: [device], connectedDeviceId: nil) + + await waitUntil { mock.startedParams.count == 1 } + let params = mock.startedParams.first + XCTAssertEqual(params?.electrumUrl, "ssl://test:1") + XCTAssertEqual(params?.network, .regtest) + XCTAssertEqual(params?.extendedKey, "z") + XCTAssertEqual(params?.accountType, .nativeSegwit) + } + + func testWatcherRestartsWhenXpubChangesForSameDeviceAndType() async { + let mock = MockWatcherService() + let vm = makeViewModel(watcherService: mock, monitored: ["nativeSegwit"]) + vm.updateDevices(knownDevices: [makeDevice(id: "dev1", xpubs: ["nativeSegwit": "z"])], connectedDeviceId: nil) + await waitUntil { mock.startedParams.count == 1 } + vm.handleWatcherEvent(watcherId: watcherId("dev1", "nativeSegwit"), event: makeEvent( + [makeActivity(txId: "t1", value: 40000, txType: .received)], total: 40000 + )) + let originalWalletId = vm.wallets.first?.walletId + + // Same device id + address type, new xpub (e.g. a passphrase/hidden wallet, or re-fetched + // accounts): the watcher id is unchanged but the watched key — and the derived wallet id — + // differ, so the old watcher must be torn down and a new one started on the new xpub + // instead of feeding the old wallet's balance under the new wallet id. + vm.updateDevices(knownDevices: [makeDevice(id: "dev1", xpubs: ["nativeSegwit": "z2"])], connectedDeviceId: nil) + await waitUntil { mock.startedParams.count == 2 } + + XCTAssertTrue(mock.stoppedWatcherIds.contains(watcherId("dev1", "nativeSegwit"))) + XCTAssertEqual(mock.startedParams.last?.extendedKey, "z2") + XCTAssertNotEqual(vm.wallets.first?.walletId, originalWalletId) + XCTAssertEqual(vm.wallets.first?.balanceSats, 0, "stale old-xpub balance is dropped until the new watcher reports") + } + + func testReconcileForSettingsChangeSkipsUnchangedAndActsOnChange() async { + let mock = MockWatcherService() + var monitored: Set = ["nativeSegwit"] + let electrum = "ssl://a:1" + var electrumCalls = 0 + let vm = HwWalletManager( + watcherService: mock, + monitoredTypes: { monitored }, + electrumUrl: { electrumCalls += 1; return electrum }, + network: { .regtest }, + persistActivities: { _ in }, + deleteActivities: { _ in } + ) + let device = makeDevice(id: "dev1", xpubs: ["nativeSegwit": "zNS", "taproot": "zTR"]) + vm.updateDevices(knownDevices: [device], connectedDeviceId: nil) + await waitUntil { mock.startedParams.count == 1 } + + // Prime the last-synced snapshot. + vm.reconcileForSettingsChange() + + // Unchanged settings: the guard short-circuits before syncWatchers, so the Electrum + // provider is read exactly once (the guard) and no watcher work happens. + electrumCalls = 0 + vm.reconcileForSettingsChange() + XCTAssertEqual(electrumCalls, 1) + XCTAssertEqual(mock.startedParams.count, 1) + + // A monitored-types change does reconcile: the taproot watcher starts. + monitored = ["nativeSegwit", "taproot"] + vm.reconcileForSettingsChange() + await waitUntil { mock.startedParams.count == 2 } + XCTAssertEqual(mock.startedParams.count, 2) + } + + func testDisablingAddressTypeClearsBalanceImmediately() async { + let mock = MockWatcherService() + var monitored: Set = ["nativeSegwit"] + let vm = HwWalletManager( + watcherService: mock, + monitoredTypes: { monitored }, + electrumUrl: { "ssl://test:1" }, + network: { .regtest }, + persistActivities: { _ in }, + deleteActivities: { _ in } + ) + let device = makeDevice(id: "dev1", xpubs: ["nativeSegwit": "zNS"]) + vm.updateDevices(knownDevices: [device], connectedDeviceId: nil) + await waitUntil { mock.startedParams.count == 1 } + + vm.handleWatcherEvent(watcherId: watcherId("dev1", "nativeSegwit"), event: makeEvent( + [makeActivity(txId: "tx1", value: 50000, txType: .received)], total: 50000 + )) + XCTAssertEqual(vm.totalSats, 50000) + + // Disabling the only monitored address type stops the watcher; the published totals must + // drop immediately, without waiting for any further watcher event. + monitored = [] + vm.reconcileForSettingsChange() + + XCTAssertEqual(vm.totalSats, 0) + XCTAssertEqual(vm.wallets.first?.balanceSats, 0) + } + + func testReceivedTxEmittedOnceAcrossMultipleWatchers() { + let device = makeDevice(id: "dev1", xpubs: ["nativeSegwit": "zNS", "taproot": "zTR"]) + let vm = makeViewModel() + vm.updateDevices(knownDevices: [device], connectedDeviceId: nil) + let ns = watcherId("dev1", "nativeSegwit") + let tr = watcherId("dev1", "taproot") + + // Baselines for both watchers. + vm.handleWatcherEvent(watcherId: ns, event: makeEvent([], total: 0)) + vm.handleWatcherEvent(watcherId: tr, event: makeEvent([], total: 0)) + + // Both watchers report the same new inbound tx — emit only once. + let tx = makeActivity(txId: "new", value: 10000, txType: .received) + vm.handleWatcherEvent(watcherId: ns, event: makeEvent([tx], total: 10000)) + vm.handleWatcherEvent(watcherId: tr, event: makeEvent([tx], total: 10000)) + + XCTAssertEqual(receivedTxs.map(\.txid), ["new"]) + } + + func testConnectedEntryWinsRepresentativeIdentity() { + // Same xpub over two entries; the more recent is `ble1`, but `usb1` is connected. + let xpubs = ["nativeSegwit": "shared"] + let ble = makeDevice(id: "ble1", xpubs: xpubs, lastConnectedAt: Date(timeIntervalSince1970: 2000)) + let usb = makeDevice(id: "usb1", xpubs: xpubs, lastConnectedAt: Date(timeIntervalSince1970: 1000)) + let vm = makeViewModel() + vm.updateDevices(knownDevices: [ble, usb], connectedDeviceId: "usb1") + + XCTAssertEqual(vm.wallets.count, 1) + XCTAssertEqual(vm.wallets[0].id, "usb1") + XCTAssertTrue(vm.wallets[0].isConnected) + } + + func testTotalSatsSaturatesInsteadOfOverflowing() { + let d1 = makeDevice(id: "d1", xpubs: ["nativeSegwit": "a"]) + let d2 = makeDevice(id: "d2", xpubs: ["nativeSegwit": "b"]) + let vm = makeViewModel() + vm.updateDevices(knownDevices: [d1, d2], connectedDeviceId: nil) + + // Per-device balance comes from the watcher's reported total; d1 maxes it out. + vm.handleWatcherEvent(watcherId: watcherId("d1", "nativeSegwit"), event: makeEvent( + [makeActivity(txId: "t1", value: 1000, txType: .received)], total: .max + )) + vm.handleWatcherEvent(watcherId: watcherId("d2", "nativeSegwit"), event: makeEvent( + [makeActivity(txId: "t2", value: 1000, txType: .received)], total: 1000 + )) + + XCTAssertEqual(vm.totalSats, .max) + } + + func testStaleWatcherKeptUntilStopSucceeds() async { + let mock = MockWatcherService() + let device = makeDevice(id: "dev1", xpubs: ["nativeSegwit": "z"]) + let vm = makeViewModel(watcherService: mock, monitored: ["nativeSegwit"]) + vm.updateDevices(knownDevices: [device], connectedDeviceId: nil) + await waitUntil { mock.startedParams.count == 1 } + + vm.handleWatcherEvent(watcherId: watcherId("dev1", "nativeSegwit"), event: makeEvent( + [makeActivity(txId: "t1", value: 40000, txType: .received)], total: 40000 + )) + XCTAssertEqual(vm.wallets.first?.balanceSats, 40000) + + // Stop fails → the watcher must stay active and keep feeding its balance. + mock.stopShouldFail = true + vm.updateDevices(knownDevices: [makeDevice(id: "dev1", xpubs: [:])], connectedDeviceId: nil) + XCTAssertTrue(mock.stoppedWatcherIds.contains(watcherId("dev1", "nativeSegwit"))) + + // Stop now succeeds → next sync removes it. + mock.stopShouldFail = false + vm.updateDevices(knownDevices: [makeDevice(id: "dev1", xpubs: [:])], connectedDeviceId: nil) + XCTAssertTrue(vm.wallets.isEmpty) + } + + func testRemoveDeviceStopsWatchersAndDeletesActivities() async throws { + let mock = MockWatcherService() + let xpubs = ["nativeSegwit": "z"] + let device = makeDevice(id: "dev1", xpubs: xpubs) + let vm = makeViewModel(watcherService: mock, monitored: ["nativeSegwit"]) + vm.updateDevices(knownDevices: [device], connectedDeviceId: nil) + await waitUntil { mock.startedParams.count == 1 } + vm.handleWatcherEvent(watcherId: watcherId("dev1", "nativeSegwit"), event: makeEvent( + [makeActivity(txId: "t1", value: 1000, txType: .received)], total: 1000 + )) + + vm.removeDevice(id: "dev1") + + XCTAssertTrue(mock.stoppedWatcherIds.contains(watcherId("dev1", "nativeSegwit"))) + XCTAssertEqual(deleted, try [HwWalletId.derive(xpubs: xpubs)]) + } + + // MARK: - Forget device deletes activities + + func testForgettingDeviceViaUpdateDeletesActivities() async throws { + let mock = MockWatcherService() + let xpubs = ["nativeSegwit": "z"] + let device = makeDevice(id: "dev1", xpubs: xpubs) + let vm = makeViewModel(watcherService: mock, monitored: ["nativeSegwit"]) + vm.updateDevices(knownDevices: [device], connectedDeviceId: nil) + await waitUntil { mock.startedParams.count == 1 } + vm.handleWatcherEvent(watcherId: watcherId("dev1", "nativeSegwit"), event: makeEvent( + [makeActivity(txId: "t1", value: 1000, txType: .received)], total: 1000 + )) + let walletId = try HwWalletId.derive(xpubs: xpubs) + + // Device forgotten → the next snapshot no longer includes it. + vm.updateDevices(knownDevices: [], connectedDeviceId: nil) + + XCTAssertEqual(deleted, [walletId]) + XCTAssertTrue(vm.wallets.isEmpty) + XCTAssertTrue(mock.stoppedWatcherIds.contains(watcherId("dev1", "nativeSegwit"))) + } + + func testUpdateKeepingDeviceDoesNotDeleteActivities() async { + let mock = MockWatcherService() + let device = makeDevice(id: "dev1", xpubs: ["nativeSegwit": "z"]) + let vm = makeViewModel(watcherService: mock, monitored: ["nativeSegwit"]) + vm.updateDevices(knownDevices: [device], connectedDeviceId: nil) + await waitUntil { mock.startedParams.count == 1 } + + // Same device pushed again (e.g. connection toggled) → no deletion. + vm.updateDevices(knownDevices: [device], connectedDeviceId: "dev1") + + XCTAssertTrue(deleted.isEmpty) + XCTAssertEqual(vm.wallets.count, 1) + } + + // MARK: - Fix 7: watcher start-race guard + + func testDoubleSyncDoesNotDoubleStartWatcher() async { + let mock = MockWatcherService() + let device = makeDevice(id: "dev1", xpubs: ["nativeSegwit": "z"]) + let vm = makeViewModel(watcherService: mock, monitored: ["nativeSegwit"]) + + // Two pushes back-to-back, before the first start's async Task can complete. + vm.updateDevices(knownDevices: [device], connectedDeviceId: nil) + vm.updateDevices(knownDevices: [device], connectedDeviceId: nil) + + await waitUntil { mock.startedParams.count >= 1 } + // Give any erroneous second start a chance to land before asserting. + try? await Task.sleep(nanoseconds: 50_000_000) + XCTAssertEqual(mock.startedParams.count, 1) + } + + // MARK: - Helpers + + private func waitUntil(timeout: TimeInterval = 2, _ condition: () -> Bool) async { + let deadline = Date().addingTimeInterval(timeout) + while !condition(), Date() < deadline { + try? await Task.sleep(nanoseconds: 10_000_000) + } + } +} diff --git a/BitkitTests/HwWalletNameTests.swift b/BitkitTests/HwWalletNameTests.swift new file mode 100644 index 000000000..11b59bc94 --- /dev/null +++ b/BitkitTests/HwWalletNameTests.swift @@ -0,0 +1,28 @@ +@testable import Bitkit +import XCTest + +final class HwWalletNameTests: XCTestCase { + func testLabelUsedWhenItDiffersFromModel() { + XCTAssertEqual(resolveHwWalletName(label: "My Trezor", model: "Safe 5"), "My Trezor") + } + + func testLabelMatchingModelFallsBackToPrefixedModel() { + XCTAssertEqual(resolveHwWalletName(label: "Safe 5", model: "Safe 5"), "Trezor Safe 5") + } + + func testModelPrefixedWhenNoLabel() { + XCTAssertEqual(resolveHwWalletName(label: nil, model: "Safe 5"), "Trezor Safe 5") + } + + func testModelAlreadyPrefixedIsNotDoublePrefixed() { + XCTAssertEqual(resolveHwWalletName(label: nil, model: "Trezor Model T"), "Trezor Model T") + } + + func testNilLabelAndModelFallsBackToVendor() { + XCTAssertEqual(resolveHwWalletName(label: nil, model: nil), "Trezor") + } + + func testEmptyLabelFallsBackToModel() { + XCTAssertEqual(resolveHwWalletName(label: "", model: "Safe 5"), "Trezor Safe 5") + } +} diff --git a/BitkitTests/TrezorViewModelWatcherTests.swift b/BitkitTests/TrezorViewModelWatcherTests.swift index 1abda22b3..35b80e813 100644 --- a/BitkitTests/TrezorViewModelWatcherTests.swift +++ b/BitkitTests/TrezorViewModelWatcherTests.swift @@ -9,7 +9,7 @@ final class TrezorViewModelWatcherTests: XCTestCase { /// Mock watcher service, standing in for Android's mocked `TrezorRepo`. /// `holdStart` mirrors the `CompletableDeferred`-backed mock used to keep /// the native start call in flight until the test resolves it. - private final class MockWatcherService: TrezorWatcherServicing, @unchecked Sendable { + private final class MockWatcherService: OnChainWatcherServicing, @unchecked Sendable { private let lock = NSLock() private(set) var startedParams: [WatcherParams] = [] @@ -77,48 +77,42 @@ final class TrezorViewModelWatcherTests: XCTestCase { total: 156_000 ) - private static let sampleTransactions: [HistoryTransaction] = [ - HistoryTransaction( - txid: "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", - received: 50000, - sent: 0, - net: 50000, - fee: nil, - amount: 50000, - direction: .received, - blockHeight: 849_990, + private static func onchainActivity(txId: String, value: UInt64, txType: PaymentType) -> Activity { + .onchain(OnchainActivity( + walletId: "trezor:watcher", + id: txId, + txType: txType, + txId: txId, + value: value, + fee: 0, + feeRate: 1, + address: "", + confirmed: true, timestamp: 1_700_000_000, - confirmations: 11 - ), - HistoryTransaction( - txid: "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", - received: 0, - sent: 20000, - net: -20000, - fee: 500, - amount: 19500, - direction: .sent, - blockHeight: 849_995, - timestamp: 1_700_001_000, - confirmations: 6 - ), - HistoryTransaction( - txid: "6f7cf9580f1c2dfb3c4d5d043cdbb128c640e3f20161245aa7372e9666168516", - received: 10000, - sent: 10500, - net: -500, - fee: 500, - amount: 500, - direction: .selfTransfer, - blockHeight: nil, - timestamp: nil, - confirmations: 0 - ), + isBoosted: false, + boostTxIds: [], + isTransfer: false, + doesExist: true, + confirmTimestamp: 1_700_000_000, + channelId: nil, + transferTxId: nil, + contact: nil, + createdAt: 1_700_000_000, + updatedAt: 1_700_000_000, + seenAt: nil + )) + } + + private static let sampleActivities: [Activity] = [ + onchainActivity(txId: "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", value: 50000, txType: .received), + onchainActivity(txId: "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", value: 19500, txType: .sent), + onchainActivity(txId: "6f7cf9580f1c2dfb3c4d5d043cdbb128c640e3f20161245aa7372e9666168516", value: 500, txType: .sent), ] private static func sampleTransactionsChangedEvent() -> WatcherEvent { .transactionsChanged( - transactions: sampleTransactions, + activities: sampleActivities, + transactionDetails: [], balance: sampleBalance, txCount: 3, blockHeight: 850_000, @@ -130,7 +124,7 @@ final class TrezorViewModelWatcherTests: XCTestCase { @MainActor private func makeViewModel(service: MockWatcherService) -> TrezorViewModel { - let viewModel = TrezorViewModel(watcherService: service) + let viewModel = TrezorViewModel(connection: TrezorManager(), watcherService: service) viewModel.watcherExtendedKey = "xpub6test123" return viewModel } @@ -184,32 +178,27 @@ final class TrezorViewModelWatcherTests: XCTestCase { @MainActor func testDisconnectedStateResetClearsSensitiveWalletState() { - let service = MockWatcherService() - let viewModel = makeViewModel(service: service) + let connection = TrezorManager() TrezorUiHandler.shared.setWalletMode(.passphraseHost, hostPassphrase: "secret") - viewModel.walletMode = .passphraseHost - viewModel.deviceFingerprint = "73c5da0a" - viewModel.generatedAddress = "bcrt1qexample" - viewModel.xpub = "xpub6previous" - viewModel.publicKeyHex = "02abcdef" - viewModel.showPinEntry = true - viewModel.showPassphraseEntry = true - viewModel.showConfirmOnDevice = true - viewModel.showWalletModeChooser = true - - viewModel.clearDisconnectedDeviceState(errorMessage: "disconnect failed") - - XCTAssertNil(viewModel.deviceFingerprint) - XCTAssertNil(viewModel.generatedAddress) - XCTAssertNil(viewModel.xpub) - XCTAssertNil(viewModel.publicKeyHex) - XCTAssertEqual(viewModel.error, "disconnect failed") - XCTAssertFalse(viewModel.showPinEntry) - XCTAssertFalse(viewModel.showPassphraseEntry) - XCTAssertFalse(viewModel.showConfirmOnDevice) - XCTAssertFalse(viewModel.showWalletModeChooser) - XCTAssertEqual(viewModel.walletMode, .standard) + connection.walletMode = .passphraseHost + connection.deviceFingerprint = "73c5da0a" + connection.showPinEntry = true + connection.showPassphraseEntry = true + connection.showConfirmOnDevice = true + connection.showWalletModeChooser = true + + connection.clearDisconnectedDeviceState(errorMessage: "disconnect failed") + + XCTAssertNil(connection.deviceFingerprint) + XCTAssertNil(connection.connectedDevice) + XCTAssertNil(connection.deviceFeatures) + XCTAssertEqual(connection.error, "disconnect failed") + XCTAssertFalse(connection.showPinEntry) + XCTAssertFalse(connection.showPassphraseEntry) + XCTAssertFalse(connection.showConfirmOnDevice) + XCTAssertFalse(connection.showWalletModeChooser) + XCTAssertEqual(connection.walletMode, .standard) switch TrezorUiHandler.shared.currentSelection() { case .standard: @@ -279,7 +268,7 @@ final class TrezorViewModelWatcherTests: XCTestCase { XCTAssertNil(viewModel.activeWatcherId) XCTAssertEqual(viewModel.watcherConnectionStatus, .idle) XCTAssertNil(viewModel.watcherBalance) - XCTAssertTrue(viewModel.watcherTransactions.isEmpty) + XCTAssertTrue(viewModel.watcherActivities.isEmpty) } /// iOS-specific: stopping while the native start call is still in flight @@ -307,7 +296,7 @@ final class TrezorViewModelWatcherTests: XCTestCase { await waitUntil(timeout: 0.2) { viewModel.watcherBalance != nil } XCTAssertNil(viewModel.watcherBalance) - XCTAssertTrue(viewModel.watcherTransactions.isEmpty) + XCTAssertTrue(viewModel.watcherActivities.isEmpty) XCTAssertEqual(viewModel.watcherConnectionStatus, .idle) // The held native call returning success must not activate the watcher. @@ -319,27 +308,11 @@ final class TrezorViewModelWatcherTests: XCTestCase { XCTAssertEqual(viewModel.watcherConnectionStatus, .idle) } - /// iOS-specific: the root view calls stopAllWatchers from onDisappear since the - /// ViewModel is app-lifetime (no onCleared equivalent). - @MainActor - func testStopAllWatchersStopsActiveWatcherAndService() async throws { - let service = MockWatcherService() - let viewModel = makeViewModel(service: service) - - await viewModel.startWatcher() - let watcherId = try XCTUnwrap(viewModel.activeWatcherId) - - viewModel.stopAllWatchers() - - XCTAssertEqual(service.stoppedWatcherIds, [watcherId]) - XCTAssertEqual(service.stopAllWatchersCallCount, 1) - XCTAssertNil(viewModel.activeWatcherId) - XCTAssertEqual(viewModel.watcherConnectionStatus, .idle) - } - - /// iOS-specific: dashboard dismissal also resets the watcher input fields. + /// iOS-specific: dashboard dismissal stops only this dashboard's dev watcher and resets + /// the watcher input fields. It must NOT call the global stop, since production hardware + /// watchers owned by HwWalletManager share the same service and have to stay live. @MainActor - func testHandleDashboardDismissStopsWatchersAndClearsInputState() async throws { + func testHandleDashboardDismissStopsDevWatcherAndClearsInputState() async throws { let service = MockWatcherService() let viewModel = makeViewModel(service: service) viewModel.watcherGapLimit = "30" @@ -351,7 +324,7 @@ final class TrezorViewModelWatcherTests: XCTestCase { viewModel.handleDashboardDismiss() XCTAssertEqual(service.stoppedWatcherIds, [watcherId]) - XCTAssertEqual(service.stopAllWatchersCallCount, 1) + XCTAssertEqual(service.stopAllWatchersCallCount, 0) XCTAssertNil(viewModel.activeWatcherId) XCTAssertEqual(viewModel.watcherExtendedKey, "") XCTAssertEqual(viewModel.watcherGapLimit, "20") diff --git a/changelog.d/next/605.added.md b/changelog.d/next/605.added.md new file mode 100644 index 000000000..6b3d4c550 --- /dev/null +++ b/changelog.d/next/605.added.md @@ -0,0 +1 @@ +Show paired Trezor hardware wallet balances and activity on the home screen, with sheets to enter the pairing code and to notify incoming hardware wallet transactions.