From dfbd33bf29af69277840c64daf6cadac00ed8a96 Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Tue, 10 Feb 2026 12:14:15 +0100 Subject: [PATCH] feat(settings): add probing tool --- Bitkit/MainNavView.swift | 1 + Bitkit/Services/LightningService.swift | 22 ++ Bitkit/ViewModels/NavigationViewModel.swift | 1 + Bitkit/Views/Settings/DevSettingsView.swift | 4 + .../Settings/ProbingTool/ProbeResult.swift | 8 + .../ProbingTool/ProbeResultSectionView.swift | 50 +++++ .../ProbingTool/ProbingToolScannerSheet.swift | 30 +++ .../ProbingTool/ProbingToolScreen.swift | 202 ++++++++++++++++++ 8 files changed, 318 insertions(+) create mode 100644 Bitkit/Views/Settings/ProbingTool/ProbeResult.swift create mode 100644 Bitkit/Views/Settings/ProbingTool/ProbeResultSectionView.swift create mode 100644 Bitkit/Views/Settings/ProbingTool/ProbingToolScannerSheet.swift create mode 100644 Bitkit/Views/Settings/ProbingTool/ProbingToolScreen.swift diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 3e0fe2a14..fad633e43 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -415,6 +415,7 @@ struct MainNavView: View { // Dev settings case .blocktankRegtest: BlocktankRegtestView() case .ldkDebug: LdkDebugScreen() + case .probingTool: ProbingToolScreen() case .orders: ChannelOrders() case .logs: LogView() } diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 75fbabb5f..ccac9e0a0 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -1105,4 +1105,26 @@ extension LightningService { return feeSat } } + + // MARK: - Probing + + /// Sends a probe to test if a payment route exists for the given invoice. + /// - Parameters: + /// - bolt11: The Lightning invoice string (BOLT 11) + /// - amountSats: Optional amount in sats for variable-amount invoices + func sendProbe(bolt11: String, amountSats: UInt64? = nil) async throws { + guard let node else { + throw AppError(serviceError: .nodeNotSetup) + } + + try await ServiceQueue.background(.ldk) { + let invoice = try Bolt11Invoice.fromStr(invoiceStr: bolt11) + if let amountSats { + let amountMsat = amountSats * 1000 + try node.bolt11Payment().sendProbesUsingAmount(invoice: invoice, amountMsat: amountMsat, routeParameters: nil) + } else { + try node.bolt11Payment().sendProbes(invoice: invoice, routeParameters: nil) + } + } + } } diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift index ceeaafec3..8f0219e27 100644 --- a/Bitkit/ViewModels/NavigationViewModel.swift +++ b/Bitkit/ViewModels/NavigationViewModel.swift @@ -96,6 +96,7 @@ enum Route: Hashable { // Dev settings case blocktankRegtest case ldkDebug + case probingTool case orders case logs } diff --git a/Bitkit/Views/Settings/DevSettingsView.swift b/Bitkit/Views/Settings/DevSettingsView.swift index 1c76722b5..4cf433415 100644 --- a/Bitkit/Views/Settings/DevSettingsView.swift +++ b/Bitkit/Views/Settings/DevSettingsView.swift @@ -24,6 +24,10 @@ struct DevSettingsView: View { SettingsListLabel(title: "LDK") } + NavigationLink(value: Route.probingTool) { + SettingsListLabel(title: "Probing Tool") + } + NavigationLink(value: Route.orders) { SettingsListLabel(title: "Orders") } diff --git a/Bitkit/Views/Settings/ProbingTool/ProbeResult.swift b/Bitkit/Views/Settings/ProbingTool/ProbeResult.swift new file mode 100644 index 000000000..b2363d0a8 --- /dev/null +++ b/Bitkit/Views/Settings/ProbingTool/ProbeResult.swift @@ -0,0 +1,8 @@ +import Foundation + +struct ProbeResult { + let success: Bool + let durationMs: Int + let estimatedFeeSats: UInt64? + let errorMessage: String? +} diff --git a/Bitkit/Views/Settings/ProbingTool/ProbeResultSectionView.swift b/Bitkit/Views/Settings/ProbingTool/ProbeResultSectionView.swift new file mode 100644 index 000000000..ed0f4fde5 --- /dev/null +++ b/Bitkit/Views/Settings/ProbingTool/ProbeResultSectionView.swift @@ -0,0 +1,50 @@ +import SwiftUI + +struct ProbeResultSectionView: View { + let result: ProbeResult + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + CaptionMText("Probe Results") + + HStack { + Image(systemName: result.success ? "checkmark.circle.fill" : "xmark.circle.fill") + .foregroundStyle(result.success ? Color.green : Color.red) + Text("Status") + Spacer() + Text(result.success ? "Success" : "Failed") + .foregroundStyle(.secondary) + } + .font(.subheadline) + + HStack { + Image(systemName: "clock") + .foregroundStyle(.secondary) + Text("Duration") + Spacer() + Text("\(result.durationMs) ms") + .foregroundStyle(.secondary) + } + .font(.subheadline) + + if let fee = result.estimatedFeeSats { + HStack { + Image(systemName: "bitcoinsign.circle") + .foregroundStyle(.secondary) + Text("Estimated Fee") + Spacer() + Text("\(fee) sats") + .foregroundStyle(.secondary) + } + .font(.subheadline) + } + + if let error = result.errorMessage, !error.isEmpty { + Text(error) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.top, 8) + } +} diff --git a/Bitkit/Views/Settings/ProbingTool/ProbingToolScannerSheet.swift b/Bitkit/Views/Settings/ProbingTool/ProbingToolScannerSheet.swift new file mode 100644 index 000000000..e521599f4 --- /dev/null +++ b/Bitkit/Views/Settings/ProbingTool/ProbingToolScannerSheet.swift @@ -0,0 +1,30 @@ +import SwiftUI + +struct ProbingToolScannerSheet: View { + @Binding var invoice: String + @Environment(\.dismiss) private var dismiss + let onScanned: () -> Void + + var body: some View { + VStack(spacing: 0) { + SheetHeader(title: t("other__qr_scan")) + + VStack(alignment: .leading, spacing: 0) { + Scanner( + onScan: { uri in + await MainActor.run { + invoice = uri.trimmingCharacters(in: .whitespacesAndNewlines) + onScanned() + dismiss() + } + }, + onImageSelection: { _ in + // Optional: could decode image and set invoice if needed + } + ) + } + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + } +} diff --git a/Bitkit/Views/Settings/ProbingTool/ProbingToolScreen.swift b/Bitkit/Views/Settings/ProbingTool/ProbingToolScreen.swift new file mode 100644 index 000000000..67634145a --- /dev/null +++ b/Bitkit/Views/Settings/ProbingTool/ProbingToolScreen.swift @@ -0,0 +1,202 @@ +import BitkitCore +import SwiftUI + +struct ProbingToolScreen: View { + @EnvironmentObject var app: AppViewModel + + @State private var invoice: String = "" + @State private var amountSats: String = "" + @State private var isLoading = false + @State private var probeResult: ProbeResult? + @State private var showScannerSheet = false + @State private var isZeroAmountInvoice: Bool? = nil + @State private var lastDecoded: (bolt11: String, amountSatoshis: UInt64)? = nil + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar( + title: "Probing Tool", + action: AnyView(Button(action: { + showScannerSheet = true + }) { + Image("scan") + .resizable() + .foregroundColor(.textPrimary) + .frame(width: 32, height: 32) + } + .accessibilityIdentifier("ProbingToolScan")) + ) + .padding(.bottom, 16) + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 24) { + VStack(alignment: .leading, spacing: 8) { + CaptionMText("Probe Invoice") + TextField("lnbc...", text: $invoice, axis: .vertical) + .lineLimit(3 ... 6) + .autocapitalization(.none) + .autocorrectionDisabled() + + CustomButton(title: "Paste", size: .small) { + pasteInvoice() + } + } + + VStack(alignment: .leading, spacing: 8) { + if isZeroAmountInvoice == true { + CaptionMText("Amount (required)") + } else { + CaptionMText("Amount (from invoice)") + } + TextField("Amount in sats", text: $amountSats) + .keyboardType(.numberPad) + .disabled(isZeroAmountInvoice == false) + .opacity(isZeroAmountInvoice == false ? 0.5 : 1) + } + + CustomButton(title: "Send Probe", isDisabled: !canSendProbe, isLoading: isLoading) { + Task { await sendProbe() } + } + + if let result = probeResult { + ProbeResultSectionView(result: result) + } + } + } + } + .task(id: invoice) { + probeResult = nil + await decodeInvoiceAndUpdateState() + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + .scrollDismissesKeyboard(.interactively) + .sheet(isPresented: $showScannerSheet) { + ProbingToolScannerSheet(invoice: $invoice) { + showScannerSheet = false + } + } + } + + private var canSendProbe: Bool { + let input = invoice.trimmingCharacters(in: .whitespacesAndNewlines) + guard !input.isEmpty, lastDecoded != nil else { return false } + if isZeroAmountInvoice == true { + let value = UInt64(amountSats.filter(\.isNumber)) ?? 0 + return value >= 1 + } + return true + } + + /// Decodes the current invoice and updates lastDecoded, isZeroAmountInvoice, and amountSats. + private func decodeInvoiceAndUpdateState() async { + let trimmed = invoice.trimmingCharacters(in: .whitespacesAndNewlines) + let decoded = await decodeInvoice(trimmed) + await MainActor.run { + lastDecoded = decoded + isZeroAmountInvoice = decoded.map { $0.amountSatoshis == 0 } + if let decoded { + amountSats = decoded.amountSatoshis > 0 ? "\(decoded.amountSatoshis)" : "" + } + } + } + + /// Decodes input; returns bolt11 and invoice amount (0 if variable). Nil if not a valid lightning invoice. + private func decodeInvoice(_ input: String) async -> (bolt11: String, amountSatoshis: UInt64)? { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + do { + let data = try await decode(invoice: trimmed) + switch data { + case let .onChain(onChainInvoice): + guard let lnInvoice = onChainInvoice.params?["lightning"] else { return nil } + if case let .lightning(invoice) = try? await decode(invoice: lnInvoice) { + return (lnInvoice, invoice.amountSatoshis) + } + return nil + case let .lightning(invoice): + return (trimmed, invoice.amountSatoshis) + default: + return nil + } + } catch { + return nil + } + } + + private func pasteInvoice() { + guard let pasted = UIPasteboard.general.string?.trimmingCharacters(in: .whitespacesAndNewlines), !pasted.isEmpty else { + app.toast(type: .warning, title: "Clipboard is empty") + return + } + invoice = pasted + } + + private func sendProbe() async { + let input = invoice.trimmingCharacters(in: .whitespacesAndNewlines) + guard !input.isEmpty else { + app.toast(type: .warning, title: "Please enter an invoice") + return + } + + await MainActor.run { + isLoading = true + probeResult = nil + } + + let decoded = await MainActor.run { lastDecoded } + guard let decoded else { + await MainActor.run { isLoading = false } + app.toast( + type: .warning, + title: "Invalid Invoice Format", + description: "Could not extract Lightning invoice" + ) + return + } + + let amountSatsValue = UInt64(amountSats) ?? 0 + let lightningService = LightningService.shared + let hasBalance = await MainActor.run { lightningService.canSend(amountSats: amountSatsValue) } + guard hasBalance else { + await MainActor.run { isLoading = false } + app.toast( + type: .warning, + title: "Insufficient Balance", + description: "More ₿ needed to probe this Lightning invoice." + ) + return + } + + let start = Date() + + do { + try await lightningService.sendProbe(bolt11: decoded.bolt11, amountSats: amountSatsValue) + let durationMs = Int(Date().timeIntervalSince(start) * 1000) + let estimatedFee: UInt64? = try? await lightningService.estimateRoutingFees(bolt11: decoded.bolt11, amountSats: amountSatsValue) + await MainActor.run { + probeResult = ProbeResult( + success: true, + durationMs: durationMs, + estimatedFeeSats: estimatedFee, + errorMessage: nil + ) + } + app.toast(type: .success, title: "Probe Successful", description: "Probe sent in \(durationMs) ms") + } catch { + let durationMs = Int(Date().timeIntervalSince(start) * 1000) + await MainActor.run { + probeResult = ProbeResult( + success: false, + durationMs: durationMs, + estimatedFeeSats: nil, + errorMessage: error.localizedDescription + ) + } + app.toast(type: .error, title: "Probe Failed", description: error.localizedDescription) + } + + await MainActor.run { isLoading = false } + } +}