diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 99f856bf..3a1e2992 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -944,7 +944,7 @@ repositoryURL = "https://github.com/synonymdev/ldk-node"; requirement = { kind = revision; - revision = ae38eadab70fceb5dbe242bc02bf895581cb7c3f; + revision = 52f73c4402cfb06a020ab8fa9594b5ecb94e3cd6; }; }; 96DEA0382DE8BBA1009932BF /* XCRemoteSwiftPackageReference "bitkit-core" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 206f9f40..a2b39acd 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -23,7 +23,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/ldk-node", "state" : { - "revision" : "ae38eadab70fceb5dbe242bc02bf895581cb7c3f" + "revision" : "52f73c4402cfb06a020ab8fa9594b5ecb94e3cd6" } }, { diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 899f7b1e..dbd6d38b 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -69,6 +69,7 @@ class LightningService { let ldkStoragePath = Env.ldkStorage(walletIndex: walletIndex).path config.storageDirPath = ldkStoragePath config.network = Env.network + config.probingLiquidityLimitMultiplier = 1 Logger.debug("Using LDK storage path: \(ldkStoragePath)") @@ -913,6 +914,10 @@ class LightningService { // MARK: UI Helpers (Published via WalletViewModel) extension LightningService { + struct ProbeDispatch { + let paymentIds: Set + } + var nodeId: String? { node?.nodeId() } @@ -1181,6 +1186,13 @@ extension LightningService { Logger.info( "🫰 Payment claimable: paymentId: \(paymentId) paymentHash: \(paymentHash) claimableAmountMsat: \(claimableAmountMsat)" ) + case let .probeSuccessful(paymentId, paymentHash): + Logger.info("🤑 Probe successful: paymentId: \(paymentId) paymentHash: \(paymentHash)") + case let .probeFailed(paymentId, paymentHash, shortChannelId): + Logger + .info( + "❌ Probe failed: paymentId: \(paymentId) paymentHash: \(paymentHash) shortChannelId: \(String(describing: shortChannelId))" + ) // Payment claimable doesn't need activity update - it's still pending // The payment will be updated when it succeeds or fails via paymentSuccessful/paymentFailed events case let .channelPending(channelId, userChannelId, formerTemporaryChannelId, counterpartyNodeId, fundingTxo): @@ -1503,19 +1515,40 @@ extension LightningService { /// - 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 { + func sendProbe(bolt11: String, amountSats: UInt64? = nil) async throws -> ProbeDispatch { guard let node else { throw AppError(serviceError: .nodeNotSetup) } - try await ServiceQueue.background(.ldk) { + return try await ServiceQueue.background(.ldk) { let invoice = try Bolt11Invoice.fromStr(invoiceStr: bolt11) + let handles: [ProbeHandle] if let amountSats { let amountMsat = amountSats * 1000 - try node.bolt11Payment().sendProbesUsingAmount(invoice: invoice, amountMsat: amountMsat, routeParameters: nil) + handles = try node.bolt11Payment().sendProbesUsingAmount(invoice: invoice, amountMsat: amountMsat, routeParameters: nil) } else { - try node.bolt11Payment().sendProbes(invoice: invoice, routeParameters: nil) + handles = try node.bolt11Payment().sendProbes(invoice: invoice, routeParameters: nil) } + + return ProbeDispatch(paymentIds: Set(handles.map(\.paymentId))) + } + } + + /// Sends payment probes over all paths of a route that would be used to pay the given + /// amount to the given nodeId. + /// - Parameters: + /// - nodeId: The ID of the node to send the probe to + /// - amountSats: Amount in sats to send + func sendProbesSpontaneous(nodeId: String, amountSats: UInt64) async throws -> ProbeDispatch { + guard let node else { + throw AppError(serviceError: .nodeNotSetup) + } + + return try await ServiceQueue.background(.ldk) { + let amountMsat = amountSats * 1000 + let handles = try node.spontaneousPayment().sendProbes(amountMsat: amountMsat, nodeId: nodeId) + + return ProbeDispatch(paymentIds: Set(handles.map(\.paymentId))) } } } diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index d8fe398b..07dc3be4 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -837,6 +837,10 @@ extension AppViewModel { break case .paymentForwarded: break + case .probeSuccessful(paymentId: _, paymentHash: _): + break + case .probeFailed(paymentId: _, paymentHash: _, shortChannelId: _): + break // MARK: New Onchain Transaction Events diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index ac3b3057..782b82a1 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -17,7 +17,7 @@ class WalletViewModel: ObservableObject { @AppStorage("onchainAddress") var onchainAddress = "" @AppStorage("bolt11") var bolt11 = "" @AppStorage("bip21") var bip21 = "" - @AppStorage("channelCount") var channelCount: Int = 0 // Keeping a cached version of this so we can better aniticipate the receive flow UI + @AppStorage("channelCount") var channelCount: Int = 0 // Keeping a cached version of this so we can better anticipate the receive flow UI // Send flow @Published var sendAmountSats: UInt64? @@ -53,6 +53,7 @@ class WalletViewModel: ObservableObject { @Published var peers: [PeerDetails]? @Published var channels: [ChannelDetails]? private var eventHandlers: [String: (Event) -> Void] = [:] + private var probeOutcomes: [PaymentId: ProbeOutcome] = [:] @AppStorage("legacyNetworkGraphCleanupDone") private var legacyNetworkGraphCleanupDone = false @@ -185,6 +186,20 @@ class WalletViewModel: ObservableObject { // Handle specific events for targeted UI updates switch event { + case let .probeSuccessful(paymentId, paymentHash: paymentHash): + self.cacheProbeOutcome( + success: true, + paymentId: paymentId, + paymentHash: paymentHash, + shortChannelId: nil + ) + case let .probeFailed(paymentId, paymentHash: paymentHash, shortChannelId: shortChannelId): + self.cacheProbeOutcome( + success: false, + paymentId: paymentId, + paymentHash: paymentHash, + shortChannelId: shortChannelId + ) case .paymentReceived, .channelReady: self.bolt11 = "" Task { @@ -354,6 +369,7 @@ class WalletViewModel: ObservableObject { nodeLifecycleState = .stopping try await lightningService.stop(clearEventCallback: clearEventCallback) nodeLifecycleState = .stopped + probeOutcomes.removeAll() syncState() } @@ -611,6 +627,90 @@ class WalletViewModel: ObservableObject { } } + struct ProbeOutcome { + let success: Bool + let paymentId: PaymentId + let paymentHash: PaymentHash + let shortChannelId: UInt64? + } + + /// Waits for probe results that match one of the returned probe `paymentId`s. + /// If any matching probe succeeds, this resolves success immediately. + /// If all matching probes fail, this resolves with the final failed probe event. + func waitForProbeOutcome(paymentIds: Set) async throws -> ProbeOutcome { + guard !paymentIds.isEmpty else { + throw AppError(message: "No probe handles returned", debugMessage: "Cannot wait for probe outcome without payment IDs") + } + + if let immediate = consumeProbeOutcomeIfReady(paymentIds: paymentIds) { + return immediate + } + + let eventId = "probe-outcome-\(UUID().uuidString)" + var pendingPaymentIds = paymentIds + var lastFailure: ProbeOutcome? + + return await withCheckedContinuation { continuation in + var resumed = false + + addOnEvent(id: eventId) { event in + guard !resumed else { return } + switch event { + case let .probeSuccessful(paymentId, paymentHash: paymentHash): + guard pendingPaymentIds.contains(paymentId) else { return } + resumed = true + self.removeOnEvent(id: eventId) + continuation.resume(returning: .init( + success: true, + paymentId: paymentId, + paymentHash: paymentHash, + shortChannelId: nil + )) + case let .probeFailed(paymentId, paymentHash: paymentHash, shortChannelId: shortChannelId): + guard pendingPaymentIds.remove(paymentId) != nil else { return } + lastFailure = .init( + success: false, + paymentId: paymentId, + paymentHash: paymentHash, + shortChannelId: shortChannelId + ) + if pendingPaymentIds.isEmpty, let lastFailure { + resumed = true + self.removeOnEvent(id: eventId) + continuation.resume(returning: lastFailure) + } + default: + break + } + } + } + } + + private func cacheProbeOutcome(success: Bool, paymentId: PaymentId, paymentHash: PaymentHash, shortChannelId: UInt64?) { + probeOutcomes[paymentId] = ProbeOutcome( + success: success, + paymentId: paymentId, + paymentHash: paymentHash, + shortChannelId: shortChannelId + ) + } + + private func consumeProbeOutcomeIfReady(paymentIds: Set) -> ProbeOutcome? { + let matched = paymentIds.compactMap { probeOutcomes[$0] } + guard !matched.isEmpty else { return nil } + + guard matched.count == paymentIds.count else { return nil } + + for paymentId in paymentIds { + probeOutcomes.removeValue(forKey: paymentId) + } + + if let firstSuccess = matched.first(where: \.success) { + return firstSuccess + } + return matched.last + } + /// Sends a lightning payment with an optional timeout. /// If the payment does not complete within `timeoutSeconds`, throws `PaymentTimeoutError.timedOut`. /// The payment continues in the background; caller should navigate to pending screen on timeout. @@ -1042,6 +1142,8 @@ class WalletViewModel: ObservableObject { try await lightningService.wipeStorage(walletIndex: 0) + probeOutcomes.removeAll() + // Reset AppStorage display values totalBalanceSats = 0 totalOnchainSats = 0 diff --git a/Bitkit/Views/Settings/ProbingTool/ProbingToolScreen.swift b/Bitkit/Views/Settings/ProbingTool/ProbingToolScreen.swift index 67634145..ee1d76a8 100644 --- a/Bitkit/Views/Settings/ProbingTool/ProbingToolScreen.swift +++ b/Bitkit/Views/Settings/ProbingTool/ProbingToolScreen.swift @@ -1,8 +1,10 @@ import BitkitCore +import LDKNode import SwiftUI struct ProbingToolScreen: View { - @EnvironmentObject var app: AppViewModel + @EnvironmentObject private var app: AppViewModel + @EnvironmentObject private var wallet: WalletViewModel @State private var invoice: String = "" @State private var amountSats: String = "" @@ -12,6 +14,22 @@ struct ProbingToolScreen: View { @State private var isZeroAmountInvoice: Bool? = nil @State private var lastDecoded: (bolt11: String, amountSatoshis: UInt64)? = nil + private enum ProbeTarget { + case invoice(bolt11: String, amountSatoshis: UInt64) + case nodeId(String) + } + + private var isNodeIdTarget: Bool { + if case .nodeId = probeTarget { + return true + } + return false + } + + private var isFixedAmountInvoice: Bool { + !isNodeIdTarget && isZeroAmountInvoice == false + } + var body: some View { VStack(alignment: .leading, spacing: 0) { NavigationBar( @@ -31,8 +49,8 @@ struct ProbingToolScreen: View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 24) { VStack(alignment: .leading, spacing: 8) { - CaptionMText("Probe Invoice") - TextField("lnbc...", text: $invoice, axis: .vertical) + CaptionMText("Probe Target") + TextField("Enter an invoice or node ID", text: $invoice, axis: .vertical) .lineLimit(3 ... 6) .autocapitalization(.none) .autocorrectionDisabled() @@ -43,15 +61,18 @@ struct ProbingToolScreen: View { } VStack(alignment: .leading, spacing: 8) { - if isZeroAmountInvoice == true { + if isNodeIdTarget || isZeroAmountInvoice == true { CaptionMText("Amount (required)") - } else { + } else if isFixedAmountInvoice { CaptionMText("Amount (from invoice)") + } else { + CaptionMText("Amount") } + TextField("Amount in sats", text: $amountSats) .keyboardType(.numberPad) - .disabled(isZeroAmountInvoice == false) - .opacity(isZeroAmountInvoice == false ? 0.5 : 1) + .disabled(!isNodeIdTarget && isZeroAmountInvoice == false) + .opacity(!isNodeIdTarget && isZeroAmountInvoice == false ? 0.5 : 1) } CustomButton(title: "Send Probe", isDisabled: !canSendProbe, isLoading: isLoading) { @@ -81,17 +102,49 @@ struct ProbingToolScreen: View { private var canSendProbe: Bool { let input = invoice.trimmingCharacters(in: .whitespacesAndNewlines) - guard !input.isEmpty, lastDecoded != nil else { return false } + + guard !input.isEmpty else { return false } + if case .nodeId = probeTarget { + let value = UInt64(amountSats.filter(\.isNumber)) ?? 0 + return value >= 1 + } + + guard lastDecoded != nil else { return false } if isZeroAmountInvoice == true { let value = UInt64(amountSats.filter(\.isNumber)) ?? 0 return value >= 1 } + return true } + private var probeTarget: ProbeTarget? { + let input = invoice.trimmingCharacters(in: .whitespacesAndNewlines) + + guard !input.isEmpty else { return nil } + + if isNodeId(input) { + return .nodeId(input) + } + + if let decoded = lastDecoded { + return .invoice(bolt11: decoded.bolt11, amountSatoshis: decoded.amountSatoshis) + } + + return nil + } + /// Decodes the current invoice and updates lastDecoded, isZeroAmountInvoice, and amountSats. private func decodeInvoiceAndUpdateState() async { let trimmed = invoice.trimmingCharacters(in: .whitespacesAndNewlines) + if isNodeId(trimmed) { + await MainActor.run { + lastDecoded = nil + isZeroAmountInvoice = true + } + return + } + let decoded = await decodeInvoice(trimmed) await MainActor.run { lastDecoded = decoded @@ -102,6 +155,14 @@ struct ProbingToolScreen: View { } } + private func isNodeId(_ input: String) -> Bool { + let cleaned = input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard cleaned.count == 66 else { return false } + return cleaned.unicodeScalars.allSatisfy { scalar in + CharacterSet(charactersIn: "0123456789abcdef").contains(scalar) + } + } + /// 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) @@ -136,7 +197,7 @@ struct ProbingToolScreen: View { private func sendProbe() async { let input = invoice.trimmingCharacters(in: .whitespacesAndNewlines) guard !input.isEmpty else { - app.toast(type: .warning, title: "Please enter an invoice") + app.toast(type: .warning, title: "Please enter an invoice or node ID") return } @@ -145,13 +206,12 @@ struct ProbingToolScreen: View { probeResult = nil } - let decoded = await MainActor.run { lastDecoded } - guard let decoded else { + guard let target = await MainActor.run(body: { probeTarget }) else { await MainActor.run { isLoading = false } app.toast( type: .warning, - title: "Invalid Invoice Format", - description: "Could not extract Lightning invoice" + title: "Invalid Target", + description: "Enter a valid Lightning invoice or node ID" ) return } @@ -172,18 +232,51 @@ struct ProbingToolScreen: View { let start = Date() do { - try await lightningService.sendProbe(bolt11: decoded.bolt11, amountSats: amountSatsValue) + let dispatch: LightningService.ProbeDispatch = switch target { + case let .invoice(bolt11, _): + try await lightningService.sendProbe(bolt11: bolt11, amountSats: amountSatsValue) + case let .nodeId(nodeId): + try await lightningService.sendProbesSpontaneous(nodeId: nodeId, amountSats: amountSatsValue) + } + + if dispatch.paymentIds.isEmpty { + await MainActor.run { isLoading = false } + app.toast(type: .error, title: "Probe Failed", description: "Probe was likely skipped (check logs)") + return + } + + let resolved = try await wallet.waitForProbeOutcome(paymentIds: dispatch.paymentIds) 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 - ) + + if resolved.success { + let estimatedFee: UInt64? = switch target { + case let .invoice(bolt11, _): + try? await lightningService.estimateRoutingFees(bolt11: bolt11, amountSats: amountSatsValue) + case .nodeId: + nil + } + await MainActor.run { + probeResult = ProbeResult( + success: true, + durationMs: durationMs, + estimatedFeeSats: estimatedFee, + errorMessage: nil + ) + } + app.toast(type: .success, title: "Probe Successful", description: "Route verified in \(durationMs) ms") + } else { + let scidText = resolved.shortChannelId.map(String.init) ?? "unknown" + let message = "Hash: \(resolved.paymentHash), SCID: \(scidText)" + await MainActor.run { + probeResult = ProbeResult( + success: false, + durationMs: durationMs, + estimatedFeeSats: nil, + errorMessage: message + ) + } + app.toast(type: .error, title: "Probe Failed", description: message) } - app.toast(type: .success, title: "Probe Successful", description: "Probe sent in \(durationMs) ms") } catch { let durationMs = Int(Date().timeIntervalSince(start) * 1000) await MainActor.run { diff --git a/CHANGELOG.md b/CHANGELOG.md index 33db3ede..c0c58fca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Update external channel success screen: rename title to "Channel opening", add dedicated "Spending Balance" nav title, and replace switch illustration with lightning bolt #521 - Use middle-ellipsis truncation for addresses on the receive screen #517 +### Fixed +- Fix probe results and add keysend probes #522 + ## [2.2.0] - 2026-04-07 ### Fixed