Skip to content

Commit d2d1e56

Browse files
authored
Merge pull request #621 from synonymdev/fix/hw-wallet-v1-polish
fix: polish trezor reliability and ux
2 parents 77e679e + 14ab27c commit d2d1e56

30 files changed

Lines changed: 843 additions & 141 deletions

Bitkit.xcodeproj/project.pbxproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1201,7 +1201,7 @@
12011201
repositoryURL = "https://github.com/synonymdev/bitkit-core";
12021202
requirement = {
12031203
kind = exactVersion;
1204-
version = 0.4.0;
1204+
version = 0.4.1;
12051205
};
12061206
};
12071207
96E20CD22CB6D91A00C24149 /* XCRemoteSwiftPackageReference "CodeScanner" */ = {

Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Bitkit/Components/HardwareWalletsGrid.swift

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,14 @@ private struct HardwareWalletCell: View {
4646
onTap(wallet)
4747
} label: {
4848
VStack(alignment: .leading) {
49-
CaptionMText(wallet.name)
50-
.lineLimit(1)
51-
.padding(.bottom, 4)
49+
HStack(spacing: 4) {
50+
CaptionMText(wallet.name)
51+
.lineLimit(1)
52+
53+
HwWalletConnectionIcon(isConnected: wallet.isConnected)
54+
.frame(width: 16, height: 16)
55+
}
56+
.padding(.bottom, 4)
5257

5358
HStack(spacing: 4) {
5459
Image("btc-circle-blue")
@@ -63,9 +68,6 @@ private struct HardwareWalletCell: View {
6368
enableHide: true,
6469
symbolColor: .textPrimary
6570
)
66-
67-
HwWalletConnectionIcon(isConnected: wallet.isConnected)
68-
.frame(width: 16, height: 16)
6971
}
7072
}
7173
.frame(maxWidth: .infinity, alignment: .leading)
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import BitkitCore
2+
import Foundation
3+
4+
extension BroadcastError {
5+
/// Whether this broadcast failure is likely transient connectivity (retry without re-signing).
6+
/// Core 0.4.1 maps both Electrum connect failures and node rejections to `ElectrumError`; rejections
7+
/// must not be treated as connectivity until core splits the variant (see bitkit-core).
8+
var isConnectivityFailure: Bool {
9+
switch self {
10+
case .InvalidHex, .InvalidTransaction:
11+
return false
12+
case .TaskError:
13+
return true
14+
case let .ElectrumError(errorDetails):
15+
return Self.isElectrumConnectivityDetails(errorDetails)
16+
}
17+
}
18+
19+
private static func isElectrumConnectivityDetails(_ details: String) -> Bool {
20+
let lower = details.lowercased()
21+
if lower.hasPrefix("broadcast failed:") {
22+
return false
23+
}
24+
if lower.hasPrefix("failed to connect to electrum:") {
25+
return true
26+
}
27+
if lower.contains("offline")
28+
|| lower.contains("timeout")
29+
|| lower.contains("connection refused")
30+
|| lower.contains("dns")
31+
|| lower.contains("network")
32+
{
33+
return true
34+
}
35+
return false
36+
}
37+
}
38+
39+
extension Error {
40+
func isBroadcastConnectivityFailure() -> Bool {
41+
if let broadcastError = self as? BroadcastError {
42+
return broadcastError.isConnectivityFailure
43+
}
44+
45+
if let appError = self as? AppError, let underlyingError = appError.underlyingError {
46+
return underlyingError.isBroadcastConnectivityFailure()
47+
}
48+
49+
return false
50+
}
51+
}

Bitkit/Extensions/TrezorError+Cancellation.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import BitkitCore
22

3+
private let firmwareErrorCode = 99
4+
35
extension Error {
46
/// Whether this error is a Trezor on-device cancellation (user declined signing, or cancelled PIN
57
/// / passphrase entry). Distinct from Swift `CancellationError` (the flow/task being dismissed).
@@ -38,4 +40,20 @@ extension Error {
3840

3941
return false
4042
}
43+
44+
func isTrezorFirmwareError() -> Bool {
45+
if let appError = self as? AppError {
46+
let message = appError.debugMessage ?? appError.message
47+
if message.contains("Device error (code \(firmwareErrorCode))") && message.contains("Firmware error") {
48+
return true
49+
}
50+
if let underlyingError = appError.underlyingError {
51+
return underlyingError.isTrezorFirmwareError()
52+
}
53+
return false
54+
}
55+
56+
let message = localizedDescription
57+
return message.contains("Device error (code \(firmwareErrorCode))") && message.contains("Firmware error")
58+
}
4159
}

Bitkit/MainNavView.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ struct MainNavView: View {
1313
@EnvironmentObject private var settings: SettingsViewModel
1414
@EnvironmentObject private var sheets: SheetViewModel
1515
@EnvironmentObject private var wallet: WalletViewModel
16+
@EnvironmentObject private var transfer: TransferViewModel
1617
@Environment(TrezorManager.self) private var trezorManager
1718
@Environment(HwWalletManager.self) private var hwWalletManager
1819
@Environment(\.scenePhase) var scenePhase
@@ -32,6 +33,12 @@ struct MainNavView: View {
3233
NavigationStack(path: $navigation.path) {
3334
navigationContent
3435
}
36+
.onChange(of: transfer.hwFundingComplete) { _, complete in
37+
if complete {
38+
transfer.consumeHwFundingComplete()
39+
navigation.navigate(.spendingHwSigned)
40+
}
41+
}
3542
.sheet(
3643
item: $sheets.addTagSheetItem,
3744
onDismiss: {

Bitkit/Managers/HwWalletManager.swift

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,17 @@ final class HwWalletManager {
164164
recomputeDerivedState()
165165
}
166166

167+
/// Removes a hardware wallet and forgets every device entry that belongs to its wallet identity.
168+
func removeWallet(
169+
_ wallet: HwWallet,
170+
forgetDevice: (String) async -> Void
171+
) async {
172+
removeDevice(id: wallet.id)
173+
for deviceId in wallet.deviceIds {
174+
await forgetDevice(deviceId)
175+
}
176+
}
177+
167178
// MARK: - Watcher orchestration
168179

169180
/// Reconcile watchers in response to a settings change, but only when the monitored address
@@ -536,10 +547,47 @@ final class HwWalletManager {
536547
sats: UInt64,
537548
satsPerVByte: UInt64,
538549
addressType: AddressScriptType = hwFundingDefaultAddressType
550+
) async throws -> HwFundingTransaction {
551+
let fingerprint = try await TrezorService.shared.getDeviceFingerprint()
552+
return try await composeFundingTransactionInternal(
553+
deviceId: deviceId,
554+
address: address,
555+
sats: sats,
556+
satsPerVByte: satsPerVByte,
557+
fingerprint: fingerprint,
558+
addressType: addressType
559+
)
560+
}
561+
562+
/// Offline coin-selection for the exact funding amount; returns the mining fee only.
563+
func estimateOfflineFundingMiningFee(
564+
deviceId: String,
565+
address: String,
566+
sats: UInt64,
567+
satsPerVByte: UInt64,
568+
addressType: AddressScriptType = hwFundingDefaultAddressType
569+
) async throws -> UInt64 {
570+
let funding = try await composeFundingTransactionInternal(
571+
deviceId: deviceId,
572+
address: address,
573+
sats: sats,
574+
satsPerVByte: satsPerVByte,
575+
fingerprint: nil,
576+
addressType: addressType
577+
)
578+
return funding.miningFeeSats
579+
}
580+
581+
private func composeFundingTransactionInternal(
582+
deviceId: String,
583+
address: String,
584+
sats: UInt64,
585+
satsPerVByte: UInt64,
586+
fingerprint: String?,
587+
addressType: AddressScriptType
539588
) async throws -> HwFundingTransaction {
540589
let account = try getFundingAccount(deviceId: deviceId, addressType: addressType)
541590
let network = networkProvider()
542-
let fingerprint = try await TrezorService.shared.getDeviceFingerprint()
543591
let params = ComposeParams(
544592
wallet: WalletParams(
545593
extendedKey: account.xpub,

Bitkit/Managers/TrezorManager.swift

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,6 @@ final class TrezorManager {
156156
default:
157157
Logger.info(message, context: "TrezorManager")
158158
}
159-
TrezorDebugLog.shared.log(message)
160159
}
161160

162161
// MARK: - State Reset Helpers
@@ -655,13 +654,37 @@ final class TrezorManager {
655654
/// failure so the transfer flow can surface a reconnect error.
656655
func ensureConnected(deviceId: String) async throws {
657656
if connectedDevice?.id == deviceId, await trezorService.isConnected() {
657+
let features = try await refreshedFeaturesIfLocked()
658+
try requireUnlocked(features: features)
658659
return
659660
}
660661
// A stale or mismatched session blocks a clean reconnect — clear it first.
661662
if connectedDevice != nil {
662663
await disconnectStaleSession(deviceId: connectedDevice?.id ?? deviceId)
663664
}
664665
try await reconnectKnownDevice(deviceId: deviceId)
666+
try requireUnlocked(features: deviceFeatures)
667+
}
668+
669+
private func refreshedFeaturesIfLocked() async throws -> TrezorFeatures {
670+
guard let features = deviceFeatures else {
671+
let refreshed = try await trezorService.refreshFeatures()
672+
deviceFeatures = refreshed
673+
return refreshed
674+
}
675+
if features.pinProtection == true, features.unlocked == false {
676+
let refreshed = try await trezorService.refreshFeatures()
677+
deviceFeatures = refreshed
678+
return refreshed
679+
}
680+
return features
681+
}
682+
683+
private func requireUnlocked(features: TrezorFeatures?) throws {
684+
guard let features else { return }
685+
if features.pinProtection == true, features.unlocked == false {
686+
throw TrezorError.DeviceBusy
687+
}
665688
}
666689

667690
private func reconnectKnownDevice(deviceId: String) async throws {
@@ -710,12 +733,15 @@ final class TrezorManager {
710733
/// Tear down the current device session so the next connect establishes a fresh one. Used after
711734
/// a signing failure or timeout, where the transport session may be left in a bad state.
712735
func disconnectStaleSession(deviceId: String) async {
713-
do {
714-
try await trezorService.disconnect()
715-
} catch {
716-
trezorLog("Failed to disconnect stale session for '\(deviceId)': \(error)", level: "warn")
717-
}
718-
clearDisconnectedDeviceState()
736+
await Task.detached { @MainActor [weak self] in
737+
guard let self else { return }
738+
do {
739+
try await trezorService.disconnect()
740+
} catch {
741+
trezorLog("Failed to disconnect stale session for '\(deviceId)': \(error)", level: "warn")
742+
}
743+
clearDisconnectedDeviceState()
744+
}.value
719745
}
720746

721747
/// Reconstruct a `TrezorDeviceInfo` for reconnecting to a known BLE device when a fresh scan

Bitkit/Resources/Localization/en.lproj/Localizable.strings

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
"hardware__connect_title" = "Connect Device";
4040
"hardware__connect_header" = "Searching for <accent>devices</accent>";
4141
"hardware__connect_text" = "Please connect your hardware wallet now via Bluetooth.";
42-
"hardware__connect_error" = "Could not connect to your Trezor. Check that it is unlocked and try again.";
42+
"hardware__connect_error" = "Could not connect to your hardware device. Make sure it is unlocked, nearby, and not connected to another phone or computer, then try again.";
4343
"hardware__search_error" = "Could not search for hardware wallets. Check your connection and try again.";
4444
"hardware__pairing_code_invalid" = "Incorrect pairing code. Put your Trezor back in pairing mode and try again.";
4545
"hardware__device_busy" = "Your Trezor is busy. Unlock it on the device, then try again.";

0 commit comments

Comments
 (0)