From 60abb1ba3ed9c390f94b8a8f49f6e582452cc82a Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 18 Mar 2026 22:36:48 +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 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) --- .../to/bitkit/services/LightningService.kt | 66 +++++++++++++++---- 1 file changed, 53 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index d50332daa..64ae0729f 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -1,6 +1,8 @@ package to.bitkit.services +import android.content.Context import com.synonym.bitkitcore.AddressType +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin @@ -71,6 +73,7 @@ typealias NodeEventHandler = suspend (Event) -> Unit @Suppress("LargeClass", "TooManyFunctions") @Singleton class LightningService @Inject constructor( + @ApplicationContext private val context: Context, @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val keychain: Keychain, private val vssStoreIdProvider: VssStoreIdProvider, @@ -81,8 +84,17 @@ class LightningService @Inject constructor( companion object { private const val TAG = "LightningService" private const val NODE_ID_PREVIEW_LEN = 20 + private const val STALE_MONITOR_RECOVERY_ATTEMPTED_KEY = "staleMonitorRecoveryAttempted" } + private val prefs by lazy { + context.getSharedPreferences("lightning_recovery", Context.MODE_PRIVATE) + } + + private var staleMonitorRecoveryAttempted: Boolean + get() = prefs.getBoolean(STALE_MONITOR_RECOVERY_ATTEMPTED_KEY, false) + set(value) = prefs.edit().putBoolean(STALE_MONITOR_RECOVERY_ATTEMPTED_KEY, value).apply() + @Volatile var node: Node? = null @@ -177,25 +189,53 @@ class LightningService @Inject constructor( passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name), ) } - try { - val vssStoreId = vssStoreIdProvider.getVssStoreId(walletIndex) - val vssUrl = Env.vssServerUrl - val lnurlAuthServerUrl = Env.lnurlAuthServerUrl - val fixedHeaders = emptyMap() - Logger.verbose( - "Building node with \n\t vssUrl: '$vssUrl'\n\t lnurlAuthServerUrl: '$lnurlAuthServerUrl'", + val vssStoreId = vssStoreIdProvider.getVssStoreId(walletIndex) + val vssUrl = Env.vssServerUrl + val lnurlAuthServerUrl = Env.lnurlAuthServerUrl + val fixedHeaders = emptyMap() + Logger.verbose( + "Building node with \n\t vssUrl: '$vssUrl'\n\t lnurlAuthServerUrl: '$lnurlAuthServerUrl'", + context = TAG, + ) + + fun buildNode() = if (lnurlAuthServerUrl.isNotEmpty()) { + builder.buildWithVssStore(vssUrl, vssStoreId, lnurlAuthServerUrl, fixedHeaders) + } else { + builder.buildWithVssStoreAndFixedHeaders(vssUrl, vssStoreId, fixedHeaders) + } + + val node = try { + buildNode() + } catch (e: BuildException.ReadFailed) { + if (staleMonitorRecoveryAttempted) throw LdkError(e) + + // Build failed with ReadFailed — likely a stale ChannelMonitor (DangerousValue). + // Retry once with accept_stale_channel_monitors for one-time recovery. + Logger.warn( + "Build failed with ReadFailed. Retrying with accept_stale_channel_monitors for one-time recovery.", + e, context = TAG, ) - if (lnurlAuthServerUrl.isNotEmpty()) { - builder.buildWithVssStore(vssUrl, vssStoreId, lnurlAuthServerUrl, fixedHeaders) - } else { - builder.buildWithVssStoreAndFixedHeaders(vssUrl, vssStoreId, fixedHeaders) + staleMonitorRecoveryAttempted = true + builder.setAcceptStaleChannelMonitors(true) + try { + val recovered = buildNode() + Logger.info("Stale monitor recovery: build succeeded with accept_stale", context = TAG) + recovered + } catch (retryError: BuildException) { + throw LdkError(retryError) } } catch (e: BuildException) { throw LdkError(e) - } finally { - // TODO: cleanup sensitive data after implementing a `SecureString` value holder for Keychain return values } + + // 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 (!staleMonitorRecoveryAttempted) { + staleMonitorRecoveryAttempted = true + } + + node } private suspend fun Builder.configureGossipSource(customRgsServerUrl: String?) {