From af0eb40470575a8971adaf5a23cc62286c6068f5 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 17 Apr 2025 17:28:08 +0200 Subject: [PATCH 1/4] fix(ios): if fails to find route, reset graph reset scorer, sync both again and attempt payment one more time --- lib/ios/Ldk.swift | 115 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 103 insertions(+), 12 deletions(-) diff --git a/lib/ios/Ldk.swift b/lib/ios/Ldk.swift index 88fe5ee9..e91f1cb0 100644 --- a/lib/ios/Ldk.swift +++ b/lib/ios/Ldk.swift @@ -151,6 +151,8 @@ class Ldk: NSObject { var currentNetwork: NSString? var currentBlockchainTipHash: NSString? var currentBlockchainHeight: NSInteger? + var currentScorerDownloadUrl: NSString? + var currentRapidGossipSyncUrl: NSString? // Peer connection checks var backgroundedAt: Date? = nil @@ -259,6 +261,8 @@ class Ldk: NSObject { guard let accountStoragePath = Ldk.accountStoragePath else { return handleReject(reject, .init_storage_path) } + + currentScorerDownloadUrl = scorerSyncUrl let destinationFile = accountStoragePath.appendingPathComponent(LdkFileNames.scorer.rawValue) @@ -299,6 +303,8 @@ class Ldk: NSObject { guard let accountStoragePath = Ldk.accountStoragePath else { return handleReject(reject, .init_storage_path) } + + currentRapidGossipSyncUrl = rapidGossipSyncUrl let networkGraphStoragePath = accountStoragePath.appendingPathComponent(LdkFileNames.network_graph.rawValue).standardizedFileURL @@ -1062,29 +1068,110 @@ class Ldk: NSObject { return resolve(invoice.asJson) // Invoice class extended in Helpers file } + + //Called when a payment fails but we want to reset graph and channel manager so if they try again it might work + func resetGraphAndScorerAndRetryPayment(orginalError: LdkErrors, paymentRequest: NSString, amountSats: NSInteger, timeoutSeconds: NSInteger, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + guard let accountStoragePath = Ldk.accountStoragePath else { + LdkEventEmitter.shared.send(withEvent: .native_log, body: "Failed to reset graph: account storage path not set") + return handleReject(reject, orginalError) + } + + let fileManager = FileManager.default + let scorerPath = accountStoragePath.appendingPathComponent(LdkFileNames.scorer.rawValue) + let networkGraphPath = accountStoragePath.appendingPathComponent(LdkFileNames.network_graph.rawValue) + + // Delete scorer if exists + if fileManager.fileExists(atPath: scorerPath.path) { + do { + try fileManager.removeItem(at: scorerPath) + LdkEventEmitter.shared.send(withEvent: .native_log, body: "Deleted scorer file") + } catch { + LdkEventEmitter.shared.send(withEvent: .native_log, body: "Failed to delete scorer file: \(error.localizedDescription)") + } + } + + // Delete network graph if exists + if fileManager.fileExists(atPath: networkGraphPath.path) { + do { + try fileManager.removeItem(at: networkGraphPath) + LdkEventEmitter.shared.send(withEvent: .native_log, body: "Deleted network graph file") + networkGraph = nil + } catch { + LdkEventEmitter.shared.send(withEvent: .native_log, body: "Failed to delete network graph file: \(error.localizedDescription)") + } + } + + guard let currentScorerDownloadUrl, let currentRapidGossipSyncUrl, let currentNetwork else { + return handleReject(reject, orginalError) + } + + LdkEventEmitter.shared.send(withEvent: .native_log, body: "Deleted scorer and network graph, resyncing from scratch so we can retry payment") + + //Download everything again and retry + self.downloadScorer(currentScorerDownloadUrl, skipHoursThreshold: 1) { _ in + self.initNetworkGraph(currentNetwork, rapidGossipSyncUrl: currentRapidGossipSyncUrl, skipHoursThreshold: 1, resolve: { _ in + self.restart { _ in + let (paymentId2, error2) = self.handlePayment(paymentRequest: paymentRequest, amountSats: amountSats, timeoutSeconds: timeoutSeconds) + if let error2 { + return handleReject(reject, error2) + } + + //2nd attempt found a path with fresh graph + return resolve(paymentId2) + } reject: { _, _, _ in + return handleReject(reject, orginalError) + } + }, reject: { _, _, _ in + return handleReject(reject, orginalError) + }) + } reject: { _, _, _ in + return handleReject(reject, orginalError) + } + } @objc func pay(_ paymentRequest: NSString, amountSats: NSInteger, timeoutSeconds: NSInteger, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + let (paymentId, error) = handlePayment(paymentRequest: paymentRequest, amountSats: amountSats, timeoutSeconds: timeoutSeconds) + if let error { + //If error is route not found, maybe a problem with the graph, so reset it, download all again and try payment one more time + if error == .invoice_payment_fail_route_not_found { + return resetGraphAndScorerAndRetryPayment( + orginalError: error, + paymentRequest: paymentRequest, + amountSats: amountSats, + timeoutSeconds: timeoutSeconds, + resolve: resolve, + reject: reject + ) + } + + return handleReject(reject, error) + } + + return resolve(paymentId) + } + + func handlePayment(paymentRequest: NSString, amountSats: NSInteger, timeoutSeconds: NSInteger) -> (String?, LdkErrors?) { guard let channelManager = channelManager else { - return handleReject(reject, .init_channel_manager) + return (nil, .init_channel_manager) } - + guard let invoice = Bolt11Invoice.fromStr(s: String(paymentRequest)).getValue() else { - return handleReject(reject, .decode_invoice_fail) + return (nil, .decode_invoice_fail) } let isZeroValueInvoice = invoice.amountMilliSatoshis() == nil // If it's a zero invoice and we don't have an amount then don't proceed guard !(isZeroValueInvoice && amountSats == 0) else { - return handleReject(reject, .invoice_payment_fail_must_specify_amount) + return (nil, .invoice_payment_fail_must_specify_amount) } // Amount was set but not allowed to set own amount guard !(amountSats > 0 && !isZeroValueInvoice) else { - return handleReject(reject, .invoice_payment_fail_must_not_specify_amount) + return (nil, .invoice_payment_fail_must_not_specify_amount) } - + let paymentId = invoice.paymentHash()! let (paymentHash, recipientOnion, routeParameters) = isZeroValueInvoice ? Bindings.paymentParametersFromZeroAmountInvoice(invoice: invoice, amountMsat: UInt64(amountSats * 1000)).getValue()! : Bindings.paymentParametersFromInvoice(invoice: invoice).getValue()! @@ -1101,22 +1188,26 @@ class Ldk: NSObject { ]) if res.isOk() { - return resolve(paymentId) + return (Data(paymentId).hexEncodedString(), nil) } guard let error = res.getError() else { - return handleReject(reject, .invoice_payment_fail_unknown) + return (nil, .invoice_payment_fail_unknown) } switch error { case .DuplicatePayment: - return handleReject(reject, .invoice_payment_fail_duplicate_payment) + return (nil, .invoice_payment_fail_duplicate_payment) case .PaymentExpired: - return handleReject(reject, .invoice_payment_fail_payment_expired) + return (nil, .invoice_payment_fail_payment_expired) case .RouteNotFound: - return handleReject(reject, .invoice_payment_fail_route_not_found) + //Delete scorer + //Delete graph + //Download and update graph again + //Retry payment + return (nil, .invoice_payment_fail_route_not_found) @unknown default: - return handleReject(reject, .invoice_payment_fail_unknown) + return (nil, .invoice_payment_fail_unknown) } } From a3e02ee4341772dc639547606c89c938afac5e57 Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 18 Apr 2025 11:21:32 +0200 Subject: [PATCH 2/4] chore: code cleanup --- .../main/java/com/reactnativeldk/LdkModule.kt | 50 ++++++++++--------- lib/ios/Ldk.swift | 7 +-- 2 files changed, 28 insertions(+), 29 deletions(-) diff --git a/lib/android/src/main/java/com/reactnativeldk/LdkModule.kt b/lib/android/src/main/java/com/reactnativeldk/LdkModule.kt index d58b950f..a94baa58 100644 --- a/lib/android/src/main/java/com/reactnativeldk/LdkModule.kt +++ b/lib/android/src/main/java/com/reactnativeldk/LdkModule.kt @@ -123,7 +123,6 @@ enum class LdkCallbackResponses { peer_already_connected, peer_currently_connecting, chain_sync_success, - invoice_payment_success, tx_set_confirmed, tx_set_unconfirmed, process_pending_htlc_forwards_success, @@ -427,7 +426,7 @@ class LdkModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMod var channelManagerSerialized: ByteArray? = null val channelManagerFile = File(accountStoragePath + "/" + LdkFileNames.ChannelManager.fileName) if (channelManagerFile.exists()) { - channelManagerSerialized = channelManagerFile.readBytes() + channelManagerSerialized = channelManagerFile.readBytes() } //Scorer setup @@ -558,11 +557,11 @@ class LdkModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMod LdkEventEmitter.send(EventTypes.channel_manager_restarted, "") LdkEventEmitter.send(EventTypes.native_log, "LDK restarted successfully") handleResolve(promise, LdkCallbackResponses.ldk_restart) - }, + }, { reject -> LdkEventEmitter.send(EventTypes.native_log, "Error restarting LDK. Error: $reject") handleReject(promise, LdkErrors.unknown_error) - }) + }) initChannelManager( currentNetwork, @@ -687,7 +686,7 @@ class LdkModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMod if (currentlyConnectingPeers.contains(pubKey)) { return handleResolve(promise, LdkCallbackResponses.peer_currently_connecting) } - + try { currentlyConnectingPeers.add(pubKey) peerHandler!!.connect(pubKey.hexa(), InetSocketAddress(address, port.toInt()), timeout.toInt()) @@ -927,11 +926,19 @@ class LdkModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMod @ReactMethod fun pay(paymentRequest: String, amountSats: Double, timeoutSeconds: Double, promise: Promise) { - channelManager ?: return handleReject(promise, LdkErrors.init_channel_manager) + val (paymentId, error) = handlePayment(paymentRequest, amountSats, timeoutSeconds) + if (error != null) { + return handleReject(promise, error) + } + return promise.resolve(paymentId) + } + + private fun handlePayment(paymentRequest: String, amountSats: Double, timeoutSeconds: Double): Pair { + channelManager ?: return Pair(null, LdkErrors.init_channel_manager) val invoiceParse = Bolt11Invoice.from_str(paymentRequest) if (!invoiceParse.is_ok) { - return handleReject(promise, LdkErrors.decode_invoice_fail) + return Pair(null, LdkErrors.decode_invoice_fail) } val invoice = (invoiceParse as Result_Bolt11InvoiceParseOrSemanticErrorZ_OK).res @@ -939,12 +946,12 @@ class LdkModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMod //If it's a zero invoice and we don't have an amount then don't proceed if (isZeroValueInvoice && amountSats == 0.0) { - return handleReject(promise, LdkErrors.invoice_payment_fail_must_specify_amount) + return Pair(null, LdkErrors.invoice_payment_fail_must_specify_amount) } //Amount was set but not allowed to set own amount if (amountSats > 0 && !isZeroValueInvoice) { - return handleReject(promise, LdkErrors.invoice_payment_fail_must_not_specify_amount) + return Pair(null, LdkErrors.invoice_payment_fail_must_not_specify_amount) } val paymentId = invoice.payment_hash() @@ -953,7 +960,7 @@ class LdkModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMod UtilMethods.payment_parameters_from_invoice(invoice) if (!detailsRes.is_ok) { - return handleReject(promise, LdkErrors.invoice_payment_fail_invoice) + return Pair(null, LdkErrors.invoice_payment_fail_invoice) } val sendDetails = detailsRes as Result_C3Tuple_ThirtyTwoBytesRecipientOnionFieldsRouteParametersZNoneZ_OK @@ -974,26 +981,23 @@ class LdkModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMod "state" to "pending" )) - return handleResolve(promise, LdkCallbackResponses.invoice_payment_success) + return Pair(paymentId.hexEncodedString(), null) } val error = res as? Result_NoneRetryableSendFailureZ_Err - ?: return handleReject(promise, LdkErrors.invoice_payment_fail_unknown) + ?: return Pair(null, LdkErrors.invoice_payment_fail_unknown) - when (error.err) { + return when (error.err) { RetryableSendFailure.LDKRetryableSendFailure_DuplicatePayment -> { - handleReject(promise, LdkErrors.invoice_payment_fail_duplicate_payment) + Pair(null, LdkErrors.invoice_payment_fail_duplicate_payment) } - RetryableSendFailure.LDKRetryableSendFailure_PaymentExpired -> { - handleReject(promise, LdkErrors.invoice_payment_fail_payment_expired) + Pair(null, LdkErrors.invoice_payment_fail_payment_expired) } - RetryableSendFailure.LDKRetryableSendFailure_RouteNotFound -> { - handleReject(promise, LdkErrors.invoice_payment_fail_route_not_found) + Pair(null, LdkErrors.invoice_payment_fail_route_not_found) } - - else -> handleReject(promise, LdkErrors.invoice_payment_fail_unknown) + else -> Pair(null, LdkErrors.invoice_payment_fail_unknown) } } @@ -1409,10 +1413,10 @@ class LdkModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMod keysManager?.inner?.as_NodeSigner() ?.get_node_id(Recipient.LDKRecipient_Node)?.let { pubKeyRes -> - if (pubKeyRes.is_ok) { - logDump.add("NodeID: ${(pubKeyRes as Result_PublicKeyNoneZ_OK).res.hexEncodedString()}") + if (pubKeyRes.is_ok) { + logDump.add("NodeID: ${(pubKeyRes as Result_PublicKeyNoneZ_OK).res.hexEncodedString()}") + } } - } channelManager?.list_channels()?.forEach { channel -> logDump.add("Open channel:") diff --git a/lib/ios/Ldk.swift b/lib/ios/Ldk.swift index e91f1cb0..6ea94b95 100644 --- a/lib/ios/Ldk.swift +++ b/lib/ios/Ldk.swift @@ -94,7 +94,6 @@ enum LdkCallbackResponses: String { case peer_already_connected case peer_currently_connecting case chain_sync_success - case invoice_payment_success case tx_set_confirmed case tx_set_unconfirmed case process_pending_htlc_forwards_success @@ -1128,7 +1127,7 @@ class Ldk: NSObject { return handleReject(reject, orginalError) } } - + @objc func pay(_ paymentRequest: NSString, amountSats: NSInteger, timeoutSeconds: NSInteger, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { let (paymentId, error) = handlePayment(paymentRequest: paymentRequest, amountSats: amountSats, timeoutSeconds: timeoutSeconds) @@ -1201,10 +1200,6 @@ class Ldk: NSObject { case .PaymentExpired: return (nil, .invoice_payment_fail_payment_expired) case .RouteNotFound: - //Delete scorer - //Delete graph - //Download and update graph again - //Retry payment return (nil, .invoice_payment_fail_route_not_found) @unknown default: return (nil, .invoice_payment_fail_unknown) From 3608f7e082eed512cb8b2ddc1a9bcf3d105f2464 Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 18 Apr 2025 12:55:37 +0200 Subject: [PATCH 3/4] fix(android): if fails to find route, reset graph, scorer, sync both again and retry payment --- .../main/java/com/reactnativeldk/LdkModule.kt | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/lib/android/src/main/java/com/reactnativeldk/LdkModule.kt b/lib/android/src/main/java/com/reactnativeldk/LdkModule.kt index a94baa58..e16d74de 100644 --- a/lib/android/src/main/java/com/reactnativeldk/LdkModule.kt +++ b/lib/android/src/main/java/com/reactnativeldk/LdkModule.kt @@ -184,6 +184,8 @@ class LdkModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMod private var currentNetwork: String? = null private var currentBlockchainTipHash: String? = null private var currentBlockchainHeight: Double? = null + private var currentScorerDownloadUrl: String? = null + private var currentRapidGossipSyncUrl: String? = null //List of peers that "should" remain connected. Stores address: String, port: Double, pubKey: String private var addedPeers = ConcurrentLinkedQueue>() @@ -291,6 +293,9 @@ class LdkModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMod @ReactMethod fun downloadScorer(scorerSyncUrl: String, skipHoursThreshold: Double, promise: Promise) { val scorerFile = File(accountStoragePath + "/" + LdkFileNames.Scorer.fileName) + + currentScorerDownloadUrl = scorerSyncUrl + //If old one is still recent, skip download. Else delete it. if (scorerFile.exists()) { val lastModifiedHours = (System.currentTimeMillis().toDouble() - scorerFile.lastModified().toDouble()) / 1000 / 60 / 60 @@ -328,6 +333,8 @@ class LdkModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMod return handleReject(promise, LdkErrors.already_init) } + currentRapidGossipSyncUrl = rapidGossipSyncUrl + val networkGraphFile = File(accountStoragePath + "/" + LdkFileNames.NetworkGraph.fileName) if (networkGraphFile.exists()) { (NetworkGraph.read(networkGraphFile.readBytes(), logger.logger) as? Result_NetworkGraphDecodeErrorZ.Result_NetworkGraphDecodeErrorZ_OK)?.let { res -> @@ -924,10 +931,107 @@ class LdkModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMod promise.resolve(parsedInvoice.res.asJson) } + private fun resetGraphAndScorerAndRetryPayment( + originalError: LdkErrors, + paymentRequest: String, + amountSats: Double, + timeoutSeconds: Double, + promise: Promise + ) { + if (accountStoragePath == "") { + LdkEventEmitter.send(EventTypes.native_log, "Failed to reset graph: account storage path not set") + return handleReject(promise, originalError) + } + + // Check required data and URLs + val currentNetwork = currentNetwork ?: return handleReject(promise, originalError) + + if (currentRapidGossipSyncUrl.isNullOrEmpty() || currentScorerDownloadUrl.isNullOrEmpty()) { + val missingUrl = if (currentRapidGossipSyncUrl.isNullOrEmpty()) "rapid gossip sync" else "scorer download" + LdkEventEmitter.send(EventTypes.native_log, "Failed to reset graph: $missingUrl URL not set") + return handleReject(promise, originalError) + } + + val scorerFile = File("$accountStoragePath/${LdkFileNames.Scorer.fileName}") + val networkGraphFile = File("$accountStoragePath/${LdkFileNames.NetworkGraph.fileName}") + + // Delete scorer if exists + if (scorerFile.exists()) { + try { + scorerFile.delete() + LdkEventEmitter.send(EventTypes.native_log, "Deleted scorer file") + } catch (e: Exception) { + LdkEventEmitter.send(EventTypes.native_log, "Failed to delete scorer file: ${e.localizedMessage}") + } + } + + // Delete network graph if exists + if (networkGraphFile.exists()) { + try { + networkGraphFile.delete() + LdkEventEmitter.send(EventTypes.native_log, "Deleted network graph file") + networkGraph = null + } catch (e: Exception) { + LdkEventEmitter.send(EventTypes.native_log, "Failed to delete network graph file: ${e.localizedMessage}") + } + } + + LdkEventEmitter.send(EventTypes.native_log, "Deleted scorer and network graph, resyncing from scratch so we can retry payment") + + // Download everything again and retry + downloadScorer(currentScorerDownloadUrl!!, 1.0, object : PromiseImpl( + { _ -> + LdkEventEmitter.send(EventTypes.native_log, "Scorer downloaded, initializing network graph...") + initNetworkGraph(currentNetwork, currentRapidGossipSyncUrl!!, 1.0, object : PromiseImpl( + { _ -> + LdkEventEmitter.send(EventTypes.native_log, "Network graph initialized, restarting channel manager...") + restart(object : PromiseImpl( + { _ -> + // Run handleDroppedPeers on a background thread (can't work in the UI thread) + Thread { + handleDroppedPeers() + }.start() + + Thread.sleep(2500) //Wait a little as android peer connections happen async so we're just making sure they're all connected + val channelsInGraph = networkGraph?.read_only()?.list_channels()?.size + LdkEventEmitter.send(EventTypes.native_log, "Channels found in graph: $channelsInGraph") + LdkEventEmitter.send(EventTypes.native_log, "Peers connected: ${peerManager?.list_peers()?.size}") + LdkEventEmitter.send(EventTypes.native_log, "Restart complete. Attempting to retry payment after graph reset...") + val (paymentId2, error2) = handlePayment(paymentRequest, amountSats, timeoutSeconds) + + if (error2 != null) { + LdkEventEmitter.send(EventTypes.native_log, "Failed to retry payment after graph reset: $error2") + handleReject(promise, error2) + } else { + LdkEventEmitter.send(EventTypes.native_log, "Successfully retried payment after graph reset") + // 2nd attempt found a path with fresh graph + promise.resolve(paymentId2) + } + }, + { _ -> handleReject(promise, originalError) } + ) {}) + }, + { _ -> handleReject(promise, originalError) } + ) {}) + }, + { _ -> handleReject(promise, originalError) } + ) {}) + } + @ReactMethod fun pay(paymentRequest: String, amountSats: Double, timeoutSeconds: Double, promise: Promise) { val (paymentId, error) = handlePayment(paymentRequest, amountSats, timeoutSeconds) if (error != null) { + // If error is route not found, maybe a problem with the graph, so reset it, download all again and try payment one more time + if (error == LdkErrors.invoice_payment_fail_route_not_found) { + return resetGraphAndScorerAndRetryPayment( + error, + paymentRequest, + amountSats, + timeoutSeconds, + promise + ) + } return handleReject(promise, error) } return promise.resolve(paymentId) From bcfa5c70744323ea3774394117ac10c837f749ac Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 18 Apr 2025 12:56:17 +0200 Subject: [PATCH 4/4] fix(ios): connecting to peers in sequence to avoid crash --- lib/ios/Ldk.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/ios/Ldk.swift b/lib/ios/Ldk.swift index 6ea94b95..0c03395c 100644 --- a/lib/ios/Ldk.swift +++ b/lib/ios/Ldk.swift @@ -727,9 +727,9 @@ class Ldk: NSObject { .listPeers() .map { Data($0.getCounterpartyNodeId()).hexEncodedString() } - addedPeers.forEach { address, port, pubKey in + for (address, port, pubKey) in addedPeers { guard !currentList.contains(pubKey) else { - return + continue } currentlyConnectingPeers.append(String(pubKey)) @@ -1110,6 +1110,12 @@ class Ldk: NSObject { self.downloadScorer(currentScorerDownloadUrl, skipHoursThreshold: 1) { _ in self.initNetworkGraph(currentNetwork, rapidGossipSyncUrl: currentRapidGossipSyncUrl, skipHoursThreshold: 1, resolve: { _ in self.restart { _ in + self.handleDroppedPeers() + + LdkEventEmitter.shared.send(withEvent: .native_log, body: "Channels found in graph: \(self.networkGraph?.readOnly().listChannels().count ?? 0)") + LdkEventEmitter.shared.send(withEvent: .native_log, body: "Peers connected: \(self.peerManager?.listPeers().count ?? 0)") + LdkEventEmitter.shared.send(withEvent: .native_log, body: "Restart complete. Attempting to retry payment after graph reset...") + let (paymentId2, error2) = self.handlePayment(paymentRequest: paymentRequest, amountSats: amountSats, timeoutSeconds: timeoutSeconds) if let error2 { return handleReject(reject, error2)