Skip to content
5 changes: 5 additions & 0 deletions Bitkit/Services/CoreService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
}
}
await MainActor.run {
self.cachedTxIdsInBoostTxIds = txIds

Check warning on line 57 in Bitkit/Services/CoreService.swift

View workflow job for this annotation

GitHub Actions / Run Tests

reference to captured var 'txIds' in concurrently-executing code; this is an error in the Swift 6 language mode
}
} catch {
Logger.error("Failed to refresh boostTxIds cache: \(error)", context: "ActivityService")
Expand Down Expand Up @@ -109,7 +109,7 @@

func isActivitySeen(id: String) async -> Bool {
do {
if let activity = try await getActivityById(activityId: id) {

Check warning on line 112 in Bitkit/Services/CoreService.swift

View workflow job for this annotation

GitHub Actions / Run Tests

no 'async' operations occur within 'await' expression
switch activity {
case let .onchain(onchain):
return onchain.seenAt != nil
Expand Down Expand Up @@ -650,7 +650,7 @@
}

private func processLightningPayment(_ payment: PaymentDetails) async throws {
guard case let .bolt11(hash, preimage, secret, description, bolt11) = payment.kind else { return }

Check warning on line 653 in Bitkit/Services/CoreService.swift

View workflow job for this annotation

GitHub Actions / Run Tests

immutable value 'secret' was never used; consider replacing with '_' or removing it

Check warning on line 653 in Bitkit/Services/CoreService.swift

View workflow job for this annotation

GitHub Actions / Run Tests

immutable value 'hash' was never used; consider replacing with '_' or removing it

// Skip pending inbound payments - just means they created an invoice
guard !(payment.status == .pending && payment.direction == .inbound) else { return }
Expand Down Expand Up @@ -703,7 +703,7 @@

for payment in payments {
do {
let state: BitkitCore.PaymentState = switch payment.status {

Check warning on line 706 in Bitkit/Services/CoreService.swift

View workflow job for this annotation

GitHub Actions / Run Tests

immutable value 'state' was never used; consider replacing with '_' or removing it
case .failed:
.failed
case .pending:
Expand Down Expand Up @@ -739,7 +739,7 @@
latestCaughtError = error
}
}
} catch {

Check warning on line 742 in Bitkit/Services/CoreService.swift

View workflow job for this annotation

GitHub Actions / Run Tests

'catch' block is unreachable because no errors are thrown in 'do' block
Logger.error("Error syncing LDK payment: \(error)", context: "CoreService")
latestCaughtError = error
}
Expand Down Expand Up @@ -801,7 +801,7 @@
}
}

let closedChannels = try await getAllClosedChannels(sortDirection: .desc)

Check warning on line 804 in Bitkit/Services/CoreService.swift

View workflow job for this annotation

GitHub Actions / Run Tests

no 'async' operations occur within 'await' expression
guard !closedChannels.isEmpty else { return nil }

let details = if let provided = transactionDetails { provided } else { await fetchTransactionDetails(txid: txid) }
Expand Down Expand Up @@ -1005,6 +1005,11 @@
}
}

/// Checks if an on-chain activity exists for a given txid (e.g., a sweep tx has been synced)
func hasOnchainActivityForTxid(txid: String) async -> Bool {
await (try? getOnchainActivityByTxId(txid: txid)) != nil
}

/// Checks if an on-chain activity exists for a given channel (e.g., close tx has been synced)
func hasOnchainActivityForChannel(channelId: String) async -> Bool {
guard let activities = try? await get(filter: .onchain, limit: 50, sortDirection: .desc) else {
Expand Down
17 changes: 15 additions & 2 deletions Bitkit/Services/MigrationsService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,7 @@ class MigrationsService: ObservableObject {
private static let rnPendingChannelMigrationKey = "rnPendingChannelMigration"
private static let rnPendingBlocktankOrderIdsKey = "rnPendingBlocktankOrderIds"
private static let rnDidAttemptPeerRecoveryKey = "rnDidAttemptMigrationPeerRecovery"
private static let rnChannelRecoveryCheckedKey = "rnChannelRecoveryChecked"

@Published var isShowingMigrationLoading = false {
didSet {
Expand Down Expand Up @@ -433,6 +434,12 @@ class MigrationsService: ObservableObject {
set { UserDefaults.standard.set(newValue, forKey: Self.rnDidAttemptPeerRecoveryKey) }
}

/// True after we've checked for orphaned channel monitors (so we don't retry every node start if all succeeded).
var isChannelRecoveryChecked: Bool {
get { UserDefaults.standard.bool(forKey: Self.rnChannelRecoveryCheckedKey) }
set { UserDefaults.standard.set(newValue, forKey: Self.rnChannelRecoveryCheckedKey) }
}

/// True if the user completed RN migration (local or remote).
var rnMigrationCompleted: Bool {
UserDefaults.standard.bool(forKey: Self.rnMigrationCompletedKey)
Expand Down Expand Up @@ -1956,13 +1963,16 @@ extension MigrationsService {
return nil
}

private func fetchRNRemoteLdkData() async {
/// Fetches channel manager and monitors from RN remote backup.
/// Returns `true` if all monitors were successfully retrieved (or none exist), `false` if some failed.
@discardableResult
func fetchRNRemoteLdkData() async -> Bool {
do {
let files = try await RNBackupClient.shared.listFiles(fileGroup: "ldk")

guard let managerData = try? await RNBackupClient.shared.retrieve(label: "channel_manager", fileGroup: "ldk") else {
Logger.debug("No channel_manager found in remote LDK backup", context: "Migration")
return
return true
}

let expectedCount = files.channel_monitors.count
Expand Down Expand Up @@ -2004,8 +2014,11 @@ extension MigrationsService {
)
Logger.info("Prepared \(monitors.count)/\(expectedCount) channel monitors for migration", context: "Migration")
}

return failedMonitors.isEmpty
} catch {
Logger.error("Failed to fetch remote LDK data: \(error)", context: "Migration")
return false
}
}

Expand Down
43 changes: 42 additions & 1 deletion Bitkit/Services/TransferService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,48 @@ class TransferService {
try await markSettled(id: transfer.id)
Logger.debug("Force close sweep detected, settled transfer: \(transfer.id)", context: "TransferService")
} else {
Logger.debug("Force close awaiting sweep detection for transfer: \(transfer.id)", context: "TransferService")
// When LDK batches sweeps from multiple channels into one transaction,
// the onchain activity may only be linked to one channel. Fall back to
// checking if there are no remaining pending sweep balances for this channel.
var sweepSpendingTxid: String?
let hasPendingSweep = balances?.pendingBalancesFromChannelClosures.contains(where: { sweep in
switch sweep {
case let .pendingBroadcast(sweepChannelId, _):
return sweepChannelId == channelId
case let .broadcastAwaitingConfirmation(sweepChannelId, _, latestSpendingTxid, _):
if sweepChannelId == channelId {
sweepSpendingTxid = latestSpendingTxid.description
return true
}
return false
case let .awaitingThresholdConfirmations(sweepChannelId, latestSpendingTxid, _, _, _):
if sweepChannelId == channelId {
sweepSpendingTxid = latestSpendingTxid.description
return true
}
return false
}
}) ?? false

if !hasPendingSweep {
try await markSettled(id: transfer.id)
Logger.debug(
"Force close sweep completed (no pending sweeps), settled transfer: \(transfer.id)",
context: "TransferService"
)
} else if let sweepTxid = sweepSpendingTxid,
await coreService.activity.hasOnchainActivityForTxid(txid: sweepTxid)
{
// The sweep tx was already synced as an onchain activity (linked to another
// channel in the same batched sweep). Safe to settle this transfer.
try await markSettled(id: transfer.id)
Logger.debug(
"Force close batched sweep detected via txid \(sweepTxid), settled transfer: \(transfer.id)",
context: "TransferService"
)
} else {
Logger.debug("Force close awaiting sweep detection for transfer: \(transfer.id)", context: "TransferService")
}
}
} else {
// For coop closes and other types, settle immediately when balance is gone
Expand Down
75 changes: 74 additions & 1 deletion Bitkit/ViewModels/WalletViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ class WalletViewModel: ObservableObject {

static var peerSimulation: BlocktankPeerSimulation = .none

private static let channelRecoveryRestartDelayMs: UInt64 = 500

@Published var isRestoringWallet = false
@Published var balanceInTransferToSavings: Int = 0
@Published var balanceInTransferToSpending: Int = 0
Expand Down Expand Up @@ -234,6 +236,78 @@ class WalletViewModel: ObservableObject {
Task { @MainActor in
try await sync()
}

// One-time check for orphaned channel monitors from RN migration
Task {
await checkForOrphanedChannelMonitorRecovery()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can run this before lightningService.setup() as well I guess, that would eliminate the need to stop and restart the node

}
}

private func checkForOrphanedChannelMonitorRecovery() async {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this have a guard for migrated users only?

guard migrations.rnMigrationCompleted else {
        migrations.isChannelRecoveryChecked = true
        return
    }

let migrations = MigrationsService.shared
guard !migrations.isChannelRecoveryChecked else { return }

Logger.info("Running one-time channel monitor recovery check", context: "WalletViewModel")

var allMonitorsRetrieved = false
do {
guard let mnemonic = try Keychain.loadString(key: .bip39Mnemonic(index: 0)) else {
Logger.debug("Channel recovery: no mnemonic, skipping", context: "WalletViewModel")
migrations.isChannelRecoveryChecked = true
return
}
let passphrase = try? Keychain.loadString(key: .bip39Passphrase(index: 0))

RNBackupClient.shared.reset()
try await RNBackupClient.shared.setup(mnemonic: mnemonic, passphrase: passphrase)

let retrieved = await migrations.fetchRNRemoteLdkData()

if let migration = migrations.pendingChannelMigration {
let monitorCount = migration.channelMonitors.count
Logger.info(
"Found \(monitorCount) monitors on RN backup, attempting recovery",
context: "WalletViewModel"
)

let channelMigration = ChannelDataMigration(
channelManager: [UInt8](migration.channelManager),
channelMonitors: migration.channelMonitors.map { [UInt8]($0) }
)
migrations.pendingChannelMigration = nil

// Stop and restart the lightning service directly (not via self.start())
// to avoid the nodeLifecycleState guard racing with concurrent sync Tasks
try await lightningService.stop()
try await Task.sleep(nanoseconds: Self.channelRecoveryRestartDelayMs * 1_000_000)

let electrumServerUrl = electrumConfigService.getCurrentServer().fullUrl
let rgsServerUrl = rgsConfigService.getCurrentServerUrl()
try await lightningService.setup(
walletIndex: 0,
electrumServerUrl: electrumServerUrl,
rgsServerUrl: rgsServerUrl.isEmpty ? nil : rgsServerUrl,
channelMigration: channelMigration
)
try await lightningService.start()

nodeLifecycleState = .running
syncState()
Logger.info("Channel monitor recovery complete", context: "WalletViewModel")
} else {
Logger.info("No channel monitors found on RN backup", context: "WalletViewModel")
}

allMonitorsRetrieved = retrieved
} catch {
Logger.error("Channel monitor recovery check failed: \(error)", context: "WalletViewModel")
}

if allMonitorsRetrieved {
migrations.isChannelRecoveryChecked = true
} else {
Logger.warn("Some monitors failed to download, will retry on next startup", context: "WalletViewModel")
}
}

private func fetchTrustedPeersFromBlocktank() async -> [LnPeer]? {
Expand All @@ -251,7 +325,6 @@ class WalletViewModel: ObservableObject {
break
}


var info: IBtInfo?
do {
info = try await coreService.blocktank.info(refresh: true)
Expand Down
Loading