diff --git a/lib/android/src/main/java/com/reactnativeldk/LdkModule.kt b/lib/android/src/main/java/com/reactnativeldk/LdkModule.kt index d58b950f..e16d74de 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, @@ -185,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>() @@ -292,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 @@ -329,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 -> @@ -427,7 +433,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 +564,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 +693,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()) @@ -925,13 +931,118 @@ 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) { - channelManager ?: return handleReject(promise, LdkErrors.init_channel_manager) + 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) + } + + 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 +1050,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 +1064,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 +1085,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 +1517,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 88fe5ee9..0c03395c 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 @@ -151,6 +150,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 +260,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 +302,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 @@ -722,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)) @@ -1062,29 +1067,116 @@ 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 + 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) + } + + //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 +1193,22 @@ 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) + return (nil, .invoice_payment_fail_route_not_found) @unknown default: - return handleReject(reject, .invoice_payment_fail_unknown) + return (nil, .invoice_payment_fail_unknown) } }