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.