You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
[Bug]: RN remote restore starts the node after 30 s without the RN channel state; LSP force-closes the channel and the monitors are discarded (RN → native restore only) #1258
Trigger: React Native → native restore only. This affects only a wallet whose Lightning channels exist solely in the legacy RN remote backup, i.e. an un-migrated RN user who restores from seed in the native app (typical case: new phone → install Bitkit from the store → restore seed). Native wallets and wallets already migrated load their channel state from ldk-node's own VSS store and are not affected; install-on-top migration (migration_2/3/4) passes the channel migration synchronously and is not affected. Nothing in the RN app is involved or would be changed — the defect is in native code that consumes the RN backup.
What happened?
WalletViewModel.start() waits for an ongoing restore for at most TIMEOUT_RESTORE_WAIT = 30 s and then starts LDK regardless (waitForRestoreIfNeeded timeout, proceeding anyway). When the restore is MigrationService.restoreFromRNRemoteBackup(), the RN channel_manager / channel_monitors are downloaded inside that restore (fetchRNRemoteLdkData()), kept in memory as pendingChannelMigration, and applied only if they exist when startNode() runs. If the download takes longer than 30 s:
LDK starts with no channels.
The Blocktank LSP sends channel_reestablish for the channel; LDK replies with a bogus one (Sending bogus ChannelReestablish for unknown channel … to force channel closure) and the LSP force-closes. The user's balance is now in the LSP's commitment tx output, spendable only with the ChannelMonitor.
When the monitors finally arrive they are applied only if the first SyncCompleted has not yet run AppViewModel.completeRNRemoteBackupRestore() (which switches isRestoringFromRNRemoteBackup off). Otherwise RN migration completed sets needsPostMigrationSync and the next sync runs completeMigration(), which calls migrationService.consumePendingChannelMigration() — discarding the monitors without applying them. The migration is then marked completed and the data was in-memory only, so nothing re-fetches: the channel and its funds are invisible to this install.
Reproduced continuously by the E2E Migration workflow, scenario migration_1 (uninstall RN → install native → restore mnemonic; RN wallet with a 100 000 sat Blocktank channel). Latest run 34833266011: 6/6 attempts on RN v1.1.4 (job) and v1.1.6 (job) ended with Balance mismatch! Expected 446184, got 346184 — exactly the 100 000 sat spending balance. App log of the v1.1.4 attempt 1 (attached):
11:06:18.034 Saved value for key 'BIP39_MNEMONIC'
11:06:19.7 VSS 'getObject' success null ×6 (fresh wallet) → RN remote restore begins (correct choice)
11:06:48.232 WARN waitForRestoreIfNeeded timeout, proceeding anyway
11:06:57.376 Node started ← no channel migration available yet
11:06:58.005 Sending bogus ChannelReestablish for unknown channel 947644da… to force channel closure
11:07:07.376 Post-migration cleanup completed ← completeRNRemoteBackupRestore ran with channelMigration == null
11:07:24.196 Found 1 channel monitors ← 27 s after node start
11:07:57.417 RN migration completed, marked for post-migration sync (99 s total; no RNBackupClient errors, just slow)
11:08:02 completeMigration() → consumePendingChannelMigration() ← monitors discarded; balance stays 346184 (on-chain only)
The RN app had uploaded the monitor at 11:05:15 (…/v1/persist?…label=channel_monitor&channelId=947644da…), 44 s before the uninstall — the data was on the server; the native download was simply slower than the 30 s cutoff. Waiting longer on the RN side does not change this.
Luckier variant — run 34821062286, v1.1.3 attempt 3 (attached): monitors arrived 4.7 s after node start, completeRNRemoteBackupRestore() restarted the node with them (Applied channel migration: 1 monitors 08:23:58), the monitor detected the LSP's force-close and swept 99 889 sats back on-chain (446 073 total at 08:26:26). Funds recovered, channel lost; the e2e passed its total-balance check by coincidence and then failed on a tag (see below).
Not new. The vulnerable code (waitForRestoreIfNeeded with the 30 s cutoff, RN restore-from-seed support) dates from e4a227115 (2025-12-30) and migration_1 has run nightly since January. Runs in which migration_1 was green on all three RN versions, per month: Jan 12/12, Feb 22/31, Mar 22/31, Apr 29/30, May 18/31, Jun 13/31, Jul 15/33, Aug 6/30, Sep 1/19 (as of 09-14). The Expected 4467xx, got 3467xx signature is confirmed in the job logs from 2026-06-20 (27858166468), July (28635426933, 29177540175, 29887193899) and August (31452988999, 31987848372); older job logs have expired. No change in the app's restore path or in the e2e flow explains the onset — the variable is the RN download time on CI (emulator + staging backend) versus the fixed 30 s. It was hidden because the workflow is nightly-only with continue-on-error, roughly two thirds of failed attempts are genuine RN-app driving flakes, and the "balance OK" variant passes the total-balance check after the force-close sweep. Real-device datapoint: on a Galaxy S22 on mainnet (1 RN-era channel) the RN remote restore took 26 s end to end — just under the cutoff; more monitors or a slower network crosses it.
Minor defect on the same path: MigrationService.applyRNMetadata() drops tags whose activity has not been synced yet (Activity not found for tags: id=1b2f7558… → Applied 1/2 pending tags) and then clears the persisted metadata, so those tags are lost permanently. This is the Tag-sent / Tag-received "not displayed" flavour of the migration_1 failures.
completeMigration() must never consume a pending channel migration that was not applied; apply it (restart the node with it, as completeRNRemoteBackupRestore() does) and only then consume.
markMigrationCompleted() must not run until the channel migration has been applied, so process death does not lose it.
Unapplied RN tags should stay persisted and be retried on later syncs instead of being cleared after one pass.
Steps to Reproduce
CI: run E2E Migration (.github/workflows/e2e_migration.yml), scenario migration_1-restore, any RN version. Locally: BACKEND=regtest npm run e2e:android -- --mochaOpts.grep "@migration_1" from bitkit-e2e-tests with bitkit_rn_regtest.apk and an E2E_BACKEND=network dev build in aut/.
Manual:
RN app (regtest): create wallet, fund on-chain, transfer to spending (Blocktank channel), wait until channel_monitor is uploaded (react-native-ldk log).
Uninstall RN, install native, restore the seed on a slow network / emulator (or temporarily add a delay in fetchRNRemoteLdkData()) so the RN download exceeds 30 s.
Observe waitForRestoreIfNeeded timeout, proceeding anyway, Node started, Sending bogus ChannelReestablish …, then Found N channel monitorsafter the node started, and a spending balance of 0.
Logs / Screenshots / Recordings
Native app logs from the two CI attempts above (regtest): 2026-09-14-rn-remote-restore-node-starts-without-channel-state-logs.zip (attached below). Full CI artifacts: e2e-artifacts_migration_1-restore_v1.1.4_261 / …_v1.1.6_261 on run 34833266011, e2e-artifacts_migration_1-restore_v1.1.3_260 on run 34821062286 (each contains app logs, logcat and a screen recording per attempt).
2.5.0 (release-2.5.0 @ eb77c8953, E2E dev build E2E_BACKEND=network, regtest). TIMEOUT_RESTORE_WAIT, waitForRestoreIfNeeded() and completeMigration() are identical in v2.4.1 — not a 2.5.0 regression.
Device / OS
CI emulator: Pixel 6, API 33, x86_64 (reactivecircus/android-emulator-runner). RN app versions v1.1.3 / v1.1.4 / v1.1.6 (migration-rn-regtest release assets).
Reproducibility
Often (>50%)
Additional context
Environment: regtest (CI) — the flow is identical on mainnet against https://blocktank.synonym.to/backups-ldk. Wallet: restored from an RN remote backup that still contains the channel state.
Why migrated / native wallets are safe: ldk-node loads channel state from its VSS store at build time regardless of the app-level restore, and the fork's apply_channel_data_migration (ldk-node src/builder.rs) skips writing a channel manager that already exists and skips any monitor whose stored update_id is newer or equal.
Scope decision needed: if RN → native restore-from-seed is no longer a supported flow, the alternative to fixing the timing is to remove the RN fallback from the restore picker (RN only when the VSS lookup succeeded and found nothing) and retire migration_1 in bitkit-e2e-tests. Either way the e2e should assert the spending balance explicitly and fail on waitForRestoreIfNeeded timeout / bogus ChannelReestablish in the collected app log.
What happened?
WalletViewModel.start()waits for an ongoing restore for at mostTIMEOUT_RESTORE_WAIT = 30 sand then starts LDK regardless (waitForRestoreIfNeeded timeout, proceeding anyway). When the restore isMigrationService.restoreFromRNRemoteBackup(), the RNchannel_manager/channel_monitors are downloaded inside that restore (fetchRNRemoteLdkData()), kept in memory aspendingChannelMigration, and applied only if they exist whenstartNode()runs. If the download takes longer than 30 s:channel_reestablishfor the channel; LDK replies with a bogus one (Sending bogus ChannelReestablish for unknown channel … to force channel closure) and the LSP force-closes. The user's balance is now in the LSP's commitment tx output, spendable only with theChannelMonitor.SyncCompletedhas not yet runAppViewModel.completeRNRemoteBackupRestore()(which switchesisRestoringFromRNRemoteBackupoff). OtherwiseRN migration completedsetsneedsPostMigrationSyncand the next sync runscompleteMigration(), which callsmigrationService.consumePendingChannelMigration()— discarding the monitors without applying them. The migration is then marked completed and the data was in-memory only, so nothing re-fetches: the channel and its funds are invisible to this install.Reproduced continuously by the
E2E Migrationworkflow, scenariomigration_1(uninstall RN → install native → restore mnemonic; RN wallet with a 100 000 sat Blocktank channel). Latest run 34833266011: 6/6 attempts on RN v1.1.4 (job) and v1.1.6 (job) ended withBalance mismatch! Expected 446184, got 346184— exactly the 100 000 sat spending balance. App log of the v1.1.4 attempt 1 (attached):The RN app had uploaded the monitor at 11:05:15 (
…/v1/persist?…label=channel_monitor&channelId=947644da…), 44 s before the uninstall — the data was on the server; the native download was simply slower than the 30 s cutoff. Waiting longer on the RN side does not change this.Luckier variant — run 34821062286, v1.1.3 attempt 3 (attached): monitors arrived 4.7 s after node start,
completeRNRemoteBackupRestore()restarted the node with them (Applied channel migration: 1 monitors08:23:58), the monitor detected the LSP's force-close and swept 99 889 sats back on-chain (446 073 total at 08:26:26). Funds recovered, channel lost; the e2e passed its total-balance check by coincidence and then failed on a tag (see below).Not new. The vulnerable code (
waitForRestoreIfNeededwith the 30 s cutoff, RN restore-from-seed support) dates frome4a227115(2025-12-30) andmigration_1has run nightly since January. Runs in whichmigration_1was green on all three RN versions, per month: Jan 12/12, Feb 22/31, Mar 22/31, Apr 29/30, May 18/31, Jun 13/31, Jul 15/33, Aug 6/30, Sep 1/19 (as of 09-14). TheExpected 4467xx, got 3467xxsignature is confirmed in the job logs from 2026-06-20 (27858166468), July (28635426933, 29177540175, 29887193899) and August (31452988999, 31987848372); older job logs have expired. No change in the app's restore path or in the e2e flow explains the onset — the variable is the RN download time on CI (emulator + staging backend) versus the fixed 30 s. It was hidden because the workflow is nightly-only withcontinue-on-error, roughly two thirds of failed attempts are genuine RN-app driving flakes, and the "balance OK" variant passes the total-balance check after the force-close sweep. Real-device datapoint: on a Galaxy S22 on mainnet (1 RN-era channel) the RN remote restore took 26 s end to end — just under the cutoff; more monitors or a slower network crosses it.Minor defect on the same path:
MigrationService.applyRNMetadata()drops tags whose activity has not been synced yet (Activity not found for tags: id=1b2f7558…→Applied 1/2 pending tags) and then clears the persisted metadata, so those tags are lost permanently. This is theTag-sent/Tag-received"not displayed" flavour of themigration_1failures.Expected behavior
WalletViewModel.fetchOrphanedChannelMonitorsIfNeededfetches the RN monitors before building the node, gated by a persistedisChannelRecoveryChecked. Android had the same: added in fix: recover orphaned channel monitors from RN backup #802 (for recover force-closed channel funds lost during RN migration #799), moved pre-startup in fix: orphaned channel recovery pre-startup #826 (Move orphaned channel monitor check to pre-setup node #819), then commented out "temporarily" on 2026-03-18 (170a50daa, shipped in chore: bump version 2.1.1 #852) and removed on 2026-04-01 (5337b50c9).isChannelRecoveryChecked/markChannelRecoveryCheckedstill exist unused inMigrationService.completeMigration()must neverconsumea pending channel migration that was not applied; apply it (restart the node with it, ascompleteRNRemoteBackupRestore()does) and only then consume.markMigrationCompleted()must not run until the channel migration has been applied, so process death does not lose it.Steps to Reproduce
CI: run
E2E Migration(.github/workflows/e2e_migration.yml), scenariomigration_1-restore, any RN version. Locally:BACKEND=regtest npm run e2e:android -- --mochaOpts.grep "@migration_1"from bitkit-e2e-tests withbitkit_rn_regtest.apkand anE2E_BACKEND=networkdev build inaut/.Manual:
channel_monitoris uploaded (react-native-ldk log).fetchRNRemoteLdkData()) so the RN download exceeds 30 s.waitForRestoreIfNeeded timeout, proceeding anyway,Node started,Sending bogus ChannelReestablish …, thenFound N channel monitorsafter the node started, and a spending balance of 0.Logs / Screenshots / Recordings
Native app logs from the two CI attempts above (regtest):
2026-09-14-rn-remote-restore-node-starts-without-channel-state-logs.zip(attached below). Full CI artifacts:e2e-artifacts_migration_1-restore_v1.1.4_261/…_v1.1.6_261on run 34833266011,e2e-artifacts_migration_1-restore_v1.1.3_260on run 34821062286 (each contains app logs, logcat and a screen recording per attempt).2026-09-14-rn-remote-restore-node-starts-without-channel-state-logs.zip
Bitkit Version
2.5.0 (
release-2.5.0@eb77c8953, E2E dev buildE2E_BACKEND=network, regtest).TIMEOUT_RESTORE_WAIT,waitForRestoreIfNeeded()andcompleteMigration()are identical in v2.4.1 — not a 2.5.0 regression.Device / OS
CI emulator: Pixel 6, API 33, x86_64 (reactivecircus/android-emulator-runner). RN app versions v1.1.3 / v1.1.4 / v1.1.6 (
migration-rn-regtestrelease assets).Reproducibility
Often (>50%)
Additional context
https://blocktank.synonym.to/backups-ldk. Wallet: restored from an RN remote backup that still contains the channel state.release-2.5.0):WalletViewModel.ktL73 (TIMEOUT_RESTORE_WAIT), L278-306 (start), L308-313 (waitForRestoreIfNeeded), L323-348 (startNode;// checkForOrphanedChannelMonitorRecovery()commented at L340);AppViewModel.ktL1283-1311 (handleSyncCompleted), L1313-1340 (completeRNRemoteBackupRestore), L1355-1381 (completeMigration,consumePendingChannelMigration()at L1367);MigrationService.ktL132-138 (consume/peekPendingChannelMigration), L1290-1304 (restoreFromRNRemoteBackup), L1329-1336 (markMigrationCompleted), L1441-1490 (fetchRNRemoteLdkData,pendingChannelMigrationset at L1480), L940-976 (applyRNMetadata).apply_channel_data_migration(ldk-nodesrc/builder.rs) skips writing a channel manager that already exists and skips any monitor whose storedupdate_idis newer or equal.ChannelReestablish→ LSP force-close → unclaimedto_local— from the earlier.mapNotNullcause fixed in fix: add retry and logging for channel monitor migration #760; the pre-startup recovery introduced for it is what is missing now), iOS feat: add claude code workflow #459 / Fix backup tags on activity edit #479 / refactor: use Nav3, pass rust types to screens and add navigation viewmodel #480 (iOS keeps the pre-startup fetch).migration_1in bitkit-e2e-tests. Either way the e2e should assert the spending balance explicitly and fail onwaitForRestoreIfNeeded timeout/bogus ChannelReestablishin the collected app log.