Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
176ce3b
feat: Hw funding models
jvsena42 Jul 3, 2026
f5a674e
feat: funding methods
jvsena42 Jul 3, 2026
7ca7a39
feat: reconnect hardening
jvsena42 Jul 3, 2026
6ff0299
feat: transfer viewmodel methods
jvsena42 Jul 3, 2026
af75113
feat: transfer activity methods
jvsena42 Jul 3, 2026
e48bad9
fix: balance double counting
jvsena42 Jul 3, 2026
2c4a140
feat: UI logic
jvsena42 Jul 3, 2026
93e760a
feat: navigation
jvsena42 Jul 3, 2026
75ba1f0
feat: localization and asets
jvsena42 Jul 3, 2026
b7b520b
test: transfer tests
jvsena42 Jul 3, 2026
f4725fa
refactor: make code more testable
jvsena42 Jul 3, 2026
a522880
refactor: extract HW methods from transfer viewmodel
jvsena42 Jul 5, 2026
63be3e7
fix: add longer read time-out
jvsena42 Jul 5, 2026
2a63fa9
doc: update changelog entry
jvsena42 Jul 6, 2026
8122f44
Merge branch 'feat/hw-wallet-connect' into feat/hw-transfer-to-spending
jvsena42 Jul 6, 2026
61cb584
Merge branch 'feat/hw-wallet-connect' into feat/hw-transfer-to-spending
jvsena42 Jul 6, 2026
d9feafa
fix: increase timout for /call messages
jvsena42 Jul 6, 2026
09a148e
Merge branch 'feat/hw-wallet-connect' into feat/hw-transfer-to-spending
jvsena42 Jul 6, 2026
c6f5c14
Merge branch 'master' into feat/hw-transfer-to-spending
jvsena42 Jul 6, 2026
3b9d131
fix: display availability error on amount screen
jvsena42 Jul 6, 2026
1a682b7
fix: stop storing the SCID in the activity's channelId; pass nil (it'…
jvsena42 Jul 6, 2026
f553e8f
fix: handle compose error
jvsena42 Jul 6, 2026
cdd13d7
fix: mark activity as transfer atomically
jvsena42 Jul 6, 2026
102f858
fix: improve send marx calc for hardware wallets
jvsena42 Jul 6, 2026
11e1690
fix: split sign timeout from broadcast
jvsena42 Jul 6, 2026
5016c33
fix: guard geoblocked HW orders
jvsena42 Jul 6, 2026
0de59ff
fix: Disable Continue on stale HW limits
jvsena42 Jul 6, 2026
6a5a667
fix: handle user cancelation error
jvsena42 Jul 7, 2026
99f05b7
test: device cancelation tests
jvsena42 Jul 7, 2026
e293b49
fix: collapse HW activities duplicates
jvsena42 Jul 7, 2026
eff5dcf
fix: fallback to cached channelId on order fetch error
jvsena42 Jul 7, 2026
f215db5
fix: rerun HW limits on Blocktank info
jvsena42 Jul 7, 2026
cdf4112
fix: cancel hwSignTask on abandonment
jvsena42 Jul 7, 2026
1d13e31
fix: port android warmup logic and reconnect toast
jvsena42 Jul 7, 2026
ee780fa
fix: bump the iOS broadcast timeout to 120s to match Android's budget
jvsena42 Jul 7, 2026
2484d8d
fix: disable order mutating controls while signing to prevent change …
jvsena42 Jul 7, 2026
3f799c2
fix: validate rezer cancelaition error from inderlying error
jvsena42 Jul 8, 2026
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
19 changes: 17 additions & 2 deletions Bitkit/AppScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,21 +74,36 @@ struct AppScene: View {
_blocktank = StateObject(wrappedValue: BlocktankViewModel())
_feeEstimatesManager = StateObject(wrappedValue: feeEstimatesManager)
_activity = StateObject(wrappedValue: ActivityListViewModel(transferService: transferService))

// Created ahead of `transfer` so the hardware-wallet transfer flow can reach the funding
// (compose/sign/broadcast) and device-session (reconnect) capabilities.
let trezorManager = TrezorManager()
let hwWalletManager = HwWalletManager()

_transfer = StateObject(wrappedValue: TransferViewModel(
transferService: transferService,
sheetViewModel: sheetViewModel,
hwFunding: hwWalletManager,
hwConnecting: trezorManager,
hwFeeRateProvider: {
guard let rates = await feeEstimatesManager.getEstimates() else { return nil }
return UInt64(SettingsViewModel.shared.defaultTransactionSpeed.getFeeRate(from: rates))
},
hwAddressProvider: {
let addressType = LDKNode.AddressType.fromStorage(UserDefaults.standard.string(forKey: "selectedAddressType"))
return try await PrivatePaykitAddressReservationStore.shared.nextNonReservedReceiveAddress(addressType: addressType)
},
onBalanceRefresh: { await walletVm.updateBalanceState() }
))
_widgets = StateObject(wrappedValue: WidgetsViewModel())
_settings = StateObject(wrappedValue: SettingsViewModel.shared)

_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())
_hwWalletManager = State(initialValue: hwWalletManager)

CoreService.shared.activity.setPrivatePaykitContactResolvers(
invoice: { paymentHash in
Expand Down
16 changes: 16 additions & 0 deletions Bitkit/Extensions/Activity+Contact.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,19 @@ extension Activity {
}
}
}

extension [Activity] {
/// Drop hardware-wallet on-chain rows whose tx already exists as a main-wallet activity, so a
/// transfer shows a single (main-wallet) row in the merged home / All Activity lists. The HW row
/// still appears on the hardware wallet detail screen (which fetches scoped to the device).
func withoutHardwareDuplicates() -> [Activity] {
let localTxIds = Set(compactMap { activity -> String? in
guard !activity.isHardwareWallet, case let .onchain(onchain) = activity else { return nil }
return onchain.txId
})
return filter { activity in
guard activity.isHardwareWallet, case let .onchain(onchain) = activity else { return true }
return !localTxIds.contains(onchain.txId)
}
}
}
25 changes: 25 additions & 0 deletions Bitkit/Extensions/TrezorError+Cancellation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import BitkitCore

extension Error {
/// Whether this error is a Trezor on-device cancellation (user declined signing, or cancelled PIN
/// / passphrase entry). Distinct from Swift `CancellationError` (the flow/task being dismissed).
/// Callers treat it as a silent no-op so the user can retry on the same screen.
func isTrezorUserCancellation() -> Bool {
if let trezorError = self as? TrezorError {
switch trezorError {
case .UserCancelled, .PinCancelled, .PassphraseCancelled:
return true
default:
return false
}
}

// `ServiceQueue` boxes core errors into a generic `AppError` before they reach the caller, so
// unwrap and re-check the preserved underlying error.
if let appError = self as? AppError, let underlyingError = appError.underlyingError {
return underlyingError.isTrezorUserCancellation()
}

return false
}
}
4 changes: 4 additions & 0 deletions Bitkit/MainNavView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,11 @@ struct MainNavView: View {
case .transferIntro: TransferIntroView()
case .fundingOptions: FundingOptions()
case .spendingIntro: SpendingIntroView()
case let .spendingIntroHw(deviceId): SpendingIntroView(deviceId: deviceId)
case .spendingAmount: SpendingAmount()
case let .spendingAmountHw(deviceId): SpendingAmountHw(deviceId: deviceId)
case let .spendingHwSign(deviceId): SpendingHwSign(deviceId: deviceId)
case .spendingHwSigned: SpendingHwSigned()
case let .spendingConfirm(order): SpendingConfirm(order: order)
case let .spendingAdvanced(order): SpendingAdvancedView(order: order)
case let .transferLearnMore(order): TransferLearnMoreView(order: order)
Expand Down
55 changes: 38 additions & 17 deletions Bitkit/Managers/BalanceManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,12 @@ class BalanceManager {
let channels = await MainActor.run { lightningService.channels } ?? []
let activeTransfers = try transferService.getActiveTransfers()

let paidOrdersSats = getOrderPaymentsSats(activeTransfers: activeTransfers)
let pendingChannelsSats = getPendingChannelsSats(
let paidOrdersSats = await getOrderPaymentsSats(
activeTransfers: activeTransfers,
channels: channels,
balances: balanceDetails
)
let pendingChannelsSats = await getPendingChannelsSats(
transfers: activeTransfers,
channels: channels,
balances: balanceDetails
Expand Down Expand Up @@ -96,21 +100,34 @@ class BalanceManager {

// MARK: - Private Helper Methods

/// Calculates the total amount paid for LSP orders before their channel is assigned.
/// Once channelId is set, funds are tracked by getPendingChannelsSats instead.
private func getOrderPaymentsSats(activeTransfers: [Transfer]) -> UInt64 {
// Only count orders that don't have a channel assigned yet
let paidOrders = activeTransfers.filter {
$0.type.isToSpending() && $0.lspOrderId != nil && $0.channelId == nil
}
/// Calculates the total amount paid for LSP orders whose funds are not yet reflected in a
/// Lightning channel balance. The channel is resolved dynamically (via the order's funding tx),
/// so an order is dropped here as soon as LDK exposes its pending channel — where
/// getPendingChannelsSats then tracks it — preventing a double-count during the
/// paid-order → pending-channel transition (notably for hardware-wallet transfers, where LDK can
/// expose the pending channel before the transfer's channelId is persisted).
private func getOrderPaymentsSats(
activeTransfers: [Transfer],
channels: [ChannelDetails],
balances: BalanceDetails
) async -> UInt64 {
var amount: UInt64 = 0
let paidOrders = activeTransfers.filter { $0.type.isToSpending() && $0.lspOrderId != nil }

for transfer in paidOrders {
Logger.debug(
"Order payment transfer: id=\(transfer.id) orderId=\(transfer.lspOrderId ?? "nil") amount=\(transfer.amountSats)"
)
let channelId = try? await transferService.resolveChannelId(for: transfer, channels: channels)
Comment thread
jvsena42 marked this conversation as resolved.
let channelBalance = channelId.flatMap { id in
balances.lightningBalances.first { $0.channelIdString == id }
}
if channelBalance == nil {
Logger.debug(
"Order payment transfer: id=\(transfer.id) orderId=\(transfer.lspOrderId ?? "nil") amount=\(transfer.amountSats)"
)
amount += transfer.amountSats
}
}

return paidOrders.reduce(0) { $0 + $1.amountSats }
return amount
}

/// Amount to subtract from displayed on-chain for order payments whose tx is not yet reflected in LDK.
Expand All @@ -129,20 +146,24 @@ class BalanceManager {
}

/// Calculates the total balance in pending (not yet usable) channels.
///
/// The channel is resolved dynamically (via the order's funding tx) rather than only from the
/// transfer's stored channelId, so a pending channel is subtracted from the Lightning total as
/// soon as LDK exposes it — even before syncTransferStates persists the channelId.
private func getPendingChannelsSats(
transfers: [Transfer],
channels: [ChannelDetails],
balances: BalanceDetails
) -> UInt64 {
) async -> UInt64 {
var amount: UInt64 = 0
let pendingTransfers = transfers.filter { $0.type.isToSpending() && $0.channelId != nil }
let pendingTransfers = transfers.filter { $0.type.isToSpending() }

Logger.debug("Checking \(pendingTransfers.count) pending transfers to spending")
Logger.debug("Available channels: \(channels.count), Lightning balances: \(balances.lightningBalances.count)")

for transfer in pendingTransfers {
guard let channelId = transfer.channelId else {
Logger.debug("Transfer \(transfer.id) has no channelId")
guard let channelId = try? await transferService.resolveChannelId(for: transfer, channels: channels) else {
Logger.debug("Transfer \(transfer.id) has no resolvable channelId")
continue
}

Expand Down
Loading
Loading