From db59970e893e1072b7636b2fc073045461404d6b Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 18 Mar 2026 22:36:11 +0800 Subject: [PATCH] feat: one-time stale channel monitor recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On BuildError.ReadFailed (likely stale ChannelMonitor from migration overwrite), automatically retry once with accept_stale_channel_monitors enabled. The ldk-node recovery flag force-syncs the monitor's update_id and heals commitment state via a delayed chain sync + keysend round-trip. A persisted UserDefaults flag ensures this only triggers once — set on any successful build (affected or not), preventing future retries. Depends on: synonymdev/ldk-node#76 Co-Authored-By: Claude Opus 4.6 (1M context) --- Bitkit/Services/LightningService.swift | 72 +++++++++++++++++++++----- 1 file changed, 60 insertions(+), 12 deletions(-) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 2bfa4a27..9ecbc9de 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -9,6 +9,17 @@ class LightningService { private var node: Node? var currentWalletIndex: Int = 0 + // MARK: - Stale monitor recovery (one-time recovery for channel monitor desync) + + private static let staleMonitorRecoveryAttemptedKey = "staleMonitorRecoveryAttempted" + + /// Whether we've already attempted stale monitor recovery (prevents infinite retry). + /// Persisted so the retry only happens once, even across app restarts. + private static var staleMonitorRecoveryAttempted: Bool { + get { UserDefaults.standard.bool(forKey: staleMonitorRecoveryAttemptedKey) } + set { UserDefaults.standard.set(newValue, forKey: staleMonitorRecoveryAttemptedKey) } + } + private let syncStatusChangedSubject = PassthroughSubject() private var channelCache: [String: ChannelDetails] = [:] @@ -124,22 +135,59 @@ class LightningService { builder.setEntropyBip39Mnemonic(mnemonic: mnemonic, passphrase: passphrase) try await ServiceQueue.background(.ldk) { - if !lnurlAuthServerUrl.isEmpty { - self.node = try builder.buildWithVssStore( - vssUrl: vssUrl, - storeId: storeId, - lnurlAuthServerUrl: lnurlAuthServerUrl, - fixedHeaders: [:] - ) - } else { - self.node = try builder.buildWithVssStoreAndFixedHeaders( - vssUrl: vssUrl, - storeId: storeId, - fixedHeaders: [:] + do { + if !lnurlAuthServerUrl.isEmpty { + self.node = try builder.buildWithVssStore( + vssUrl: vssUrl, + storeId: storeId, + lnurlAuthServerUrl: lnurlAuthServerUrl, + fixedHeaders: [:] + ) + } else { + self.node = try builder.buildWithVssStoreAndFixedHeaders( + vssUrl: vssUrl, + storeId: storeId, + fixedHeaders: [:] + ) + } + } catch let error as BuildError { + guard case .ReadFailed = error, !Self.staleMonitorRecoveryAttempted else { + throw error + } + + // Build failed with ReadFailed — likely a stale ChannelMonitor (DangerousValue). + // Retry once with accept_stale_channel_monitors to recover. + Logger.warn( + "Build failed with ReadFailed. Retrying with accept_stale_channel_monitors for one-time recovery.", + context: "Recovery" ) + Self.staleMonitorRecoveryAttempted = true + builder.setAcceptStaleChannelMonitors(accept: true) + + if !lnurlAuthServerUrl.isEmpty { + self.node = try builder.buildWithVssStore( + vssUrl: vssUrl, + storeId: storeId, + lnurlAuthServerUrl: lnurlAuthServerUrl, + fixedHeaders: [:] + ) + } else { + self.node = try builder.buildWithVssStoreAndFixedHeaders( + vssUrl: vssUrl, + storeId: storeId, + fixedHeaders: [:] + ) + } + Logger.info("Stale monitor recovery: build succeeded with accept_stale", context: "Recovery") } } + // Mark recovery as attempted after any successful build (whether recovery was needed or not). + // This ensures unaffected users never trigger the retry path on future startups. + if !Self.staleMonitorRecoveryAttempted { + Self.staleMonitorRecoveryAttempted = true + } + Logger.info("LDK node setup") // Clear memory