Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
61e74dc
fix: recover connections during wallet restore
ben-kaufman Oct 26, 2026
9481b50
docs: number connection recovery changelog
ben-kaufman Oct 26, 2026
868ef74
fix: complete guarded connection recovery
ben-kaufman Oct 27, 2026
93181d5
Merge validated backup lifecycle parent
ben-kaufman Oct 27, 2026
f3222bf
Merge validated backup lifecycle parent into connection recovery
ben-kaufman Oct 27, 2026
911f24c
fix: preserve readiness after activity sync errors
ben-kaufman Oct 27, 2026
e7d5595
fix: defer wallet creation while offline
ben-kaufman Oct 28, 2026
753d6d1
docs: condense recovery changelog entry
ben-kaufman Oct 28, 2026
be804c7
Merge current restore recovery parent
ben-kaufman Oct 28, 2026
1f8e07e
Merge current backup lifecycle parent into connection recovery
ben-kaufman Oct 29, 2026
a4f5ca6
fix: drain node event listeners before retry
ben-kaufman Oct 29, 2026
f38c33e
Merge backup parent and preserve weak node-release checks
ben-kaufman Oct 29, 2026
ca3dc95
Merge backup lifecycle guards into connection recovery
ben-kaufman Oct 30, 2026
13b9119
fix: wait for node startup before stopping
ben-kaufman Oct 31, 2026
f8bd749
Merge updated backup lifecycle parent into connection recovery
ben-kaufman Oct 31, 2026
89e5e94
Merge current backup lifecycle into connection recovery
ben-kaufman Nov 1, 2026
4637d91
fix: retain connection recovery after failed startup retry
ben-kaufman Nov 1, 2026
34925fb
Merge cached backup read protection and use explicit paste in profile…
ben-kaufman Nov 3, 2026
85db6cf
Merge scheduled backup completion fix into connection recovery
ben-kaufman Nov 3, 2026
5063625
merge: compose backup authentication transport recovery
ben-kaufman Nov 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Bitkit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@
Services/HwActivityTagBackup.swift,
Services/HwSnapshotMerge.swift,
Services/LightningService.swift,
Services/NodeEventListener.swift,
Services/MigrationsService.swift,
Services/RNBackupClient.swift,
Services/ServiceQueue.swift,
Expand Down Expand Up @@ -205,6 +206,7 @@
Services/HwActivityTagBackup.swift,
Services/HwSnapshotMerge.swift,
Services/LightningService.swift,
Services/NodeEventListener.swift,
Services/ServiceQueue.swift,
Services/VssStoreIdProvider.swift,
Services/WatchOnlyAccountService.swift,
Expand Down
142 changes: 130 additions & 12 deletions Bitkit/AppScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,8 @@ struct AppScene: View {
@State private var walletIsInitializing: Bool? = nil
@State private var isWalletBackupRestoreRunning = false
@State private var didWalletBackupRestoreFail = false
@ObservedObject private var connectionRecovery = ConnectionRecoveryService.shared
@State private var connectionEditor: ConnectionRecoveryService.Editor?
@State private var isPinVerified: Bool = false
@State private var showRecoveryScreen = false
/// Lets only a return from the background retry a failed node start, not a brief inactive phase.
Expand Down Expand Up @@ -928,7 +930,14 @@ struct AppScene: View {

@ViewBuilder
private var existingWalletContent: some View {
if walletIsInitializing == true {
if let connectionEditor, connectionRecovery.isCurrent(connectionEditor) {
WalletConnectionRecoveryView(
editor: connectionEditor,
onBack: { self.connectionEditor = nil },
onRetry: retryWalletStart,
recovery: connectionRecovery
)
} else if walletIsInitializing == true {
// New wallet is being created or restored
initializingContent
} else if wallet.isRestoringWallet {
Expand All @@ -951,20 +960,24 @@ struct AppScene: View {
@ViewBuilder
private var initializingContent: some View {
if didWalletBackupRestoreFail {
WalletRestoreError {
didWalletBackupRestoreFail = false
await restoreWalletBackupAndStart()
}
WalletRestoreError(onRetry: retryWalletStart, onConnectionSettings: connectionRecovery.isBusy ? nil : openConnectionRecovery)
} else if case .errorStarting = wallet.nodeLifecycleState {
WalletRestoreError(onRetry: retryWalletStart)
WalletRestoreError(onRetry: retryWalletStart, onConnectionSettings: connectionRecovery.isBusy ? nil : openConnectionRecovery)
} else {
let initializationLifecycle = connectionRecovery.lifecycleID
InitializingWalletView(nodeLifecycleState: $wallet.nodeLifecycleState) {
Logger.debug("Wallet finished initializing but node state is \(wallet.nodeLifecycleState)")

if wallet.nodeLifecycleState == .running {
if connectionRecovery.canFinishInitialization(
lifecycle: initializationLifecycle,
isRunning: wallet.nodeLifecycleState == .running,
hasPendingRestore: BackupService.shared.hasPendingWalletRestore(),
didFail: didWalletBackupRestoreFail
) {
walletIsInitializing = false
}
}
.id(connectionRecovery.isBusy)
Comment thread
ben-kaufman marked this conversation as resolved.
Resolved
}
}

Expand Down Expand Up @@ -994,6 +1007,15 @@ struct AppScene: View {
}
}

private func openConnectionRecovery() {
guard !connectionRecovery.isBusy else { return }
do {
connectionEditor = try connectionRecovery.editor()
} catch {
app.toast(error)
}
}

private func handleWalletExistsChange(_: Bool?) {
Logger.info("Wallet exists state changed: \(wallet.walletExists?.description ?? "nil")")

Expand All @@ -1008,7 +1030,10 @@ struct AppScene: View {
}
}

guard wallet.walletExists == true else { return }
guard wallet.walletExists == true else {
connectionEditor = nil
return
}

// Don't start wallet if we're in recovery mode
guard !showRecoveryScreen else { return }
Expand All @@ -1018,7 +1043,7 @@ struct AppScene: View {
}

let shouldRestoreWalletBackup = wallet.isRestoringWallet || BackupService.shared.hasPendingWalletRestore()
if shouldRestoreWalletBackup {
if connectionRecovery.requiresRestrictedStartup(hasPendingRestore: shouldRestoreWalletBackup) {
walletIsInitializing = true
}

Expand All @@ -1038,7 +1063,26 @@ struct AppScene: View {
}

private func startWallet(completingBackupRestore: Bool = false, playsErrorHaptic: Bool = true) async {
let lifecycle = connectionRecovery.lifecycleID
do {
try await connectionRecovery.perform(cancelsWithCaller: false) {
await startAdmittedWallet(completingBackupRestore: completingBackupRestore, playsErrorHaptic: playsErrorHaptic)
}
} catch ConnectionRecoveryService.AdmissionError.busy {
return
} catch {
guard !Task.isCancelled, connectionRecovery.isCurrentLifecycle(lifecycle) else { return }
Logger.error(error, context: "Wallet connection recovery")
didWalletBackupRestoreFail = true
walletIsInitializing = true
}
}

private func startAdmittedWallet(completingBackupRestore: Bool, playsErrorHaptic: Bool) async {
let hasPendingRestore = BackupService.shared.hasPendingWalletRestore()
if connectionRecovery.requiresRestrictedStartup(hasPendingRestore: hasPendingRestore) {
walletIsInitializing = true
}
guard !WalletBackupRestoreGate.blocksWalletStart(
isRestoreRunning: isWalletBackupRestoreRunning,
hasPendingRestore: hasPendingRestore,
Expand All @@ -1061,7 +1105,18 @@ struct AppScene: View {
}

do {
let revision = try connectionRecovery.replay()
if revision != nil, wallet.nodeLifecycleState != .stopped, wallet.nodeLifecycleState != .initializing {
try await wallet.stopLightningNode()
try connectionRecovery.checkCurrentOperation()
}
try await wallet.start()
try connectionRecovery.checkCurrentOperation()
try connectionRecovery.complete(revision: revision, hasPendingRestore: BackupService.shared.hasPendingWalletRestore(),
isRunning: wallet.nodeLifecycleState == .running)
{
didWalletBackupRestoreFail = false
}
try await activity.syncLdkNodePayments()

// Open the swap updates stream so any pending LN -> onchain swaps resume
Expand All @@ -1071,7 +1126,11 @@ struct AppScene: View {
// Schedule full backup after wallet create/restore to prevent epoch dates in backup status
await BackupService.shared.scheduleFullBackup()
} catch {
guard connectionRecovery.canReportOperationFailure() else { return }
Logger.error(error, context: "Failed to start wallet")
if walletIsInitializing == true, connectionRecovery.canReportStartupFailure() {
didWalletBackupRestoreFail = true
}
if playsErrorHaptic {
Haptics.notify(.error)
}
Expand Down Expand Up @@ -1190,6 +1249,22 @@ struct AppScene: View {
}

private func restoreWalletBackupAndStart() async {
let lifecycle = connectionRecovery.lifecycleID
do {
try await connectionRecovery.perform(cancelsWithCaller: false) {
await restoreAdmittedWalletBackupAndStart()
}
} catch ConnectionRecoveryService.AdmissionError.busy {
return
} catch {
guard !Task.isCancelled, connectionRecovery.isCurrentLifecycle(lifecycle) else { return }
Logger.error(error, context: "Restore connection recovery")
didWalletBackupRestoreFail = true
walletIsInitializing = true
}
}

private func restoreAdmittedWalletBackupAndStart() async {
guard !isWalletBackupRestoreRunning else { return }
isWalletBackupRestoreRunning = true
walletIsInitializing = true
Expand All @@ -1201,6 +1276,14 @@ struct AppScene: View {
} else {
await restoreFromMostRecentBackup()
}
do {
try connectionRecovery.checkCurrentOperation()
} catch {
guard connectionRecovery.canReportOperationFailure() else { return }
didWalletBackupRestoreFail = true
app.toast(error)
return
}
guard didRestore else {
didWalletBackupRestoreFail = true
return
Expand All @@ -1213,12 +1296,45 @@ struct AppScene: View {
}

private func retryWalletStart() async {
let lifecycle = connectionRecovery.lifecycleID
do {
wallet.nodeLifecycleState = .initializing
try await wallet.start()
try wallet.setWalletExistsState()
try await connectionRecovery.perform(cancelsWithCaller: false) {
do {
walletIsInitializing = true
didWalletBackupRestoreFail = false
let needsRestore = wallet.isRestoringWallet || BackupService.shared.hasPendingWalletRestore()
if !needsRestore {
_ = try connectionRecovery.replay()
}
try await wallet.stopLightningNode()
Comment thread
ben-kaufman marked this conversation as resolved.
Resolved
try connectionRecovery.checkCurrentOperation()
if needsRestore || BackupService.shared.hasPendingWalletRestore() {
await restoreAdmittedWalletBackupAndStart()
} else {
await startAdmittedWallet(completingBackupRestore: false, playsErrorHaptic: true)
Comment thread
ben-kaufman marked this conversation as resolved.
Resolved
}
try connectionRecovery.checkCurrentOperation()
if connectionRecovery.canReportStartupFailure() {
didWalletBackupRestoreFail = true
}
if wallet.nodeLifecycleState == .running, !BackupService.shared.hasPendingWalletRestore(), !didWalletBackupRestoreFail {
connectionEditor = nil
}
} catch {
guard connectionRecovery.canReportOperationFailure() else { return }
walletIsInitializing = true
didWalletBackupRestoreFail = true
app.toast(error)
}
}
} catch ConnectionRecoveryService.AdmissionError.busy {
return
} catch {
guard !Task.isCancelled, connectionRecovery.isCurrentLifecycle(lifecycle) else { return }
walletIsInitializing = true
didWalletBackupRestoreFail = true
Logger.error("Failed to start wallet on retry", context: "AppScene")
app.toast(error)
Haptics.notify(.error)
}
}
Expand Down Expand Up @@ -1273,6 +1389,7 @@ struct AppScene: View {
try await BackupService.shared.restoreUsdtRecoveryAfterLegacyMigration()
return true
} catch {
guard connectionRecovery.canReportOperationFailure() else { return false }
app.toast(error)
return false
}
Expand All @@ -1286,6 +1403,7 @@ struct AppScene: View {
try await BackupService.shared.performFullRestoreFromLatestBackup()
return true
} catch {
guard connectionRecovery.canReportOperationFailure() else { return false }
app.toast(error)
return false
}
Expand Down
3 changes: 3 additions & 0 deletions Bitkit/Managers/PubkyProfileManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1227,6 +1227,9 @@ class PubkyProfileManager: ObservableObject {
let resolve = remoteProfileResolver

do {
#if DEBUG
try ConnectionRecoveryTestFault.check("profile:load")
#endif
let loadedProfile = try await Task.detached {
try await resolve(pk)
}.value
Expand Down
2 changes: 2 additions & 0 deletions Bitkit/Resources/Localization/ar.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1566,4 +1566,6 @@
"subscriptions__allowance_limit_description" = "طلب بقيمة {amount} من {name} يتجاوز مخصصك. راجعه للدفع.";
"subscriptions__allowance_auto_paid" = "مدفوع تلقائيًا";

"onboarding__connection_settings" = "إعدادات الاتصال";
"onboarding__connection_saved" = "تم الحفظ. أعد المحاولة لمتابعة الاستعادة.";
"wallet__balance_updating" = "جارٍ تحديث الرصيد…";
2 changes: 2 additions & 0 deletions Bitkit/Resources/Localization/ca.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1750,4 +1750,6 @@
"subscriptions__allowance_limit_description" = "Una sol·licitud de {amount} de {name} supera la teva assignació. Revisa-la per pagar.";
"subscriptions__allowance_auto_paid" = "Pagat automàticament";

"onboarding__connection_settings" = "Configuració de connexió";
"onboarding__connection_saved" = "Desat. Torna-ho a provar per continuar restaurant.";
"wallet__balance_updating" = "S’està actualitzant el saldo…";
2 changes: 2 additions & 0 deletions Bitkit/Resources/Localization/cs.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1889,4 +1889,6 @@
"subscriptions__allowance_limit_description" = "Žádost o {amount} od {name} přesahuje vaše povolení. Zkontrolujte ji a zaplaťte.";
"subscriptions__allowance_auto_paid" = "Zaplaceno automaticky";

"onboarding__connection_settings" = "Nastavení připojení";
"onboarding__connection_saved" = "Uloženo. Zkuste to znovu a pokračujte v obnově.";
"wallet__balance_updating" = "Aktualizace zůstatku…";
2 changes: 2 additions & 0 deletions Bitkit/Resources/Localization/de.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1886,4 +1886,6 @@
"subscriptions__allowance_limit_description" = "Eine Anforderung über {amount} von {name} übersteigt dein Kontingent. Prüfe sie, um zu bezahlen.";
"subscriptions__allowance_auto_paid" = "Automatisch bezahlt";

"onboarding__connection_settings" = "Verbindungseinstellungen";
"onboarding__connection_saved" = "Gespeichert. Versuche es erneut, um die Wiederherstellung fortzusetzen.";
"wallet__balance_updating" = "Kontostand wird aktualisiert…";
2 changes: 2 additions & 0 deletions Bitkit/Resources/Localization/el.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1621,4 +1621,6 @@
"subscriptions__allowance_limit_description" = "Ένα αίτημα {amount} από {name} υπερβαίνει την προέγκρισή σου. Έλεγξέ το για να πληρώσεις.";
"subscriptions__allowance_auto_paid" = "Πληρώθηκε αυτόματα";

"onboarding__connection_settings" = "Ρυθμίσεις σύνδεσης";
"onboarding__connection_saved" = "Αποθηκεύτηκε. Δοκιμάστε ξανά για να συνεχίσετε την επαναφορά.";
"wallet__balance_updating" = "Ενημέρωση υπολοίπου…";
2 changes: 2 additions & 0 deletions Bitkit/Resources/Localization/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1978,4 +1978,6 @@
"usdt__error_bridge_liquidity" = "This route cannot complete the transfer right now. Try another network or try again later.";
"usdt__receive_copied" = "Address copied to clipboard.\nMake sure USDT is only sent on the selected network.";

"onboarding__connection_settings" = "Connection settings";
"onboarding__connection_saved" = "Saved. Retry to continue restoring.";
"wallet__balance_updating" = "Updating balance…";
Original file line number Diff line number Diff line change
Expand Up @@ -1892,4 +1892,6 @@
"subscriptions__allowance_limit_description" = "Una solicitud de {amount} de {name} supera su asignación. Revísela para pagar.";
"subscriptions__allowance_auto_paid" = "Pagado automáticamente";

"onboarding__connection_settings" = "Configuración de conexión";
"onboarding__connection_saved" = "Guardado. Reintenta para continuar la restauración.";
"wallet__balance_updating" = "Actualizando saldo…";
2 changes: 2 additions & 0 deletions Bitkit/Resources/Localization/es.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1770,4 +1770,6 @@
"subscriptions__allowance_limit_description" = "Una solicitud de {amount} de {name} supera su asignación. Revísela para pagar.";
"subscriptions__allowance_auto_paid" = "Pagado automáticamente";

"onboarding__connection_settings" = "Ajustes de conexión";
"onboarding__connection_saved" = "Guardado. Reintenta para continuar la restauración.";
"wallet__balance_updating" = "Actualizando saldo…";
2 changes: 2 additions & 0 deletions Bitkit/Resources/Localization/fr.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1900,4 +1900,6 @@
"subscriptions__allowance_limit_description" = "Une demande de {amount} de {name} dépasse votre allocation. Examinez-la pour payer.";
"subscriptions__allowance_auto_paid" = "Payé automatiquement";

"onboarding__connection_settings" = "Paramètres de connexion";
"onboarding__connection_saved" = "Enregistré. Réessayez pour poursuivre la restauration.";
"wallet__balance_updating" = "Mise à jour du solde…";
2 changes: 2 additions & 0 deletions Bitkit/Resources/Localization/it.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1859,4 +1859,6 @@
"subscriptions__allowance_limit_description" = "Una richiesta di {amount} da {name} supera il tuo plafond. Controllala per pagare.";
"subscriptions__allowance_auto_paid" = "Pagato automaticamente";

"onboarding__connection_settings" = "Impostazioni di connessione";
"onboarding__connection_saved" = "Salvato. Riprova per continuare il ripristino.";
"wallet__balance_updating" = "Aggiornamento del saldo…";
2 changes: 2 additions & 0 deletions Bitkit/Resources/Localization/nl.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1894,4 +1894,6 @@
"subscriptions__allowance_limit_description" = "Een verzoek van {amount} van {name} overschrijdt uw toelage. Bekijk het om te betalen.";
"subscriptions__allowance_auto_paid" = "Automatisch betaald";

"onboarding__connection_settings" = "Verbindingsinstellingen";
"onboarding__connection_saved" = "Opgeslagen. Probeer opnieuw om het herstel voort te zetten.";
"wallet__balance_updating" = "Saldo bijwerken…";
2 changes: 2 additions & 0 deletions Bitkit/Resources/Localization/pl.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1899,4 +1899,6 @@
"subscriptions__allowance_limit_description" = "Żądanie {amount} od {name} przekracza Twój przydział. Przejrzyj je, aby zapłacić.";
"subscriptions__allowance_auto_paid" = "Opłacono automatycznie";

"onboarding__connection_settings" = "Ustawienia połączenia";
"onboarding__connection_saved" = "Zapisano. Spróbuj ponownie, aby kontynuować przywracanie.";
"wallet__balance_updating" = "Aktualizowanie salda…";
2 changes: 2 additions & 0 deletions Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1900,4 +1900,6 @@
"subscriptions__allowance_limit_description" = "Uma solicitação de {amount} de {name} excede sua pré-aprovação. Revise-a para pagar.";
"subscriptions__allowance_auto_paid" = "Pago automaticamente";

"onboarding__connection_settings" = "Configurações de conexão";
"onboarding__connection_saved" = "Salvo. Tente novamente para continuar a restauração.";
"wallet__balance_updating" = "Atualizando saldo…";
2 changes: 2 additions & 0 deletions Bitkit/Resources/Localization/pt.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1546,4 +1546,6 @@
"subscriptions__allowance_limit_description" = "Um pedido de {amount} de {name} excede a sua pré-aprovação. Reveja-o para pagar.";
"subscriptions__allowance_auto_paid" = "Pago automaticamente";

"onboarding__connection_settings" = "Definições de ligação";
"onboarding__connection_saved" = "Guardado. Tente novamente para continuar o restauro.";
"wallet__balance_updating" = "A atualizar o saldo…";
2 changes: 2 additions & 0 deletions Bitkit/Resources/Localization/ru.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1880,4 +1880,6 @@
"subscriptions__allowance_limit_description" = "Запрос на {amount} от {name} превышает ваш лимит автооплаты. Проверьте его, чтобы оплатить.";
"subscriptions__allowance_auto_paid" = "Оплачено автоматически";

"onboarding__connection_settings" = "Настройки подключения";
"onboarding__connection_saved" = "Сохранено. Повторите попытку, чтобы продолжить восстановление.";
"wallet__balance_updating" = "Обновление баланса…";
Loading
Loading