From 59ce16505e94b6f16316be9e471c8c55689945ff Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Mon, 9 Feb 2026 17:29:29 +0100 Subject: [PATCH 1/3] fix(transfer): UI fixes --- Bitkit/MainNavView.swift | 6 +- Bitkit/Models/LnPeer.swift | 2 +- Bitkit/ViewModels/AppViewModel.swift | 18 +----- Bitkit/ViewModels/NavigationViewModel.swift | 2 + .../Views/Transfer/FundManualAmountView.swift | 13 ++-- .../Transfer/FundManualConfirmView.swift | 61 +++++++------------ .../Views/Transfer/FundManualSetupView.swift | 18 +++--- .../Transfer/FundManualSuccessView.swift | 4 +- 8 files changed, 48 insertions(+), 76 deletions(-) diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 3e0fe2a14..2025ba3ee 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -326,6 +326,9 @@ struct MainNavView: View { case .buyBitcoin: BuyBitcoinView() case .savingsWallet: SavingsWalletView() case .spendingWallet: SpendingWalletView() + case .scanner: ScannerScreen() + + // Transfer case .transferIntro: TransferIntroView() case .fundingOptions: FundingOptions() case .spendingIntro: SpendingIntroView() @@ -336,6 +339,8 @@ struct MainNavView: View { case .settingUp: SettingUpView() case .fundingAdvanced: FundAdvancedOptions() case let .fundManual(nodeUri): FundManualSetupView(initialNodeUri: nodeUri) + case let .fundManualAmount(lnPeer): FundManualAmountView(lnPeer: lnPeer) + case let .fundManualConfirm(lnPeer, amountSats): FundManualConfirmView(lnPeer: lnPeer, amountSats: amountSats) case .fundManualSuccess: FundManualSuccessView() case let .lnurlChannel(channelData): LnurlChannel(channelData: channelData) case .savingsIntro: SavingsIntroView() @@ -343,7 +348,6 @@ struct MainNavView: View { case .savingsConfirm: SavingsConfirmView() case .savingsAdvanced: SavingsAdvancedView() case .savingsProgress: SavingsProgressView() - case .scanner: ScannerScreen() // Profile & Contacts case .contacts: ComingSoonScreen() diff --git a/Bitkit/Models/LnPeer.swift b/Bitkit/Models/LnPeer.swift index 29f65a5ae..7c87e34eb 100644 --- a/Bitkit/Models/LnPeer.swift +++ b/Bitkit/Models/LnPeer.swift @@ -16,7 +16,7 @@ extension LnPeerError: LocalizedError { } } -struct LnPeer { +struct LnPeer: Hashable { let nodeId: String let host: String let port: UInt16 diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 0eb93f71a..e6c628a93 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -423,25 +423,13 @@ extension AppViewModel { case let .lnurlAuth(data: lnurlAuthData): Logger.debug("LNURL: \(lnurlAuthData)") handleLnurlAuth(lnurlAuthData, lnurl: uri) - case let .nodeId(url, network): + case let .nodeId(url, _): guard lightningService.status?.isRunning == true else { toast(type: .error, title: "Lightning not running", description: "Please try again later.") return } - // Check network - treat wrong network as decoding error - let nodeNetwork = NetworkValidationHelper.convertNetworkType(network) - if NetworkValidationHelper.isNetworkMismatch(addressNetwork: nodeNetwork, currentNetwork: Env.network) { - toast( - type: .error, - title: t("other__scan_err_decoding"), - description: t("other__scan__error__generic"), - accessibilityIdentifier: "InvalidAddressToast" - ) - return - } - - handleNodeUri(url, network) + handleNodeUri(url) case let .gift(code, amount): sheetViewModel.showSheet(.gift, data: GiftConfig(code: code, amount: Int(amount))) default: @@ -555,7 +543,7 @@ extension AppViewModel { sheetViewModel.showSheet(.lnurlAuth, data: LnurlAuthConfig(lnurl: lnurl, authData: data)) } - private func handleNodeUri(_ url: String, _ network: NetworkType) { + private func handleNodeUri(_ url: String) { sheetViewModel.hideSheet() navigationViewModel.navigate(.fundManual(nodeUri: url)) } diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift index ceeaafec3..a3ebddeb0 100644 --- a/Bitkit/ViewModels/NavigationViewModel.swift +++ b/Bitkit/ViewModels/NavigationViewModel.swift @@ -23,6 +23,8 @@ enum Route: Hashable { case settingUp case fundingAdvanced case fundManual(nodeUri: String?) + case fundManualAmount(lnPeer: LnPeer) + case fundManualConfirm(lnPeer: LnPeer, amountSats: UInt64) case fundManualSuccess case lnurlChannel(channelData: LnurlChannelData) case savingsIntro diff --git a/Bitkit/Views/Transfer/FundManualAmountView.swift b/Bitkit/Views/Transfer/FundManualAmountView.swift index b633f1828..dd7c4e6bc 100644 --- a/Bitkit/Views/Transfer/FundManualAmountView.swift +++ b/Bitkit/Views/Transfer/FundManualAmountView.swift @@ -1,9 +1,10 @@ import SwiftUI struct FundManualAmountView: View { - @EnvironmentObject var wallet: WalletViewModel @EnvironmentObject var app: AppViewModel @EnvironmentObject var currency: CurrencyViewModel + @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var wallet: WalletViewModel let lnPeer: LnPeer @@ -16,7 +17,7 @@ struct FundManualAmountView: View { var body: some View { VStack(spacing: 0) { - NavigationBar(title: t("lightning__connections")) + NavigationBar(title: t("lightning__external__nav_title")) .padding(.bottom, 16) VStack(alignment: .leading, spacing: 0) { @@ -52,11 +53,9 @@ struct FundManualAmountView: View { amountViewModel.handleNumberPadInput(key, currency: currency) } - CustomButton( - title: t("common__continue"), - isDisabled: amountSats == 0, - destination: FundManualConfirmView(lnPeer: lnPeer, amountSats: amountSats) - ) + CustomButton(title: t("common__continue"), isDisabled: amountSats == 0) { + navigation.navigate(.fundManualConfirm(lnPeer: lnPeer, amountSats: amountSats)) + } .accessibilityIdentifier("ExternalAmountContinue") } } diff --git a/Bitkit/Views/Transfer/FundManualConfirmView.swift b/Bitkit/Views/Transfer/FundManualConfirmView.swift index 57656bc42..ad7156621 100644 --- a/Bitkit/Views/Transfer/FundManualConfirmView.swift +++ b/Bitkit/Views/Transfer/FundManualConfirmView.swift @@ -1,13 +1,10 @@ import SwiftUI struct FundManualConfirmView: View { - @State private var showSuccess = false - @State private var hideSwipeButton = false - - @EnvironmentObject var wallet: WalletViewModel @EnvironmentObject var app: AppViewModel - @EnvironmentObject var currency: CurrencyViewModel + @EnvironmentObject var navigation: NavigationViewModel @EnvironmentObject var transfer: TransferViewModel + @EnvironmentObject var wallet: WalletViewModel let lnPeer: LnPeer let amountSats: UInt64 @@ -31,10 +28,10 @@ struct FundManualConfirmView: View { var body: some View { VStack(spacing: 0) { VStack(alignment: .leading, spacing: 16) { - NavigationBar(title: t("lightning__connections")) + NavigationBar(title: t("lightning__external__nav_title")) + .padding(.bottom, 16) DisplayText(t("lightning__transfer__confirm"), accentColor: .purpleAccent) - .padding(.top, 16) VStack(spacing: 16) { HStack { @@ -69,44 +66,30 @@ struct FundManualConfirmView: View { Spacer() - if !hideSwipeButton { - SwipeButton( - title: t("lightning__transfer__swipe"), - accentColor: .purpleAccent - ) { - do { - let (channelId, _) = try await transfer.openManualChannel( - peer: lnPeer, - amountSats: amountSats, - onEvent: { eventId, handler in - wallet.addOnEvent(id: eventId, handler: handler) - }, - removeEvent: { eventId in - wallet.removeOnEvent(id: eventId) - } - ) - - Logger.info("Channel opened successfully with ID: \(channelId)") + SwipeButton(title: t("lightning__transfer__swipe"), accentColor: .purpleAccent) { + do { + let (channelId, _) = try await transfer.openManualChannel( + peer: lnPeer, + amountSats: amountSats, + onEvent: { eventId, handler in + wallet.addOnEvent(id: eventId, handler: handler) + }, + removeEvent: { eventId in + wallet.removeOnEvent(id: eventId) + } + ) - try await Task.sleep(nanoseconds: 500_000_000) - showSuccess = true + Logger.info("Channel opened successfully with ID: \(channelId)") - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - hideSwipeButton = true - } - } catch { - Logger.error("Failed to open channel: \(error)") - app.toast(error) - } + try await Task.sleep(nanoseconds: 500_000_000) + navigation.navigate(.fundManualSuccess) + } catch { + Logger.error("Failed to open channel: \(error)") + app.toast(error) } } } .padding(.horizontal, 16) - .padding(.bottom, 16) - - NavigationLink(destination: FundManualSuccessView(), isActive: $showSuccess) { - EmptyView() - } } .navigationBarHidden(true) .task { diff --git a/Bitkit/Views/Transfer/FundManualSetupView.swift b/Bitkit/Views/Transfer/FundManualSetupView.swift index 13633ed71..7f3dc53a2 100644 --- a/Bitkit/Views/Transfer/FundManualSetupView.swift +++ b/Bitkit/Views/Transfer/FundManualSetupView.swift @@ -53,10 +53,7 @@ struct FundManualSetupView: View { GeometryReader { geometry in ScrollView(showsIndicators: false) { VStack(spacing: 16) { - DisplayText( - t("lightning__external_manual__title"), - accentColor: .purpleAccent - ) + DisplayText(t("lightning__external_manual__title"), accentColor: .purpleAccent) BodyMText(t("lightning__external_manual__text")) .frame(maxWidth: .infinity, alignment: .leading) @@ -65,9 +62,8 @@ struct FundManualSetupView: View { // Node ID field VStack(alignment: .leading, spacing: 8) { CaptionMText(t("lightning__external_manual__node_id")) - TextField("00000000000000000000000000000000000000000000000000000000000000", text: $nodeId, submitLabel: .done) + TextField("038543a13c2c040d0cd2d16c312a08fa5397c0329dd1d08a704e5c18aeced50e29", text: $nodeId, submitLabel: .done) .focused($isTextFieldFocused) - .lineLimit(2 ... 2) .autocapitalization(.none) .autocorrectionDisabled(true) .accessibilityIdentifier("NodeIdInput") @@ -76,7 +72,7 @@ struct FundManualSetupView: View { // Host field VStack(alignment: .leading, spacing: 8) { CaptionMText(t("lightning__external_manual__host")) - TextField("00.00.00.00", text: $host, submitLabel: .done) + TextField("127.0.0.1", text: $host, submitLabel: .done) .focused($isTextFieldFocused) .autocapitalization(.none) .autocorrectionDisabled(true) @@ -118,10 +114,10 @@ struct FundManualSetupView: View { CustomButton( title: t("common__continue"), - variant: .primary, - isDisabled: nodeId.isEmpty || host.isEmpty || port.isEmpty, - destination: FundManualAmountView(lnPeer: LnPeer(nodeId: nodeId, host: host, port: UInt16(port) ?? 0)) - ) + isDisabled: nodeId.isEmpty || host.isEmpty || port.isEmpty + ) { + navigation.navigate(.fundManualAmount(lnPeer: LnPeer(nodeId: nodeId, host: host, port: UInt16(port) ?? 0))) + } .accessibilityIdentifier("ExternalContinue") } .bottomSafeAreaPadding() diff --git a/Bitkit/Views/Transfer/FundManualSuccessView.swift b/Bitkit/Views/Transfer/FundManualSuccessView.swift index 1343bfe34..5810c7591 100644 --- a/Bitkit/Views/Transfer/FundManualSuccessView.swift +++ b/Bitkit/Views/Transfer/FundManualSuccessView.swift @@ -9,7 +9,7 @@ struct FundManualSuccessView: View { var body: some View { VStack(spacing: 0) { - NavigationBar(title: t("lightning__transfer_success__nav_title"), showBackButton: false) + NavigationBar(title: t("lightning__external__nav_title"), showBackButton: false) .padding(.bottom, 16) VStack(alignment: .leading, spacing: 16) { @@ -43,12 +43,12 @@ struct FundManualSuccessView: View { } .accessibilityIdentifier("ExternalSuccess-button") } - .padding(.horizontal, 16) .accessibilityElement(children: .contain) .accessibilityIdentifier("ExternalSuccess") } .navigationBarHidden(true) .interactiveDismissDisabled() + .padding(.horizontal, 16) } } From f38a8e9cc1c43cf8b2e6bcb127b3583885b68de1 Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Mon, 9 Feb 2026 19:14:57 +0100 Subject: [PATCH 2/3] fix(migration): persist custom peers --- Bitkit/Services/MigrationsService.swift | 64 +++++++++++++++++++++++++ Bitkit/ViewModels/WalletViewModel.swift | 11 +++++ 2 files changed, 75 insertions(+) diff --git a/Bitkit/Services/MigrationsService.swift b/Bitkit/Services/MigrationsService.swift index 9c98318ad..01ceeba1d 100644 --- a/Bitkit/Services/MigrationsService.swift +++ b/Bitkit/Services/MigrationsService.swift @@ -311,6 +311,13 @@ struct PendingChannelMigration: Codable { let channelMonitors: [Data] } +/// Peer entry from backup peers.json: [{"pubKey":"...","address":"...","port":9735}, ...] +struct BackupPeerEntry: Codable { + let pubKey: String + let address: String + let port: UInt16 +} + // MARK: - MigrationsService class MigrationsService: ObservableObject { @@ -328,6 +335,7 @@ class MigrationsService: ObservableObject { private static let rnPendingRemotePaidOrdersKey = "rnPendingRemotePaidOrders" private static let rnPendingChannelMigrationKey = "rnPendingChannelMigration" private static let rnPendingBlocktankOrderIdsKey = "rnPendingBlocktankOrderIds" + private static let rnDidAttemptPeerRecoveryKey = "rnDidAttemptMigrationPeerRecovery" @Published var isShowingMigrationLoading = false { didSet { @@ -419,6 +427,17 @@ class MigrationsService: ObservableObject { set { UserDefaults.standard.set(newValue, forKey: Self.rnPendingBlocktankOrderIdsKey) } } + /// True after we've attempted once to fetch peers from remote backup (so we don't retry every node start). + var didAttemptPeerRecovery: Bool { + get { UserDefaults.standard.bool(forKey: Self.rnDidAttemptPeerRecoveryKey) } + set { UserDefaults.standard.set(newValue, forKey: Self.rnDidAttemptPeerRecoveryKey) } + } + + /// True if the user completed RN migration (local or remote). + var rnMigrationCompleted: Bool { + UserDefaults.standard.bool(forKey: Self.rnMigrationCompletedKey) + } + private init() {} // MARK: - UserDefaults Helpers @@ -1990,6 +2009,51 @@ extension MigrationsService { } } + /// Fetches peers.json from remote backup once, returns URIs to connect (or []). + func tryFetchMigrationPeersFromBackup(walletIndex: Int) async -> [String] { + guard rnMigrationCompleted else { return [] } + guard !didAttemptPeerRecovery else { return [] } + + didAttemptPeerRecovery = true + + do { + guard let mnemonic = try Keychain.loadString(key: .bip39Mnemonic(index: walletIndex)) else { + Logger.debug("Migration peer recovery: no mnemonic, skipping", context: "Migration") + return [] + } + let passphrase = try? Keychain.loadString(key: .bip39Passphrase(index: walletIndex)) + + RNBackupClient.shared.reset() + try await RNBackupClient.shared.setup(mnemonic: mnemonic, passphrase: passphrase) + + let data: Data + do { + data = try await RNBackupClient.shared.retrieve(label: "peers", fileGroup: "ldk") + } catch { + Logger.debug("Migration peer recovery: retrieve peers failed: \(error)", context: "Migration") + return [] + } + + guard let peers = try? JSONDecoder().decode([BackupPeerEntry].self, from: data) else { + Logger.warn("Migration peer recovery: decode failed (data count=\(data.count))", context: "Migration") + return [] + } + + guard !peers.isEmpty else { + Logger.debug("Migration peer recovery: peers array empty", context: "Migration") + return [] + } + + let trustedIds = Set(Env.trustedLnPeers.map(\.nodeId)) + let uris = peers.filter { !trustedIds.contains($0.pubKey) }.map { "\($0.pubKey)@\($0.address):\($0.port)" } + Logger.info("Migration peer recovery: fetched \(uris.count) peer(s) from remote backup", context: "Migration") + return uris + } catch { + Logger.warn("Migration peer recovery failed (will not retry): \(error)", context: "Migration") + return [] + } + } + private func applyRNRemoteSettings(_ data: Data) async throws { struct BackupEnvelope: Codable { let data: RNSettings diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 5bed14fce..854543236 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -207,6 +207,17 @@ class WalletViewModel: ObservableObject { Logger.error("Failed to connect to trusted peers") } + // Migration only: fetch peers from remote backup (once) and persist in ldk-node + let peerUris = await MigrationsService.shared.tryFetchMigrationPeersFromBackup(walletIndex: walletIndex) + for uri in peerUris { + guard let peer = try? LnPeer(connection: uri) else { continue } + do { + try await lightningService.connectPeer(peer: peer, persist: true) + } catch { + Logger.error("Failed to connect migration peer \(peer.nodeId): \(error)") + } + } + Task { @MainActor in try await refreshBip21() } From d76385c11c6497c990e31332352ca21e0fbe1897 Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Mon, 16 Feb 2026 13:32:02 +0100 Subject: [PATCH 3/3] fix(transfer): text field line limit --- Bitkit/Views/Transfer/FundManualSetupView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Bitkit/Views/Transfer/FundManualSetupView.swift b/Bitkit/Views/Transfer/FundManualSetupView.swift index 7f3dc53a2..491489e9c 100644 --- a/Bitkit/Views/Transfer/FundManualSetupView.swift +++ b/Bitkit/Views/Transfer/FundManualSetupView.swift @@ -64,6 +64,7 @@ struct FundManualSetupView: View { CaptionMText(t("lightning__external_manual__node_id")) TextField("038543a13c2c040d0cd2d16c312a08fa5397c0329dd1d08a704e5c18aeced50e29", text: $nodeId, submitLabel: .done) .focused($isTextFieldFocused) + .lineLimit(1) .autocapitalization(.none) .autocorrectionDisabled(true) .accessibilityIdentifier("NodeIdInput")