Skip to content

Commit 56a988d

Browse files
authored
Merge branch 'master' into feat/multiple-addresses-types
2 parents 858b17b + 11ac987 commit 56a988d

7 files changed

Lines changed: 140 additions & 17 deletions

File tree

Bitkit.xcodeproj/project.pbxproj

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -500,7 +500,7 @@
500500
buildSettings = {
501501
CODE_SIGN_ENTITLEMENTS = BitkitNotification/BitkitNotification.entitlements;
502502
CODE_SIGN_STYLE = Automatic;
503-
CURRENT_PROJECT_VERSION = 178;
503+
CURRENT_PROJECT_VERSION = 180;
504504
DEVELOPMENT_TEAM = KYH47R284B;
505505
GENERATE_INFOPLIST_FILE = YES;
506506
INFOPLIST_FILE = BitkitNotification/Info.plist;
@@ -512,7 +512,7 @@
512512
"@executable_path/Frameworks",
513513
"@executable_path/../../Frameworks",
514514
);
515-
MARKETING_VERSION = 2.0.5;
515+
MARKETING_VERSION = 2.0.6;
516516
PRODUCT_BUNDLE_IDENTIFIER = to.bitkit.notification;
517517
PRODUCT_NAME = "$(TARGET_NAME)";
518518
SDKROOT = iphoneos;
@@ -528,7 +528,7 @@
528528
buildSettings = {
529529
CODE_SIGN_ENTITLEMENTS = BitkitNotification/BitkitNotification.entitlements;
530530
CODE_SIGN_STYLE = Automatic;
531-
CURRENT_PROJECT_VERSION = 178;
531+
CURRENT_PROJECT_VERSION = 180;
532532
DEVELOPMENT_TEAM = KYH47R284B;
533533
GENERATE_INFOPLIST_FILE = YES;
534534
INFOPLIST_FILE = BitkitNotification/Info.plist;
@@ -540,7 +540,7 @@
540540
"@executable_path/Frameworks",
541541
"@executable_path/../../Frameworks",
542542
);
543-
MARKETING_VERSION = 2.0.5;
543+
MARKETING_VERSION = 2.0.6;
544544
PRODUCT_BUNDLE_IDENTIFIER = to.bitkit.notification;
545545
PRODUCT_NAME = "$(TARGET_NAME)";
546546
SDKROOT = iphoneos;
@@ -674,7 +674,7 @@
674674
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
675675
CODE_SIGN_ENTITLEMENTS = Bitkit/Bitkit.entitlements;
676676
CODE_SIGN_STYLE = Automatic;
677-
CURRENT_PROJECT_VERSION = 178;
677+
CURRENT_PROJECT_VERSION = 180;
678678
DEVELOPMENT_ASSET_PATHS = "\"Bitkit/Preview Content\"";
679679
DEVELOPMENT_TEAM = KYH47R284B;
680680
ENABLE_HARDENED_RUNTIME = YES;
@@ -699,7 +699,7 @@
699699
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
700700
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
701701
MACOSX_DEPLOYMENT_TARGET = 14.0;
702-
MARKETING_VERSION = 2.0.5;
702+
MARKETING_VERSION = 2.0.6;
703703
PRODUCT_BUNDLE_IDENTIFIER = to.bitkit;
704704
PRODUCT_NAME = "$(TARGET_NAME)";
705705
SDKROOT = auto;
@@ -717,7 +717,7 @@
717717
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
718718
CODE_SIGN_ENTITLEMENTS = Bitkit/Bitkit.entitlements;
719719
CODE_SIGN_STYLE = Automatic;
720-
CURRENT_PROJECT_VERSION = 178;
720+
CURRENT_PROJECT_VERSION = 180;
721721
DEVELOPMENT_ASSET_PATHS = "\"Bitkit/Preview Content\"";
722722
DEVELOPMENT_TEAM = KYH47R284B;
723723
ENABLE_HARDENED_RUNTIME = YES;
@@ -742,7 +742,7 @@
742742
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
743743
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
744744
MACOSX_DEPLOYMENT_TARGET = 14.0;
745-
MARKETING_VERSION = 2.0.5;
745+
MARKETING_VERSION = 2.0.6;
746746
PRODUCT_BUNDLE_IDENTIFIER = to.bitkit;
747747
PRODUCT_NAME = "$(TARGET_NAME)";
748748
SDKROOT = auto;

Bitkit/Services/CoreService.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -931,6 +931,11 @@ class ActivityService {
931931
}
932932
}
933933

934+
/// Checks if an on-chain activity exists for a given txid (e.g., a sweep tx has been synced)
935+
func hasOnchainActivityForTxid(txid: String) async -> Bool {
936+
await (try? getOnchainActivityByTxId(txid: txid)) != nil
937+
}
938+
934939
/// Checks if an on-chain activity exists for a given channel (e.g., close tx has been synced)
935940
func hasOnchainActivityForChannel(channelId: String) async -> Bool {
936941
guard let activities = try? await get(filter: .onchain, limit: 50, sortDirection: .desc) else {

Bitkit/Services/MigrationsService.swift

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,7 @@ class MigrationsService: ObservableObject {
338338
private static let rnPendingChannelMigrationKey = "rnPendingChannelMigration"
339339
private static let rnPendingBlocktankOrderIdsKey = "rnPendingBlocktankOrderIds"
340340
private static let rnDidAttemptPeerRecoveryKey = "rnDidAttemptMigrationPeerRecovery"
341+
private static let rnChannelRecoveryCheckedKey = "rnChannelRecoveryChecked"
341342
private static let didCleanupInvalidTransfersKey = "didCleanupInvalidMigrationTransfers"
342343

343344
@Published var isShowingMigrationLoading = false {
@@ -436,6 +437,12 @@ class MigrationsService: ObservableObject {
436437
set { UserDefaults.standard.set(newValue, forKey: Self.rnDidAttemptPeerRecoveryKey) }
437438
}
438439

440+
/// True after we've checked for orphaned channel monitors (so we don't retry every node start if all succeeded).
441+
var isChannelRecoveryChecked: Bool {
442+
get { UserDefaults.standard.bool(forKey: Self.rnChannelRecoveryCheckedKey) }
443+
set { UserDefaults.standard.set(newValue, forKey: Self.rnChannelRecoveryCheckedKey) }
444+
}
445+
439446
/// True if the user completed RN migration (local or remote).
440447
var rnMigrationCompleted: Bool {
441448
UserDefaults.standard.bool(forKey: Self.rnMigrationCompletedKey)
@@ -2090,13 +2097,16 @@ extension MigrationsService {
20902097
return nil
20912098
}
20922099

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

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

21022112
let expectedCount = files.channel_monitors.count
@@ -2138,8 +2148,11 @@ extension MigrationsService {
21382148
)
21392149
Logger.info("Prepared \(monitors.count)/\(expectedCount) channel monitors for migration", context: "Migration")
21402150
}
2151+
2152+
return failedMonitors.isEmpty
21412153
} catch {
21422154
Logger.error("Failed to fetch remote LDK data: \(error)", context: "Migration")
2155+
return false
21432156
}
21442157
}
21452158

Bitkit/Services/TransferService.swift

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,48 @@ class TransferService {
172172
try await markSettled(id: transfer.id)
173173
Logger.debug("Force close sweep detected, settled transfer: \(transfer.id)", context: "TransferService")
174174
} else {
175-
Logger.debug("Force close awaiting sweep detection for transfer: \(transfer.id)", context: "TransferService")
175+
// When LDK batches sweeps from multiple channels into one transaction,
176+
// the onchain activity may only be linked to one channel. Fall back to
177+
// checking if there are no remaining pending sweep balances for this channel.
178+
var sweepSpendingTxid: String?
179+
let hasPendingSweep = balances?.pendingBalancesFromChannelClosures.contains(where: { sweep in
180+
switch sweep {
181+
case let .pendingBroadcast(sweepChannelId, _):
182+
return sweepChannelId == channelId
183+
case let .broadcastAwaitingConfirmation(sweepChannelId, _, latestSpendingTxid, _):
184+
if sweepChannelId == channelId {
185+
sweepSpendingTxid = latestSpendingTxid.description
186+
return true
187+
}
188+
return false
189+
case let .awaitingThresholdConfirmations(sweepChannelId, latestSpendingTxid, _, _, _):
190+
if sweepChannelId == channelId {
191+
sweepSpendingTxid = latestSpendingTxid.description
192+
return true
193+
}
194+
return false
195+
}
196+
}) ?? false
197+
198+
if !hasPendingSweep {
199+
try await markSettled(id: transfer.id)
200+
Logger.debug(
201+
"Force close sweep completed (no pending sweeps), settled transfer: \(transfer.id)",
202+
context: "TransferService"
203+
)
204+
} else if let sweepTxid = sweepSpendingTxid,
205+
await coreService.activity.hasOnchainActivityForTxid(txid: sweepTxid)
206+
{
207+
// The sweep tx was already synced as an onchain activity (linked to another
208+
// channel in the same batched sweep). Safe to settle this transfer.
209+
try await markSettled(id: transfer.id)
210+
Logger.debug(
211+
"Force close batched sweep detected via txid \(sweepTxid), settled transfer: \(transfer.id)",
212+
context: "TransferService"
213+
)
214+
} else {
215+
Logger.debug("Force close awaiting sweep detection for transfer: \(transfer.id)", context: "TransferService")
216+
}
176217
}
177218
} else {
178219
// For coop closes and other types, settle immediately when balance is gone

Bitkit/ViewModels/WalletViewModel.swift

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,21 @@ class WalletViewModel: ObservableObject {
137137
MigrationsService.shared.pendingChannelMigration = nil
138138
}
139139

140+
// If no local migration data, try fetching from RN remote backup (one-time)
141+
if channelMigration == nil {
142+
let (remoteMigration, allRetrieved) = await fetchOrphanedChannelMonitorsIfNeeded(walletIndex: walletIndex)
143+
if let remoteMigration {
144+
channelMigration = ChannelDataMigration(
145+
channelManager: [UInt8](remoteMigration.channelManager),
146+
channelMonitors: remoteMigration.channelMonitors.map { [UInt8]($0) }
147+
)
148+
MigrationsService.shared.pendingChannelMigration = nil
149+
}
150+
if allRetrieved {
151+
MigrationsService.shared.isChannelRecoveryChecked = true
152+
}
153+
}
154+
140155
await runLegacyNetworkGraphCleanupIfNeeded()
141156

142157
try await lightningService.setup(
@@ -237,6 +252,43 @@ class WalletViewModel: ObservableObject {
237252
}
238253
}
239254

255+
/// Fetches orphaned channel monitors from RN remote backup before LDK setup.
256+
/// Returns (migration data if found, whether all monitors were successfully retrieved).
257+
private func fetchOrphanedChannelMonitorsIfNeeded(walletIndex: Int) async -> (PendingChannelMigration?, Bool) {
258+
let migrations = MigrationsService.shared
259+
guard !migrations.isChannelRecoveryChecked else { return (nil, true) }
260+
261+
Logger.info("Running pre-startup channel monitor recovery check", context: "WalletViewModel")
262+
263+
do {
264+
guard let mnemonic = try Keychain.loadString(key: .bip39Mnemonic(index: walletIndex)) else {
265+
Logger.debug("Channel recovery: no mnemonic, skipping", context: "WalletViewModel")
266+
migrations.isChannelRecoveryChecked = true
267+
return (nil, true)
268+
}
269+
let passphrase = try? Keychain.loadString(key: .bip39Passphrase(index: walletIndex))
270+
271+
RNBackupClient.shared.reset()
272+
try await RNBackupClient.shared.setup(mnemonic: mnemonic, passphrase: passphrase)
273+
274+
let allRetrieved = await migrations.fetchRNRemoteLdkData()
275+
276+
if let migration = migrations.pendingChannelMigration {
277+
Logger.info(
278+
"Found \(migration.channelMonitors.count) monitors on RN backup for pre-startup recovery",
279+
context: "WalletViewModel"
280+
)
281+
return (migration, allRetrieved)
282+
} else {
283+
Logger.info("No channel monitors found on RN backup", context: "WalletViewModel")
284+
return (nil, allRetrieved)
285+
}
286+
} catch {
287+
Logger.error("Pre-startup channel monitor fetch failed: \(error)", context: "WalletViewModel")
288+
return (nil, false)
289+
}
290+
}
291+
240292
private func fetchTrustedPeersFromBlocktank() async -> [LnPeer]? {
241293
switch Self.peerSimulation {
242294
case .apiFailure:

Bitkit/Views/Wallets/SavingsWalletView.swift

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ struct SavingsWalletView: View {
66
@EnvironmentObject var navigation: NavigationViewModel
77
@EnvironmentObject var wallet: WalletViewModel
88

9+
/// Whether there are any onchain activities to display
10+
private var hasOnchainActivities: Bool {
11+
guard let activities = activity.onchainActivities else { return false }
12+
return !activities.isEmpty
13+
}
14+
915
/// Calculate remaining duration for force close transfers
1016
private var forceCloseRemainingDuration: String? {
1117
guard let claimableAtHeight = wallet.forceCloseClaimableAtHeight,
@@ -42,8 +48,8 @@ struct SavingsWalletView: View {
4248
.padding(.top, 16)
4349
}
4450

45-
if wallet.totalOnchainSats > 0 {
46-
if !GeoService.shared.isGeoBlocked {
51+
if wallet.totalOnchainSats > 0 || hasOnchainActivities {
52+
if wallet.totalOnchainSats > 0, !GeoService.shared.isGeoBlocked {
4753
transferButton
4854
.transition(.move(edge: .leading).combined(with: .opacity))
4955
.padding(.top, 32)
@@ -96,7 +102,7 @@ struct SavingsWalletView: View {
96102
.navigationBarHidden(true)
97103
.animation(.spring(response: 0.3), value: wallet.totalOnchainSats)
98104
.overlay {
99-
if wallet.totalOnchainSats == 0 {
105+
if wallet.totalOnchainSats == 0 && !hasOnchainActivities {
100106
EmptyStateView(type: .savings)
101107
.padding(.horizontal)
102108
.transition(.move(edge: .trailing).combined(with: .opacity))

Bitkit/Views/Wallets/SpendingWalletView.swift

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ struct SpendingWalletView: View {
66
@EnvironmentObject var navigation: NavigationViewModel
77
@EnvironmentObject var wallet: WalletViewModel
88

9+
/// Whether there are any lightning activities to display
10+
private var hasLightningActivities: Bool {
11+
guard let activities = activity.lightningActivities else { return false }
12+
return !activities.isEmpty
13+
}
14+
915
var body: some View {
1016
ZStack(alignment: .top) {
1117
VStack(spacing: 0) {
@@ -27,8 +33,8 @@ struct SpendingWalletView: View {
2733
.padding(.top, 16)
2834
}
2935

30-
if wallet.totalLightningSats > 0 {
31-
if let channels = wallet.channels, !channels.isEmpty {
36+
if wallet.totalLightningSats > 0 || hasLightningActivities {
37+
if wallet.totalLightningSats > 0, let channels = wallet.channels, !channels.isEmpty {
3238
transferButton
3339
.transition(.move(edge: .leading).combined(with: .opacity))
3440
.padding(.top, 32)
@@ -81,7 +87,7 @@ struct SpendingWalletView: View {
8187
.navigationBarHidden(true)
8288
.animation(.spring(response: 0.3), value: wallet.totalLightningSats)
8389
.overlay {
84-
if wallet.totalLightningSats == 0 {
90+
if wallet.totalLightningSats == 0 && !hasLightningActivities {
8591
EmptyStateView(type: .spending)
8692
.padding(.horizontal)
8793
.transition(.move(edge: .trailing).combined(with: .opacity))

0 commit comments

Comments
 (0)