Skip to content

Commit 835923b

Browse files
authored
Merge branch 'master' into fix/reset-suggestions-when-empty
2 parents b1ded4f + b0185fa commit 835923b

5 files changed

Lines changed: 103 additions & 13 deletions

File tree

Bitkit/ViewModels/AppViewModel.swift

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -387,7 +387,17 @@ extension AppViewModel {
387387
return
388388
}
389389

390-
// Lightning insufficient for any reason (no channels, no capacity, etc).
390+
// Channels exist but none are usable (e.g. peer offline): still prefer
391+
// lightning. The send sheet shows the sync overlay and either proceeds
392+
// over lightning when the peer reconnects or falls back to onchain
393+
// after its timeout.
394+
if let channels = lightningService.channels, !channels.isEmpty, !channels.contains(where: \.isUsable) {
395+
handleScannedLightningInvoice(lightningInvoice, bolt11: lnInvoice, onchainInvoice: invoice)
396+
return
397+
}
398+
399+
// Lightning insufficient for any other reason (no channels at all, or
400+
// usable channels without capacity).
391401
// Fall back to onchain and validate onchain balance immediately.
392402
let onchainBalance = lightningService.balances?.spendableOnchainBalanceSats ?? 0
393403
guard validateOnchainBalance(invoiceAmount: invoice.amountSatoshis, onchainBalance: onchainBalance) else {

Bitkit/Views/Offline/OfflineSheetScreen.swift

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,17 @@ private struct OfflineSheetOverlayModifier: ViewModifier {
2525
@EnvironmentObject private var network: NetworkMonitor
2626

2727
let title: String
28+
var forceShow = false
29+
30+
private var isShowing: Bool {
31+
!network.isConnected || forceShow
32+
}
2833

2934
func body(content: Content) -> some View {
3035
ZStack(alignment: .top) {
3136
content
3237

33-
if !network.isConnected {
38+
if isShowing {
3439
OfflineSheetScreen(title: title)
3540
.transition(.opacity)
3641

@@ -41,14 +46,15 @@ private struct OfflineSheetOverlayModifier: ViewModifier {
4146
.padding(.top, 12)
4247
}
4348
}
44-
.animation(.easeInOut(duration: 0.3), value: network.isConnected)
49+
.animation(.easeInOut(duration: 0.3), value: isShowing)
4550
}
4651
}
4752

4853
extension View {
49-
/// Overlays a `OfflineSheetScreen` when the device is offline.
54+
/// Overlays a `OfflineSheetScreen` when the device is offline, or whenever `forceShow` is true
55+
/// (e.g. connection issues beyond device connectivity, like an unreachable Lightning peer).
5056
/// The underlying content remains mounted so navigation state and inputs are preserved.
51-
func offlineSheetOverlay(title: String) -> some View {
52-
modifier(OfflineSheetOverlayModifier(title: title))
57+
func offlineSheetOverlay(title: String, forceShow: Bool = false) -> some View {
58+
modifier(OfflineSheetOverlayModifier(title: title, forceShow: forceShow))
5359
}
5460
}

Bitkit/Views/Wallets/Send/SendAmountView.swift

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -297,9 +297,20 @@ struct SendAmountView: View {
297297
guard app.selectedWalletToPayFrom == .lightning else { return }
298298
guard let bolt11 = app.scannedLightningInvoice?.bolt11 else { return }
299299

300+
let buffer: UInt64 = 2 // TODO: find out why this is needed
301+
302+
// Without usable outbound capacity (e.g. peer offline) there is nothing to estimate,
303+
// and subtracting the buffer below would underflow `UInt64`.
304+
let maxSendable = UInt64(clamping: wallet.maxSendLightningSats)
305+
guard maxSendable > buffer else {
306+
await MainActor.run {
307+
routingFee = 0
308+
}
309+
return
310+
}
311+
300312
do {
301-
let buffer: UInt64 = 2 // TODO: find out why this is needed
302-
let fee = try await wallet.estimateRoutingFees(bolt11: bolt11, amountSats: UInt64(wallet.maxSendLightningSats) - buffer)
313+
let fee = try await wallet.estimateRoutingFees(bolt11: bolt11, amountSats: maxSendable - buffer)
303314
await MainActor.run {
304315
routingFee = fee + buffer
305316
}

Bitkit/Views/Wallets/Send/SendSheet.swift

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ struct SendSheetItem: SheetItem {
4343

4444
struct SendSheet: View {
4545
@EnvironmentObject private var app: AppViewModel
46+
@EnvironmentObject private var network: NetworkMonitor
4647
@EnvironmentObject private var settings: SettingsViewModel
4748
@EnvironmentObject private var sheets: SheetViewModel
4849
@EnvironmentObject private var tagManager: TagManager
@@ -52,15 +53,17 @@ struct SendSheet: View {
5253

5354
@State private var navigationPath: [SendRoute] = []
5455
@State private var hasValidatedAfterSync = false
56+
@State private var syncTimedOut = false
5557
@State private var pinCheckContinuations: [CheckedContinuation<Bool, Never>] = []
5658

59+
/// How long the sync overlay may wait for channels to become usable before falling back
60+
private static let syncTimeoutSeconds: TimeInterval = 20
61+
5762
/// Show sync overlay when node is not ready for payments
5863
/// For lightning: need node running AND at least one usable channel (peer connected).
5964
/// If there are no channels at all, we should NOT wait behind the sync UI – that's a capacity issue, not a sync issue.
6065
/// For onchain: only need node running.
6166
private var shouldShowSyncOverlay: Bool {
62-
Logger.debug("shouldShowSyncOverlay: \(wallet.nodeLifecycleState)", context: "SendSheet")
63-
6467
// Node must be running
6568
guard wallet.nodeLifecycleState == .running else { return true }
6669

@@ -83,6 +86,22 @@ struct SendSheet: View {
8386
return false
8487
}
8588

89+
/// Identity for the sync timeout task. The timer only counts down while the overlay is visible
90+
/// and the device is online (while offline, `offlineSheetOverlay` covers the sheet and a timeout
91+
/// would act on stale state). Node readiness is part of the identity so the window restarts when
92+
/// the node reaches `.running` mid-wait and the timeout can still fall back with fresh balances.
93+
private struct SyncTimeoutPhase: Equatable {
94+
let isActive: Bool
95+
let isNodeRunning: Bool
96+
}
97+
98+
private var syncTimeoutPhase: SyncTimeoutPhase {
99+
SyncTimeoutPhase(
100+
isActive: shouldShowSyncOverlay && network.isConnected,
101+
isNodeRunning: wallet.nodeLifecycleState == .running
102+
)
103+
}
104+
86105
var body: some View {
87106
Sheet(id: .send, data: config) {
88107
if shouldShowSyncOverlay {
@@ -99,11 +118,21 @@ struct SendSheet: View {
99118
}
100119
}
101120
.animation(.easeInOut(duration: 0.3), value: shouldShowSyncOverlay)
102-
.offlineSheetOverlay(title: t("wallet__send_bitcoin"))
121+
.offlineSheetOverlay(title: t("wallet__send_bitcoin"), forceShow: syncTimedOut)
122+
.onChange(of: shouldShowSyncOverlay, initial: true) { _, isShowing in
123+
Logger.debug("shouldShowSyncOverlay: \(isShowing) (node: \(wallet.nodeLifecycleState))", context: "SendSheet")
124+
}
103125
.onAppear {
104126
tagManager.clearSelectedTags()
105127
wallet.resetSendState(speed: settings.defaultTransactionSpeed)
106128
hasValidatedAfterSync = false
129+
syncTimedOut = false
130+
131+
// A plain Send open (TabBar) must not inherit invoice state from an earlier scan,
132+
// e.g. one abandoned behind the sync overlay. Invoice-carrying opens use other routes.
133+
if config.initialRoute == .options {
134+
app.resetSendState()
135+
}
107136

108137
Task {
109138
do {
@@ -145,6 +174,38 @@ struct SendSheet: View {
145174
validatePaymentAfterSync()
146175
}
147176
}
177+
.task(id: syncTimeoutPhase) {
178+
// Bound the sync overlay wait so a peer that never connects can't leave the user
179+
// waiting indefinitely (the onChange above only fires if channels become usable).
180+
guard syncTimeoutPhase.isActive else {
181+
// Reset the connection-issues overlay only once the sync wait actually resolves;
182+
// going offline merely pauses the timer and keeps the timed-out state.
183+
if !shouldShowSyncOverlay {
184+
syncTimedOut = false
185+
}
186+
return
187+
}
188+
189+
try? await Task.sleep(for: .seconds(Self.syncTimeoutSeconds))
190+
guard !Task.isCancelled, syncTimeoutPhase.isActive, !hasValidatedAfterSync else { return }
191+
192+
handleSyncTimeout()
193+
}
194+
}
195+
196+
/// Called when the sync overlay has been visible for `syncTimeoutSeconds` without channels becoming usable.
197+
/// Unified invoices fall back to onchain; everything else shows the connection-issues screen
198+
/// (via the forced `offlineSheetOverlay`) and keeps waiting for the peer.
199+
private func handleSyncTimeout() {
200+
let canFallBackToOnchain = wallet.nodeLifecycleState == .running
201+
&& app.scannedLightningInvoice != nil
202+
&& app.scannedOnchainInvoice != nil
203+
204+
if canFallBackToOnchain {
205+
validatePaymentAfterSync(ignoreChannelWait: true)
206+
} else {
207+
syncTimedOut = true
208+
}
148209
}
149210

150211
/// Validates onchain balance and shows toast + dismisses sheet if insufficient.
@@ -198,15 +259,16 @@ struct SendSheet: View {
198259
/// Validates payment affordability after sync completes
199260
/// For lightning: falls back to onchain for unified invoices, shows error for pure lightning invoices
200261
/// For onchain: validates balance and shows error if insufficient
201-
private func validatePaymentAfterSync() {
262+
/// Pass `ignoreChannelWait: true` to validate even while channels are unusable (sync timeout).
263+
private func validatePaymentAfterSync(ignoreChannelWait: Bool = false) {
202264
// Validate lightning payment if present
203265
if let lightningInvoice = app.scannedLightningInvoice {
204266
// For lightning, if we have channels but none are usable yet, wait for them
205267
// to become usable. If there are no channels at all, or channels are already
206268
// usable, proceed with validation/fallback.
207269
// Use channelCount as fallback in case channels array is nil but count is cached
208270
let hasAnyChannels = (wallet.channels?.isEmpty == false) || wallet.channelCount > 0
209-
if hasAnyChannels, !wallet.hasUsableChannels {
271+
if hasAnyChannels, !wallet.hasUsableChannels, !ignoreChannelWait {
210272
// We have channels but none usable yet → wait
211273
return
212274
}

changelog.d/next/590.fixed.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Sending no longer waits indefinitely or crashes when Lightning channels stay unavailable: Bitkit now prefers Lightning while the peer reconnects, then falls back to an onchain payment when possible or shows the connection issues screen after a short wait.

0 commit comments

Comments
 (0)