Skip to content

Commit 60abb1b

Browse files
ben-kaufmanclaude
andcommitted
feat: one-time stale channel monitor recovery
On BuildException.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 SharedPreferences 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 e476205 commit 60abb1b

1 file changed

Lines changed: 53 additions & 13 deletions

File tree

app/src/main/java/to/bitkit/services/LightningService.kt

Lines changed: 53 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package to.bitkit.services
22

3+
import android.content.Context
34
import com.synonym.bitkitcore.AddressType
5+
import dagger.hilt.android.qualifiers.ApplicationContext
46
import kotlinx.coroutines.CoroutineDispatcher
57
import kotlinx.coroutines.Job
68
import kotlinx.coroutines.cancelAndJoin
@@ -71,6 +73,7 @@ typealias NodeEventHandler = suspend (Event) -> Unit
7173
@Suppress("LargeClass", "TooManyFunctions")
7274
@Singleton
7375
class LightningService @Inject constructor(
76+
@ApplicationContext private val context: Context,
7477
@BgDispatcher private val bgDispatcher: CoroutineDispatcher,
7578
private val keychain: Keychain,
7679
private val vssStoreIdProvider: VssStoreIdProvider,
@@ -81,8 +84,17 @@ class LightningService @Inject constructor(
8184
companion object {
8285
private const val TAG = "LightningService"
8386
private const val NODE_ID_PREVIEW_LEN = 20
87+
private const val STALE_MONITOR_RECOVERY_ATTEMPTED_KEY = "staleMonitorRecoveryAttempted"
8488
}
8589

90+
private val prefs by lazy {
91+
context.getSharedPreferences("lightning_recovery", Context.MODE_PRIVATE)
92+
}
93+
94+
private var staleMonitorRecoveryAttempted: Boolean
95+
get() = prefs.getBoolean(STALE_MONITOR_RECOVERY_ATTEMPTED_KEY, false)
96+
set(value) = prefs.edit().putBoolean(STALE_MONITOR_RECOVERY_ATTEMPTED_KEY, value).apply()
97+
8698
@Volatile
8799
var node: Node? = null
88100

@@ -177,25 +189,53 @@ class LightningService @Inject constructor(
177189
passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name),
178190
)
179191
}
180-
try {
181-
val vssStoreId = vssStoreIdProvider.getVssStoreId(walletIndex)
182-
val vssUrl = Env.vssServerUrl
183-
val lnurlAuthServerUrl = Env.lnurlAuthServerUrl
184-
val fixedHeaders = emptyMap<String, String>()
185-
Logger.verbose(
186-
"Building node with \n\t vssUrl: '$vssUrl'\n\t lnurlAuthServerUrl: '$lnurlAuthServerUrl'",
192+
val vssStoreId = vssStoreIdProvider.getVssStoreId(walletIndex)
193+
val vssUrl = Env.vssServerUrl
194+
val lnurlAuthServerUrl = Env.lnurlAuthServerUrl
195+
val fixedHeaders = emptyMap<String, String>()
196+
Logger.verbose(
197+
"Building node with \n\t vssUrl: '$vssUrl'\n\t lnurlAuthServerUrl: '$lnurlAuthServerUrl'",
198+
context = TAG,
199+
)
200+
201+
fun buildNode() = if (lnurlAuthServerUrl.isNotEmpty()) {
202+
builder.buildWithVssStore(vssUrl, vssStoreId, lnurlAuthServerUrl, fixedHeaders)
203+
} else {
204+
builder.buildWithVssStoreAndFixedHeaders(vssUrl, vssStoreId, fixedHeaders)
205+
}
206+
207+
val node = try {
208+
buildNode()
209+
} catch (e: BuildException.ReadFailed) {
210+
if (staleMonitorRecoveryAttempted) throw LdkError(e)
211+
212+
// Build failed with ReadFailed — likely a stale ChannelMonitor (DangerousValue).
213+
// Retry once with accept_stale_channel_monitors for one-time recovery.
214+
Logger.warn(
215+
"Build failed with ReadFailed. Retrying with accept_stale_channel_monitors for one-time recovery.",
216+
e,
187217
context = TAG,
188218
)
189-
if (lnurlAuthServerUrl.isNotEmpty()) {
190-
builder.buildWithVssStore(vssUrl, vssStoreId, lnurlAuthServerUrl, fixedHeaders)
191-
} else {
192-
builder.buildWithVssStoreAndFixedHeaders(vssUrl, vssStoreId, fixedHeaders)
219+
staleMonitorRecoveryAttempted = true
220+
builder.setAcceptStaleChannelMonitors(true)
221+
try {
222+
val recovered = buildNode()
223+
Logger.info("Stale monitor recovery: build succeeded with accept_stale", context = TAG)
224+
recovered
225+
} catch (retryError: BuildException) {
226+
throw LdkError(retryError)
193227
}
194228
} catch (e: BuildException) {
195229
throw LdkError(e)
196-
} finally {
197-
// TODO: cleanup sensitive data after implementing a `SecureString` value holder for Keychain return values
198230
}
231+
232+
// Mark recovery as attempted after any successful build (whether recovery was needed or not).
233+
// This ensures unaffected users never trigger the retry path on future startups.
234+
if (!staleMonitorRecoveryAttempted) {
235+
staleMonitorRecoveryAttempted = true
236+
}
237+
238+
node
199239
}
200240

201241
private suspend fun Builder.configureGossipSource(customRgsServerUrl: String?) {

0 commit comments

Comments
 (0)