From 8eb83db06a87f8c01f1f1ba8c80751c7003cae20 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 18 Mar 2026 22:36:11 +0800 Subject: [PATCH 1/9] 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 From e3cd736df08f1f8ded40418f471e6312d6ef9cc5 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 19 Mar 2026 07:18:00 +0800 Subject: [PATCH 2/9] fix: catch DangerousValue instead of ReadFailed ReadFailed fires for 19+ code paths (KVStore errors, deserialization failures, etc). DangerousValue is the dedicated variant that only fires for the specific stale channel monitor case. Co-Authored-By: Claude Opus 4.6 (1M context) --- Bitkit/Services/LightningService.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 9ecbc9de..5f37469d 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -151,14 +151,14 @@ class LightningService { ) } } catch let error as BuildError { - guard case .ReadFailed = error, !Self.staleMonitorRecoveryAttempted else { + guard case .DangerousValue = error, !Self.staleMonitorRecoveryAttempted else { throw error } - // Build failed with ReadFailed — likely a stale ChannelMonitor (DangerousValue). + // Build failed with DangerousValue — stale ChannelMonitor vs ChannelManager. // 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.", + "Build failed with DangerousValue (stale channel monitors). Retrying with accept_stale_channel_monitors for one-time recovery.", context: "Recovery" ) Self.staleMonitorRecoveryAttempted = true From bcedd4288f25a0ec7be48ad1f1b7711ca15bb63c Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 19 Mar 2026 07:26:53 +0800 Subject: [PATCH 3/9] fix: simplify stale monitor recovery, drop one-shot flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the persisted staleMonitorRecoveryAttempted flag and always retry on DangerousValue. The flag was unnecessary — once monitors are healed, DangerousValue never fires again on subsequent startups. This matches the simpler approach in bitkit-android PR #855. Co-Authored-By: Claude Opus 4.6 (1M context) --- Bitkit/Services/LightningService.swift | 27 +++----------------------- 1 file changed, 3 insertions(+), 24 deletions(-) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 5f37469d..4d9b0a0f 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -9,17 +9,6 @@ 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] = [:] @@ -151,17 +140,13 @@ class LightningService { ) } } catch let error as BuildError { - guard case .DangerousValue = error, !Self.staleMonitorRecoveryAttempted else { - throw error - } + guard case .DangerousValue = error else { throw error } - // Build failed with DangerousValue — stale ChannelMonitor vs ChannelManager. - // Retry once with accept_stale_channel_monitors to recover. + // Stale ChannelMonitor vs ChannelManager — retry with accept_stale to recover. Logger.warn( - "Build failed with DangerousValue (stale channel monitors). Retrying with accept_stale_channel_monitors for one-time recovery.", + "Build failed with DangerousValue. Retrying with accept_stale_channel_monitors for recovery.", context: "Recovery" ) - Self.staleMonitorRecoveryAttempted = true builder.setAcceptStaleChannelMonitors(accept: true) if !lnurlAuthServerUrl.isEmpty { @@ -182,12 +167,6 @@ class LightningService { } } - // 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 From 16d0324d9f4c95b6220e0192d23c7105df1d96fb Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 19 Mar 2026 07:29:49 +0800 Subject: [PATCH 4/9] chore: bump ldk-node to rc.34 (stale monitor recovery + secrets reset) Update from c5698d0 (pre-rc.33, no monitor overwrite protection) to 153ecbe (rc.34) which includes: - accept_stale_channel_monitors flag - BuildError.DangerousValue variant - Commitment secrets reset on force_set_latest_update_id - Delayed chain sync with keysend-based healing - Sentinel skip in provide_secret for reset trees Co-Authored-By: Claude Opus 4.6 (1M context) --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 3b930521..36865b8b 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -928,7 +928,7 @@ repositoryURL = "https://github.com/synonymdev/ldk-node"; requirement = { kind = revision; - revision = c5698d00066e0e50f33696afc562d71023da2373; + revision = 153ecbeb44c8f68fbc8faf1ea6e15c258053507a; }; }; 96DEA0382DE8BBA1009932BF /* XCRemoteSwiftPackageReference "bitkit-core" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index e14c1eae..dfe80ecd 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -24,7 +24,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/ldk-node", "state" : { - "revision" : "c5698d00066e0e50f33696afc562d71023da2373" + "revision" : "153ecbeb44c8f68fbc8faf1ea6e15c258053507a" } }, { From 1b9fea236f6374b7d60a24ba09bc3ef60796200c Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 19 Mar 2026 07:32:09 +0800 Subject: [PATCH 5/9] fix: add required connectionTimeoutSecs for ElectrumSyncConfig rc.34 bindings require connectionTimeoutSecs as a non-optional field. Set to 10 seconds matching Android PR #855. Co-Authored-By: Claude Opus 4.6 (1M context) --- Bitkit/Services/LightningService.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 4d9b0a0f..357287e2 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -95,7 +95,8 @@ class LightningService { onchainWalletSyncIntervalSecs: Env.walletSyncIntervalSecs, lightningWalletSyncIntervalSecs: Env.walletSyncIntervalSecs, feeRateCacheUpdateIntervalSecs: Env.walletSyncIntervalSecs - ) + ), + connectionTimeoutSecs: 10 ) builder.setChainSourceElectrum(serverUrl: resolvedElectrumServerUrl, config: electrumConfig) From 6bea39c4fa894afe839b862253d1334f75d6c4c3 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 19 Mar 2026 08:09:25 +0800 Subject: [PATCH 6/9] fix: add DangerousValue case to BuildError switch in Errors.swift The exhaustive switch on BuildError was missing the new DangerousValue variant from rc.34, which would cause a compile error. Co-Authored-By: Claude Opus 4.6 (1M context) --- Bitkit/Utilities/Errors.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Bitkit/Utilities/Errors.swift b/Bitkit/Utilities/Errors.swift index e4b1f62d..6630577f 100644 --- a/Bitkit/Utilities/Errors.swift +++ b/Bitkit/Utilities/Errors.swift @@ -185,6 +185,9 @@ struct AppError: LocalizedError { case let .ReadFailed(message: ldkMessage): message = "Read failed" debugMessage = ldkMessage + case let .DangerousValue(message: ldkMessage): + message = "Dangerous value" + debugMessage = ldkMessage case let .WriteFailed(message: ldkMessage): message = "Write failed" debugMessage = ldkMessage From 6db9f6dd4508568ad775fc167a3a012cbfec6b12 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 19 Mar 2026 18:37:35 +0800 Subject: [PATCH 7/9] chore: bump ldk-node to db5ce1b (rc.35 with chain sync fix) --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 36865b8b..70f2c947 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -928,7 +928,7 @@ repositoryURL = "https://github.com/synonymdev/ldk-node"; requirement = { kind = revision; - revision = 153ecbeb44c8f68fbc8faf1ea6e15c258053507a; + revision = db5ce1b1dd9f530de2ecc67efc3ab3de45cb4099; }; }; 96DEA0382DE8BBA1009932BF /* XCRemoteSwiftPackageReference "bitkit-core" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index dfe80ecd..b7186c0f 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -24,7 +24,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/ldk-node", "state" : { - "revision" : "153ecbeb44c8f68fbc8faf1ea6e15c258053507a" + "revision" : "db5ce1b1dd9f530de2ecc67efc3ab3de45cb4099" } }, { From 165b7b1cafc14d14926a00bfe533c80e7349eb9d Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Fri, 20 Mar 2026 09:28:35 +0100 Subject: [PATCH 8/9] chore: bump ldk-node to rc.36 --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 70f2c947..d6e4a2ee 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -928,7 +928,7 @@ repositoryURL = "https://github.com/synonymdev/ldk-node"; requirement = { kind = revision; - revision = db5ce1b1dd9f530de2ecc67efc3ab3de45cb4099; + revision = ae38eadab70fceb5dbe242bc02bf895581cb7c3f; }; }; 96DEA0382DE8BBA1009932BF /* XCRemoteSwiftPackageReference "bitkit-core" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index b7186c0f..b1ca3574 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -24,7 +24,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/ldk-node", "state" : { - "revision" : "db5ce1b1dd9f530de2ecc67efc3ab3de45cb4099" + "revision" : "ae38eadab70fceb5dbe242bc02bf895581cb7c3f" } }, { From 119957b4e3232e53b380589702b3ff961e545ee7 Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Fri, 20 Mar 2026 09:38:36 +0100 Subject: [PATCH 9/9] fix: reenable orphaned channel recovery --- Bitkit/ViewModels/WalletViewModel.swift | 28 ++++++++++++------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 1949c215..0678251d 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -138,20 +138,20 @@ class WalletViewModel: ObservableObject { } // If no local migration data, try fetching from RN remote backup (one-time) - // if channelMigration == nil { - // let (remoteMigration, allRetrieved) = await fetchOrphanedChannelMonitorsIfNeeded(walletIndex: walletIndex) - // if let remoteMigration { - // channelMigration = ChannelDataMigration( - // // don't overwrite channel manager, we only need the monitors for the sweep - // channelManager: nil, - // channelMonitors: remoteMigration.channelMonitors.map { [UInt8]($0) } - // ) - // MigrationsService.shared.pendingChannelMigration = nil - // } - // if allRetrieved { - // MigrationsService.shared.isChannelRecoveryChecked = true - // } - // } + if channelMigration == nil { + let (remoteMigration, allRetrieved) = await fetchOrphanedChannelMonitorsIfNeeded(walletIndex: walletIndex) + if let remoteMigration { + channelMigration = ChannelDataMigration( + // don't overwrite channel manager, we only need the monitors for the sweep + channelManager: nil, + channelMonitors: remoteMigration.channelMonitors.map { [UInt8]($0) } + ) + MigrationsService.shared.pendingChannelMigration = nil + } + if allRetrieved { + MigrationsService.shared.isChannelRecoveryChecked = true + } + } await runLegacyNetworkGraphCleanupIfNeeded()