Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions Bitkit/MainNavView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,7 @@ struct MainNavView: View {
// Dev settings
case .blocktankRegtest: BlocktankRegtestView()
case .ldkDebug: LdkDebugScreen()
case .probingTool: ProbingToolScreen()
case .orders: ChannelOrders()
case .logs: LogView()
}
Expand Down
22 changes: 22 additions & 0 deletions Bitkit/Services/LightningService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -524,7 +524,7 @@
}

func closeChannel(_ channel: ChannelDetails, force: Bool = false, forceCloseReason: String? = nil) async throws {
guard let node else {

Check warning on line 527 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Tests

value 'node' was defined but never used; consider replacing with boolean test

Check warning on line 527 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Integration Tests

value 'node' was defined but never used; consider replacing with boolean test
throw AppError(serviceError: .nodeNotStarted)
}

Expand Down Expand Up @@ -765,7 +765,7 @@
onEvent?(event)

switch event {
case let .paymentSuccessful(paymentId, paymentHash, paymentPreimage, feePaidMsat):

Check warning on line 768 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Tests

immutable value 'paymentPreimage' was never used; consider replacing with '_' or removing it

Check warning on line 768 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Integration Tests

immutable value 'paymentPreimage' was never used; consider replacing with '_' or removing it
Logger.info("✅ Payment successful: paymentId: \(paymentId ?? "?") paymentHash: \(paymentHash) feePaidMsat: \(feePaidMsat ?? 0)")
Task {
let hash = paymentId ?? paymentHash
Expand All @@ -790,7 +790,7 @@
Logger.warn("No paymentId or paymentHash available for failed payment", context: "LightningService")
}
}
case let .paymentReceived(paymentId, paymentHash, amountMsat, feePaidMsat):

Check warning on line 793 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Integration Tests

immutable value 'feePaidMsat' was never used; consider replacing with '_' or removing it
Logger.info("🤑 Payment received: paymentId: \(paymentId ?? "?") paymentHash: \(paymentHash) amountMsat: \(amountMsat)")
Task {
let hash = paymentId ?? paymentHash
Expand All @@ -800,7 +800,7 @@
Logger.error("Failed to handle payment received for \(hash): \(error)", context: "LightningService")
}
}
case let .paymentClaimable(paymentId, paymentHash, claimableAmountMsat, claimDeadline, customRecords):

Check warning on line 803 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Integration Tests

immutable value 'customRecords' was never used; consider replacing with '_' or removing it

Check warning on line 803 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Integration Tests

immutable value 'claimDeadline' was never used; consider replacing with '_' or removing it
Logger.info(
"🫰 Payment claimable: paymentId: \(paymentId) paymentHash: \(paymentHash) claimableAmountMsat: \(claimableAmountMsat)"
)
Expand Down Expand Up @@ -829,7 +829,7 @@

if let channel {
await registerClosedChannel(channel: channel, reason: reasonString)
await MainActor.run {

Check warning on line 832 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Tests

result of call to 'run(resultType:body:)' is unused

Check warning on line 832 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Integration Tests

result of call to 'run(resultType:body:)' is unused
channelCache.removeValue(forKey: channelIdString)
}
} else {
Expand All @@ -852,7 +852,7 @@
Logger.error("Failed to handle transaction received for \(txid): \(error)", context: "LightningService")
}
}
case let .onchainTransactionConfirmed(txid, blockHash, blockHeight, confirmationTime, details):

Check warning on line 855 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Integration Tests

immutable value 'confirmationTime' was never used; consider replacing with '_' or removing it

Check warning on line 855 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Integration Tests

immutable value 'blockHash' was never used; consider replacing with '_' or removing it
Logger.info("✅ Onchain transaction confirmed: txid=\(txid) blockHeight=\(blockHeight) amountSats=\(details.amountSats)")
Task {
do {
Expand Down Expand Up @@ -906,7 +906,7 @@

// MARK: Balance Events

case let .balanceChanged(oldSpendableOnchain, newSpendableOnchain, oldTotalOnchain, newTotalOnchain, oldLightning, newLightning):

Check warning on line 909 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Integration Tests

immutable value 'oldTotalOnchain' was never used; consider replacing with '_' or removing it
Logger
.info("💰 Balance changed: onchain=\(oldSpendableOnchain)->\(newSpendableOnchain) lightning=\(oldLightning)->\(newLightning)")

Expand Down Expand Up @@ -1105,4 +1105,26 @@
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)
}
}
}
}
1 change: 1 addition & 0 deletions Bitkit/ViewModels/NavigationViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ enum Route: Hashable {
// Dev settings
case blocktankRegtest
case ldkDebug
case probingTool
case orders
case logs
}
Expand Down
4 changes: 4 additions & 0 deletions Bitkit/Views/Settings/DevSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
8 changes: 8 additions & 0 deletions Bitkit/Views/Settings/ProbingTool/ProbeResult.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import Foundation

struct ProbeResult {
let success: Bool
let durationMs: Int
let estimatedFeeSats: UInt64?
let errorMessage: String?
}
50 changes: 50 additions & 0 deletions Bitkit/Views/Settings/ProbingTool/ProbeResultSectionView.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
30 changes: 30 additions & 0 deletions Bitkit/Views/Settings/ProbingTool/ProbingToolScannerSheet.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
202 changes: 202 additions & 0 deletions Bitkit/Views/Settings/ProbingTool/ProbingToolScreen.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
}
Loading