From a82c16990a7c2768c3026a06214a78a73b31002c Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 17 Jun 2026 11:04:50 -0300 Subject: [PATCH 1/5] feat: implement manual graph reset button on revocery screen --- .../Localization/en.lproj/Localizable.strings | 7 +++ Bitkit/ViewModels/WalletViewModel.swift | 28 +++++++--- Bitkit/Views/Recovery/RecoveryScreen.swift | 51 +++++++++++++++++++ changelog.d/next/600.fixed.md | 1 + 4 files changed, 80 insertions(+), 7 deletions(-) create mode 100644 changelog.d/next/600.fixed.md diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 0f40db181..9b7951617 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -569,6 +569,13 @@ "security__reset_dialog_title" = "Reset Bitkit?"; "security__reset_dialog_desc" = "Are you sure you want to reset your Bitkit Wallet? Do you have a backup of your recovery phrase and wallet data?"; "security__reset_confirm" = "Yes, Reset"; +"security__reset_graph_button" = "Reset Network Graph"; +"security__reset_graph_confirm" = "Yes, Reset"; +"security__reset_graph_dialog_desc" = "This clears the cached Lightning network graph so it is downloaded again from scratch. It can fix \"route not found\" errors. Bitkit will restart automatically."; +"security__reset_graph_dialog_title" = "Reset Network Graph?"; +"security__reset_graph_error" = "Could not reset the network graph. Please try again."; +"security__reset_graph_success_description" = "Bitkit will restart in a few seconds to download a fresh network graph."; +"security__reset_graph_success_title" = "Network Graph Reset"; "security__recovery" = "Recovery"; "security__recovery_text" = "You\'ve entered Bitkit\'s recovery mode. Here are some actions to perform when running into issues that prevent the app from fully functioning. Restart the app for a normal startup."; "security__display_seed" = "Show Seed Phrase"; diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index f6681de68..9cd752198 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -873,18 +873,32 @@ class WalletViewModel: ObservableObject { guard !legacyNetworkGraphCleanupDone else { return } Logger.info("Running legacy network graph cleanup", context: "WalletViewModel") do { - _ = try await VssBackupClient.shared.deleteKey("network_graph") + try await clearNetworkGraph() } catch { - Logger.debug("VSS deleteKey(network_graph): \(error)", context: "WalletViewModel") - } - do { - try await lightningService.deleteNetworkGraph() - } catch { - Logger.debug("Local network graph cache cleanup: \(error)", context: "WalletViewModel") + Logger.debug("Legacy network graph cleanup: \(error)", context: "WalletViewModel") } legacyNetworkGraphCleanupDone = true } + /// Manual recovery action: stop the node and clear the cached network graph so a fresh full + /// snapshot is downloaded on the next startup. Non-destructive to funds. Propagates failures + /// since a reset that leaves the graph in VSS is ineffective. Caller should restart afterwards. + func resetNetworkGraph() async throws { + Logger.warn("Resetting network graph (manual)", context: "WalletViewModel") + if nodeLifecycleState == .starting || nodeLifecycleState == .running { + try await stopLightningNode() + } + try await clearNetworkGraph() + } + + /// Clears the cached Lightning network graph: the local cache file and the VSS backup copy. + /// Shared by the legacy one-time startup cleanup and the manual recovery reset. + private func clearNetworkGraph() async throws { + try await lightningService.deleteNetworkGraph() + _ = try await VssBackupClient.shared.deleteKey("network_graph") + Logger.info("Cleared network graph from VSS", context: "WalletViewModel") + } + /// Refreshes cache and syncs all UI state including balance /// Use this for any event that may have changed balances or channel state private func refreshAndSyncState() async { diff --git a/Bitkit/Views/Recovery/RecoveryScreen.swift b/Bitkit/Views/Recovery/RecoveryScreen.swift index dffc613e9..f1cbd9d99 100644 --- a/Bitkit/Views/Recovery/RecoveryScreen.swift +++ b/Bitkit/Views/Recovery/RecoveryScreen.swift @@ -10,8 +10,13 @@ struct RecoveryScreen: View { @State private var locked = true @State private var showPinCheck = false @State private var showWipeAlert = false + @State private var showResetGraphAlert = false + @State private var isResettingGraph = false @State private var pendingAction: PendingAction? + /// Delay before restarting so the success toast is visible. + private let resetGraphRestartDelay: Duration = .seconds(3) + enum PendingAction { case showSeed case wipeApp @@ -21,6 +26,14 @@ struct RecoveryScreen: View { VStack(alignment: .leading, spacing: 0) { NavigationBar(title: t("security__recovery"), showBackButton: false, showMenuButton: false) .padding(.bottom, 16) + .alert(t("security__reset_graph_dialog_title"), isPresented: $showResetGraphAlert) { + Button(t("common__cancel"), role: .cancel) {} + Button(t("security__reset_graph_confirm"), role: .destructive) { + onResetGraphConfirmed() + } + } message: { + Text(t("security__reset_graph_dialog_desc")) + } ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { @@ -52,6 +65,15 @@ struct RecoveryScreen: View { onContactSupport() } + CustomButton( + title: t("security__reset_graph_button"), + variant: .secondary, + isDisabled: locked || wallet.walletExists != true, + isLoading: isResettingGraph + ) { + showResetGraphAlert = true + } + CustomButton( title: t("security__wipe_app"), variant: .secondary, @@ -223,4 +245,33 @@ struct RecoveryScreen: View { showWipeAlert = false } + + private func onResetGraphConfirmed() { + isResettingGraph = true + + Task { + do { + try await wallet.resetNetworkGraph() + + app.toast( + type: .success, + title: t("security__reset_graph_success_title"), + description: t("security__reset_graph_success_description") + ) + + // Keep the loading state and restart so the graph is re-downloaded on next launch. + try? await Task.sleep(for: resetGraphRestartDelay) + session.skipSplashOnce = true + session.bump() + } catch { + Logger.error("Failed to reset network graph: \(error)", context: "RecoveryScreen") + app.toast( + type: .error, + title: t("common__error"), + description: t("security__reset_graph_error") + ) + isResettingGraph = false + } + } + } } diff --git a/changelog.d/next/600.fixed.md b/changelog.d/next/600.fixed.md new file mode 100644 index 000000000..a7fd9a71c --- /dev/null +++ b/changelog.d/next/600.fixed.md @@ -0,0 +1 @@ +Recovery mode now has a Reset Network Graph option that re-downloads the Lightning network graph to fix "route not found" errors. From f2503cee8c81fac21697e667a75b9af7b85fc7eb Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 17 Jun 2026 13:19:55 -0300 Subject: [PATCH 2/5] fix: node lifecicle race condition --- Bitkit/Services/LightningService.swift | 4 ++++ Bitkit/ViewModels/WalletViewModel.swift | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 07b272257..28ac102f2 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -975,6 +975,10 @@ extension LightningService { node?.nodeId() } + var hasNode: Bool { + node != nil + } + /// Use cached values to avoid blocking LDK calls on main thread @MainActor var balances: BalanceDetails? { cachedBalances diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 9cd752198..a8f3ac2da 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -885,7 +885,8 @@ class WalletViewModel: ObservableObject { /// since a reset that leaves the graph in VSS is ineffective. Caller should restart afterwards. func resetNetworkGraph() async throws { Logger.warn("Resetting network graph (manual)", context: "WalletViewModel") - if nodeLifecycleState == .starting || nodeLifecycleState == .running { + await waitForNodeToRun(timeoutSeconds: 5.0) + if lightningService.hasNode { try await stopLightningNode() } try await clearNetworkGraph() From 1d3654d3df799a3dd46fdd79a3002182ec2a25f5 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 17 Jun 2026 13:33:47 -0300 Subject: [PATCH 3/5] fix: display spinning on secondary button --- Bitkit/Components/Button/Button.swift | 3 ++- Bitkit/Components/Button/SecondaryButtonView.swift | 9 +++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/Bitkit/Components/Button/Button.swift b/Bitkit/Components/Button/Button.swift index 88eaf94fc..61582d0bd 100644 --- a/Bitkit/Components/Button/Button.swift +++ b/Bitkit/Components/Button/Button.swift @@ -166,7 +166,8 @@ struct CustomButton: View { size: size, icon: icon, isDisabled: effectiveIsDisabled, - isPressed: isPressed + isPressed: isPressed, + isLoading: isLoading )) case .tertiary: AnyView(TertiaryButtonView( diff --git a/Bitkit/Components/Button/SecondaryButtonView.swift b/Bitkit/Components/Button/SecondaryButtonView.swift index 3e15fc5d1..c146b9bd5 100644 --- a/Bitkit/Components/Button/SecondaryButtonView.swift +++ b/Bitkit/Components/Button/SecondaryButtonView.swift @@ -6,14 +6,19 @@ struct SecondaryButtonView: View { let icon: AnyView? let isDisabled: Bool let isPressed: Bool + var isLoading: Bool = false var body: some View { HStack(spacing: 8) { - if let icon { + if let icon, !isLoading { icon } - if size == .small { + if isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: textColor)) + .frame(width: 20, height: 20) + } else if size == .small { CaptionBText(title, textColor: textColor) } else { BodySSBText(title, textColor: textColor) From 7584a32b91d981b02d8c99468505a2abdb16a8ad Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 17 Jun 2026 14:00:01 -0300 Subject: [PATCH 4/5] fix: abort graph reset if node startup still in flight --- Bitkit/ViewModels/WalletViewModel.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index a8f3ac2da..2704edc3f 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -885,7 +885,11 @@ class WalletViewModel: ObservableObject { /// since a reset that leaves the graph in VSS is ineffective. Caller should restart afterwards. func resetNetworkGraph() async throws { Logger.warn("Resetting network graph (manual)", context: "WalletViewModel") - await waitForNodeToRun(timeoutSeconds: 5.0) + // Let any in-progress startup settle so a node assigned mid-setup isn't missed. + let settled = await waitForNodeToRun(timeoutSeconds: 5.0) + if !settled, nodeLifecycleState == .starting { + throw AppError(message: "Node still starting", debugMessage: "resetNetworkGraph aborted: startup in flight") + } if lightningService.hasNode { try await stopLightningNode() } From 0092285346dbe2c32b92cdc8f12eb1ca0d7d2634 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 19 Jun 2026 10:42:43 -0300 Subject: [PATCH 5/5] refactor: reuse shared method clearNetworkGraph --- Bitkit/ViewModels/WalletViewModel.swift | 4 ++-- Bitkit/Views/Settings/LdkDebugScreen.swift | 11 +---------- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 2704edc3f..6646fc71a 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -897,8 +897,8 @@ class WalletViewModel: ObservableObject { } /// Clears the cached Lightning network graph: the local cache file and the VSS backup copy. - /// Shared by the legacy one-time startup cleanup and the manual recovery reset. - private func clearNetworkGraph() async throws { + /// Shared by the legacy one-time startup cleanup, the manual recovery reset, and the LDK debug screen. + func clearNetworkGraph() async throws { try await lightningService.deleteNetworkGraph() _ = try await VssBackupClient.shared.deleteKey("network_graph") Logger.info("Cleared network graph from VSS", context: "WalletViewModel") diff --git a/Bitkit/Views/Settings/LdkDebugScreen.swift b/Bitkit/Views/Settings/LdkDebugScreen.swift index 49439a570..c39a5b6c2 100644 --- a/Bitkit/Views/Settings/LdkDebugScreen.swift +++ b/Bitkit/Views/Settings/LdkDebugScreen.swift @@ -135,17 +135,8 @@ struct LdkDebugScreen: View { } func deleteNetworkGraph() async { - // Delete network graph from VSS do { - _ = try await VssBackupClient.shared.deleteKey("network_graph") - } catch { - Logger.debug("VSS deleteKey(network_graph): \(error)", context: "LdkDebugScreen") - } - - // Delete local network graph cache - do { - let lightningService = LightningService.shared - try await lightningService.deleteNetworkGraph() + try await wallet.clearNetworkGraph() app.toast(type: .success, title: "Network Graph Deleted", description: "Network graph deleted successfully") } catch { Logger.error("Failed to delete network graph: \(error)")