@@ -43,6 +43,7 @@ struct SendSheetItem: SheetItem {
4343
4444struct 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 }
0 commit comments