From ba77753bd14963e6944faaf5decf98c47c51a0d1 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 30 Sep 2026 02:25:31 +0200 Subject: [PATCH 1/4] test: fix existing wallet test compilation --- src/wallet/mod.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 241ee1cde5..98a6f00d55 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2424,6 +2424,8 @@ impl ChangeDestinationSource for WalletKeysManager { #[cfg(test)] mod tests { + use std::str::FromStr; + use bdk_wallet_aggregate::UtxoPsbtInfo; use bitcoin::{psbt, OutPoint, TxIn, Weight}; @@ -2516,7 +2518,7 @@ mod tests { .assume_checked(); let fee_rate = bitcoin::FeeRate::from_sat_per_kwu(250); assert_eq!( - checked_payment_target(u64::MAX, recipient.script_pubkey(), None, fee_rate), + checked_payment_target(u64::MAX, &recipient.script_pubkey(), None, fee_rate), Err(Error::InsufficientFunds) ); let utxo = UtxoPsbtInfo { @@ -2526,7 +2528,7 @@ mod tests { is_primary: true, }; assert_eq!( - checked_payment_target(u64::MAX, recipient.script_pubkey(), Some(&[utxo]), fee_rate), + checked_payment_target(u64::MAX, &recipient.script_pubkey(), Some(&[utxo]), fee_rate), Err(Error::InsufficientFunds) ); } From 2dac2c08f59d71af650c726eb766ba26cd49cbff Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 30 Sep 2026 02:26:13 +0200 Subject: [PATCH 2/4] fix: report on-chain broadcast outcomes --- CHANGELOG.md | 11 +- Cargo.lock | 2 +- Cargo.toml | 2 +- Package.swift | 4 +- README.md | 17 + .../kotlin/ldk-node-android/gradle.properties | 2 +- .../ldknode/ldk_node.android.kt | 128 ++++++++ .../ldknode/ldk_node.common.kt | 32 ++ .../kotlin/ldk-node-jvm/gradle.properties | 2 +- bindings/ldk_node.udl | 11 + bindings/python/pyproject.toml | 2 +- bindings/python/src/ldk_node/ldk_node.py | 202 ++++++++++++ bindings/swift/Sources/LDKNode/LDKNode.swift | 91 ++++++ src/chain/bitcoind.rs | 144 ++++++--- src/chain/electrum.rs | 300 ++++++++++++++++-- src/chain/esplora.rs | 200 ++++++++---- src/chain/mod.rs | 33 +- src/lib.rs | 6 +- src/payment/mod.rs | 2 +- src/payment/onchain.rs | 107 ++++++- src/payment/unified_qr.rs | 7 +- src/wallet/mod.rs | 22 +- tests/integration_tests_rust.rs | 162 +++++++++- 23 files changed, 1327 insertions(+), 162 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1abfe1ccac..011b75f0c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# 0.7.0-rc.66 (Synonym Fork) +# 0.7.0-rc.67 (Synonym Fork) ## Bug Fixes @@ -6,16 +6,8 @@ - Keep exported payment and liquidity handles from calling into a shutting-down runtime, refuse restart while detached work is still live, and stop Electrum confirm gating from blocking or panicking shutdown. - Add keep consumer rules for JNA types UniFFI needs under R8. -# 0.7.0-rc.64 (Synonym Fork) - -## Bug Fixes - - The Android AAR now ships targeted R8 consumer keep rules for the UniFFI/JNA FFI surface. -# 0.7.0-rc.63 (Synonym Fork) - -## Bug Fixes - - Moved peer persistence to async KV storage so slow writes no longer hold the peer-store lock. - Prevented Electrum runtime self-drop crashes and unbounded shutdown waits. - Scaled Electrum full-scan timeouts for additional wallets with the configured stop gap and batch @@ -84,6 +76,7 @@ ## Synonym Fork Additions +- Added explicit accepted, rejected, or unknown broadcast outcomes with transaction IDs for on-chain sends. - Removed `set_accept_stale_channel_monitors` and the patched Synonym `rust-lightning` branch. Stale channel-monitor mismatches now fail closed with `BuildError::DangerousValue`. Lightning crates come from crates.io `0.2.0` again. diff --git a/Cargo.lock b/Cargo.lock index 5be41f2822..e3651d70e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1901,7 +1901,7 @@ dependencies = [ [[package]] name = "ldk-node" -version = "0.7.0-rc.66" +version = "0.7.0-rc.67" dependencies = [ "base64 0.22.1", "bdk-wallet-aggregate", diff --git a/Cargo.toml b/Cargo.toml index aff8af778d..ff9c863010 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ exclude = ["bindings/uniffi-bindgen"] [package] name = "ldk-node" -version = "0.7.0-rc.66" +version = "0.7.0-rc.67" authors = ["Elias Rohrer "] homepage = "https://lightningdevkit.org/" license = "MIT OR Apache-2.0" diff --git a/Package.swift b/Package.swift index f8cc852f0e..24752d2299 100644 --- a/Package.swift +++ b/Package.swift @@ -3,8 +3,8 @@ import PackageDescription -let tag = "v0.7.0-rc.66" -let checksum = "21ac13bfdc9fdd3099a688bd0053f8b14c74b5957943b2f09624886e65556a8e" +let tag = "v0.7.0-rc.67" +let checksum = "8b5196adcd7b6b4a8304080a0b7fa80a919a6fa527b75ccde982a75298c92da9" let url = "https://github.com/synonymdev/ldk-node/releases/download/\(tag)/LDKNodeFFI.xcframework.zip" let package = Package( diff --git a/README.md b/README.md index d11c5fc8e3..ee647980c9 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,23 @@ LDK Node currently comes with a decidedly opinionated set of design choices: - Gossip data may be sourced via Lightning's peer-to-peer network or the [Rapid Gossip Sync](https://docs.rs/lightning-rapid-gossip-sync/*/lightning_rapid_gossip_sync/) protocol. - Entropy for the Lightning and on-chain wallets may be sourced from raw bytes or a [BIP39](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki) mnemonic. In addition, LDK Node offers the means to generate and persist the entropy bytes to disk. +### On-chain send results + +`OnchainPayment::send_to_address_with_broadcast_result` and +`send_all_to_address_with_broadcast_result` submit a signed transaction directly to the +configured chain source. They return `Accepted { txid }` only when that backend acknowledges +the submitted transaction. `Rejected { txid, reason }` records a recognized refusal response; +`Unknown { txid }` covers a lost or ambiguous result. Both retain the locally computed txid. +Neither a refusal nor an unknown result proves the transaction was never delivered, so callers +must not create a replacement payment from either result. Backend acknowledgement is not +confirmation or guaranteed network propagation. An `Err` from these methods means this call did +not initiate broadcast. + +The existing `send_to_address` and `send_all_to_address` methods still return a local txid after +attempting queue admission, even if admission fails. Their returned txid gives no backend +acceptance assurance. The on-chain variant of +`UnifiedQrPayment::send` has the same queueing semantics. + ## Language Support LDK Node itself is written in [Rust][rust] and may therefore be natively added as a library dependency to any `std` Rust program. However, beyond its Rust API it also offers language bindings for [Swift][swift], [Kotlin][kotlin], and [Python][python] based on the [UniFFI](https://github.com/mozilla/uniffi-rs/). Moreover, [Flutter bindings][flutter_bindings] are also available. diff --git a/bindings/kotlin/ldk-node-android/gradle.properties b/bindings/kotlin/ldk-node-android/gradle.properties index 694c826d8a..e2fa1e9991 100644 --- a/bindings/kotlin/ldk-node-android/gradle.properties +++ b/bindings/kotlin/ldk-node-android/gradle.properties @@ -3,4 +3,4 @@ android.useAndroidX=true android.enableJetifier=true kotlin.code.style=official group=com.synonym -version=0.7.0-rc.66 +version=0.7.0-rc.67 diff --git a/bindings/kotlin/ldk-node-android/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.android.kt b/bindings/kotlin/ldk-node-android/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.android.kt index 65e18cbb88..bdc40f4a9d 100644 --- a/bindings/kotlin/ldk-node-android/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.android.kt +++ b/bindings/kotlin/ldk-node-android/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.android.kt @@ -1549,6 +1549,10 @@ internal typealias UniffiVTableCallbackInterfaceVssHeaderProviderUniffiByValue = + + + + @@ -2738,6 +2742,13 @@ internal interface UniffiLib : Library { `feeRate`: RustBufferByValue, uniffiCallStatus: UniffiRustCallStatus, ): RustBufferByValue + fun uniffi_ldk_node_fn_method_onchainpayment_send_all_to_address_with_broadcast_result( + `ptr`: Pointer?, + `address`: RustBufferByValue, + `retainReserves`: Byte, + `feeRate`: RustBufferByValue, + uniffiCallStatus: UniffiRustCallStatus, + ): RustBufferByValue fun uniffi_ldk_node_fn_method_onchainpayment_send_to_address( `ptr`: Pointer?, `address`: RustBufferByValue, @@ -2746,6 +2757,14 @@ internal interface UniffiLib : Library { `utxosToSpend`: RustBufferByValue, uniffiCallStatus: UniffiRustCallStatus, ): RustBufferByValue + fun uniffi_ldk_node_fn_method_onchainpayment_send_to_address_with_broadcast_result( + `ptr`: Pointer?, + `address`: RustBufferByValue, + `amountSats`: Long, + `feeRate`: RustBufferByValue, + `utxosToSpend`: RustBufferByValue, + uniffiCallStatus: UniffiRustCallStatus, + ): RustBufferByValue fun uniffi_ldk_node_fn_clone_refund( `ptr`: Pointer?, uniffiCallStatus: UniffiRustCallStatus, @@ -3506,8 +3525,12 @@ internal interface UniffiLib : Library { ): Short fun uniffi_ldk_node_checksum_method_onchainpayment_send_all_to_address( ): Short + fun uniffi_ldk_node_checksum_method_onchainpayment_send_all_to_address_with_broadcast_result( + ): Short fun uniffi_ldk_node_checksum_method_onchainpayment_send_to_address( ): Short + fun uniffi_ldk_node_checksum_method_onchainpayment_send_to_address_with_broadcast_result( + ): Short fun uniffi_ldk_node_checksum_method_refund_absolute_expiry_seconds( ): Short fun uniffi_ldk_node_checksum_method_refund_amount_msats( @@ -4153,9 +4176,15 @@ private fun uniffiCheckApiChecksums(lib: UniffiLib) { if (lib.uniffi_ldk_node_checksum_method_onchainpayment_send_all_to_address() != 37748.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_ldk_node_checksum_method_onchainpayment_send_all_to_address_with_broadcast_result() != 45110.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_ldk_node_checksum_method_onchainpayment_send_to_address() != 28826.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_ldk_node_checksum_method_onchainpayment_send_to_address_with_broadcast_result() != 16277.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_ldk_node_checksum_method_refund_absolute_expiry_seconds() != 43722.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -8625,6 +8654,21 @@ open class OnchainPayment: Disposable, OnchainPaymentInterface { }) } + @Throws(NodeException::class) + override fun `sendAllToAddressWithBroadcastResult`(`address`: Address, `retainReserves`: kotlin.Boolean, `feeRate`: FeeRate?): OnchainSendResult { + return FfiConverterTypeOnchainSendResult.lift(callWithPointer { + uniffiRustCallWithError(NodeExceptionErrorHandler) { uniffiRustCallStatus -> + UniffiLib.INSTANCE.uniffi_ldk_node_fn_method_onchainpayment_send_all_to_address_with_broadcast_result( + it, + FfiConverterTypeAddress.lower(`address`), + FfiConverterBoolean.lower(`retainReserves`), + FfiConverterOptionalTypeFeeRate.lower(`feeRate`), + uniffiRustCallStatus, + ) + } + }) + } + @Throws(NodeException::class) override fun `sendToAddress`(`address`: Address, `amountSats`: kotlin.ULong, `feeRate`: FeeRate?, `utxosToSpend`: List?): Txid { return FfiConverterTypeTxid.lift(callWithPointer { @@ -8641,6 +8685,22 @@ open class OnchainPayment: Disposable, OnchainPaymentInterface { }) } + @Throws(NodeException::class) + override fun `sendToAddressWithBroadcastResult`(`address`: Address, `amountSats`: kotlin.ULong, `feeRate`: FeeRate?, `utxosToSpend`: List?): OnchainSendResult { + return FfiConverterTypeOnchainSendResult.lift(callWithPointer { + uniffiRustCallWithError(NodeExceptionErrorHandler) { uniffiRustCallStatus -> + UniffiLib.INSTANCE.uniffi_ldk_node_fn_method_onchainpayment_send_to_address_with_broadcast_result( + it, + FfiConverterTypeAddress.lower(`address`), + FfiConverterULong.lower(`amountSats`), + FfiConverterOptionalTypeFeeRate.lower(`feeRate`), + FfiConverterOptionalSequenceTypeSpendableUtxo.lower(`utxosToSpend`), + uniffiRustCallStatus, + ) + } + }) + } + @@ -12543,6 +12603,74 @@ object FfiConverterTypeOfferAmount : FfiConverterRustBuffer{ +object FfiConverterTypeOnchainSendResult : FfiConverterRustBuffer{ + override fun read(buf: ByteBuffer): OnchainSendResult { + return when(buf.getInt()) { + 1 -> OnchainSendResult.Accepted( + FfiConverterTypeTxid.read(buf), + ) + 2 -> OnchainSendResult.Rejected( + FfiConverterTypeTxid.read(buf), + FfiConverterString.read(buf), + ) + 3 -> OnchainSendResult.Unknown( + FfiConverterTypeTxid.read(buf), + ) + else -> throw RuntimeException("invalid enum value, something is very wrong!!") + } + } + + override fun allocationSize(value: OnchainSendResult) = when(value) { + is OnchainSendResult.Accepted -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterTypeTxid.allocationSize(value.`txid`) + ) + } + is OnchainSendResult.Rejected -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterTypeTxid.allocationSize(value.`txid`) + + FfiConverterString.allocationSize(value.`reason`) + ) + } + is OnchainSendResult.Unknown -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterTypeTxid.allocationSize(value.`txid`) + ) + } + } + + override fun write(value: OnchainSendResult, buf: ByteBuffer) { + when(value) { + is OnchainSendResult.Accepted -> { + buf.putInt(1) + FfiConverterTypeTxid.write(value.`txid`, buf) + Unit + } + is OnchainSendResult.Rejected -> { + buf.putInt(2) + FfiConverterTypeTxid.write(value.`txid`, buf) + FfiConverterString.write(value.`reason`, buf) + Unit + } + is OnchainSendResult.Unknown -> { + buf.putInt(3) + FfiConverterTypeTxid.write(value.`txid`, buf) + Unit + } + }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } + } +} + + + + + object FfiConverterTypePaymentDirection: FfiConverterRustBuffer { override fun read(buf: ByteBuffer) = try { PaymentDirection.entries[buf.getInt() - 1] diff --git a/bindings/kotlin/ldk-node-android/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.common.kt b/bindings/kotlin/ldk-node-android/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.common.kt index c51632c07a..abda2da249 100644 --- a/bindings/kotlin/ldk-node-android/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.common.kt +++ b/bindings/kotlin/ldk-node-android/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.common.kt @@ -653,9 +653,15 @@ interface OnchainPaymentInterface { @Throws(NodeException::class) fun `sendAllToAddress`(`address`: Address, `retainReserve`: kotlin.Boolean, `feeRate`: FeeRate?): Txid + @Throws(NodeException::class) + fun `sendAllToAddressWithBroadcastResult`(`address`: Address, `retainReserves`: kotlin.Boolean, `feeRate`: FeeRate?): OnchainSendResult + @Throws(NodeException::class) fun `sendToAddress`(`address`: Address, `amountSats`: kotlin.ULong, `feeRate`: FeeRate?, `utxosToSpend`: List?): Txid + @Throws(NodeException::class) + fun `sendToAddressWithBroadcastResult`(`address`: Address, `amountSats`: kotlin.ULong, `feeRate`: FeeRate?, `utxosToSpend`: List?): OnchainSendResult + companion object } @@ -2051,6 +2057,32 @@ sealed class OfferAmount { +@kotlinx.serialization.Serializable +sealed class OnchainSendResult { + @kotlinx.serialization.Serializable + data class Accepted( + val `txid`: Txid, + ) : OnchainSendResult() { + } + @kotlinx.serialization.Serializable + data class Rejected( + val `txid`: Txid, + val `reason`: kotlin.String, + ) : OnchainSendResult() { + } + @kotlinx.serialization.Serializable + data class Unknown( + val `txid`: Txid, + ) : OnchainSendResult() { + } + +} + + + + + + @kotlinx.serialization.Serializable enum class PaymentDirection { diff --git a/bindings/kotlin/ldk-node-jvm/gradle.properties b/bindings/kotlin/ldk-node-jvm/gradle.properties index 34dc4dc264..a55a347783 100644 --- a/bindings/kotlin/ldk-node-jvm/gradle.properties +++ b/bindings/kotlin/ldk-node-jvm/gradle.properties @@ -1,4 +1,4 @@ org.gradle.jvmargs=-Xmx1536m kotlin.code.style=official group=com.synonym -version=0.7.0-rc.66 +version=0.7.0-rc.67 diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index 9cdcdc85b4..927ad0784f 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -387,6 +387,10 @@ interface OnchainPayment { [Throws=NodeError] Txid send_all_to_address([ByRef]Address address, boolean retain_reserve, FeeRate? fee_rate); [Throws=NodeError] + OnchainSendResult send_to_address_with_broadcast_result([ByRef]Address address, u64 amount_sats, FeeRate? fee_rate, sequence? utxos_to_spend); + [Throws=NodeError] + OnchainSendResult send_all_to_address_with_broadcast_result([ByRef]Address address, boolean retain_reserves, FeeRate? fee_rate); + [Throws=NodeError] Txid bump_fee_by_rbf([ByRef]Txid txid, FeeRate fee_rate); [Throws=NodeError] Txid accelerate_by_cpfp([ByRef]Txid txid, FeeRate? fee_rate, Address? destination_address); @@ -398,6 +402,13 @@ interface OnchainPayment { u64 calculate_send_all_fee([ByRef]Address address, boolean retain_reserves, FeeRate? fee_rate); }; +[Enum] +interface OnchainSendResult { + Accepted(Txid txid); + Rejected(Txid txid, string reason); + Unknown(Txid txid); +}; + enum CoinSelectionAlgorithm { "BranchAndBound", "LargestFirst", diff --git a/bindings/python/pyproject.toml b/bindings/python/pyproject.toml index 93f0f4095b..271e65c7e6 100644 --- a/bindings/python/pyproject.toml +++ b/bindings/python/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ldk_node" -version = "0.7.0-rc.66" +version = "0.7.0-rc.67" authors = [ { name="Elias Rohrer", email="dev@tnull.de" }, ] diff --git a/bindings/python/src/ldk_node/ldk_node.py b/bindings/python/src/ldk_node/ldk_node.py index 0dda20db99..4674007bc7 100644 --- a/bindings/python/src/ldk_node/ldk_node.py +++ b/bindings/python/src/ldk_node/ldk_node.py @@ -845,8 +845,12 @@ def _uniffi_check_api_checksums(lib): raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_ldk_node_checksum_method_onchainpayment_send_all_to_address() != 37748: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_ldk_node_checksum_method_onchainpayment_send_all_to_address_with_broadcast_result() != 45110: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_ldk_node_checksum_method_onchainpayment_send_to_address() != 28826: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_ldk_node_checksum_method_onchainpayment_send_to_address_with_broadcast_result() != 16277: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_ldk_node_checksum_method_refund_absolute_expiry_seconds() != 43722: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_ldk_node_checksum_method_refund_amount_msats() != 26467: @@ -2397,6 +2401,14 @@ class _UniffiVTableCallbackInterfaceVssHeaderProvider(ctypes.Structure): ctypes.POINTER(_UniffiRustCallStatus), ) _UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_send_all_to_address.restype = _UniffiRustBuffer +_UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_send_all_to_address_with_broadcast_result.argtypes = ( + ctypes.c_void_p, + _UniffiRustBuffer, + ctypes.c_int8, + _UniffiRustBuffer, + ctypes.POINTER(_UniffiRustCallStatus), +) +_UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_send_all_to_address_with_broadcast_result.restype = _UniffiRustBuffer _UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_send_to_address.argtypes = ( ctypes.c_void_p, _UniffiRustBuffer, @@ -2406,6 +2418,15 @@ class _UniffiVTableCallbackInterfaceVssHeaderProvider(ctypes.Structure): ctypes.POINTER(_UniffiRustCallStatus), ) _UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_send_to_address.restype = _UniffiRustBuffer +_UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_send_to_address_with_broadcast_result.argtypes = ( + ctypes.c_void_p, + _UniffiRustBuffer, + ctypes.c_uint64, + _UniffiRustBuffer, + _UniffiRustBuffer, + ctypes.POINTER(_UniffiRustCallStatus), +) +_UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_send_to_address_with_broadcast_result.restype = _UniffiRustBuffer _UniffiLib.uniffi_ldk_node_fn_clone_refund.argtypes = ( ctypes.c_void_p, ctypes.POINTER(_UniffiRustCallStatus), @@ -3449,9 +3470,15 @@ class _UniffiVTableCallbackInterfaceVssHeaderProvider(ctypes.Structure): _UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_send_all_to_address.argtypes = ( ) _UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_send_all_to_address.restype = ctypes.c_uint16 +_UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_send_all_to_address_with_broadcast_result.argtypes = ( +) +_UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_send_all_to_address_with_broadcast_result.restype = ctypes.c_uint16 _UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_send_to_address.argtypes = ( ) _UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_send_to_address.restype = ctypes.c_uint16 +_UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_send_to_address_with_broadcast_result.argtypes = ( +) +_UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_send_to_address_with_broadcast_result.restype = ctypes.c_uint16 _UniffiLib.uniffi_ldk_node_checksum_method_refund_absolute_expiry_seconds.argtypes = ( ) _UniffiLib.uniffi_ldk_node_checksum_method_refund_absolute_expiry_seconds.restype = ctypes.c_uint16 @@ -6925,8 +6952,12 @@ def select_utxos_with_algorithm(self, target_amount_sats: "int",fee_rate: "typin raise NotImplementedError def send_all_to_address(self, address: "Address",retain_reserve: "bool",fee_rate: "typing.Optional[FeeRate]"): raise NotImplementedError + def send_all_to_address_with_broadcast_result(self, address: "Address",retain_reserves: "bool",fee_rate: "typing.Optional[FeeRate]"): + raise NotImplementedError def send_to_address(self, address: "Address",amount_sats: "int",fee_rate: "typing.Optional[FeeRate]",utxos_to_spend: "typing.Optional[typing.List[SpendableUtxo]]"): raise NotImplementedError + def send_to_address_with_broadcast_result(self, address: "Address",amount_sats: "int",fee_rate: "typing.Optional[FeeRate]",utxos_to_spend: "typing.Optional[typing.List[SpendableUtxo]]"): + raise NotImplementedError class OnchainPayment: @@ -7276,6 +7307,24 @@ def send_all_to_address(self, address: "Address",retain_reserve: "bool",fee_rate + def send_all_to_address_with_broadcast_result(self, address: "Address",retain_reserves: "bool",fee_rate: "typing.Optional[FeeRate]") -> "OnchainSendResult": + _UniffiConverterTypeAddress.check_lower(address) + + _UniffiConverterBool.check_lower(retain_reserves) + + _UniffiConverterOptionalTypeFeeRate.check_lower(fee_rate) + + return _UniffiConverterTypeOnchainSendResult.lift( + _uniffi_rust_call_with_error(_UniffiConverterTypeNodeError,_UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_send_all_to_address_with_broadcast_result,self._uniffi_clone_pointer(), + _UniffiConverterTypeAddress.lower(address), + _UniffiConverterBool.lower(retain_reserves), + _UniffiConverterOptionalTypeFeeRate.lower(fee_rate)) + ) + + + + + def send_to_address(self, address: "Address",amount_sats: "int",fee_rate: "typing.Optional[FeeRate]",utxos_to_spend: "typing.Optional[typing.List[SpendableUtxo]]") -> "Txid": _UniffiConverterTypeAddress.check_lower(address) @@ -7297,6 +7346,27 @@ def send_to_address(self, address: "Address",amount_sats: "int",fee_rate: "typin + def send_to_address_with_broadcast_result(self, address: "Address",amount_sats: "int",fee_rate: "typing.Optional[FeeRate]",utxos_to_spend: "typing.Optional[typing.List[SpendableUtxo]]") -> "OnchainSendResult": + _UniffiConverterTypeAddress.check_lower(address) + + _UniffiConverterUInt64.check_lower(amount_sats) + + _UniffiConverterOptionalTypeFeeRate.check_lower(fee_rate) + + _UniffiConverterOptionalSequenceTypeSpendableUtxo.check_lower(utxos_to_spend) + + return _UniffiConverterTypeOnchainSendResult.lift( + _uniffi_rust_call_with_error(_UniffiConverterTypeNodeError,_UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_send_to_address_with_broadcast_result,self._uniffi_clone_pointer(), + _UniffiConverterTypeAddress.lower(address), + _UniffiConverterUInt64.lower(amount_sats), + _UniffiConverterOptionalTypeFeeRate.lower(fee_rate), + _UniffiConverterOptionalSequenceTypeSpendableUtxo.lower(utxos_to_spend)) + ) + + + + + class _UniffiConverterTypeOnchainPayment: @@ -14187,6 +14257,137 @@ def write(value, buf): +class OnchainSendResult: + def __init__(self): + raise RuntimeError("OnchainSendResult cannot be instantiated directly") + + # Each enum variant is a nested class of the enum itself. + class ACCEPTED: + txid: "Txid" + + def __init__(self,txid: "Txid"): + self.txid = txid + + def __str__(self): + return "OnchainSendResult.ACCEPTED(txid={})".format(self.txid) + + def __eq__(self, other): + if not other.is_accepted(): + return False + if self.txid != other.txid: + return False + return True + + class REJECTED: + txid: "Txid" + reason: "str" + + def __init__(self,txid: "Txid", reason: "str"): + self.txid = txid + self.reason = reason + + def __str__(self): + return "OnchainSendResult.REJECTED(txid={}, reason={})".format(self.txid, self.reason) + + def __eq__(self, other): + if not other.is_rejected(): + return False + if self.txid != other.txid: + return False + if self.reason != other.reason: + return False + return True + + class UNKNOWN: + txid: "Txid" + + def __init__(self,txid: "Txid"): + self.txid = txid + + def __str__(self): + return "OnchainSendResult.UNKNOWN(txid={})".format(self.txid) + + def __eq__(self, other): + if not other.is_unknown(): + return False + if self.txid != other.txid: + return False + return True + + + + # For each variant, we have an `is_NAME` method for easily checking + # whether an instance is that variant. + def is_accepted(self) -> bool: + return isinstance(self, OnchainSendResult.ACCEPTED) + def is_rejected(self) -> bool: + return isinstance(self, OnchainSendResult.REJECTED) + def is_unknown(self) -> bool: + return isinstance(self, OnchainSendResult.UNKNOWN) + + +# Now, a little trick - we make each nested variant class be a subclass of the main +# enum class, so that method calls and instance checks etc will work intuitively. +# We might be able to do this a little more neatly with a metaclass, but this'll do. +OnchainSendResult.ACCEPTED = type("OnchainSendResult.ACCEPTED", (OnchainSendResult.ACCEPTED, OnchainSendResult,), {}) # type: ignore +OnchainSendResult.REJECTED = type("OnchainSendResult.REJECTED", (OnchainSendResult.REJECTED, OnchainSendResult,), {}) # type: ignore +OnchainSendResult.UNKNOWN = type("OnchainSendResult.UNKNOWN", (OnchainSendResult.UNKNOWN, OnchainSendResult,), {}) # type: ignore + + + + +class _UniffiConverterTypeOnchainSendResult(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + variant = buf.read_i32() + if variant == 1: + return OnchainSendResult.ACCEPTED( + _UniffiConverterTypeTxid.read(buf), + ) + if variant == 2: + return OnchainSendResult.REJECTED( + _UniffiConverterTypeTxid.read(buf), + _UniffiConverterString.read(buf), + ) + if variant == 3: + return OnchainSendResult.UNKNOWN( + _UniffiConverterTypeTxid.read(buf), + ) + raise InternalError("Raw enum value doesn't match any cases") + + @staticmethod + def check_lower(value): + if value.is_accepted(): + _UniffiConverterTypeTxid.check_lower(value.txid) + return + if value.is_rejected(): + _UniffiConverterTypeTxid.check_lower(value.txid) + _UniffiConverterString.check_lower(value.reason) + return + if value.is_unknown(): + _UniffiConverterTypeTxid.check_lower(value.txid) + return + raise ValueError(value) + + @staticmethod + def write(value, buf): + if value.is_accepted(): + buf.write_i32(1) + _UniffiConverterTypeTxid.write(value.txid, buf) + if value.is_rejected(): + buf.write_i32(2) + _UniffiConverterTypeTxid.write(value.txid, buf) + _UniffiConverterString.write(value.reason, buf) + if value.is_unknown(): + buf.write_i32(3) + _UniffiConverterTypeTxid.write(value.txid, buf) + + + + + + + class PaymentDirection(enum.Enum): INBOUND = 0 @@ -17669,6 +17870,7 @@ def generate_entropy_mnemonic(word_count: "typing.Optional[WordCount]") -> "Mnem "Network", "NodeError", "OfferAmount", + "OnchainSendResult", "PaymentDirection", "PaymentFailureReason", "PaymentKind", diff --git a/bindings/swift/Sources/LDKNode/LDKNode.swift b/bindings/swift/Sources/LDKNode/LDKNode.swift index c58b0d716a..16ea8a8a95 100644 --- a/bindings/swift/Sources/LDKNode/LDKNode.swift +++ b/bindings/swift/Sources/LDKNode/LDKNode.swift @@ -3395,7 +3395,11 @@ public protocol OnchainPaymentProtocol: AnyObject { func sendAllToAddress(address: Address, retainReserve: Bool, feeRate: FeeRate?) throws -> Txid + func sendAllToAddressWithBroadcastResult(address: Address, retainReserves: Bool, feeRate: FeeRate?) throws -> OnchainSendResult + func sendToAddress(address: Address, amountSats: UInt64, feeRate: FeeRate?, utxosToSpend: [SpendableUtxo]?) throws -> Txid + + func sendToAddressWithBroadcastResult(address: Address, amountSats: UInt64, feeRate: FeeRate?, utxosToSpend: [SpendableUtxo]?) throws -> OnchainSendResult } open class OnchainPayment: @@ -3615,6 +3619,15 @@ open class OnchainPayment: }) } + open func sendAllToAddressWithBroadcastResult(address: Address, retainReserves: Bool, feeRate: FeeRate?) throws -> OnchainSendResult { + return try FfiConverterTypeOnchainSendResult.lift(rustCallWithError(FfiConverterTypeNodeError.lift) { + uniffi_ldk_node_fn_method_onchainpayment_send_all_to_address_with_broadcast_result(self.uniffiClonePointer(), + FfiConverterTypeAddress.lower(address), + FfiConverterBool.lower(retainReserves), + FfiConverterOptionTypeFeeRate.lower(feeRate), $0) + }) + } + open func sendToAddress(address: Address, amountSats: UInt64, feeRate: FeeRate?, utxosToSpend: [SpendableUtxo]?) throws -> Txid { return try FfiConverterTypeTxid.lift(rustCallWithError(FfiConverterTypeNodeError.lift) { uniffi_ldk_node_fn_method_onchainpayment_send_to_address(self.uniffiClonePointer(), @@ -3624,6 +3637,16 @@ open class OnchainPayment: FfiConverterOptionSequenceTypeSpendableUtxo.lower(utxosToSpend), $0) }) } + + open func sendToAddressWithBroadcastResult(address: Address, amountSats: UInt64, feeRate: FeeRate?, utxosToSpend: [SpendableUtxo]?) throws -> OnchainSendResult { + return try FfiConverterTypeOnchainSendResult.lift(rustCallWithError(FfiConverterTypeNodeError.lift) { + uniffi_ldk_node_fn_method_onchainpayment_send_to_address_with_broadcast_result(self.uniffiClonePointer(), + FfiConverterTypeAddress.lower(address), + FfiConverterUInt64.lower(amountSats), + FfiConverterOptionTypeFeeRate.lower(feeRate), + FfiConverterOptionSequenceTypeSpendableUtxo.lower(utxosToSpend), $0) + }) + } } #if swift(>=5.8) @@ -9808,6 +9831,68 @@ extension OfferAmount: Equatable, Hashable {} // Note that we don't yet support `indirect` for enums. // See https://github.com/mozilla/uniffi-rs/issues/396 for further discussion. +public enum OnchainSendResult { + case accepted(txid: Txid) + case rejected(txid: Txid, reason: String) + case unknown(txid: Txid) +} + +#if swift(>=5.8) + @_documentation(visibility: private) +#endif +public struct FfiConverterTypeOnchainSendResult: FfiConverterRustBuffer { + typealias SwiftType = OnchainSendResult + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> OnchainSendResult { + let variant: Int32 = try readInt(&buf) + switch variant { + case 1: return try .accepted(txid: FfiConverterTypeTxid.read(from: &buf)) + + case 2: return try .rejected(txid: FfiConverterTypeTxid.read(from: &buf), reason: FfiConverterString.read(from: &buf)) + + case 3: return try .unknown(txid: FfiConverterTypeTxid.read(from: &buf)) + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: OnchainSendResult, into buf: inout [UInt8]) { + switch value { + case let .accepted(txid): + writeInt(&buf, Int32(1)) + FfiConverterTypeTxid.write(txid, into: &buf) + + case let .rejected(txid, reason): + writeInt(&buf, Int32(2)) + FfiConverterTypeTxid.write(txid, into: &buf) + FfiConverterString.write(reason, into: &buf) + + case let .unknown(txid): + writeInt(&buf, Int32(3)) + FfiConverterTypeTxid.write(txid, into: &buf) + } + } +} + +#if swift(>=5.8) + @_documentation(visibility: private) +#endif +public func FfiConverterTypeOnchainSendResult_lift(_ buf: RustBuffer) throws -> OnchainSendResult { + return try FfiConverterTypeOnchainSendResult.lift(buf) +} + +#if swift(>=5.8) + @_documentation(visibility: private) +#endif +public func FfiConverterTypeOnchainSendResult_lower(_ value: OnchainSendResult) -> RustBuffer { + return FfiConverterTypeOnchainSendResult.lower(value) +} + +extension OnchainSendResult: Equatable, Hashable {} + +// Note that we don't yet support `indirect` for enums. +// See https://github.com/mozilla/uniffi-rs/issues/396 for further discussion. + public enum PaymentDirection { case inbound case outbound @@ -13623,9 +13708,15 @@ private var initializationResult: InitializationResult = { if uniffi_ldk_node_checksum_method_onchainpayment_send_all_to_address() != 37748 { return InitializationResult.apiChecksumMismatch } + if uniffi_ldk_node_checksum_method_onchainpayment_send_all_to_address_with_broadcast_result() != 45110 { + return InitializationResult.apiChecksumMismatch + } if uniffi_ldk_node_checksum_method_onchainpayment_send_to_address() != 28826 { return InitializationResult.apiChecksumMismatch } + if uniffi_ldk_node_checksum_method_onchainpayment_send_to_address_with_broadcast_result() != 16277 { + return InitializationResult.apiChecksumMismatch + } if uniffi_ldk_node_checksum_method_refund_absolute_expiry_seconds() != 43722 { return InitializationResult.apiChecksumMismatch } diff --git a/src/chain/bitcoind.rs b/src/chain/bitcoind.rs index 7467af86f7..2d6afb46a7 100644 --- a/src/chain/bitcoind.rs +++ b/src/chain/bitcoind.rs @@ -27,7 +27,10 @@ use lightning_block_sync::{ }; use serde::Serialize; -use super::{periodically_archive_fully_resolved_monitors, WalletSyncStatus}; +use super::{ + non_final_rejection, periodically_archive_fully_resolved_monitors, BroadcastResponse, + WalletSyncStatus, +}; use crate::config::{ BitcoindRestClientConfig, Config, OnchainWalletAccount, FEE_RATE_CACHE_UPDATE_TIMEOUT_SECS, TX_BROADCAST_TIMEOUT_SECS, @@ -63,6 +66,21 @@ pub(super) struct BitcoindChainSource { pub(super) node_metrics: Arc>, } +fn classify_bitcoind_broadcast(expected: Txid, result: std::io::Result) -> BroadcastResponse { + match result { + Ok(actual) if actual == expected => BroadcastResponse::Accepted, + Err(error) => { + if let Some(rpc) = error.get_ref().and_then(|e| e.downcast_ref::()) { + if non_final_rejection(rpc.code, &rpc.message) { + return BroadcastResponse::Rejected(rpc.message.clone()); + } + } + BroadcastResponse::Unknown + }, + _ => BroadcastResponse::Unknown, + } +} + impl BitcoindChainSource { pub(crate) fn new_rpc( rpc_host: String, rpc_port: u16, rpc_user: String, rpc_password: String, @@ -643,46 +661,64 @@ impl BitcoindChainSource { Ok(()) } + pub(crate) async fn broadcast_transaction_with_result( + &self, tx: &Transaction, + ) -> BroadcastResponse { + let expected = tx.compute_txid(); + match tokio::time::timeout( + Duration::from_secs(TX_BROADCAST_TIMEOUT_SECS), + self.api_client.broadcast_transaction(tx), + ) + .await + { + Ok(Ok(actual)) if actual == expected => { + log_trace!(self.logger, "Successfully broadcast transaction {}", expected); + BroadcastResponse::Accepted + }, + Ok(Ok(actual)) => { + log_error!( + self.logger, + "Broadcast transaction {} returned mismatched txid {}", + expected, + actual + ); + log_trace!( + self.logger, + "Failed broadcast transaction bytes: {}", + log_bytes!(tx.encode()) + ); + BroadcastResponse::Unknown + }, + Ok(Err(error)) => { + log_error!(self.logger, "Failed to broadcast transaction {}: {}", expected, error); + log_trace!( + self.logger, + "Failed broadcast transaction bytes: {}", + log_bytes!(tx.encode()) + ); + classify_bitcoind_broadcast(expected, Err(error)) + }, + Err(error) => { + log_error!( + self.logger, + "Failed to broadcast transaction due to timeout {}: {}", + expected, + error + ); + log_trace!( + self.logger, + "Failed broadcast transaction bytes: {}", + log_bytes!(tx.encode()) + ); + BroadcastResponse::Unknown + }, + } + } + pub(crate) async fn process_broadcast_package(&self, package: Vec) { - // While it's a bit unclear when we'd be able to lean on Bitcoin Core >v28 - // features, we should eventually switch to use `submitpackage` via the - // `rust-bitcoind-json-rpc` crate rather than just broadcasting individual - // transactions. + // Preserve package order and the same per-transaction bounded wait for LDK broadcasts. for tx in &package { - let txid = tx.compute_txid(); - let timeout_fut = tokio::time::timeout( - Duration::from_secs(TX_BROADCAST_TIMEOUT_SECS), - self.api_client.broadcast_transaction(tx), - ); - match timeout_fut.await { - Ok(res) => match res { - Ok(id) => { - debug_assert_eq!(id, txid); - log_trace!(self.logger, "Successfully broadcast transaction {}", txid); - }, - Err(e) => { - log_error!(self.logger, "Failed to broadcast transaction {}: {}", txid, e); - log_trace!( - self.logger, - "Failed broadcast transaction bytes: {}", - log_bytes!(tx.encode()) - ); - }, - }, - Err(e) => { - log_error!( - self.logger, - "Failed to broadcast transaction due to timeout {}: {}", - txid, - e - ); - log_trace!( - self.logger, - "Failed broadcast transaction bytes: {}", - log_bytes!(tx.encode()) - ); - }, - } + let _ = self.broadcast_transaction_with_result(tx).await; } } } @@ -1614,6 +1650,7 @@ mod tests { use bitcoin::{FeeRate, Network, OutPoint, ScriptBuf, Transaction, TxIn, TxOut, Txid, Witness}; use lightning::chain::{BestBlock, Listen}; use lightning_block_sync::http::JsonResponse; + use lightning_block_sync::rpc::RpcError; use proptest::arbitrary::any; use proptest::collection::vec; use proptest::{prop_assert_eq, prop_compose, proptest}; @@ -1621,15 +1658,38 @@ mod tests { use crate::builder::NodeBuilder; use crate::chain::bitcoind::{ - should_emit_mempool_entry, AccountChainListener, AccountChainListenerOutcome, - BitcoindClient, FeeResponse, GetMempoolEntryResponse, GetRawMempoolResponse, - GetRawTransactionResponse, MempoolMinFeeResponse, MempoolUpdate, + classify_bitcoind_broadcast, should_emit_mempool_entry, AccountChainListener, + AccountChainListenerOutcome, BitcoindClient, FeeResponse, GetMempoolEntryResponse, + GetRawMempoolResponse, GetRawTransactionResponse, MempoolMinFeeResponse, MempoolUpdate, }; + use crate::chain::BroadcastResponse; use crate::config::{AddressType, Config, OnchainWalletAccount}; use crate::io::test_utils::InMemoryStore; use crate::types::DynStore; use crate::Error; + #[test] + fn bitcoind_broadcast_checks_txid_and_only_classifies_non_final_refusal() { + let txid = Txid::from_byte_array([1; 32]); + assert_eq!(classify_bitcoind_broadcast(txid, Ok(txid)), BroadcastResponse::Accepted); + assert_eq!( + classify_bitcoind_broadcast(txid, Ok(Txid::from_byte_array([2; 32]))), + BroadcastResponse::Unknown + ); + for (code, message, expected) in [ + (-26, "non-final", BroadcastResponse::Rejected("non-final".to_owned())), + (-25, "non-final", BroadcastResponse::Unknown), + (-26, "missing inputs", BroadcastResponse::Unknown), + ] { + let error = std::io::Error::other(RpcError { code, message: message.to_owned() }); + assert_eq!(classify_bitcoind_broadcast(txid, Err(error)), expected); + } + assert_eq!( + classify_bitcoind_broadcast(txid, Err(std::io::Error::other("timeout"))), + BroadcastResponse::Unknown + ); + } + fn test_node() -> (crate::Node, [u8; 64]) { let seed = [42u8; 64]; let mut config = Config::default(); diff --git a/src/chain/electrum.rs b/src/chain/electrum.rs index 2e8a8cb181..5080244a78 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -27,7 +27,10 @@ use lightning::util::ser::Writeable; use lightning_transaction_sync::ElectrumSyncClient; use tokio::runtime::Handle; -use super::{periodically_archive_fully_resolved_monitors, WalletSyncStatus}; +use super::{ + non_final_rejection, periodically_archive_fully_resolved_monitors, BroadcastResponse, + WalletSyncStatus, +}; use crate::config::{ AddressTypeRuntimeConfig, Config, ElectrumSyncConfig, BDK_CLIENT_STOP_GAP, BDK_ELECTRUM_CLIENT_BATCH_SIZE, BDK_WALLET_SYNC_TIMEOUT_SECS, @@ -46,6 +49,49 @@ use crate::NodeMetrics; const ELECTRUM_CLIENT_NUM_RETRIES: u8 = 3; +fn classify_electrum_broadcast( + expected: Txid, result: Result, +) -> BroadcastResponse { + match result { + Ok(actual) if actual == expected => BroadcastResponse::Accepted, + Err(electrum_client::Error::Protocol(response)) => { + if let Some(message) = response.as_str() { + // Blockstream electrs serializes daemon errors as a string rather than + // an Electrum error object. Parse only its sendrawtransaction wrapper. + let rpc = message + .strip_prefix("sendrawtransaction RPC error: ") + .and_then(|body| serde_json::from_str::(body).ok()); + return match rpc { + Some(rpc) + if rpc.get("code").and_then(|c| c.as_i64()) == Some(-26) + && rpc + .get("message") + .and_then(|m| m.as_str()) + .is_some_and(|m| non_final_rejection(-26, m)) => + { + BroadcastResponse::Rejected(message.to_owned()) + }, + _ => BroadcastResponse::Unknown, + }; + } + let code = response.get("code").and_then(|c| c.as_i64()); + let message = response.get("message").and_then(|m| m.as_str()); + match (code, message) { + (Some(code), Some(message)) + if non_final_rejection(code, message) + // electrs wraps Bitcoin Core's -26 refusal as Electrum code 2, + // retaining the daemon's exact message. + || (code == 2 && message.trim() == "non-final") => + { + BroadcastResponse::Rejected(message.to_owned()) + }, + _ => BroadcastResponse::Unknown, + } + }, + _ => BroadcastResponse::Unknown, + } +} + fn effective_connection_timeout_secs(configured_timeout_secs: u64, logger: &Logger) -> u8 { let requested_timeout = if configured_timeout_secs == 0 { log_warn!( @@ -534,10 +580,18 @@ impl ElectrumChainSource { }; for tx in package { - electrum_client.broadcast(tx).await; + let _ = electrum_client.broadcast(tx).await; } } + pub(crate) async fn broadcast_transaction_with_result( + &self, tx: &Transaction, + ) -> Result { + let client = + self.electrum_runtime_status.read().unwrap().client().ok_or(Error::NotRunning)?; + Ok(client.broadcast(tx.clone()).await) + } + pub(super) async fn get_address_balance(&self, address: &bitcoin::Address) -> Option { let electrum_client: Arc = if let Some(client) = self.electrum_runtime_status.read().unwrap().client().as_ref() { @@ -831,43 +885,73 @@ impl ElectrumRuntimeClient { }) } - async fn broadcast(&self, tx: Transaction) { - let electrum_client = Arc::clone(&self.electrum_client); + async fn broadcast(&self, tx: Transaction) -> BroadcastResponse { + self.broadcast_with_timeout(tx, Duration::from_secs(TX_BROADCAST_TIMEOUT_SECS)).await + } - let txid = tx.compute_txid(); + async fn broadcast_with_timeout( + &self, tx: Transaction, timeout: Duration, + ) -> BroadcastResponse { + let client = Arc::clone(&self.electrum_client); + let expected = tx.compute_txid(); let tx_bytes = tx.encode(); - - let spawn_fut = - self.runtime_handle.spawn_blocking(move || electrum_client.transaction_broadcast(&tx)); - let timeout_fut = - tokio::time::timeout(Duration::from_secs(TX_BROADCAST_TIMEOUT_SECS), spawn_fut); - - match timeout_fut.await { - Ok(res) => match res { - Ok(_) => { - log_trace!(self.logger, "Successfully broadcast transaction {}", txid); - }, - Err(e) => { - log_error!(self.logger, "Failed to broadcast transaction {}: {}", txid, e); - log_trace!( - self.logger, - "Failed broadcast transaction bytes: {}", - log_bytes!(tx_bytes) - ); - }, + // A timed-out spawn_blocking task may continue on its thread. Its result is unknown. + let task = self.runtime_handle.spawn_blocking(move || client.transaction_broadcast(&tx)); + match tokio::time::timeout(timeout, task).await { + Ok(Ok(Ok(actual))) if actual == expected => { + log_trace!(self.logger, "Successfully broadcast transaction {}", expected); + BroadcastResponse::Accepted }, - Err(e) => { + Ok(Ok(Ok(actual))) => { + log_error!( + self.logger, + "Broadcast transaction {} returned mismatched txid {}", + expected, + actual + ); + log_trace!( + self.logger, + "Failed broadcast transaction bytes: {}", + log_bytes!(tx_bytes) + ); + BroadcastResponse::Unknown + }, + Ok(Ok(Err(error))) => { + log_error!(self.logger, "Failed to broadcast transaction {}: {}", expected, error); + log_trace!( + self.logger, + "Failed broadcast transaction bytes: {}", + log_bytes!(tx_bytes) + ); + classify_electrum_broadcast(expected, Err(error)) + }, + Ok(Err(error)) => { + log_error!( + self.logger, + "Failed to broadcast transaction {} due to task failure: {}", + expected, + error + ); + log_trace!( + self.logger, + "Failed broadcast transaction bytes: {}", + log_bytes!(tx_bytes) + ); + BroadcastResponse::Unknown + }, + Err(error) => { log_error!( self.logger, "Failed to broadcast transaction due to timeout {}: {}", - txid, - e + expected, + error ); log_trace!( self.logger, "Failed broadcast transaction bytes: {}", log_bytes!(tx_bytes) ); + BroadcastResponse::Unknown }, } } @@ -1053,6 +1137,8 @@ impl Filter for ElectrumRuntimeClient { #[cfg(test)] mod tests { + use bitcoin::hashes::Hash; + use std::io::{BufRead, BufReader, Write}; use std::net::TcpListener; use std::panic::{catch_unwind, AssertUnwindSafe}; use std::process::Command; @@ -1064,8 +1150,168 @@ mod tests { use bitcoin::blockdata::constants::genesis_block; use super::*; + use crate::logger::Logger; use crate::runtime::Runtime; + #[test] + fn electrum_broadcast_unwraps_rpc_result_and_keeps_ambiguous_attempts_unknown() { + let txid = Txid::from_byte_array([1; 32]); + let other = Txid::from_byte_array([2; 32]); + assert_eq!(classify_electrum_broadcast(txid, Ok(txid)), BroadcastResponse::Accepted); + assert_eq!(classify_electrum_broadcast(txid, Ok(other)), BroadcastResponse::Unknown); + let refusal = electrum_client::Error::Protocol( + serde_json::json!({"code": -26, "message": "non-final"}), + ); + assert_eq!( + classify_electrum_broadcast(txid, Err(refusal)), + BroadcastResponse::Rejected("non-final".to_owned()) + ); + let electrs_refusal = electrum_client::Error::Protocol( + serde_json::json!({"code": 2, "message": "non-final"}), + ); + assert_eq!( + classify_electrum_broadcast(txid, Err(electrs_refusal)), + BroadcastResponse::Rejected("non-final".to_owned()) + ); + let blockstream_refusal = + "sendrawtransaction RPC error: {\"code\":-26,\"message\":\"non-final\"}"; + assert_eq!( + classify_electrum_broadcast( + txid, + Err(electrum_client::Error::Protocol(serde_json::json!(blockstream_refusal))) + ), + BroadcastResponse::Rejected(blockstream_refusal.to_owned()) + ); + for response in [ + serde_json::json!({"code": 2, "message": "missing inputs"}), + serde_json::json!({"code": 2, "message": "Transaction already in block chain"}), + serde_json::json!( + "sendrawtransaction RPC error: {\"code\":-25,\"message\":\"non-final\"}" + ), + serde_json::json!( + "sendrawtransaction RPC error: {\"code\":-26,\"message\":\"missing inputs\"}" + ), + serde_json::json!({"code": -25, "message": "non-final"}), + serde_json::json!({"code": -26, "message": "missing inputs"}), + serde_json::json!({"code": -26, "message": "already known"}), + ] { + assert_eq!( + classify_electrum_broadcast(txid, Err(electrum_client::Error::Protocol(response))), + BroadcastResponse::Unknown + ); + } + assert_eq!( + classify_electrum_broadcast( + txid, + Err(electrum_client::Error::IOError(std::io::Error::other("lost response"))) + ), + BroadcastResponse::Unknown + ); + } + + fn assert_electrum_protocol_refusal(error: serde_json::Value, expected_reason: String) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("tcp://{}", listener.local_addr().unwrap()); + let server = thread::spawn(move || { + let (broadcast_stream, _) = listener.accept().unwrap(); + let (_sync_stream, _) = listener.accept().unwrap(); + let mut line = String::new(); + BufReader::new(broadcast_stream.try_clone().unwrap()).read_line(&mut line).unwrap(); + let request: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!(request["method"], "blockchain.transaction.broadcast"); + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": request["id"], + "error": error + }); + writeln!(&broadcast_stream, "{}", response).unwrap(); + }); + + let logger = Arc::new(Logger::new_log_facade()); + let runtime = Runtime::new(Arc::clone(&logger)).unwrap(); + let client = ElectrumRuntimeClient::new( + url, + runtime.handle().clone(), + Arc::new(Config::default()), + logger, + 1, + ) + .unwrap(); + let tx = Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: vec![], + output: vec![], + }; + assert_eq!( + runtime.block_on(client.broadcast(tx)), + BroadcastResponse::Rejected(expected_reason) + ); + server.join().unwrap(); + } + + #[test] + fn electrum_protocol_refusal_does_not_become_acceptance_after_join() { + assert_electrum_protocol_refusal( + serde_json::json!({"code": 2, "message": "non-final"}), + "non-final".to_owned(), + ); + let blockstream_refusal = + "sendrawtransaction RPC error: {\"code\":-26,\"message\":\"non-final\"}"; + assert_electrum_protocol_refusal( + serde_json::json!(blockstream_refusal), + blockstream_refusal.to_owned(), + ); + } + + #[test] + fn electrum_timeout_returns_unknown_while_blocking_rpc_remains_in_flight() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("tcp://{}", listener.local_addr().unwrap()); + let (release_sender, release_receiver) = sync_channel::<()>(0); + let (finished_sender, finished_receiver) = sync_channel::<()>(0); + let server = thread::spawn(move || { + let (mut broadcast_stream, _) = listener.accept().unwrap(); + let (_sync_stream, _) = listener.accept().unwrap(); + let mut line = String::new(); + BufReader::new(broadcast_stream.try_clone().unwrap()).read_line(&mut line).unwrap(); + let request: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!(request["method"], "blockchain.transaction.broadcast"); + release_receiver.recv().unwrap(); + let response = serde_json::json!({ + "jsonrpc": "2.0", "id": request["id"], + "result": bitcoin::hashes::sha256d::Hash::hash(&[]).to_string() + }); + writeln!(broadcast_stream, "{}", response).unwrap(); + finished_sender.send(()).unwrap(); + }); + + let logger = Arc::new(Logger::new_log_facade()); + let runtime = Runtime::new(Arc::clone(&logger)).unwrap(); + let client = ElectrumRuntimeClient::new( + url, + runtime.handle().clone(), + Arc::new(Config::default()), + logger, + 1, + ) + .unwrap(); + let tx = Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: vec![], + output: vec![], + }; + assert_eq!( + runtime.block_on(client.broadcast_with_timeout(tx, Duration::from_millis(100))), + BroadcastResponse::Unknown + ); + assert!(finished_receiver.try_recv().is_err()); + release_sender.send(()).unwrap(); + finished_receiver.recv_timeout(Duration::from_secs(2)).unwrap(); + server.join().unwrap(); + } + const RUNTIME_SELF_DROP_CHILD_ENV: &str = "LDK_NODE_ELECTRUM_RUNTIME_SELF_DROP_CHILD"; struct RecordingConfirm { diff --git a/src/chain/esplora.rs b/src/chain/esplora.rs index 8de1a23962..e530173b91 100644 --- a/src/chain/esplora.rs +++ b/src/chain/esplora.rs @@ -17,7 +17,10 @@ use lightning::log_warn; use lightning::util::ser::Writeable; use lightning_transaction_sync::EsploraSyncClient; -use super::{periodically_archive_fully_resolved_monitors, WalletSyncStatus}; +use super::{ + non_final_rejection, periodically_archive_fully_resolved_monitors, BroadcastResponse, + WalletSyncStatus, +}; use crate::config::{ AddressTypeRuntimeConfig, Config, EsploraSyncConfig, BDK_CLIENT_CONCURRENCY, BDK_CLIENT_STOP_GAP, BDK_WALLET_SYNC_TIMEOUT_SECS, DEFAULT_ESPLORA_CLIENT_TIMEOUT_SECS, @@ -46,6 +49,31 @@ pub(super) struct EsploraChainSource { pub(super) node_metrics: Arc>, } +fn classify_esplora_broadcast(result: Result<(), esplora_client::Error>) -> BroadcastResponse { + match result { + Ok(()) => BroadcastResponse::Accepted, + Err(esplora_client::Error::HttpResponse { status: 400, message }) => { + // Esplora wraps Bitcoin Core's structured sendrawtransaction error in HTTP 400. + let rpc = message + .strip_prefix("sendrawtransaction RPC error: ") + .and_then(|body| serde_json::from_str::(body).ok()); + match rpc { + Some(rpc) + if rpc.get("code").and_then(|c| c.as_i64()) == Some(-26) + && rpc + .get("message") + .and_then(|m| m.as_str()) + .is_some_and(|m| non_final_rejection(-26, m)) => + { + BroadcastResponse::Rejected(message) + }, + _ => BroadcastResponse::Unknown, + } + }, + _ => BroadcastResponse::Unknown, + } +} + impl EsploraChainSource { pub(crate) fn new( server_url: String, headers: HashMap, sync_config: EsploraSyncConfig, @@ -479,74 +507,72 @@ impl EsploraChainSource { Ok(()) } - pub(crate) async fn process_broadcast_package(&self, package: Vec) { - for tx in &package { - let txid = tx.compute_txid(); - let timeout_fut = tokio::time::timeout( - Duration::from_secs(TX_BROADCAST_TIMEOUT_SECS), - self.esplora_client.broadcast(tx), - ); - match timeout_fut.await { - Ok(res) => match res { - Ok(()) => { - log_trace!(self.logger, "Successfully broadcast transaction {}", txid); + pub(crate) async fn broadcast_transaction_with_result( + &self, tx: &Transaction, + ) -> BroadcastResponse { + let txid = tx.compute_txid(); + match tokio::time::timeout( + Duration::from_secs(TX_BROADCAST_TIMEOUT_SECS), + self.esplora_client.broadcast(tx), + ) + .await + { + Ok(Ok(())) => { + log_trace!(self.logger, "Successfully broadcast transaction {}", txid); + BroadcastResponse::Accepted + }, + Ok(Err(error)) => { + match &error { + esplora_client::Error::HttpResponse { status: 400, message } => { + // An already-known transaction commonly produces HTTP 400. + log_trace!( + self.logger, + "Failed to broadcast due to HTTP connection error: {}", + message + ); }, - Err(e) => match e { - esplora_client::Error::HttpResponse { status, message } => { - if status == 400 { - // Log 400 at lesser level, as this often just means bitcoind already knows the - // transaction. - // FIXME: We can further differentiate here based on the error - // message which will be available with rust-esplora-client 0.7 and - // later. - log_trace!( - self.logger, - "Failed to broadcast due to HTTP connection error: {}", - message - ); - } else { - log_error!( - self.logger, - "Failed to broadcast due to HTTP connection error: {} - {}", - status, - message - ); - } - log_trace!( - self.logger, - "Failed broadcast transaction bytes: {}", - log_bytes!(tx.encode()) - ); - }, - _ => { - log_error!( - self.logger, - "Failed to broadcast transaction {}: {}", - txid, - e - ); - log_trace!( - self.logger, - "Failed broadcast transaction bytes: {}", - log_bytes!(tx.encode()) - ); - }, + esplora_client::Error::HttpResponse { status, message } => { + log_error!( + self.logger, + "Failed to broadcast due to HTTP connection error: {} - {}", + status, + message + ); }, - }, - Err(e) => { - log_error!( + _ => log_error!( self.logger, - "Failed to broadcast transaction due to timeout {}: {}", + "Failed to broadcast transaction {}: {}", txid, - e - ); - log_trace!( - self.logger, - "Failed broadcast transaction bytes: {}", - log_bytes!(tx.encode()) - ); - }, - } + error + ), + } + log_trace!( + self.logger, + "Failed broadcast transaction bytes: {}", + log_bytes!(tx.encode()) + ); + classify_esplora_broadcast(Err(error)) + }, + Err(error) => { + log_error!( + self.logger, + "Failed to broadcast transaction due to timeout {}: {}", + txid, + error + ); + log_trace!( + self.logger, + "Failed broadcast transaction bytes: {}", + log_bytes!(tx.encode()) + ); + BroadcastResponse::Unknown + }, + } + } + + pub(crate) async fn process_broadcast_package(&self, package: Vec) { + for tx in &package { + let _ = self.broadcast_transaction_with_result(tx).await; } } @@ -584,3 +610,47 @@ impl Filter for EsploraChainSource { self.tx_sync.register_output(output); } } + +#[cfg(test)] +mod broadcast_tests { + use super::*; + + #[test] + fn esplora_broadcast_requires_structured_non_final_refusal() { + assert_eq!(classify_esplora_broadcast(Ok(())), BroadcastResponse::Accepted); + for (status, message, expected) in [ + ( + 400, + "sendrawtransaction RPC error: {\"code\":-26,\"message\":\"non-final\"}", + BroadcastResponse::Rejected( + "sendrawtransaction RPC error: {\"code\":-26,\"message\":\"non-final\"}" + .to_owned(), + ), + ), + (400, "non-final", BroadcastResponse::Unknown), + ( + 400, + "sendrawtransaction RPC error: {\"code\":-25,\"message\":\"non-final\"}", + BroadcastResponse::Unknown, + ), + ( + 400, + "sendrawtransaction RPC error: {\"code\":-26,\"message\":\"missing inputs\"}", + BroadcastResponse::Unknown, + ), + ( + 503, + "sendrawtransaction RPC error: {\"code\":-26,\"message\":\"non-final\"}", + BroadcastResponse::Unknown, + ), + ] { + assert_eq!( + classify_esplora_broadcast(Err(esplora_client::Error::HttpResponse { + status, + message: message.to_owned() + })), + expected + ); + } + } +} diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 4a20065f59..5d10567e7f 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -17,7 +17,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use bdk_chain::spk_client::{FullScanRequest, SyncRequest}; use bdk_wallet::event::WalletEvent as BdkWalletEvent; use bdk_wallet::{KeychainKind, Update as BdkUpdate}; -use bitcoin::{Script, Txid}; +use bitcoin::{Script, Transaction, Txid}; use lightning::chain::{BestBlock, Filter}; use lightning::log_warn; use lightning_block_sync::gossip::UtxoSource; @@ -110,6 +110,20 @@ enum ChainSourceKind { Bitcoind(BitcoindChainSource), } +/// A single backend response. The caller attaches the locally computed txid. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum BroadcastResponse { + Accepted, + Rejected(String), + Unknown, +} + +// Only Bitcoin Core's documented transaction refusal code with the observed non-final +// message is classified as a rejection. Other RPC/HTTP errors may hide prior delivery. +fn non_final_rejection(code: i64, message: &str) -> bool { + code == -26 && message.trim() == "non-final" +} + // Check for evicted transactions by comparing unconfirmed txids before and after sync. // Returns a list of txids that were unconfirmed before but are no longer unconfirmed // and are not confirmed in the wallet. @@ -1322,6 +1336,23 @@ impl ChainSource { } } } + + /// Submit one user-created transaction directly, without entering the LDK package queue. + /// A missing Electrum client is a definite pre-dispatch error; all attempted submissions + /// return a response even when the backend result is lost. + pub(crate) async fn broadcast_user_transaction( + &self, tx: &Transaction, + ) -> Result { + match &self.kind { + ChainSourceKind::Esplora(source) => { + Ok(source.broadcast_transaction_with_result(tx).await) + }, + ChainSourceKind::Electrum(source) => source.broadcast_transaction_with_result(tx).await, + ChainSourceKind::Bitcoind(source) => { + Ok(source.broadcast_transaction_with_result(tx).await) + }, + } + } } impl Filter for ChainSource { diff --git a/src/lib.rs b/src/lib.rs index 5b51005963..d7e76330bc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -155,7 +155,7 @@ use liquidity::{LSPS1Liquidity, LiquiditySource}; use logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; use payment::asynchronous::om_mailbox::OnionMessageMailbox; use payment::asynchronous::static_invoice_store::StaticInvoiceStore; -pub use payment::{AddressInfo, KeychainKind}; +pub use payment::{AddressInfo, KeychainKind, OnchainSendResult}; use payment::{ Bolt11Payment, Bolt12Payment, OnchainPayment, PaymentDetails, SpontaneousPayment, UnifiedQrPayment, @@ -1111,6 +1111,8 @@ impl Node { #[cfg(not(feature = "uniffi"))] pub fn onchain_payment(&self) -> OnchainPayment { OnchainPayment::new( + self.runtime.control(), + Arc::clone(&self.chain_source), Arc::clone(&self.wallet), Arc::clone(&self.channel_manager), Arc::clone(&self.config), @@ -1123,6 +1125,8 @@ impl Node { #[cfg(feature = "uniffi")] pub fn onchain_payment(&self) -> Arc { Arc::new(OnchainPayment::new( + self.runtime.control(), + Arc::clone(&self.chain_source), Arc::clone(&self.wallet), Arc::clone(&self.channel_manager), Arc::clone(&self.config), diff --git a/src/payment/mod.rs b/src/payment/mod.rs index ba59aea06a..3bfa28115e 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -17,7 +17,7 @@ mod unified_qr; pub use bolt11::Bolt11Payment; pub use bolt12::Bolt12Payment; -pub use onchain::{AddressInfo, KeychainKind, OnchainPayment}; +pub use onchain::{AddressInfo, KeychainKind, OnchainPayment, OnchainSendResult}; pub use spontaneous::SpontaneousPayment; pub use store::{ ConfirmationStatus, LSPFeeLimits, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, diff --git a/src/payment/onchain.rs b/src/payment/onchain.rs index ef30c0279a..c4b8e791e6 100644 --- a/src/payment/onchain.rs +++ b/src/payment/onchain.rs @@ -11,10 +11,12 @@ use std::sync::{Arc, RwLock}; use bitcoin::{Address, Txid}; +use crate::chain::{BroadcastResponse, ChainSource}; use crate::config::{AddressType, Config, OnchainWalletAccount}; use crate::error::Error; use crate::fee_estimator::ConfirmationTarget; use crate::logger::{log_info, LdkLogger, Logger}; +use crate::runtime::RuntimeControl; use crate::types::{ChannelManager, SpendableUtxo, Wallet}; use crate::wallet::{CoinSelectionAlgorithm, OnchainSendAmount}; @@ -97,6 +99,8 @@ impl From for AddressInfo { /// /// [`Node::onchain_payment`]: crate::Node::onchain_payment pub struct OnchainPayment { + runtime: Arc, + chain_source: Arc, wallet: Arc, channel_manager: Arc, config: Arc, @@ -104,12 +108,64 @@ pub struct OnchainPayment { logger: Arc, } +/// Result of one direct backend submission of a locally created on-chain transaction. +/// A returned txid identifies the attempt; only `Accepted` means backend acknowledgement. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OnchainSendResult { + /// The configured backend acknowledged this transaction, without guaranteeing confirmation. + Accepted { + /// Locally computed transaction ID acknowledged by the backend. + txid: Txid, + }, + /// A recognized refusal response was received; earlier delivery is still possible. + Rejected { + /// Locally computed transaction ID that received a refusal response. + txid: Txid, + /// Diagnostic refusal reported by the backend. + reason: String, + }, + /// The backend's acceptance is indeterminate. Never infer retry safety from this. + Unknown { + /// Locally computed transaction ID whose broadcast result is unknown. + txid: Txid, + }, +} + impl OnchainPayment { pub(crate) fn new( - wallet: Arc, channel_manager: Arc, config: Arc, - is_running: Arc>, logger: Arc, + runtime: Arc, chain_source: Arc, wallet: Arc, + channel_manager: Arc, config: Arc, is_running: Arc>, + logger: Arc, ) -> Self { - Self { wallet, channel_manager, config, is_running, logger } + Self { runtime, chain_source, wallet, channel_manager, config, is_running, logger } + } + + fn send_with_broadcast_result( + &self, address: &Address, send_amount: OnchainSendAmount, fee_rate: Option, + utxos_to_spend: Option>, + ) -> Result { + if !*self.is_running.read().unwrap() { + return Err(Error::NotRunning); + } + let tx = self.wallet.prepare_send_to_address( + address, + send_amount, + maybe_map_fee_rate_opt!(fee_rate), + utxos_to_spend, + &self.channel_manager, + )?; + let txid = tx.compute_txid(); + let source = Arc::clone(&self.chain_source); + // try_block_on can fail only before polling the submission future. Once polled, + // every backend failure is carried with the locally computed txid. + let response = self + .runtime + .try_block_on(async move { source.broadcast_user_transaction(&tx).await })??; + Ok(match response { + BroadcastResponse::Accepted => OnchainSendResult::Accepted { txid }, + BroadcastResponse::Rejected(reason) => OnchainSendResult::Rejected { txid, reason }, + BroadcastResponse::Unknown => OnchainSendResult::Unknown { txid }, + }) } /// Retrieve a new on-chain/funding address. @@ -538,6 +594,11 @@ impl OnchainPayment { /// If `fee_rate` is set it will be used on the resulting transaction. Otherwise we'll retrieve /// a reasonable estimate from the configured chain source. /// + /// This legacy method returns the locally computed txid after attempting to queue the signed + /// transaction. Queue admission can fail without changing the returned txid. + /// It does not report backend acceptance; use [`Self::send_to_address_with_broadcast_result`] when + /// the caller needs that distinction. + /// /// [`BalanceDetails::total_anchor_channels_reserve_sats`]: crate::BalanceDetails::total_anchor_channels_reserve_sats pub fn send_to_address( &self, address: &bitcoin::Address, amount_sats: u64, fee_rate: Option, @@ -578,6 +639,11 @@ impl OnchainPayment { /// If `fee_rate` is set it will be used on the resulting transaction. Otherwise a reasonable /// we'll retrieve an estimate from the configured chain source. /// + /// This legacy method returns the locally computed txid after attempting to queue the signed + /// transaction. Queue admission can fail without changing the returned txid. + /// It does not report backend acceptance; use [`Self::send_all_to_address_with_broadcast_result`] + /// when the caller needs that distinction. + /// /// [`calculate_send_all_fee`]: Self::calculate_send_all_fee /// [`BalanceDetails::spendable_onchain_balance_sats`]: crate::balance::BalanceDetails::spendable_onchain_balance_sats pub fn send_all_to_address( @@ -599,6 +665,41 @@ impl OnchainPayment { self.wallet.send_to_address(address, send_amount, fee_rate_opt, None, &self.channel_manager) } + /// Create and submit a fixed-amount on-chain send through one direct backend route. + /// + /// `Err` proves this invocation did not start broadcast. `Rejected` and `Unknown` both + /// retain the txid and do not authorize another payment. This does not wait for confirmation. + pub fn send_to_address_with_broadcast_result( + &self, address: &bitcoin::Address, amount_sats: u64, fee_rate: Option, + utxos_to_spend: Option>, + ) -> Result { + let reserve = + crate::total_anchor_channels_reserve_sats(&self.channel_manager, &self.config); + let amount = OnchainSendAmount::ExactRetainingReserve { + amount_sats, + cur_anchor_reserve_sats: reserve, + }; + let outpoints = utxos_to_spend.map(|utxos| utxos.into_iter().map(|u| u.outpoint).collect()); + self.send_with_broadcast_result(address, amount, fee_rate, outpoints) + } + + /// Create and submit a send-all transaction through one direct backend route. + /// + /// The reserve behavior matches [`Self::send_all_to_address`]. `Err` is limited to a proven + /// pre-dispatch failure. Any attempted submission returns its txid, even if unresolved. + pub fn send_all_to_address_with_broadcast_result( + &self, address: &bitcoin::Address, retain_reserves: bool, fee_rate: Option, + ) -> Result { + let amount = if retain_reserves { + let reserve = + crate::total_anchor_channels_reserve_sats(&self.channel_manager, &self.config); + OnchainSendAmount::AllRetainingReserve { cur_anchor_reserve_sats: reserve } + } else { + OnchainSendAmount::AllDrainingReserve + }; + self.send_with_broadcast_result(address, amount, fee_rate, None) + } + /// Bumps the fee of an existing transaction using Replace-By-Fee (RBF). /// /// This allows a previously sent transaction to be replaced with a new version diff --git a/src/payment/unified_qr.rs b/src/payment/unified_qr.rs index fcfb4388cc..f62cfc0f22 100644 --- a/src/payment/unified_qr.rs +++ b/src/payment/unified_qr.rs @@ -135,7 +135,9 @@ impl UnifiedQrPayment { /// has an offer and or invoice, it will try to pay the offer first followed by the invoice. /// If they both fail, the on-chain payment will be paid. /// - /// Returns a `QrPaymentResult` indicating the outcome of the payment. If an error + /// Returns a `QrPaymentResult` indicating the locally initiated payment. Its on-chain + /// variant contains a locally computed txid after attempting queue admission, without backend acceptance + /// assurance. If an error /// occurs, an `Error` is returned detailing the issue encountered. /// /// If `route_parameters` are provided they will override the default as well as the @@ -188,7 +190,8 @@ impl UnifiedQrPayment { /// Represents the result of a payment made using a [BIP 21] QR code. /// -/// After a successful on-chain transaction, the transaction ID ([`Txid`]) is returned. +/// For an on-chain attempt, the locally computed transaction ID ([`Txid`]) is returned after +/// attempting queue admission; it does not establish backend acceptance or confirmation. /// For BOLT11 and BOLT12 payments, the corresponding [`PaymentId`] is returned. /// /// [BIP 21]: https://github.com/bitcoin/bips/blob/master/bip-0021.mediawiki diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 98a6f00d55..3a61996f71 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -1879,6 +1879,24 @@ impl Wallet { &self, address: &Address, send_amount: OnchainSendAmount, fee_rate: Option, utxos_to_spend: Option>, channel_manager: &ChannelManager, ) -> Result { + let tx = self.prepare_send_to_address( + address, + send_amount, + fee_rate, + utxos_to_spend, + channel_manager, + )?; + self.broadcaster.broadcast_transactions(&[&tx]); + Ok(tx.compute_txid()) + } + + /// Sign a user transaction and persist wallet changes without dispatching it. + /// The caller owns its single broadcast route. + #[allow(deprecated)] + pub(crate) fn prepare_send_to_address( + &self, address: &Address, send_amount: OnchainSendAmount, fee_rate: Option, + utxos_to_spend: Option>, channel_manager: &ChannelManager, + ) -> Result { self.parse_and_validate_address(&address)?; // Use the set fee_rate or default to fee estimation. @@ -1928,8 +1946,6 @@ impl Wallet { tx }; - self.broadcaster.broadcast_transactions(&[&tx]); - let txid = tx.compute_txid(); match send_amount { @@ -1961,7 +1977,7 @@ impl Wallet { }, } - Ok(txid) + Ok(tx) } pub(crate) fn select_confirmed_utxos( diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 7cb56ed19a..cc6082a6b2 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -15,6 +15,8 @@ use bitcoin::address::NetworkUnchecked; use bitcoin::hashes::sha256::Hash as Sha256Hash; use bitcoin::hashes::Hash; use bitcoin::{Address, Amount, ScriptBuf, Txid}; +#[cfg(not(feature = "uniffi"))] +use common::logging::MockLogFacadeLogger; use common::logging::{init_log_logger, validate_log_entry, MultiNodeLogger, TestLogWriter}; use common::{ api_fee_rate, bump_fee_and_broadcast, distribute_funds_unconfirmed, do_channel_full_cycle, @@ -31,7 +33,8 @@ use ldk_node::payment::{ ConfirmationStatus, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, QrPaymentResult, }; -use ldk_node::{Builder, DynStore, Event, NodeError}; +use ldk_node::{Builder, DynStore, Event, NodeError, OnchainSendResult}; + use lightning::ln::channelmanager::PaymentId; use lightning::routing::gossip::{NodeAlias, NodeId}; use lightning::routing::router::RouteParametersConfig; @@ -39,6 +42,163 @@ use lightning_invoice::{Bolt11InvoiceDescription, Description}; use lightning_types::payment::{PaymentHash, PaymentPreimage}; use log::LevelFilter; +#[cfg(not(feature = "uniffi"))] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn direct_onchain_sends_return_backend_outcome_and_local_txid() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + for chain_source in [ + TestChainSource::Esplora(&electrsd), + TestChainSource::Electrum(&electrsd), + TestChainSource::BitcoindRpcSync(&bitcoind), + ] { + let logs = Arc::new(MockLogFacadeLogger::new()); + let mut config = random_config(false); + config.log_writer = TestLogWriter::Custom(logs.clone()); + let node = setup_node(&chain_source, config, None); + let recipient: Address = + bitcoind.client.get_new_address(None, None).unwrap().0.parse().unwrap(); + let recipient = recipient.assume_checked(); + let funding_address = node.onchain_payment().new_address().unwrap(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![funding_address], + Amount::from_sat(100_000), + ) + .await; + node.sync_wallets().unwrap(); + + assert_eq!( + node.onchain_payment() + .send_to_address_with_broadcast_result(&recipient, 200_000, None, None,), + Err(NodeError::InsufficientFunds) + ); + let fixed = node + .onchain_payment() + .send_to_address_with_broadcast_result(&recipient, 20_000, None, None) + .unwrap(); + let fixed_txid = match fixed { + OnchainSendResult::Accepted { txid } => txid, + other => panic!("expected backend acknowledgement for fixed send: {other:?}"), + }; + wait_for_tx(&electrsd.client, fixed_txid).await; + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + assert_eq!( + logs.retrieve_logs() + .iter() + .filter(|line| { + line.contains(&format!("Successfully broadcast transaction {fixed_txid}")) + || line.contains(&format!("Failed to broadcast transaction {fixed_txid}")) + }) + .count(), + 1, + "direct fixed send must use one backend submission" + ); + assert!(!logs + .retrieve_logs() + .iter() + .any(|line| { line.contains("Failed to broadcast due to HTTP connection error") })); + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; + node.sync_wallets().unwrap(); + + let all = node + .onchain_payment() + .send_all_to_address_with_broadcast_result(&recipient, false, None) + .unwrap(); + let all_txid = match all { + OnchainSendResult::Accepted { txid } => txid, + other => panic!("expected backend acknowledgement for send-all: {other:?}"), + }; + assert_ne!(fixed_txid, all_txid); + wait_for_tx(&electrsd.client, all_txid).await; + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + assert_eq!( + logs.retrieve_logs() + .iter() + .filter(|line| { + line.contains(&format!("Successfully broadcast transaction {all_txid}")) + || line.contains(&format!("Failed to broadcast transaction {all_txid}")) + }) + .count(), + 1, + "direct send-all must use one backend submission" + ); + assert!(!logs + .retrieve_logs() + .iter() + .any(|line| { line.contains("Failed to broadcast due to HTTP connection error") })); + node.stop().unwrap(); + assert_eq!( + node.onchain_payment() + .send_to_address_with_broadcast_result(&recipient, 1_000, None, None,), + Err(NodeError::NotRunning), + ); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn legacy_onchain_send_returns_txid_without_backend_acceptance() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Electrum(&electrsd); + let node = setup_node(&chain_source, random_config(false), None); + let funding_address = node.onchain_payment().new_address().unwrap(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![funding_address], + Amount::from_sat(100_000), + ) + .await; + node.sync_wallets().unwrap(); + let recipient: Address = + bitcoind.client.get_new_address(None, None).unwrap().0.parse().unwrap(); + let recipient = recipient.assume_checked(); + drop(chain_source); + drop(electrsd); + + // The legacy method queues locally and returns a txid even with no Electrum server. + let txid = node.onchain_payment().send_to_address(&recipient, 20_000, None, None).unwrap(); + assert!(bitcoind + .client + .call::("getmempoolentry", &[serde_json::json!(txid.to_string())],) + .is_err()); + node.stop().unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn direct_onchain_send_keeps_txid_when_electrum_reply_is_lost() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Electrum(&electrsd); + let node = setup_node(&chain_source, random_config(false), None); + let funding_address = node.onchain_payment().new_address().unwrap(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![funding_address], + Amount::from_sat(100_000), + ) + .await; + node.sync_wallets().unwrap(); + let recipient: Address = + bitcoind.client.get_new_address(None, None).unwrap().0.parse().unwrap(); + let recipient = recipient.assume_checked(); + drop(chain_source); + drop(electrsd); + + let result = node + .onchain_payment() + .send_to_address_with_broadcast_result(&recipient, 20_000, None, None) + .unwrap(); + let txid = match result { + OnchainSendResult::Unknown { txid } => txid, + other => panic!("expected an unknown submission with its local txid: {other:?}"), + }; + assert!(bitcoind + .client + .call::("getmempoolentry", &[serde_json::json!(txid.to_string())],) + .is_err()); + node.stop().unwrap(); +} #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn channel_full_cycle() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); From f148c154c31f7a8c6a28e3f2e03c42a3a051586d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 30 Sep 2026 03:50:42 +0200 Subject: [PATCH 3/4] fix: require verified on-chain broadcast acknowledgement --- src/chain/esplora.rs | 121 +++++++++++++++++++++++++++++++- src/payment/onchain.rs | 9 +++ tests/integration_tests_rust.rs | 55 +++++++++++++++ 3 files changed, 184 insertions(+), 1 deletion(-) diff --git a/src/chain/esplora.rs b/src/chain/esplora.rs index e530173b91..e95c8d631d 100644 --- a/src/chain/esplora.rs +++ b/src/chain/esplora.rs @@ -507,13 +507,39 @@ impl EsploraChainSource { Ok(()) } + async fn broadcast_transaction_checked( + &self, tx: &Transaction, + ) -> Result<(), esplora_client::Error> { + // esplora-client's broadcast discards the success body. Reuse its configured client + // and URL so acknowledgement validation preserves headers and transport settings. + let response = self + .esplora_client + .client() + .post(format!("{}/tx", self.esplora_client.url())) + .body(bitcoin::consensus::encode::serialize_hex(tx)) + .send() + .await?; + let status = response.status(); + let body = response.text().await?; + if !status.is_success() { + return Err(esplora_client::Error::HttpResponse { + status: status.as_u16(), + message: body, + }); + } + if body.trim().parse::().ok() != Some(tx.compute_txid()) { + return Err(esplora_client::Error::InvalidResponse); + } + Ok(()) + } + pub(crate) async fn broadcast_transaction_with_result( &self, tx: &Transaction, ) -> BroadcastResponse { let txid = tx.compute_txid(); match tokio::time::timeout( Duration::from_secs(TX_BROADCAST_TIMEOUT_SECS), - self.esplora_client.broadcast(tx), + self.broadcast_transaction_checked(tx), ) .await { @@ -613,8 +639,101 @@ impl Filter for EsploraChainSource { #[cfg(test)] mod broadcast_tests { + use std::io::{BufRead, BufReader, Read, Write}; + use std::net::TcpListener; + use std::thread; + + use crate::io::test_utils::InMemoryStore; + use crate::runtime::Runtime; + use super::*; + #[test] + fn esplora_broadcast_http_requires_matching_txid_acknowledgement() { + let tx = Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: vec![], + output: vec![], + }; + let txid = tx.compute_txid(); + let refusal = "sendrawtransaction RPC error: {\"code\":-26,\"message\":\"non-final\"}"; + for (name, status, body, extra_length, expected) in [ + ("matching", 200, txid.to_string(), 0, BroadcastResponse::Accepted), + ("matching with newline", 201, format!("{txid}\n"), 0, BroadcastResponse::Accepted), + ("empty", 200, String::new(), 0, BroadcastResponse::Unknown), + ("no content", 204, String::new(), 0, BroadcastResponse::Unknown), + ("malformed", 200, "success".to_owned(), 0, BroadcastResponse::Unknown), + ("mismatched", 200, "00".repeat(32), 0, BroadcastResponse::Unknown), + ("unreadable", 200, txid.to_string(), 1, BroadcastResponse::Unknown), + ( + "refused", + 400, + refusal.to_owned(), + 0, + BroadcastResponse::Rejected(refusal.to_owned()), + ), + ("unstructured refusal", 400, "non-final".to_owned(), 0, BroadcastResponse::Unknown), + ("server failure", 503, refusal.to_owned(), 0, BroadcastResponse::Unknown), + ] { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}/api", listener.local_addr().unwrap()); + let expected_body = bitcoin::consensus::encode::serialize_hex(&tx); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + assert_eq!(line, "POST /api/tx HTTP/1.1\r\n"); + let mut content_length = None; + let mut fixture_header = false; + loop { + line.clear(); + reader.read_line(&mut line).unwrap(); + if line == "\r\n" { + break; + } + let header = line.to_ascii_lowercase(); + if let Some(length) = header.strip_prefix("content-length: ") { + content_length = Some(length.trim().parse::().unwrap()); + } + fixture_header |= header == "x-broadcast-fixture: preserved\r\n"; + } + assert!(fixture_header, "configured client headers must be preserved"); + let mut request_body = vec![0; content_length.unwrap()]; + reader.read_exact(&mut request_body).unwrap(); + assert_eq!(request_body, expected_body.as_bytes()); + write!( + stream, + "HTTP/1.1 {status} Fixture\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + extra_length + ) + .unwrap(); + listener + }); + let logger = Arc::new(Logger::new_log_facade()); + let runtime = Runtime::new(Arc::clone(&logger)).unwrap(); + let config = Arc::new(Config::default()); + let source = EsploraChainSource::new( + url, + HashMap::from([("X-Broadcast-Fixture".to_owned(), "preserved".to_owned())]), + EsploraSyncConfig::default(), + Arc::new(OnchainFeeEstimator::new()), + Arc::new(InMemoryStore::new()), + Arc::clone(&config), + Arc::new(RwLock::new(AddressTypeRuntimeConfig::from_config(&config, vec![]))), + logger, + Arc::new(RwLock::new(NodeMetrics::default())), + ); + let result = runtime.block_on(source.broadcast_transaction_with_result(&tx)); + let listener = server.join().unwrap(); + listener.set_nonblocking(true).unwrap(); + assert!(listener.accept().is_err(), "{name}: must submit only once"); + assert_eq!(result, expected, "{name}"); + } + } + #[test] fn esplora_broadcast_requires_structured_non_final_refusal() { assert_eq!(classify_esplora_broadcast(Ok(())), BroadcastResponse::Accepted); diff --git a/src/payment/onchain.rs b/src/payment/onchain.rs index c4b8e791e6..f8502d559d 100644 --- a/src/payment/onchain.rs +++ b/src/payment/onchain.rs @@ -147,6 +147,13 @@ impl OnchainPayment { if !*self.is_running.read().unwrap() { return Err(Error::NotRunning); } + // A synchronous backend wait cannot run inside a current-thread Tokio runtime. + // Reject before preparing, signing, or persisting a transaction. + if tokio::runtime::Handle::try_current().is_ok_and(|handle| { + handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::CurrentThread + }) { + return Err(Error::PaymentSendingFailed); + } let tx = self.wallet.prepare_send_to_address( address, send_amount, @@ -669,6 +676,7 @@ impl OnchainPayment { /// /// `Err` proves this invocation did not start broadcast. `Rejected` and `Unknown` both /// retain the txid and do not authorize another payment. This does not wait for confirmation. + /// Current-thread Tokio callers receive [`Error::PaymentSendingFailed`] before wallet preparation. pub fn send_to_address_with_broadcast_result( &self, address: &bitcoin::Address, amount_sats: u64, fee_rate: Option, utxos_to_spend: Option>, @@ -687,6 +695,7 @@ impl OnchainPayment { /// /// The reserve behavior matches [`Self::send_all_to_address`]. `Err` is limited to a proven /// pre-dispatch failure. Any attempted submission returns its txid, even if unresolved. + /// Current-thread Tokio callers receive [`Error::PaymentSendingFailed`] before wallet preparation. pub fn send_all_to_address_with_broadcast_result( &self, address: &bitcoin::Address, retain_reserves: bool, fee_rate: Option, ) -> Result { diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index cc6082a6b2..631de346b7 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -42,6 +42,61 @@ use lightning_invoice::{Bolt11InvoiceDescription, Description}; use lightning_types::payment::{PaymentHash, PaymentPreimage}; use log::LevelFilter; +#[cfg(not(feature = "uniffi"))] +#[test] +fn direct_onchain_current_thread_rejects_before_wallet_preparation() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let config = random_config(false); + let store: Arc = + Arc::new(TestSyncStore::new(config.node_config.storage_dir_path.clone().into())); + let mut builder = Builder::from_config(config.node_config); + builder.set_chain_source_esplora( + format!("http://{}", electrsd.esplora_url.as_ref().unwrap()), + Some(EsploraSyncConfig { background_sync_config: None }), + ); + // Build/start outside Tokio, then move the caller into a current-thread runtime. + let node = builder.build_with_store(Arc::clone(&store)).unwrap(); + node.start().unwrap(); + let funding_address = node.onchain_payment().new_address().unwrap(); + let funding_runtime = tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap(); + funding_runtime.block_on(premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![funding_address], + Amount::from_sat(100_000), + )); + node.sync_wallets().unwrap(); + let recipient: Address = + bitcoind.client.get_new_address(None, None).unwrap().0.parse().unwrap(); + let recipient = recipient.assume_checked(); + let snapshot = || { + ["indexer", "tx_graph"].map(|key| { + lightning::util::persist::KVStoreSync::read(&*store, "bdk_wallet", "native_segwit", key) + .unwrap() + }) + }; + let before = snapshot(); + let mempool_before: Vec = bitcoind.client.call("getrawmempool", &[]).unwrap(); + let caller = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap(); + caller.block_on(async { + assert_eq!( + node.onchain_payment() + .send_to_address_with_broadcast_result(&recipient, 20_000, None, None), + Err(NodeError::PaymentSendingFailed) + ); + assert_eq!(snapshot(), before, "fixed send must not persist a prepared transaction"); + assert_eq!( + node.onchain_payment() + .send_all_to_address_with_broadcast_result(&recipient, false, None), + Err(NodeError::PaymentSendingFailed) + ); + assert_eq!(snapshot(), before, "send-all must not persist a prepared transaction"); + }); + let mempool_after: Vec = bitcoind.client.call("getrawmempool", &[]).unwrap(); + assert_eq!(mempool_after, mempool_before, "neither method must submit a transaction"); + node.stop().unwrap(); +} + #[cfg(not(feature = "uniffi"))] #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn direct_onchain_sends_return_backend_outcome_and_local_txid() { From 773792d83ff1371b9c6c9886bffcd61cd48bdf6f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 30 Sep 2026 04:21:14 +0200 Subject: [PATCH 4/4] chore: prepare broadcast outcome rc68 --- CHANGELOG.md | 2 +- Cargo.lock | 2 +- Cargo.toml | 2 +- Package.swift | 4 ++-- bindings/kotlin/ldk-node-android/gradle.properties | 2 +- bindings/kotlin/ldk-node-jvm/gradle.properties | 2 +- bindings/python/pyproject.toml | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 011b75f0c9..6768130884 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# 0.7.0-rc.67 (Synonym Fork) +# 0.7.0-rc.68 (Synonym Fork) ## Bug Fixes diff --git a/Cargo.lock b/Cargo.lock index e3651d70e3..e5d104ade4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1901,7 +1901,7 @@ dependencies = [ [[package]] name = "ldk-node" -version = "0.7.0-rc.67" +version = "0.7.0-rc.68" dependencies = [ "base64 0.22.1", "bdk-wallet-aggregate", diff --git a/Cargo.toml b/Cargo.toml index ff9c863010..48fdbf03e0 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ exclude = ["bindings/uniffi-bindgen"] [package] name = "ldk-node" -version = "0.7.0-rc.67" +version = "0.7.0-rc.68" authors = ["Elias Rohrer "] homepage = "https://lightningdevkit.org/" license = "MIT OR Apache-2.0" diff --git a/Package.swift b/Package.swift index 24752d2299..0aeb05c2cf 100644 --- a/Package.swift +++ b/Package.swift @@ -3,8 +3,8 @@ import PackageDescription -let tag = "v0.7.0-rc.67" -let checksum = "8b5196adcd7b6b4a8304080a0b7fa80a919a6fa527b75ccde982a75298c92da9" +let tag = "v0.7.0-rc.68" +let checksum = "1955a179fdb6acc5159ead68225dd27029d2f2a74c4b8b13164038eb9f95462f" let url = "https://github.com/synonymdev/ldk-node/releases/download/\(tag)/LDKNodeFFI.xcframework.zip" let package = Package( diff --git a/bindings/kotlin/ldk-node-android/gradle.properties b/bindings/kotlin/ldk-node-android/gradle.properties index e2fa1e9991..d5e6219fa1 100644 --- a/bindings/kotlin/ldk-node-android/gradle.properties +++ b/bindings/kotlin/ldk-node-android/gradle.properties @@ -3,4 +3,4 @@ android.useAndroidX=true android.enableJetifier=true kotlin.code.style=official group=com.synonym -version=0.7.0-rc.67 +version=0.7.0-rc.68 diff --git a/bindings/kotlin/ldk-node-jvm/gradle.properties b/bindings/kotlin/ldk-node-jvm/gradle.properties index a55a347783..d4df9ae04c 100644 --- a/bindings/kotlin/ldk-node-jvm/gradle.properties +++ b/bindings/kotlin/ldk-node-jvm/gradle.properties @@ -1,4 +1,4 @@ org.gradle.jvmargs=-Xmx1536m kotlin.code.style=official group=com.synonym -version=0.7.0-rc.67 +version=0.7.0-rc.68 diff --git a/bindings/python/pyproject.toml b/bindings/python/pyproject.toml index 271e65c7e6..ab12984871 100644 --- a/bindings/python/pyproject.toml +++ b/bindings/python/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ldk_node" -version = "0.7.0-rc.67" +version = "0.7.0-rc.68" authors = [ { name="Elias Rohrer", email="dev@tnull.de" }, ]