Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions Bitkit/AppScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -851,7 +851,8 @@ struct AppScene: View {
do {
try await app.handleScannedData(
paymentTarget,
claimedContactPaymentContext: contactPaymentContext
claimedContactPaymentContext: contactPaymentContext,
alternativeOnchainBalanceSats: hwWalletManager.maximumFundingBalanceSats
)
guard paykitPaymentRequestManager.isCurrentPresentation(request),
app.ownsContactPaymentContext(contactPaymentContext),
Expand Down Expand Up @@ -896,8 +897,12 @@ struct AppScene: View {
continue
}

let route: SendRoute = app.lnurlPayData == nil ? .confirm : .lnurlPayConfirm
guard paykitPaymentRequestManager.isCurrentPresentation(request) else {
guard let route = PaymentNavigationHelper.contactPaymentRoute(
app: app,
currency: currency,
settings: settings
), paykitPaymentRequestManager.isCurrentPresentation(request)
else {
app.resetSendState()
wallet.resetSendState(speed: settings.defaultTransactionSpeed)
return
Expand Down
6 changes: 5 additions & 1 deletion Bitkit/Components/TabBar/TabBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,11 @@ struct TabBar: View {
}

private func onSendPress() {
sheets.showSheet(.send)
if case let .hardwareWallet(walletId) = navigation.currentRoute {
sheets.showSheet(.send, data: SendConfig(hardwareWalletId: walletId))
} else {
sheets.showSheet(.send)
}
}

private func onReceivePress() {
Expand Down
25 changes: 25 additions & 0 deletions Bitkit/Extensions/TrezorError+Cancellation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,29 @@ extension Error {
let message = localizedDescription
return message.contains("Device error (code \(firmwareErrorCode))") && message.contains("Firmware error")
}

/// Whether the current Trezor channel can no longer be used and must be re-established.
func isTrezorSessionFailure() -> Bool {
if let trezorError = self as? TrezorError {
switch trezorError {
case .TransportError, .DeviceDisconnected, .ConnectionError, .Timeout, .NotConnected, .SessionError, .IoError:
return true
case let .ProtocolError(errorDetails):
let details = errorDetails.lowercased()
return details.contains("thp decryption")
|| details.contains("thp encryption")
|| details.contains("thp ack")
|| details.contains("thp invalid sync")
|| details.contains("thp state missing")
default:
return false
}
}

if let appError = self as? AppError, let underlyingError = appError.underlyingError {
return underlyingError.isTrezorSessionFailure()
}

return false
}
}
13 changes: 11 additions & 2 deletions Bitkit/MainNavView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,9 @@ struct MainNavView: View {
// surface the app-wide Pair Device sheet. Hidden again once submitted/cancelled.
if needsCode {
guard !sheets.hardwareConnectHandlesPairing else { return }
if sheets.activeSheetConfiguration?.id == .send {
return
}
sheets.showSheet(.hardwarePairing)
} else {
sheets.hideSheetIfActive(.hardwarePairing, reason: "Pairing code resolved")
Expand Down Expand Up @@ -354,7 +357,10 @@ struct MainNavView: View {
}

do {
try await app.handleScannedData(url.absoluteString)
try await app.handleScannedData(
url.absoluteString,
alternativeOnchainBalanceSats: hwWalletManager.maximumFundingBalanceSats
)
if shouldOpenPaymentSheet(for: url.absoluteString) {
PaymentNavigationHelper.openPaymentSheet(
app: app,
Expand Down Expand Up @@ -658,7 +664,10 @@ struct MainNavView: View {

await wallet.waitForNodeToRun()
try await Task.sleep(nanoseconds: Self.nodeReadyDelayNanoseconds)
try await app.handleScannedData(uri)
try await app.handleScannedData(
uri,
alternativeOnchainBalanceSats: hwWalletManager.maximumFundingBalanceSats
)

try await Task.sleep(nanoseconds: Self.statePropagationDelayNanoseconds)
if shouldOpenPaymentSheet(for: uri) {
Expand Down
39 changes: 36 additions & 3 deletions Bitkit/Managers/HwWalletManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,6 @@ import Foundation
/// injected `HwDeviceSessioning` seam, and read the stored entries fresh from it: a connect that
/// just wrote one lands there before the push does. Never references `TrezorManager` concretely.
///
/// 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 {
Expand All @@ -36,6 +33,12 @@ final class HwWalletManager {
/// Sum of every paired wallet's balance.
private(set) var totalSats: UInt64 = 0

/// Largest funding-account balance held by one paired hardware wallet. Hardware and software
/// balances are separate funding sources; fee-adjusted availability is resolved in the send flow.
var maximumFundingBalanceSats: UInt64 {
wallets.map(\.fundingBalanceSats).max() ?? 0
}

/// bitkit-core wallet ids for the paired hardware wallets — the activity list queries these.
private(set) var hwWalletIds: Set<String> = []

Expand Down Expand Up @@ -106,6 +109,7 @@ final class HwWalletManager {

private var emittedReceivedTxIds: Set<String> = []
private var listeners: [String: TrezorEventListener] = [:]
private var staleSessionCleanupTasks: [String: Task<Void, Never>] = [:]

init(
session: HwDeviceSessioning? = nil,
Expand Down Expand Up @@ -312,6 +316,7 @@ final class HwWalletManager {
guard let session else {
throw AppError(message: "Unavailable", debugMessage: "No device session to open a passphrase wallet with")
}
await waitForStaleSessionCleanup(deviceId: deviceId)
// Absent features mean there is nothing to read the setting from — a session that dropped
// between pairing and this call — which is a reconnect problem and not a device that refuses
// hidden wallets.
Expand Down Expand Up @@ -350,6 +355,7 @@ final class HwWalletManager {
throw AppError(message: "Unavailable", debugMessage: "No device session for wallet '\(walletId)'")
}
let deviceId = try requireTransportDeviceId(for: walletId)
await waitForStaleSessionCleanup(deviceId: deviceId)
try await session.ensureConnected(deviceId: deviceId)
if session.connectedWalletId == walletId { return }

Expand All @@ -373,6 +379,31 @@ final class HwWalletManager {

func disconnectStaleSession(walletId: String) async {
guard let deviceId = transportDeviceId(for: walletId) else { return }
if let cleanup = staleSessionCleanupTasks[deviceId] {
await cleanup.value
return
}
await performStaleSessionCleanup(deviceId: deviceId)
}

/// Starts timeout recovery without blocking the current UI operation. Any subsequent connect
/// for the same physical device waits for this task before opening a new session.
func scheduleStaleSessionCleanup(walletId: String) {
guard let deviceId = transportDeviceId(for: walletId) else { return }
guard staleSessionCleanupTasks[deviceId] == nil else { return }

staleSessionCleanupTasks[deviceId] = Task { @MainActor [weak self] in
guard let self else { return }
await performStaleSessionCleanup(deviceId: deviceId)
staleSessionCleanupTasks[deviceId] = nil
}
}

private func waitForStaleSessionCleanup(deviceId: String) async {
await staleSessionCleanupTasks[deviceId]?.value
}

private func performStaleSessionCleanup(deviceId: String) async {
await session?.disconnectStaleSession(deviceId: deviceId)
}

Expand All @@ -388,6 +419,7 @@ final class HwWalletManager {
// about to need. The prompt reopens it properly a moment later.
guard !needsPassphrase(walletId: walletId) else { return }
guard let deviceId = transportDeviceId(for: walletId) else { return }
guard staleSessionCleanupTasks[deviceId] == nil else { return }
session?.warmUpConnection(deviceId: deviceId)
}

Expand All @@ -400,6 +432,7 @@ final class HwWalletManager {
throw AppError(message: "Unavailable", debugMessage: "No device session for wallet '\(walletId)'")
}
let deviceId = try requireTransportDeviceId(for: walletId)
await waitForStaleSessionCleanup(deviceId: deviceId)
let watchedBefore = watchedWalletIds()
// Not `ensureConnected`: the session this reopens is usually already gone, either because the
// app restarted or because a wrong passphrase closed it.
Expand Down
31 changes: 25 additions & 6 deletions Bitkit/Managers/ScannerManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class ScannerManager: ObservableObject {
private var pubkyProfile: PubkyProfileManager?
private var sheets: SheetViewModel?
private var wallet: WalletViewModel?
private weak var hwWalletManager: HwWalletManager?

func configure(
app: AppViewModel,
Expand All @@ -28,7 +29,8 @@ class ScannerManager: ObservableObject {
navigation: NavigationViewModel? = nil,
pubkyProfile: PubkyProfileManager? = nil,
sheets: SheetViewModel? = nil,
wallet: WalletViewModel? = nil
wallet: WalletViewModel? = nil,
hwWalletManager: HwWalletManager? = nil
) {
self.app = app
self.contactsManager = contactsManager
Expand All @@ -38,6 +40,7 @@ class ScannerManager: ObservableObject {
self.pubkyProfile = pubkyProfile
self.sheets = sheets
self.wallet = wallet
self.hwWalletManager = hwWalletManager
}

func handleScan(_ uri: String, context: ScannerContext) async {
Expand Down Expand Up @@ -76,7 +79,10 @@ class ScannerManager: ObservableObject {
return
}

try await app.handleScannedData(uri)
try await app.handleScannedData(
uri,
alternativeOnchainBalanceSats: hwWalletManager?.maximumFundingBalanceSats ?? 0
)
guard shouldOpenPaymentFlow(for: uri) else { return }

if let currency, let settings, let sheets {
Expand Down Expand Up @@ -123,7 +129,11 @@ class ScannerManager: ObservableObject {
return true
}

func handleSendScan(_ uri: String, completion: @escaping (SendRoute?) -> Void) async {
func handleSendScan(
_ uri: String,
scope: ScanHandlingScope = .unrestricted,
completion: @escaping (SendRoute?) -> Void
) async {
guard let app, let currency, let settings else {
completion(nil)
return
Expand All @@ -147,7 +157,11 @@ class ScannerManager: ObservableObject {
return
}

try await app.handleScannedData(uri)
try await app.handleScannedData(
uri,
scope: scope,
alternativeOnchainBalanceSats: hwWalletManager?.maximumFundingBalanceSats ?? 0
)
guard shouldOpenPaymentFlow(for: uri) else {
completion(nil)
return
Expand Down Expand Up @@ -220,7 +234,12 @@ class ScannerManager: ObservableObject {
await handleScan(uri.trimmingCharacters(in: .whitespacesAndNewlines), context: context)
}

func handleImageSelection(_ item: PhotosPickerItem?, context: ScannerContext, completion: @escaping (SendRoute?) -> Void = { _ in }) async {
func handleImageSelection(
_ item: PhotosPickerItem?,
context: ScannerContext,
scope: ScanHandlingScope = .unrestricted,
completion: @escaping (SendRoute?) -> Void = { _ in }
) async {
guard let app, let item else { return }

do {
Expand Down Expand Up @@ -287,7 +306,7 @@ class ScannerManager: ObservableObject {
DispatchQueue.main.async {
if context == .send {
Task {
await self?.handleSendScan(payload, completion: completion)
await self?.handleSendScan(payload, scope: scope, completion: completion)
}
} else {
Task {
Expand Down
Loading
Loading