Skip to content
Draft
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
41 changes: 17 additions & 24 deletions Bitkit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,10 @@
3D76260F4C9C4A53B1E4A001 /* CoreBluetooth.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3D76260E4C9C4A53B1E4A001 /* CoreBluetooth.framework */; };
3D7626104C9C4A53B1E4A001 /* CoreBluetooth.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3D76260E4C9C4A53B1E4A001 /* CoreBluetooth.framework */; };
4AAB08CA2E1FE77600BA63DF /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 4AAB08C92E1FE77600BA63DF /* Lottie */; };
4ACE9F5E2F6B3B87000121C2 /* LDKNode in Frameworks */ = {isa = PBXBuildFile; productRef = 4ACE9F5D2F6B3B87000121C2 /* LDKNode */; };
4AFCA3702E05933800205CAE /* Zip in Frameworks */ = {isa = PBXBuildFile; productRef = 4AFCA36F2E05933800205CAE /* Zip */; };
4AFCA3722E0596D900205CAE /* Zip in Frameworks */ = {isa = PBXBuildFile; productRef = 4AFCA3712E0596D900205CAE /* Zip */; };
961058E32C355B5500E1F1D8 /* BitkitNotification.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 961058DC2C355B5500E1F1D8 /* BitkitNotification.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
968FDF162DFAFE230053CD7F /* LDKNode in Frameworks */ = {isa = PBXBuildFile; productRef = 9613018B2C5022D700878183 /* LDKNode */; };
968FE1402DFB016B0053CD7F /* LDKNode in Frameworks */ = {isa = PBXBuildFile; productRef = 968FE13F2DFB016B0053CD7F /* LDKNode */; };
96DEA03A2DE8BBA1009932BF /* BitkitCore in Frameworks */ = {isa = PBXBuildFile; productRef = 96DEA0392DE8BBA1009932BF /* BitkitCore */; };
96DEA03C2DE8BBAB009932BF /* BitkitCore in Frameworks */ = {isa = PBXBuildFile; productRef = 96DEA03B2DE8BBAB009932BF /* BitkitCore */; };
96E20CD42CB6D91A00C24149 /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 96E20CD32CB6D91A00C24149 /* CodeScanner */; };
Expand Down Expand Up @@ -155,8 +154,8 @@
buildActionMask = 2147483647;
files = (
3D7626104C9C4A53B1E4A001 /* CoreBluetooth.framework in Frameworks */,
968FE1402DFB016B0053CD7F /* LDKNode in Frameworks */,
96DEA03C2DE8BBAB009932BF /* BitkitCore in Frameworks */,
4ACE9F5E2F6B3B87000121C2 /* LDKNode in Frameworks */,
4AFCA3722E0596D900205CAE /* Zip in Frameworks */,
96E493A82C943184000E8BC2 /* secp256k1 in Frameworks */,
18D65E022EB964BD00252335 /* VssRustClientFfi in Frameworks */,
Expand Down Expand Up @@ -247,8 +246,8 @@
96E493A72C943184000E8BC2 /* secp256k1 */,
96DEA03B2DE8BBAB009932BF /* BitkitCore */,
4AFCA3712E0596D900205CAE /* Zip */,
968FE13F2DFB016B0053CD7F /* LDKNode */,
18D65E012EB964BD00252335 /* VssRustClientFfi */,
4ACE9F5D2F6B3B87000121C2 /* LDKNode */,
);
productName = BitkitNotification;
productReference = 961058DC2C355B5500E1F1D8 /* BitkitNotification.appex */;
Expand All @@ -274,7 +273,7 @@
);
name = Bitkit;
packageProductDependencies = (
9613018B2C5022D700878183 /* LDKNode */,
4ACE9F5D2F6B3B87000121C2 /* LDKNode */,
96E493A32C942FD1000E8BC2 /* secp256k1 */,
96E20CD32CB6D91A00C24149 /* CodeScanner */,
96DEA0392DE8BBA1009932BF /* BitkitCore */,
Expand Down Expand Up @@ -385,9 +384,9 @@
96E20CD22CB6D91A00C24149 /* XCRemoteSwiftPackageReference "CodeScanner" */,
96DEA0382DE8BBA1009932BF /* XCRemoteSwiftPackageReference "bitkit-core" */,
4AFCA36E2E05933800205CAE /* XCRemoteSwiftPackageReference "Zip" */,
968FE13E2DFB016B0053CD7F /* XCRemoteSwiftPackageReference "ldk-node" */,
4AAB08C82E1FE77600BA63DF /* XCRemoteSwiftPackageReference "lottie-ios" */,
18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */,
4ACE9F5C2F6B30B0000121C2 /* XCLocalSwiftPackageReference "../ldk-node" */,
);
productRefGroup = 96FE1F622C2DE6AA006D0C8B /* Products */;
projectDirPath = "";
Expand Down Expand Up @@ -900,6 +899,13 @@
};
/* End XCConfigurationList section */

/* Begin XCLocalSwiftPackageReference section */
4ACE9F5C2F6B30B0000121C2 /* XCLocalSwiftPackageReference "../ldk-node" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = "../ldk-node";
};
/* End XCLocalSwiftPackageReference section */

/* Begin XCRemoteSwiftPackageReference section */
18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */ = {
isa = XCRemoteSwiftPackageReference;
Expand All @@ -925,14 +931,6 @@
minimumVersion = 2.1.2;
};
};
968FE13E2DFB016B0053CD7F /* XCRemoteSwiftPackageReference "ldk-node" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/synonymdev/ldk-node";
requirement = {
kind = revision;
revision = ae38eadab70fceb5dbe242bc02bf895581cb7c3f;
};
};
96DEA0382DE8BBA1009932BF /* XCRemoteSwiftPackageReference "bitkit-core" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/synonymdev/bitkit-core";
Expand Down Expand Up @@ -975,6 +973,11 @@
package = 4AAB08C82E1FE77600BA63DF /* XCRemoteSwiftPackageReference "lottie-ios" */;
productName = Lottie;
};
4ACE9F5D2F6B3B87000121C2 /* LDKNode */ = {
isa = XCSwiftPackageProductDependency;
package = 4ACE9F5C2F6B30B0000121C2 /* XCLocalSwiftPackageReference "../ldk-node" */;
productName = LDKNode;
};
4AFCA36F2E05933800205CAE /* Zip */ = {
isa = XCSwiftPackageProductDependency;
package = 4AFCA36E2E05933800205CAE /* XCRemoteSwiftPackageReference "Zip" */;
Expand All @@ -985,16 +988,6 @@
package = 4AFCA36E2E05933800205CAE /* XCRemoteSwiftPackageReference "Zip" */;
productName = Zip;
};
9613018B2C5022D700878183 /* LDKNode */ = {
isa = XCSwiftPackageProductDependency;
package = 968FE13E2DFB016B0053CD7F /* XCRemoteSwiftPackageReference "ldk-node" */;
productName = LDKNode;
};
968FE13F2DFB016B0053CD7F /* LDKNode */ = {
isa = XCSwiftPackageProductDependency;
package = 968FE13E2DFB016B0053CD7F /* XCRemoteSwiftPackageReference "ldk-node" */;
productName = LDKNode;
};
96DEA0392DE8BBA1009932BF /* BitkitCore */ = {
isa = XCSwiftPackageProductDependency;
package = 96DEA0382DE8BBA1009932BF /* XCRemoteSwiftPackageReference "bitkit-core" */;
Expand Down

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

40 changes: 36 additions & 4 deletions Bitkit/Services/LightningService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -913,6 +913,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 +1185,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 +1514,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 @@ -810,6 +810,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
105 changes: 104 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,91 @@ 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
}
}
}
}

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 }

for paymentId in paymentIds {
probeOutcomes.removeValue(forKey: paymentId)
}

if let firstSuccess = matched.first(where: \.success) {
return firstSuccess
}
if matched.count == paymentIds.count {
return matched.last
}
return nil
}

/// 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 +1143,8 @@ class WalletViewModel: ObservableObject {

try await lightningService.wipeStorage(walletIndex: 0)

probeOutcomes.removeAll()

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