Skip to content

Commit b0185fa

Browse files
author
CypherPoet
authored
fix(send): resolve indefinite sync overlay wait (#590)
* fix(send): resolve indefinite sync overlay wait When a lightning payment starts while channels exist but the peer is not connected, the send sheet's "Connecting to network" overlay waited forever: deferred validation early-returns and is only re-triggered by a hasUsableChannels change that never fires if the peer never connects. The overlay wait is now bounded to 20 seconds while the device is online. On timeout, unified invoices take the existing lightning-to-onchain fallback (the same decision AppViewModel makes at scan time when lightning cannot pay), and everything else swaps the overlay copy to the existing "Connection issues" strings while continuing to listen for the peer, with swipe-down dismissal available throughout. * chore: rename changelog fragment * fix(send): restart sync timeout once the node is running Review follow-up: the timeout task identity now includes node readiness, so a window that started while the node was still starting restarts at .running and the unified-invoice onchain fallback can still fire with fresh balances (previously the Bool id never flipped again and no second window was scheduled). The long-wait copy is also preserved while the timer is merely paused by an offline blip, resetting only when the overlay itself resolves. * fix(send): show connection issues screen on sync timeout Review feedback: instead of swapping the sync screen's copy, reuse the existing OfflineSheetScreen for the timed-out state. OfflineSheetOverlayModifier gains a forceShow flag so SendSheet can present it when the Lightning peer is unreachable, not only when the device is offline. Also clears stale scanned-invoice state when the send sheet opens fresh at the options route, so closing the sheet during the sync overlay and tapping Send again no longer reopens the overlay. The reset runs in onAppear for the options route rather than onDisappear to avoid wiping a freshly scanned invoice when SheetViewModel hides and re-shows the sheet for an incoming deep link. * fix(send): prefer lightning for unified invoices when peer offline Review feedback: with the node running, unified invoices used to fall back to onchain immediately when no channel was usable. They now prefer Lightning and let the send sheet's sync overlay and timeout decide, so a briefly offline peer no longer forces an onchain payment. No usable channels at all, or usable channels without capacity, still fall back immediately. * fix(send): guard routing-fee estimate against zero capacity Found while testing the unified-invoice path with the peer disconnected: calculateRoutingFee computed UInt64(maxSendLightningSats) - buffer, which underflows and traps when maxSendLightningSats is 0 (no usable channel). A zero-amount Lightning invoice routes to this screen, so the app crashed. With no usable outbound capacity there is nothing to estimate, so the routing fee is now 0 and the subtraction is guarded. * refactor(send): tidy sync timeout additions Cleanup pass over the PR's changes: - Collapse the channels-but-unusable check in AppViewModel into a single if-let, matching the idiom already used in the pure-bolt11 branch below. - Use UInt64(clamping:) for the routing-fee capacity conversion instead of a hand-rolled max(0, ...) inside a trapping initializer. - Log shouldShowSyncOverlay on transitions via onChange instead of on every evaluation; the new syncTimeoutPhase property raised the per-render evaluation count, which tripled identical debug lines in the session log.
1 parent f01def0 commit b0185fa

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)