Skip to content

Commit 6c93c2e

Browse files
ben-kaufmanclaude
andcommitted
feat: one-time stale channel monitor recovery
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) <noreply@anthropic.com>
1 parent a150549 commit 6c93c2e

1 file changed

Lines changed: 60 additions & 12 deletions

File tree

Bitkit/Services/LightningService.swift

Lines changed: 60 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,17 @@ class LightningService {
99
private var node: Node?
1010
var currentWalletIndex: Int = 0
1111

12+
// MARK: - Stale monitor recovery (one-time recovery for channel monitor desync)
13+
14+
private static let staleMonitorRecoveryAttemptedKey = "staleMonitorRecoveryAttempted"
15+
16+
/// Whether we've already attempted stale monitor recovery (prevents infinite retry).
17+
/// Persisted so the retry only happens once, even across app restarts.
18+
private static var staleMonitorRecoveryAttempted: Bool {
19+
get { UserDefaults.standard.bool(forKey: staleMonitorRecoveryAttemptedKey) }
20+
set { UserDefaults.standard.set(newValue, forKey: staleMonitorRecoveryAttemptedKey) }
21+
}
22+
1223
private let syncStatusChangedSubject = PassthroughSubject<UInt64, Never>()
1324

1425
private var channelCache: [String: ChannelDetails] = [:]
@@ -124,22 +135,59 @@ class LightningService {
124135
builder.setEntropyBip39Mnemonic(mnemonic: mnemonic, passphrase: passphrase)
125136

126137
try await ServiceQueue.background(.ldk) {
127-
if !lnurlAuthServerUrl.isEmpty {
128-
self.node = try builder.buildWithVssStore(
129-
vssUrl: vssUrl,
130-
storeId: storeId,
131-
lnurlAuthServerUrl: lnurlAuthServerUrl,
132-
fixedHeaders: [:]
133-
)
134-
} else {
135-
self.node = try builder.buildWithVssStoreAndFixedHeaders(
136-
vssUrl: vssUrl,
137-
storeId: storeId,
138-
fixedHeaders: [:]
138+
do {
139+
if !lnurlAuthServerUrl.isEmpty {
140+
self.node = try builder.buildWithVssStore(
141+
vssUrl: vssUrl,
142+
storeId: storeId,
143+
lnurlAuthServerUrl: lnurlAuthServerUrl,
144+
fixedHeaders: [:]
145+
)
146+
} else {
147+
self.node = try builder.buildWithVssStoreAndFixedHeaders(
148+
vssUrl: vssUrl,
149+
storeId: storeId,
150+
fixedHeaders: [:]
151+
)
152+
}
153+
} catch let error as BuildError {
154+
guard case .ReadFailed = error, !Self.staleMonitorRecoveryAttempted else {
155+
throw error
156+
}
157+
158+
// Build failed with ReadFailed — likely a stale ChannelMonitor (DangerousValue).
159+
// Retry once with accept_stale_channel_monitors to recover.
160+
Logger.warn(
161+
"Build failed with ReadFailed. Retrying with accept_stale_channel_monitors for one-time recovery.",
162+
context: "Recovery"
139163
)
164+
Self.staleMonitorRecoveryAttempted = true
165+
builder.setAcceptStaleChannelMonitors(accept: true)
166+
167+
if !lnurlAuthServerUrl.isEmpty {
168+
self.node = try builder.buildWithVssStore(
169+
vssUrl: vssUrl,
170+
storeId: storeId,
171+
lnurlAuthServerUrl: lnurlAuthServerUrl,
172+
fixedHeaders: [:]
173+
)
174+
} else {
175+
self.node = try builder.buildWithVssStoreAndFixedHeaders(
176+
vssUrl: vssUrl,
177+
storeId: storeId,
178+
fixedHeaders: [:]
179+
)
180+
}
181+
Logger.info("Stale monitor recovery: build succeeded with accept_stale", context: "Recovery")
140182
}
141183
}
142184

185+
// Mark recovery as attempted after any successful build (whether recovery was needed or not).
186+
// This ensures unaffected users never trigger the retry path on future startups.
187+
if !Self.staleMonitorRecoveryAttempted {
188+
Self.staleMonitorRecoveryAttempted = true
189+
}
190+
143191
Logger.info("LDK node setup")
144192

145193
// Clear memory

0 commit comments

Comments
 (0)