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
2 changes: 1 addition & 1 deletion Bitkit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -944,7 +944,7 @@
repositoryURL = "https://github.com/synonymdev/ldk-node";
requirement = {
kind = revision;
revision = ae38eadab70fceb5dbe242bc02bf895581cb7c3f;
revision = 52f73c4402cfb06a020ab8fa9594b5ecb94e3cd6;
};
};
96DEA0382DE8BBA1009932BF /* XCRemoteSwiftPackageReference "bitkit-core" */ = {
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

41 changes: 37 additions & 4 deletions Bitkit/Services/LightningService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")

Expand Down Expand Up @@ -913,6 +914,10 @@ class LightningService {
// MARK: UI Helpers (Published via WalletViewModel)

extension LightningService {
struct ProbeDispatch {
let paymentIds: Set<PaymentId>
}

var nodeId: String? {
node?.nodeId()
}
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)))
}
}
}
4 changes: 4 additions & 0 deletions Bitkit/ViewModels/AppViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
104 changes: 103 additions & 1 deletion Bitkit/ViewModels/WalletViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -354,6 +369,7 @@ class WalletViewModel: ObservableObject {
nodeLifecycleState = .stopping
try await lightningService.stop(clearEventCallback: clearEventCallback)
nodeLifecycleState = .stopped
probeOutcomes.removeAll()
syncState()
}

Expand Down Expand Up @@ -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<PaymentId>) 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
}
}
Comment thread
pwltr marked this conversation as resolved.
}
}

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<PaymentId>) -> 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.
Expand Down Expand Up @@ -1042,6 +1142,8 @@ class WalletViewModel: ObservableObject {

try await lightningService.wipeStorage(walletIndex: 0)

probeOutcomes.removeAll()

// Reset AppStorage display values
totalBalanceSats = 0
totalOnchainSats = 0
Expand Down
Loading
Loading