Skip to content

[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

Description

@piotr-iohk

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:

  1. LDK starts with no channels.
  2. 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.
  3. 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.

Expected behavior

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:

  1. RN app (regtest): create wallet, fund on-chain, transfer to spending (Blocktank channel), wait until channel_monitor is uploaded (react-native-ldk log).
  2. 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.
  3. Observe waitForRestoreIfNeeded timeout, proceeding anyway, Node started, Sending bogus ChannelReestablish …, then Found N channel monitors after 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).

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 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.
  • Code (release-2.5.0): WalletViewModel.kt L73 (TIMEOUT_RESTORE_WAIT), L278-306 (start), L308-313 (waitForRestoreIfNeeded), L323-348 (startNode; // checkForOrphanedChannelMonitorRecovery() commented at L340); AppViewModel.kt L1283-1311 (handleSyncCompleted), L1313-1340 (completeRNRemoteBackupRestore), L1355-1381 (completeMigration, consumePendingChannelMigration() at L1367); MigrationService.kt L132-138 (consume / peekPendingChannelMigration), L1290-1304 (restoreFromRNRemoteBackup), L1329-1336 (markMigrationCompleted), L1441-1490 (fetchRNRemoteLdkData, pendingChannelMigration set at L1480), L940-976 (applyRNMetadata).
  • 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.
  • Related: recover force-closed channel funds lost during RN migration #799 / fix: recover orphaned channel monitors from RN backup #802 / fix: orphaned channel recovery pre-startup #826 / Move orphaned channel monitor check to pre-setup node #819 (same failure mode — bogus ChannelReestablish → LSP force-close → unclaimed to_local — from the earlier .mapNotNull cause 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).
  • 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions